// Copyright 2020 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package oidc contains common OIDC functionality needed by Pinniped. package oidc import ( "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "github.com/ory/fosite/compose" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) const ( WellKnownEndpointPath = "/.well-known/openid-configuration" AuthorizationEndpointPath = "/oauth2/authorize" TokenEndpointPath = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential CallbackEndpointPath = "/callback" JWKSEndpointPath = "/jwks.json" ) const ( // Just in case we need to make a breaking change to the format of the upstream state param, // we are including a format version number. This gives the opportunity for a future version of Pinniped // to have the consumer of this format decide to reject versions that it doesn't understand. UpstreamStateParamFormatVersion = "1" // The `name` passed to the encoder for encoding the upstream state param value. This name is short // because it will be encoded into the upstream state param value and we're trying to keep that small. UpstreamStateParamEncodingName = "s" // CSRFCookieName is the name of the browser cookie which shall hold our CSRF value. // The `__Host` prefix has a special meaning. See: // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Cookie_prefixes. CSRFCookieName = "__Host-pinniped-csrf" // CSRFCookieEncodingName is the `name` passed to the encoder for encoding and decoding the CSRF // cookie contents. CSRFCookieEncodingName = "csrf" // The name of the issuer claim specified in the OIDC spec. IDTokenIssuerClaim = "iss" // The name of the subject claim specified in the OIDC spec. IDTokenSubjectClaim = "sub" // DownstreamUsernameClaim is a custom claim in the downstream ID token // whose value is mapped from a claim in the upstream token. // By default the value is the same as the downstream subject claim's. DownstreamUsernameClaim = "username" // DownstreamGroupsClaim is what we will use to encode the groups in the downstream OIDC ID token // information. DownstreamGroupsClaim = "groups" // CSRFCookieLifespan is the length of time that the CSRF cookie is valid. After this time, the // Supervisor's authorization endpoint should give the browser a new CSRF cookie. We set it to // a week so that it is unlikely to expire during a login. CSRFCookieLifespan = time.Hour * 24 * 7 ) // Encoder is the encoding side of the securecookie.Codec interface. type Encoder interface { Encode(name string, value interface{}) (string, error) } // Decoder is the decoding side of the securecookie.Codec interface. type Decoder interface { Decode(name, value string, into interface{}) error } // Codec is both the encoding and decoding sides of the securecookie.Codec interface. It is // interface'd here so that we properly wrap the securecookie dependency. type Codec interface { Encoder Decoder } // UpstreamStateParamData is the format of the state parameter that we use when we communicate to an // upstream OIDC provider. // // Keep the JSON to a minimal size because the upstream provider could impose size limitations on // the state param. type UpstreamStateParamData struct { AuthParams string `json:"p"` UpstreamName string `json:"u"` Nonce nonce.Nonce `json:"n"` CSRFToken csrftoken.CSRFToken `json:"c"` PKCECode pkce.Code `json:"k"` FormatVersion string `json:"v"` } func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient { return &fosite.DefaultOpenIDConnectClient{ DefaultClient: &fosite.DefaultClient{ ID: "pinniped-cli", Public: true, RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"code"}, GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, Scopes: []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience"}, }, TokenEndpointAuthMethod: "none", } } type TimeoutsConfiguration struct { // The length of time that our state param that we encrypt and pass to the upstream OIDC IDP should be considered // valid. If a state param generated by the authorize endpoint is sent to the callback endpoint after this much // time has passed, then the callback endpoint should reject it. This allows us to set a limit on how long // the end user has to finish their login with the upstream IDP, including the time that it takes to fumble // with password manager and two-factor authenticator apps, and also accounting for taking a coffee break while // the browser is sitting at the upstream IDP's login page. UpstreamStateParamLifespan time.Duration // How long an authcode issued by the callback endpoint is valid. This determines how much time the end user // has to come back to exchange the authcode for tokens at the token endpoint. AuthorizeCodeLifespan time.Duration // The lifetime of an downstream access token issued by the token endpoint. Access tokens should generally // be fairly short-lived. AccessTokenLifespan time.Duration // The lifetime of an downstream ID token issued by the token endpoint. This should generally be the same // as the AccessTokenLifespan, or longer if it would be useful for the user's proof of identity to be valid // for longer than their proof of authorization. IDTokenLifespan time.Duration // The lifetime of an downstream refresh token issued by the token endpoint. This should generally be // significantly longer than the access token lifetime, so it can be used to refresh the access token // multiple times. Once the refresh token expires, the user's session is over and they will need // to start a new authorization request, which will require them to log in again with the upstream IDP // in their web browser. RefreshTokenLifespan time.Duration // AuthorizationCodeSessionStorageLifetime is the length of time after which an authcode is allowed to be garbage // collected from storage. Authcodes are kept in storage after they are redeemed to allow the system to mark the // authcode as already used, so it can reject any future uses of the same authcode with special case handling which // include revoking the access and refresh tokens associated with the session. Therefore, this should be // significantly longer than the AuthorizeCodeLifespan, and there is probably no reason to make it longer than // the sum of the AuthorizeCodeLifespan and the RefreshTokenLifespan. AuthorizationCodeSessionStorageLifetime time.Duration // PKCESessionStorageLifetime is the length of time after which PKCE data is allowed to be garbage collected from // storage. PKCE sessions are closely related to authorization code sessions. After the authcode is successfully // redeemed, the PKCE session is explicitly deleted. After the authcode expires, the PKCE session is no longer needed, // but it is not explicitly deleted. Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll // avoid making it exactly the same as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it // while it is being used. PKCESessionStorageLifetime time.Duration // OIDCSessionStorageLifetime is the length of time after which the OIDC session data related to an authcode // is allowed to be garbage collected from storage. Due to a bug in an underlying library, these are not explicitly // deleted. Similar to the PKCE session, they are not needed anymore after the corresponding authcode has expired. // Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll avoid making it exactly the same // as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it while it is being used. OIDCSessionStorageLifetime time.Duration // AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid. // Therefore, this can be just slightly longer than the AccessTokenLifespan. Access tokens are handed back to // the token endpoint for the token exchange use case. During a token exchange, if the access token is expired // and still exists in storage, then the endpoint will be able to give a slightly more specific error message, // rather than a more generic error that is returned when the token does not exist. If this is desirable, then // the AccessTokenSessionStorageLifetime can be made to be significantly larger than AccessTokenLifespan, at the // cost of slower cleanup. AccessTokenSessionStorageLifetime time.Duration // RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid. // Therefore, this can be just slightly longer than the RefreshTokenLifespan. We'll avoid making it exactly the same // as RefreshTokenLifespan to avoid any chance of the garbage collector deleting it while it is being used. // If an expired token is still stored when the user tries to refresh it, then they will get a more specific // error message telling them that the token is expired, rather than a more generic error that is returned // when the token does not exist. If this is desirable, then the RefreshTokenSessionStorageLifetime can be made // to be significantly larger than RefreshTokenLifespan, at the cost of slower cleanup. RefreshTokenSessionStorageLifetime time.Duration } // Get the defaults for the Supervisor server. func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration { accessTokenLifespan := 15 * time.Minute authorizationCodeLifespan := 10 * time.Minute refreshTokenLifespan := 9 * time.Hour return TimeoutsConfiguration{ UpstreamStateParamLifespan: 90 * time.Minute, AuthorizeCodeLifespan: authorizationCodeLifespan, AccessTokenLifespan: accessTokenLifespan, IDTokenLifespan: accessTokenLifespan, RefreshTokenLifespan: refreshTokenLifespan, AuthorizationCodeSessionStorageLifetime: authorizationCodeLifespan + refreshTokenLifespan, PKCESessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute), OIDCSessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute), AccessTokenSessionStorageLifetime: accessTokenLifespan + (1 * time.Minute), RefreshTokenSessionStorageLifetime: refreshTokenLifespan + accessTokenLifespan, } } func FositeOauth2Helper( oauthStore interface{}, issuer string, hmacSecretOfLengthAtLeast32Func func() []byte, jwksProvider jwks.DynamicJWKSProvider, timeoutsConfiguration TimeoutsConfiguration, ) fosite.OAuth2Provider { oauthConfig := &compose.Config{ IDTokenIssuer: issuer, AuthorizeCodeLifespan: timeoutsConfiguration.AuthorizeCodeLifespan, IDTokenLifespan: timeoutsConfiguration.IDTokenLifespan, AccessTokenLifespan: timeoutsConfiguration.AccessTokenLifespan, RefreshTokenLifespan: timeoutsConfiguration.RefreshTokenLifespan, ScopeStrategy: fosite.ExactScopeStrategy, EnforcePKCE: true, // "offline_access" as per https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess RefreshTokenScopes: []string{coreosoidc.ScopeOfflineAccess}, // The default is to support all prompt values from the spec. // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest // We'll make a best effort to support these by passing the value of this prompt param to the upstream IDP // and rely on its implementation of this param. AllowedPromptValues: nil, // Use the fosite default to make it more likely that off the shelf OIDC clients can work with the supervisor. MinParameterEntropy: fosite.MinParameterEntropy, } return compose.Compose( oauthConfig, oauthStore, &compose.CommonStrategy{ // Note that Fosite requires the HMAC secret to be at least 32 bytes. CoreStrategy: newDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func), OpenIDConnectTokenStrategy: newDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), }, nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets. compose.OAuth2AuthorizeExplicitFactory, compose.OAuth2RefreshTokenGrantFactory, compose.OpenIDConnectExplicitFactory, compose.OpenIDConnectRefreshFactory, compose.OAuth2PKCEFactory, TokenExchangeFactory, ) } // FositeErrorForLog generates a list of information about the provided Fosite error that can be // passed to a plog function (e.g., plog.Info()). // // Sample usage: // err := someFositeLibraryFunction() // if err != nil { // plog.Info("some error", FositeErrorForLog(err)...) // ... // } func FositeErrorForLog(err error) []interface{} { rfc6749Error := fosite.ErrorToRFC6749Error(err) keysAndValues := make([]interface{}, 0) keysAndValues = append(keysAndValues, "name") keysAndValues = append(keysAndValues, rfc6749Error.ErrorField) keysAndValues = append(keysAndValues, "status") keysAndValues = append(keysAndValues, rfc6749Error.Status()) keysAndValues = append(keysAndValues, "description") keysAndValues = append(keysAndValues, rfc6749Error.DescriptionField) return keysAndValues } type IDPListGetter interface { GetIDPList() []provider.UpstreamOIDCIdentityProviderI } func GrantScopeIfRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) { if ScopeWasRequested(authorizeRequester, scopeName) { authorizeRequester.GrantScope(scopeName) } } func ScopeWasRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) bool { for _, scope := range authorizeRequester.GetRequestedScopes() { if scope == scopeName { return true } } return false }