// Package oidc implements OpenID Connect client logic for the golang.org/x/oauth2 package. package oidc import ( "context" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/json" "errors" "fmt" "hash" "io/ioutil" "mime" "net/http" "strings" "sync" "time" "golang.org/x/oauth2" ) const ( // ScopeOpenID is the mandatory scope for all OpenID Connect OAuth2 requests. ScopeOpenID = "openid" // ScopeOfflineAccess is an optional scope defined by OpenID Connect for requesting // OAuth2 refresh tokens. // // Support for this scope differs between OpenID Connect providers. For instance // Google rejects it, favoring appending "access_type=offline" as part of the // authorization request instead. // // See: https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess ScopeOfflineAccess = "offline_access" ) var ( errNoAtHash = errors.New("id token did not have an access token hash") errInvalidAtHash = errors.New("access token hash does not match value in ID token") ) type contextKey int var issuerURLKey contextKey // ClientContext returns a new Context that carries the provided HTTP client. // // This method sets the same context key used by the golang.org/x/oauth2 package, // so the returned context works for that package too. // // myClient := &http.Client{} // ctx := oidc.ClientContext(parentContext, myClient) // // // This will use the custom client // provider, err := oidc.NewProvider(ctx, "https://accounts.example.com") func ClientContext(ctx context.Context, client *http.Client) context.Context { return context.WithValue(ctx, oauth2.HTTPClient, client) } func getClient(ctx context.Context) *http.Client { if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok { return c } return nil } // InsecureIssuerURLContext allows discovery to work when the issuer_url reported // by upstream is mismatched with the discovery URL. This is meant for integration // with off-spec providers such as Azure. // // discoveryBaseURL := "https://login.microsoftonline.com/organizations/v2.0" // issuerURL := "https://login.microsoftonline.com/my-tenantid/v2.0" // // ctx := oidc.InsecureIssuerURLContext(parentContext, issuerURL) // // // Provider will be discovered with the discoveryBaseURL, but use issuerURL // // for future issuer validation. // provider, err := oidc.NewProvider(ctx, discoveryBaseURL) // // This is insecure because validating the correct issuer is critical for multi-tenant // proivders. Any overrides here MUST be carefully reviewed. func InsecureIssuerURLContext(ctx context.Context, issuerURL string) context.Context { return context.WithValue(ctx, issuerURLKey, issuerURL) } func doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { client := http.DefaultClient if c := getClient(ctx); c != nil { client = c } return client.Do(req.WithContext(ctx)) } // Provider represents an OpenID Connect server's configuration. type Provider struct { issuer string authURL string tokenURL string userInfoURL string jwksURL string algorithms []string // Raw claims returned by the server. rawClaims []byte // Guards all of the following fields. mu sync.Mutex // HTTP client specified from the initial NewProvider request. This is used // when creating the common key set. client *http.Client // A key set that uses context.Background() and is shared between all code paths // that don't have a convinent way of supplying a unique context. commonRemoteKeySet KeySet } func (p *Provider) remoteKeySet() KeySet { p.mu.Lock() defer p.mu.Unlock() if p.commonRemoteKeySet == nil { ctx := context.Background() if p.client != nil { ctx = ClientContext(ctx, p.client) } p.commonRemoteKeySet = NewRemoteKeySet(ctx, p.jwksURL) } return p.commonRemoteKeySet } type providerJSON struct { Issuer string `json:"issuer"` AuthURL string `json:"authorization_endpoint"` TokenURL string `json:"token_endpoint"` JWKSURL string `json:"jwks_uri"` UserInfoURL string `json:"userinfo_endpoint"` Algorithms []string `json:"id_token_signing_alg_values_supported"` } // supportedAlgorithms is a list of algorithms explicitly supported by this // package. If a provider supports other algorithms, such as HS256 or none, // those values won't be passed to the IDTokenVerifier. var supportedAlgorithms = map[string]bool{ RS256: true, RS384: true, RS512: true, ES256: true, ES384: true, ES512: true, PS256: true, PS384: true, PS512: true, EdDSA: true, } // ProviderConfig allows creating providers when discovery isn't supported. It's // generally easier to use NewProvider directly. type ProviderConfig struct { // IssuerURL is the identity of the provider, and the string it uses to sign // ID tokens with. For example "https://accounts.google.com". This value MUST // match ID tokens exactly. IssuerURL string // AuthURL is the endpoint used by the provider to support the OAuth 2.0 // authorization endpoint. AuthURL string // TokenURL is the endpoint used by the provider to support the OAuth 2.0 // token endpoint. TokenURL string // UserInfoURL is the endpoint used by the provider to support the OpenID // Connect UserInfo flow. // // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo UserInfoURL string // JWKSURL is the endpoint used by the provider to advertise public keys to // verify issued ID tokens. This endpoint is polled as new keys are made // available. JWKSURL string // Algorithms, if provided, indicate a list of JWT algorithms allowed to sign // ID tokens. If not provided, this defaults to the algorithms advertised by // the JWK endpoint, then the set of algorithms supported by this package. Algorithms []string } // NewProvider initializes a provider from a set of endpoints, rather than // through discovery. func (p *ProviderConfig) NewProvider(ctx context.Context) *Provider { return &Provider{ issuer: p.IssuerURL, authURL: p.AuthURL, tokenURL: p.TokenURL, userInfoURL: p.UserInfoURL, jwksURL: p.JWKSURL, algorithms: p.Algorithms, client: getClient(ctx), } } // NewProvider uses the OpenID Connect discovery mechanism to construct a Provider. // // The issuer is the URL identifier for the service. For example: "https://accounts.google.com" // or "https://login.salesforce.com". func NewProvider(ctx context.Context, issuer string) (*Provider, error) { wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" req, err := http.NewRequest("GET", wellKnown, nil) if err != nil { return nil, err } resp, err := doRequest(ctx, req) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("unable to read response body: %v", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: %s", resp.Status, body) } var p providerJSON err = unmarshalResp(resp, body, &p) if err != nil { return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %v", err) } issuerURL, skipIssuerValidation := ctx.Value(issuerURLKey).(string) if !skipIssuerValidation { issuerURL = issuer } if p.Issuer != issuerURL && !skipIssuerValidation { return nil, fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", issuer, p.Issuer) } var algs []string for _, a := range p.Algorithms { if supportedAlgorithms[a] { algs = append(algs, a) } } return &Provider{ issuer: issuerURL, authURL: p.AuthURL, tokenURL: p.TokenURL, userInfoURL: p.UserInfoURL, jwksURL: p.JWKSURL, algorithms: algs, rawClaims: body, client: getClient(ctx), }, nil } // Claims unmarshals raw fields returned by the server during discovery. // // var claims struct { // ScopesSupported []string `json:"scopes_supported"` // ClaimsSupported []string `json:"claims_supported"` // } // // if err := provider.Claims(&claims); err != nil { // // handle unmarshaling error // } // // For a list of fields defined by the OpenID Connect spec see: // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata func (p *Provider) Claims(v interface{}) error { if p.rawClaims == nil { return errors.New("oidc: claims not set") } return json.Unmarshal(p.rawClaims, v) } // Endpoint returns the OAuth2 auth and token endpoints for the given provider. func (p *Provider) Endpoint() oauth2.Endpoint { return oauth2.Endpoint{AuthURL: p.authURL, TokenURL: p.tokenURL} } // UserInfoEndpoint returns the OpenID Connect userinfo endpoint for the given // provider. func (p *Provider) UserInfoEndpoint() string { return p.userInfoURL } // UserInfo represents the OpenID Connect userinfo claims. type UserInfo struct { Subject string `json:"sub"` Profile string `json:"profile"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` claims []byte } type userInfoRaw struct { Subject string `json:"sub"` Profile string `json:"profile"` Email string `json:"email"` // Handle providers that return email_verified as a string // https://forums.aws.amazon.com/thread.jspa?messageID=949441󧳁 and // https://discuss.elastic.co/t/openid-error-after-authenticating-against-aws-cognito/206018/11 EmailVerified stringAsBool `json:"email_verified"` } // Claims unmarshals the raw JSON object claims into the provided object. func (u *UserInfo) Claims(v interface{}) error { if u.claims == nil { return errors.New("oidc: claims not set") } return json.Unmarshal(u.claims, v) } // UserInfo uses the token source to query the provider's user info endpoint. func (p *Provider) UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*UserInfo, error) { if p.userInfoURL == "" { return nil, errors.New("oidc: user info endpoint is not supported by this provider") } req, err := http.NewRequest("GET", p.userInfoURL, nil) if err != nil { return nil, fmt.Errorf("oidc: create GET request: %v", err) } token, err := tokenSource.Token() if err != nil { return nil, fmt.Errorf("oidc: get access token: %v", err) } token.SetAuthHeader(req) resp, err := doRequest(ctx, req) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: %s", resp.Status, body) } ct := resp.Header.Get("Content-Type") mediaType, _, parseErr := mime.ParseMediaType(ct) if parseErr == nil && mediaType == "application/jwt" { payload, err := p.remoteKeySet().VerifySignature(ctx, string(body)) if err != nil { return nil, fmt.Errorf("oidc: invalid userinfo jwt signature %v", err) } body = payload } var userInfo userInfoRaw if err := json.Unmarshal(body, &userInfo); err != nil { return nil, fmt.Errorf("oidc: failed to decode userinfo: %v", err) } return &UserInfo{ Subject: userInfo.Subject, Profile: userInfo.Profile, Email: userInfo.Email, EmailVerified: bool(userInfo.EmailVerified), claims: body, }, nil } // IDToken is an OpenID Connect extension that provides a predictable representation // of an authorization event. // // The ID Token only holds fields OpenID Connect requires. To access additional // claims returned by the server, use the Claims method. type IDToken struct { // The URL of the server which issued this token. OpenID Connect // requires this value always be identical to the URL used for // initial discovery. // // Note: Because of a known issue with Google Accounts' implementation // this value may differ when using Google. // // See: https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo Issuer string // The client ID, or set of client IDs, that this token is issued for. For // common uses, this is the client that initialized the auth flow. // // This package ensures the audience contains an expected value. Audience []string // A unique string which identifies the end user. Subject string // Expiry of the token. Ths package will not process tokens that have // expired unless that validation is explicitly turned off. Expiry time.Time // When the token was issued by the provider. IssuedAt time.Time // Initial nonce provided during the authentication redirect. // // This package does NOT provided verification on the value of this field // and it's the user's responsibility to ensure it contains a valid value. Nonce string // at_hash claim, if set in the ID token. Callers can verify an access token // that corresponds to the ID token using the VerifyAccessToken method. AccessTokenHash string // signature algorithm used for ID token, needed to compute a verification hash of an // access token sigAlgorithm string // Raw payload of the id_token. claims []byte // Map of distributed claim names to claim sources distributedClaims map[string]claimSource } // Claims unmarshals the raw JSON payload of the ID Token into a provided struct. // // idToken, err := idTokenVerifier.Verify(rawIDToken) // if err != nil { // // handle error // } // var claims struct { // Email string `json:"email"` // EmailVerified bool `json:"email_verified"` // } // if err := idToken.Claims(&claims); err != nil { // // handle error // } func (i *IDToken) Claims(v interface{}) error { if i.claims == nil { return errors.New("oidc: claims not set") } return json.Unmarshal(i.claims, v) } // VerifyAccessToken verifies that the hash of the access token that corresponds to the iD token // matches the hash in the id token. It returns an error if the hashes don't match. // It is the caller's responsibility to ensure that the optional access token hash is present for the ID token // before calling this method. See https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken func (i *IDToken) VerifyAccessToken(accessToken string) error { if i.AccessTokenHash == "" { return errNoAtHash } var h hash.Hash switch i.sigAlgorithm { case RS256, ES256, PS256: h = sha256.New() case RS384, ES384, PS384: h = sha512.New384() case RS512, ES512, PS512, EdDSA: h = sha512.New() default: return fmt.Errorf("oidc: unsupported signing algorithm %q", i.sigAlgorithm) } h.Write([]byte(accessToken)) // hash documents that Write will never return an error sum := h.Sum(nil)[:h.Size()/2] actual := base64.RawURLEncoding.EncodeToString(sum) if actual != i.AccessTokenHash { return errInvalidAtHash } return nil } type idToken struct { Issuer string `json:"iss"` Subject string `json:"sub"` Audience audience `json:"aud"` Expiry jsonTime `json:"exp"` IssuedAt jsonTime `json:"iat"` NotBefore *jsonTime `json:"nbf"` Nonce string `json:"nonce"` AtHash string `json:"at_hash"` ClaimNames map[string]string `json:"_claim_names"` ClaimSources map[string]claimSource `json:"_claim_sources"` } type claimSource struct { Endpoint string `json:"endpoint"` AccessToken string `json:"access_token"` } type stringAsBool bool func (sb *stringAsBool) UnmarshalJSON(b []byte) error { switch string(b) { case "true", `"true"`: *sb = true case "false", `"false"`: *sb = false default: return errors.New("invalid value for boolean") } return nil } type audience []string func (a *audience) UnmarshalJSON(b []byte) error { var s string if json.Unmarshal(b, &s) == nil { *a = audience{s} return nil } var auds []string if err := json.Unmarshal(b, &auds); err != nil { return err } *a = auds return nil } type jsonTime time.Time func (j *jsonTime) UnmarshalJSON(b []byte) error { var n json.Number if err := json.Unmarshal(b, &n); err != nil { return err } var unix int64 if t, err := n.Int64(); err == nil { unix = t } else { f, err := n.Float64() if err != nil { return err } unix = int64(f) } *j = jsonTime(time.Unix(unix, 0)) return nil } func unmarshalResp(r *http.Response, body []byte, v interface{}) error { err := json.Unmarshal(body, &v) if err == nil { return nil } ct := r.Header.Get("Content-Type") mediaType, _, parseErr := mime.ParseMediaType(ct) if parseErr == nil && mediaType == "application/json" { return fmt.Errorf("got Content-Type = application/json, but could not unmarshal as JSON: %v", err) } return fmt.Errorf("expected Content-Type = application/json, got %q: %v", ct, err) }