From 6193bf55668a108f8689b2bbb99cd3e7aa87595d Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Sun, 19 Nov 2017 19:18:14 +0300 Subject: [PATCH 1/2] connector: implement Microsoft connector connector/microsoft implements authorization strategy via Microsoft's OAuth2 endpoint + Graph API. It allows to choose what kind of tenants are allowed to authenticate in Dex via Microsoft: * common - both personal and business/school accounts * organizations - only business/school accounts * consumers - only personal accounts * - only account of specific tenant Signed-off-by: Pavel Borzenkov --- Documentation/microsoft-connector.md | 66 +++++++ README.md | 1 + connector/microsoft/microsoft.go | 282 +++++++++++++++++++++++++++ server/server.go | 2 + web/static/img/microsoft-icon.svg | 9 + web/static/main.css | 4 + 6 files changed, 364 insertions(+) create mode 100644 Documentation/microsoft-connector.md create mode 100644 connector/microsoft/microsoft.go create mode 100644 web/static/img/microsoft-icon.svg diff --git a/Documentation/microsoft-connector.md b/Documentation/microsoft-connector.md new file mode 100644 index 00000000..a7318ba0 --- /dev/null +++ b/Documentation/microsoft-connector.md @@ -0,0 +1,66 @@ +# Authentication through Microsoft + +## Overview + +One of the login options for dex uses the Microsoft OAuth2 flow to identify the +end user through their Microsoft account. + +When a client redeems a refresh token through dex, dex will re-query Microsoft +to update user information in the ID Token. To do this, __dex stores a readonly +Microsoft access and refresh tokens in its backing datastore.__ Users that +reject dex's access through Microsoft will also revoke all dex clients which +authenticated them through Microsoft. + +## Configuration + +Register a new application on https://apps.dev.microsoft.com via `Add an app` +ensuring the callback URL is `(dex issuer)/callback`. For example if dex +is listening at the non-root path `https://auth.example.com/dex` the callback +would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```yaml +connectors: + - type: microsoft + # Required field for connector id. + id: microsoft + # Required field for connector name. + name: Microsoft + config: + # Credentials can be string literals or pulled from the environment. + clientID: $MICROSOFT_APPLICATION_ID + clientSecret: $MICROSOFT_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback +``` + +`tenant` configuration parameter controls what kinds of accounts may be +authenticated in dex. By default, all types of Microsoft accounts (consumers +and organizations) can authenticate in dex via Microsoft. To change this, set +the `tenant` parameter to one of the following: + +- `common`- both personal and business/school accounts can authenticate in dex + via Microsoft (default) +- `consumers` - only personal accounts can authenticate in dex +- `organizations` - only business/school accounts can authenticate in dex +- `` or `` - only accounts belonging to specific + tenant identified by either `` or `` can + authenticate in dex + +For example, the following snippet configures dex to only allow business/school +accounts: + +```yaml +connectors: + - type: microsoft + # Required field for connector id. + id: microsoft + # Required field for connector name. + name: Microsoft + config: + # Credentials can be string literals or pulled from the environment. + clientID: $MICROSOFT_APPLICATION_ID + clientSecret: $MICROSOFT_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + tenant: organizations +``` diff --git a/README.md b/README.md index d536eff0..1352c642 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ More docs for running dex as a Kubernetes authenticator can be found [here](Docu * [OpenID Connect](Documentation/oidc-connector.md) (includes Google, Salesforce, Azure, etc.) * [authproxy](Documentation/authproxy.md) (Apache2 mod_auth, etc.) * [LinkedIn](Documentation/linkedin-connector.md) + * [Microsoft](Documentation/microsoft-connection.md) * Client libraries * [Go][go-oidc] diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go new file mode 100644 index 00000000..4f2b49a8 --- /dev/null +++ b/connector/microsoft/microsoft.go @@ -0,0 +1,282 @@ +// Package microsoft provides authentication strategies using Microsoft. +package microsoft + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/coreos/dex/connector" + "github.com/sirupsen/logrus" +) + +const ( + apiURL = "https://graph.microsoft.com" + // Microsoft requires this scope to access user's profile + scopeUser = "user.read" +) + +// Config holds configuration options for microsoft logins. +type Config struct { + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + Tenant string `json:"tenant"` +} + +// Open returns a strategy for logging in through Microsoft. +func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { + m := microsoftConnector{ + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + tenant: c.Tenant, + logger: logger, + } + // By default allow logins from both personal and business/school + // accounts. + if m.tenant == "" { + m.tenant = "common" + } + + return &m, nil +} + +type connectorData struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiry time.Time `json:"expiry"` +} + +var ( + _ connector.CallbackConnector = (*microsoftConnector)(nil) + _ connector.RefreshConnector = (*microsoftConnector)(nil) +) + +type microsoftConnector struct { + redirectURI string + clientID string + clientSecret string + tenant string + logger logrus.FieldLogger +} + +func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { + microsoftScopes := []string{scopeUser} + + return &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/token", + }, + Scopes: microsoftScopes, + RedirectURL: c.redirectURI, + } +} + +func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + } + + return c.oauth2Config(scopes).AuthCodeURL(state), nil +} + +func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, &oauth2Error{errType, q.Get("error_description")} + } + + oauth2Config := c.oauth2Config(s) + + ctx := r.Context() + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("microsoft: failed to get token: %v", err) + } + + client := oauth2Config.Client(ctx, token) + + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("microsoft: get user: %v", err) + } + + identity = connector.Identity{ + UserID: user.ID, + Username: user.Name, + Email: user.Email, + EmailVerified: true, + } + + if s.OfflineAccess { + data := connectorData{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("microsoft: marshal connector data: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +type tokenNotifyFunc func(*oauth2.Token) error + +// notifyRefreshTokenSource is essentially `oauth2.ResuseTokenSource` with `TokenNotifyFunc` added. +type notifyRefreshTokenSource struct { + new oauth2.TokenSource + mu sync.Mutex // guards t + t *oauth2.Token + f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted +} + +// Token returns the current token if it's still valid, else will +// refresh the current token (using r.Context for HTTP client +// information) and return the new one. +func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + s.t = t + return t, s.f(t) +} + +func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + if len(identity.ConnectorData) == 0 { + return identity, errors.New("microsoft: no upstream access token found") + } + + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + return identity, fmt.Errorf("microsoft: unmarshal access token: %v", err) + } + tok := &oauth2.Token{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Expiry: data.Expiry, + } + + client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ + new: c.oauth2Config(s).TokenSource(ctx, tok), + t: tok, + f: func(tok *oauth2.Token) error { + data := connectorData{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("microsoft: marshal connector data: %v", err) + } + identity.ConnectorData = connData + return nil + }, + }) + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("microsoft: get user: %v", err) + } + + identity.Username = user.Name + identity.Email = user.Email + + return identity, nil +} + +// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user +// id - The unique identifier for the user. Inherited from +// directoryObject. Key. Not nullable. Read-only. +// displayName - The name displayed in the address book for the user. +// This is usually the combination of the user's first name, +// middle initial and last name. This property is required +// when a user is created and it cannot be cleared during +// updates. Supports $filter and $orderby. +// userPrincipalName - The user principal name (UPN) of the user. +// The UPN is an Internet-style login name for the user +// based on the Internet standard RFC 822. By convention, +// this should map to the user's email name. The general +// format is alias@domain, where domain must be present in +// the tenant’s collection of verified domains. This +// property is required when a user is created. The +// verified domains for the tenant can be accessed from the +// verifiedDomains property of organization. Supports +// $filter and $orderby. +type user struct { + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"userPrincipalName"` +} + +func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) { + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get + req, err := http.NewRequest("GET", apiURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil) + if err != nil { + return u, fmt.Errorf("new req: %v", err) + } + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return u, fmt.Errorf("get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // https://developer.microsoft.com/en-us/graph/docs/concepts/errors + var ge graphError + if err := json.NewDecoder(resp.Body).Decode(&struct { + Error *graphError `json:"error"` + }{&ge}); err != nil { + return u, fmt.Errorf("JSON error decode: %v", err) + } + return u, &ge + } + + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { + return u, fmt.Errorf("JSON decode: %v", err) + } + + return u, err +} + +type graphError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *graphError) Error() string { + return e.Code + ": " + e.Message +} + +type oauth2Error struct { + error string + errorDescription string +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} diff --git a/server/server.go b/server/server.go index f915b5ac..6b609cc7 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "github.com/coreos/dex/connector/gitlab" "github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/linkedin" + "github.com/coreos/dex/connector/microsoft" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" "github.com/coreos/dex/connector/saml" @@ -415,6 +416,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "saml": func() ConnectorConfig { return new(saml.Config) }, "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, + "microsoft": func() ConnectorConfig { return new(microsoft.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/web/static/img/microsoft-icon.svg b/web/static/img/microsoft-icon.svg new file mode 100644 index 00000000..739c395a --- /dev/null +++ b/web/static/img/microsoft-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/web/static/main.css b/web/static/main.css index c147f35c..552479ad 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -88,6 +88,10 @@ body { background-size: contain; } +.dex-btn-icon--microsoft { + background-image: url(../static/img/microsoft-icon.svg); +} + .dex-btn-text { font-weight: 600; line-height: 36px; From 47df6ea2ff094a0c223293128028ee786f49b159 Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Tue, 21 Nov 2017 18:58:22 +0300 Subject: [PATCH 2/2] connector/microsoft: add support for groups Microsoft connector now provides support for 'groups' claim in case 'tenant' is configured in Dex config for the connector. It's possible to deny user authentication if the user is not a member of at least one configured groups. Signed-off-by: Pavel Borzenkov --- Documentation/microsoft-connector.md | 46 ++++++ connector/microsoft/microsoft.go | 215 ++++++++++++++++++++++++--- 2 files changed, 239 insertions(+), 22 deletions(-) diff --git a/Documentation/microsoft-connector.md b/Documentation/microsoft-connector.md index a7318ba0..11024a6d 100644 --- a/Documentation/microsoft-connector.md +++ b/Documentation/microsoft-connector.md @@ -11,6 +11,21 @@ Microsoft access and refresh tokens in its backing datastore.__ Users that reject dex's access through Microsoft will also revoke all dex clients which authenticated them through Microsoft. +### Caveats + +`groups` claim in dex is only supported when `tenant` is specified in Microsoft +connector config. In order for dex to be able to list groups on behalf of +logged in user, an explicit organization administrator consent is required. To +obtain the consent do the following: + + - when registering dex application on https://apps.dev.microsoft.com add + an explicit `Directory.Read.All` permission to the list of __Delegated + Permissions__ + - open the following link in your browser and log in under organization + administrator account: + +`https://login.microsoftonline.com//adminconsent?client_id=` + ## Configuration Register a new application on https://apps.dev.microsoft.com via `Add an app` @@ -64,3 +79,34 @@ connectors: redirectURI: http://127.0.0.1:5556/dex/callback tenant: organizations ``` + +### Groups + +When the `groups` claim is present in a request to dex __and__ `tenant` is +configured, dex will query Microsoft API to obtain a list of groups the user is +a member of. `onlySecurityGroups` configuration option restricts the list to +include only security groups. By default all groups (security, Office 365, +mailing lists) are included. + +It is possible to require a user to be a member of a particular group in order +to be successfully authenticated in dex. For example, with the following +configuration file only the users who are members of at least one of the listed +groups will be able to successfully authenticate in dex: + +```yaml +connectors: + - type: microsoft + # Required field for connector id. + id: microsoft + # Required field for connector name. + name: Microsoft + config: + # Credentials can be string literals or pulled from the environment. + clientID: $MICROSOFT_APPLICATION_ID + clientSecret: $MICROSOFT_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + tenant: myorg.onmicrosoft.com + groups: + - developers + - devops +``` diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go index 4f2b49a8..7ea672fa 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -2,10 +2,12 @@ package microsoft import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net/http" "sync" "time" @@ -20,24 +22,31 @@ const ( apiURL = "https://graph.microsoft.com" // Microsoft requires this scope to access user's profile scopeUser = "user.read" + // Microsoft requires this scope to list groups the user is a member of + // and resolve their UUIDs to groups names. + scopeGroups = "directory.read.all" ) // Config holds configuration options for microsoft logins. type Config struct { - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectURI"` - Tenant string `json:"tenant"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + Tenant string `json:"tenant"` + OnlySecurityGroups bool `json:"onlySecurityGroups"` + Groups []string `json:"groups"` } // Open returns a strategy for logging in through Microsoft. func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { m := microsoftConnector{ - redirectURI: c.RedirectURI, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - tenant: c.Tenant, - logger: logger, + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + tenant: c.Tenant, + onlySecurityGroups: c.OnlySecurityGroups, + groups: c.Groups, + logger: logger, } // By default allow logins from both personal and business/school // accounts. @@ -60,15 +69,28 @@ var ( ) type microsoftConnector struct { - redirectURI string - clientID string - clientSecret string - tenant string - logger logrus.FieldLogger + redirectURI string + clientID string + clientSecret string + tenant string + onlySecurityGroups bool + groups []string + logger logrus.FieldLogger +} + +func (c *microsoftConnector) isOrgTenant() bool { + return c.tenant != "common" && c.tenant != "consumers" && c.tenant != "organizations" +} + +func (c *microsoftConnector) groupsRequired(groupScope bool) bool { + return (len(c.groups) > 0 || groupScope) && c.isOrgTenant() } func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { microsoftScopes := []string{scopeUser} + if c.groupsRequired(scopes.Groups) { + microsoftScopes = append(microsoftScopes, scopeGroups) + } return &oauth2.Config{ ClientID: c.clientID, @@ -119,6 +141,14 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) EmailVerified: true, } + if c.groupsRequired(s.Groups) { + groups, err := c.getGroups(ctx, client, user.ID) + if err != nil { + return identity, fmt.Errorf("microsoft: get groups: %v", err) + } + identity.Groups = groups + } + if s.OfflineAccess { data := connectorData{ AccessToken: token.AccessToken, @@ -202,6 +232,14 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id identity.Username = user.Name identity.Email = user.Email + if c.groupsRequired(s.Groups) { + groups, err := c.getGroups(ctx, client, user.ID) + if err != nil { + return identity, fmt.Errorf("microsoft: get groups: %v", err) + } + identity.Groups = groups + } + return identity, nil } @@ -243,14 +281,7 @@ func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u u defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - // https://developer.microsoft.com/en-us/graph/docs/concepts/errors - var ge graphError - if err := json.NewDecoder(resp.Body).Decode(&struct { - Error *graphError `json:"error"` - }{&ge}); err != nil { - return u, fmt.Errorf("JSON error decode: %v", err) - } - return u, &ge + return u, newGraphError(resp.Body) } if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { @@ -260,6 +291,135 @@ func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u u return u, err } +// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/group +// displayName - The display name for the group. This property is required when +// a group is created and it cannot be cleared during updates. +// Supports $filter and $orderby. +type group struct { + Name string `json:"displayName"` +} + +func (c *microsoftConnector) getGroups(ctx context.Context, client *http.Client, userID string) (groups []string, err error) { + ids, err := c.getGroupIDs(ctx, client) + if err != nil { + return groups, err + } + + groups, err = c.getGroupNames(ctx, client, ids) + if err != nil { + return + } + + // ensure that the user is in at least one required group + isInGroups := false + if len(c.groups) > 0 { + gs := make(map[string]struct{}) + for _, g := range c.groups { + gs[g] = struct{}{} + } + + for _, g := range groups { + if _, ok := gs[g]; ok { + isInGroups = true + break + } + } + } + if len(c.groups) > 0 && !isInGroups { + return nil, fmt.Errorf("microsoft: user %v not in required groups", userID) + } + + return +} + +func (c *microsoftConnector) getGroupIDs(ctx context.Context, client *http.Client) (ids []string, err error) { + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_getmembergroups + in := &struct { + SecurityEnabledOnly bool `json:"securityEnabledOnly"` + }{c.onlySecurityGroups} + reqURL := apiURL + "/v1.0/me/getMemberGroups" + for { + var out []string + var next string + + next, err = c.post(ctx, client, reqURL, in, &out) + if err != nil { + return ids, err + } + + ids = append(ids, out...) + if next == "" { + return + } + reqURL = next + } +} + +func (c *microsoftConnector) getGroupNames(ctx context.Context, client *http.Client, ids []string) (groups []string, err error) { + if len(ids) == 0 { + return + } + + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/directoryobject_getbyids + in := &struct { + IDs []string `json:"ids"` + Types []string `json:"types"` + }{ids, []string{"group"}} + reqURL := apiURL + "/v1.0/directoryObjects/getByIds" + for { + var out []group + var next string + + next, err = c.post(ctx, client, reqURL, in, &out) + if err != nil { + return groups, err + } + + for _, g := range out { + groups = append(groups, g.Name) + } + if next == "" { + return + } + reqURL = next + } +} + +func (c *microsoftConnector) post(ctx context.Context, client *http.Client, reqURL string, in interface{}, out interface{}) (string, error) { + var payload bytes.Buffer + + err := json.NewEncoder(&payload).Encode(in) + if err != nil { + return "", fmt.Errorf("microsoft: JSON encode: %v", err) + } + + req, err := http.NewRequest("POST", reqURL, &payload) + if err != nil { + return "", fmt.Errorf("new req: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("post URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", newGraphError(resp.Body) + } + + var next string + if err = json.NewDecoder(resp.Body).Decode(&struct { + NextLink *string `json:"@odata.nextLink"` + Value interface{} `json:"value"` + }{&next, out}); err != nil { + return "", fmt.Errorf("JSON decode: %v", err) + } + + return next, nil +} + type graphError struct { Code string `json:"code"` Message string `json:"message"` @@ -269,6 +429,17 @@ func (e *graphError) Error() string { return e.Code + ": " + e.Message } +func newGraphError(r io.Reader) error { + // https://developer.microsoft.com/en-us/graph/docs/concepts/errors + var ge graphError + if err := json.NewDecoder(r).Decode(&struct { + Error *graphError `json:"error"` + }{&ge}); err != nil { + return fmt.Errorf("JSON error decode: %v", err) + } + return &ge +} + type oauth2Error struct { error string errorDescription string