294 lines
14 KiB
Go
294 lines
14 KiB
Go
// Copyright 2020-2021 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/internal/oidc/provider/formposthtml"
|
|
"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"
|
|
PinnipedIDPsPathV1Alpha1 = "/v1alpha1/pinniped_identity_providers"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
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
|
|
// or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan.
|
|
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 := 2 * 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: refreshTokenLifespan + accessTokenLifespan,
|
|
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,
|
|
}
|
|
|
|
provider := 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,
|
|
)
|
|
provider.(*fosite.Fosite).FormPostHTMLTemplate = formposthtml.Template()
|
|
return provider
|
|
}
|
|
|
|
// 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 UpstreamOIDCIdentityProvidersLister interface {
|
|
GetOIDCIdentityProviders() []provider.UpstreamOIDCIdentityProviderI
|
|
}
|
|
|
|
type UpstreamLDAPIdentityProvidersLister interface {
|
|
GetLDAPIdentityProviders() []provider.UpstreamLDAPIdentityProviderI
|
|
}
|
|
|
|
type UpstreamActiveDirectoryIdentityProviderLister interface {
|
|
GetActiveDirectoryIdentityProviders() []provider.UpstreamLDAPIdentityProviderI
|
|
}
|
|
|
|
type UpstreamIdentityProvidersLister interface {
|
|
UpstreamOIDCIdentityProvidersLister
|
|
UpstreamLDAPIdentityProvidersLister
|
|
UpstreamActiveDirectoryIdentityProviderLister
|
|
}
|
|
|
|
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
|
|
}
|