diff --git a/compose/compose_rfc8693.go b/compose/compose_rfc8693.go new file mode 100644 index 00000000..dd91c525 --- /dev/null +++ b/compose/compose_rfc8693.go @@ -0,0 +1,63 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package compose + +import ( + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/rfc8693" + "github.com/ory/fosite/token/jwt" +) + +// RFC8693AccessTokenTypeHandlerFactory creates a access token type handler. +func RFC8693AccessTokenTypeHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.AccessTokenTypeHandler{ + CoreStrategy: strategy.(oauth2.CoreStrategy), + Storage: storage.(rfc8693.Storage), + Config: config, + } +} + +// RFC8693RefreshTokenTypeHandlerFactory creates a refresh token type handler. +func RFC8693RefreshTokenTypeHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.RefreshTokenTypeHandler{ + CoreStrategy: strategy.(oauth2.CoreStrategy), + Storage: storage.(rfc8693.Storage), + Config: config, + } +} + +// RFC8693ActorTokenValidationHandlerFactory creates a actor token validation handler. +func RFC8693ActorTokenValidationHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.ActorTokenValidationHandler{} +} + +// RFC8693CustomJWTTypeHandlerFactory creates a custom JWT token type handler. +func RFC8693CustomJWTTypeHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.CustomJWTTypeHandler{ + JWTStrategy: strategy.(jwt.Signer), + Storage: storage.(rfc8693.Storage), + Config: config, + } +} + +// RFC8693TokenExchangeGrantHandlerFactory creates the request validation handler for token exchange. This should be the first +// in the list. +func RFC8693TokenExchangeGrantHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.TokenExchangeGrantHandler{ + Config: config, + } +} + +// RFC8693IDTokenTypeHandlerFactory creates a ID token type handler. +func RFC8693IDTokenTypeHandlerFactory(config fosite.Configurator, storage interface{}, strategy interface{}) interface{} { + return &rfc8693.IDTokenTypeHandler{ + JWTStrategy: strategy.(jwt.Signer), + Storage: storage.(rfc8693.Storage), + Config: config, + IssueStrategy: strategy.(openid.OpenIDConnectTokenStrategy), + ValidationStrategy: strategy.(openid.OpenIDConnectTokenValidationStrategy), + } +} diff --git a/config.go b/config.go index 7b7f6c0d..0cdf2b53 100644 --- a/config.go +++ b/config.go @@ -306,3 +306,9 @@ type PushedAuthorizeRequestConfigProvider interface { // must contain the PAR request_uri. EnforcePushedAuthorize(ctx context.Context) bool } + +type RFC8693ConfigProvider interface { + GetTokenTypes(ctx context.Context) map[string]RFC8693TokenType + + GetDefaultRequestedTokenType(ctx context.Context) string +} diff --git a/config_default.go b/config_default.go index 2c348a3a..f4acc3fd 100644 --- a/config_default.go +++ b/config_default.go @@ -215,6 +215,10 @@ type Config struct { // IsPushedAuthorizeEnforced enforces pushed authorization request for /authorize IsPushedAuthorizeEnforced bool + + RFC8693TokenTypes map[string]RFC8693TokenType + + DefaultRequestedTokenType string } func (c *Config) GetGlobalSecret(ctx context.Context) ([]byte, error) { @@ -499,3 +503,11 @@ func (c *Config) GetPushedAuthorizeContextLifespan(ctx context.Context) time.Dur func (c *Config) EnforcePushedAuthorize(ctx context.Context) bool { return c.IsPushedAuthorizeEnforced } + +func (c *Config) GetTokenTypes(ctx context.Context) map[string]RFC8693TokenType { + return c.RFC8693TokenTypes +} + +func (c *Config) GetDefaultRequestedTokenType(ctx context.Context) string { + return c.DefaultRequestedTokenType +} diff --git a/handler/openid/strategy.go b/handler/openid/strategy.go index f5353d0b..684657dc 100644 --- a/handler/openid/strategy.go +++ b/handler/openid/strategy.go @@ -8,8 +8,13 @@ import ( "time" "github.com/ory/fosite" + "github.com/ory/fosite/token/jwt" ) type OpenIDConnectTokenStrategy interface { GenerateIDToken(ctx context.Context, lifespan time.Duration, requester fosite.Requester) (token string, err error) } + +type OpenIDConnectTokenValidationStrategy interface { + ValidateIDToken(ctx context.Context, requester fosite.Requester, token string) (jwt.MapClaims, error) +} diff --git a/handler/rfc8693/access_token_type_handler.go b/handler/rfc8693/access_token_type_handler.go new file mode 100644 index 00000000..936b5430 --- /dev/null +++ b/handler/rfc8693/access_token_type_handler.go @@ -0,0 +1,207 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/storage" + "github.com/ory/x/errorsx" + "github.com/pkg/errors" +) + +var _ fosite.TokenEndpointHandler = (*AccessTokenTypeHandler)(nil) + +type AccessTokenTypeHandler struct { + Config fosite.Configurator + oauth2.CoreStrategy + Storage +} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *AccessTokenTypeHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + form := request.GetRequestForm() + if form.Get("subject_token_type") != AccessTokenType && form.Get("actor_token_type") != AccessTokenType { + return nil + } + + if form.Get("actor_token_type") == AccessTokenType { + token := form.Get("actor_token") + if _, unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + session.SetActorToken(unpacked) + } + } + + if form.Get("subject_token_type") == AccessTokenType { + token := form.Get("subject_token") + if subjectTokenSession, unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + session.SetSubjectToken(unpacked) + session.SetSubject(subjectTokenSession.GetSubject()) + } + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *AccessTokenTypeHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + form := request.GetRequestForm() + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + requestedTokenType = teConfig.GetDefaultRequestedTokenType(ctx) + } + + if requestedTokenType != AccessTokenType { + return nil + } + + if err := c.issue(ctx, request, responder); err != nil { + return err + } + + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *AccessTokenTypeHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *AccessTokenTypeHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + // Value MUST be set to "password". + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} + +func (c *AccessTokenTypeHandler) validate(ctx context.Context, request fosite.AccessRequester, token string) (fosite.Session, map[string]interface{}, error) { + + session, _ := request.GetSession().(Session) + if session == nil { + return nil, nil, errorsx.WithStack(fosite.ErrServerError.WithDebug( + "Failed to perform token exchange because the session is not of the right type.")) + } + + client := request.GetClient() + + sig := c.CoreStrategy.AccessTokenSignature(ctx, token) + or, err := c.Storage.GetAccessTokenSession(ctx, sig, request.GetSession()) + if err != nil { + return nil, nil, errors.WithStack(fosite.ErrInvalidRequest.WithHint("Token is not valid or has expired.").WithDebug(err.Error())) + } else if err := c.CoreStrategy.ValidateAccessToken(ctx, or, token); err != nil { + return nil, nil, err + } + + subjectTokenClientID := or.GetClient().GetID() + // forbid original subjects client to exchange its own token + if client.GetID() == subjectTokenClientID { + return nil, nil, errors.WithStack(fosite.ErrRequestForbidden.WithHint("Clients are not allowed to perform a token exchange on their own tokens.")) + } + + // Check if the client is allowed to exchange this token + if subjectTokenClient, ok := or.GetClient().(Client); ok { + allowed := subjectTokenClient.TokenExchangeAllowed(client) + if !allowed { + return nil, nil, errors.WithStack(fosite.ErrRequestForbidden.WithHintf( + "The OAuth 2.0 client is not permitted to exchange a subject token issued to client %s", subjectTokenClientID)) + } + } + + // Convert to flat session with only access token claims + tokenObject := session.AccessTokenClaimsMap() + tokenObject["client_id"] = or.GetClient().GetID() + tokenObject["scope"] = or.GetGrantedScopes() + tokenObject["aud"] = or.GetGrantedAudience() + + return or.GetSession(), tokenObject, nil +} + +func (c *AccessTokenTypeHandler) issue(ctx context.Context, request fosite.AccessRequester, response fosite.AccessResponder) error { + request.GetSession().SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(c.Config.GetAccessTokenLifespan(ctx))) + + token, signature, err := c.CoreStrategy.GenerateAccessToken(ctx, request) + if err != nil { + return err + } else if err := c.Storage.CreateAccessTokenSession(ctx, signature, request.Sanitize([]string{})); err != nil { + return err + } + + issueRefreshToken := c.canIssueRefreshToken(ctx, request) + if issueRefreshToken { + request.GetSession().SetExpiresAt(fosite.RefreshToken, time.Now().UTC().Add(c.Config.GetRefreshTokenLifespan(ctx)).Round(time.Second)) + refresh, refreshSignature, err := c.CoreStrategy.GenerateRefreshToken(ctx, request) + if err != nil { + return errors.WithStack(fosite.ErrServerError.WithDebug(err.Error())) + } + + if refreshSignature != "" { + if err := c.Storage.CreateRefreshTokenSession(ctx, refreshSignature, request.Sanitize([]string{})); err != nil { + if rollBackTxnErr := storage.MaybeRollbackTx(ctx, c.Storage); rollBackTxnErr != nil { + err = rollBackTxnErr + } + return errors.WithStack(fosite.ErrServerError.WithDebug(err.Error())) + } + } + + response.SetExtra("refresh_token", refresh) + } + + response.SetAccessToken(token) + response.SetTokenType("bearer") + response.SetExpiresIn(c.getExpiresIn(request, fosite.AccessToken, c.Config.GetAccessTokenLifespan(ctx), time.Now().UTC())) + response.SetScopes(request.GetGrantedScopes()) + + return nil +} + +func (c *AccessTokenTypeHandler) canIssueRefreshToken(ctx context.Context, request fosite.Requester) bool { + // Require one of the refresh token scopes, if set. + scopes := c.Config.GetRefreshTokenScopes(ctx) + if len(scopes) > 0 && !request.GetGrantedScopes().HasOneOf(scopes...) { + return false + } + // Do not issue a refresh token to clients that cannot use the refresh token grant type. + if !request.GetClient().GetGrantTypes().Has("refresh_token") { + return false + } + return true +} + +func (c *AccessTokenTypeHandler) getExpiresIn(r fosite.Requester, key fosite.TokenType, defaultLifespan time.Duration, now time.Time) time.Duration { + if r.GetSession().GetExpiresAt(key).IsZero() { + return defaultLifespan + } + return time.Duration(r.GetSession().GetExpiresAt(key).UnixNano() - now.UnixNano()) +} diff --git a/handler/rfc8693/actor_token_validation_handler.go b/handler/rfc8693/actor_token_validation_handler.go new file mode 100644 index 00000000..e65c0a88 --- /dev/null +++ b/handler/rfc8693/actor_token_validation_handler.go @@ -0,0 +1,66 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + + "github.com/ory/fosite" + "github.com/ory/x/errorsx" + "github.com/pkg/errors" +) + +var _ fosite.TokenEndpointHandler = (*ActorTokenValidationHandler)(nil) + +type ActorTokenValidationHandler struct{} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *ActorTokenValidationHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + client := request.GetClient() + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + // Validate that the actor or client is allowed to make this request + subjectTokenObject := session.GetSubjectToken() + if mayAct, _ := subjectTokenObject["may_act"].(map[string]interface{}); mayAct != nil { + actorTokenObject := session.GetActorToken() + if actorTokenObject == nil { + actorTokenObject = map[string]interface{}{ + "sub": client.GetID(), + "client_id": client.GetID(), + } + } + + for k, v := range mayAct { + if actorTokenObject[k] != v { + return errors.WithStack(fosite.ErrInvalidRequest.WithHint("The actor or client is not authorized to act on behalf of the subject.")) + } + } + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *ActorTokenValidationHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *ActorTokenValidationHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *ActorTokenValidationHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + // Value MUST be set to "password". + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} diff --git a/handler/rfc8693/client.go b/handler/rfc8693/client.go new file mode 100644 index 00000000..d94b1db3 --- /dev/null +++ b/handler/rfc8693/client.go @@ -0,0 +1,21 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import "github.com/ory/fosite" + +type Client interface { + // GetSupportedSubjectTokenTypes indicates the token types allowed for subject_token + GetSupportedSubjectTokenTypes() []string + // GetSupportedActorTokenTypes indicates the token types allowed for subject_token + GetSupportedActorTokenTypes() []string + // GetSupportedRequestTokenTypes indicates the token types allowed for requested_token_type + GetSupportedRequestTokenTypes() []string + // TokenExchangeAllowed checks if the subject token client allows the specified client + // to perform the exchange + TokenExchangeAllowed(client fosite.Client) bool + // ActorTokenRequired indicates that one of the allowed actor tokens must be provided + // in the request + ActorTokenRequired() bool +} diff --git a/handler/rfc8693/custom_jwt_type_handler.go b/handler/rfc8693/custom_jwt_type_handler.go new file mode 100644 index 00000000..3651ca55 --- /dev/null +++ b/handler/rfc8693/custom_jwt_type_handler.go @@ -0,0 +1,248 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/token/jwt" + "github.com/ory/x/errorsx" +) + +type CustomJWTTypeHandler struct { + Config fosite.Configurator + JWTStrategy jwt.Signer + Storage +} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *CustomJWTTypeHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + form := request.GetRequestForm() + tokenTypes := teConfig.GetTokenTypes(ctx) + actorTokenType := tokenTypes[form.Get("actor_token_type")] + subjectTokenType := tokenTypes[form.Get("subject_token_type")] + if actorTokenType != nil && actorTokenType.GetType(ctx) == JWTTokenType { + token := form.Get("actor_token") + if unpacked, err := c.validate(ctx, request, actorTokenType, token); err != nil { + return err + } else { + session.SetActorToken(unpacked) + } + } + + if subjectTokenType != nil && subjectTokenType.GetType(ctx) == JWTTokenType { + token := form.Get("subject_token") + if unpacked, err := c.validate(ctx, request, subjectTokenType, token); err != nil { + return err + } else { + session.SetSubjectToken(unpacked) + // Get the subject and populate session + if subject, err := c.Storage.GetSubjectForTokenExchange(ctx, request, unpacked); err != nil { + return err + } else { + session.SetSubject(subject) + } + } + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *CustomJWTTypeHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + form := request.GetRequestForm() + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + requestedTokenType = teConfig.GetDefaultRequestedTokenType(ctx) + } + + tokenTypes := teConfig.GetTokenTypes(ctx) + tokenType := tokenTypes[requestedTokenType] + if tokenType == nil || tokenType.GetType(ctx) != JWTTokenType { + return nil + } + + if err := c.issue(ctx, request, tokenType, responder); err != nil { + return err + } + + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *CustomJWTTypeHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *CustomJWTTypeHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + // Value MUST be set to "password". + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} + +func (c *CustomJWTTypeHandler) validate(ctx context.Context, _ fosite.AccessRequester, tokenType fosite.RFC8693TokenType, token string) (map[string]interface{}, error) { + + jwtType, _ := tokenType.(*JWTType) + if jwtType == nil { + return nil, errorsx.WithStack( + fosite.ErrServerError.WithDebugf( + "Token type '%s' is supposed to be of type JWT but is not castable to 'JWTType'", tokenType.GetName(ctx))) + } + + // Parse the token + ftoken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, jwtType.ValidateFunc) + if err != nil { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Unable to parse the JSON web token").WithWrap(err).WithDebug(err.Error())) + } + + window := jwtType.JWTLifetimeToleranceWindow + if window == 0 { + window = 1 * time.Hour + } + claims := ftoken.Claims + + if issued, exists := claims["iat"]; exists { + if time.Unix(toInt64(issued), 0).Add(window).Before(time.Now()) { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'iat' from token is too far in the past.")) + } + } + + if _, exists := claims["exp"]; !exists { // Validate 'exp' is mandatory + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'exp' from token is missing.")) + } + expiry := toInt64(claims["exp"]) + if time.Now().Add(window).Before(time.Unix(expiry, 0)) { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'exp' from token is too far in the future.")) + } + + if !claims.VerifyIssuer(jwtType.Issuer, true) { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf("Claim 'iss' from token must match the '%s'.", jwtType.Issuer)) + } + + // Validate the JTI is unique if required + if jwtType.ValidateJTI { + jti, _ := claims["jti"].(string) + if jti == "" { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'jti' from token is missing.")) + } + + if c.Storage.SetTokenExchangeCustomJWT(ctx, jti, time.Unix(expiry, 0)) != nil { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'jti' from the token must be used only once.")) + } + } + + return map[string]interface{}(claims), nil +} + +func (c *CustomJWTTypeHandler) issue(ctx context.Context, request fosite.AccessRequester, tokenType fosite.RFC8693TokenType, response fosite.AccessResponder) error { + jwtType, _ := tokenType.(*JWTType) + if jwtType == nil { + return errorsx.WithStack( + fosite.ErrServerError.WithDebugf( + "Token type '%s' is supposed to be of type JWT but is not castable to 'JWTType'", tokenType.GetName(ctx))) + } + + sess, ok := request.GetSession().(openid.Session) + if !ok { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to generate JWT because session must be of type fosite/handler/openid.Session.")) + } + + claims := sess.IDTokenClaims() + if claims.Subject == "" { + claims.Subject = request.GetClient().GetID() + } + + if claims.ExpiresAt.IsZero() { + claims.ExpiresAt = time.Now().UTC().Add(jwtType.Expiry) + } + + if claims.Issuer == "" { + claims.Issuer = jwtType.Issuer + } + + if len(request.GetRequestedAudience()) > 0 { + claims.Audience = append(claims.Audience, request.GetRequestedAudience()...) + } + + if len(claims.Audience) == 0 { + aud := jwtType.JWTIssueConfig.Audience + if len(aud) == 0 { + aud = append(aud, request.GetClient().GetID()) + } + + claims.Audience = append(claims.Audience, aud...) + } + + if claims.JTI == "" { + claims.JTI = uuid.New().String() + } + + claims.IssuedAt = time.Now().UTC() + + token, _, err := c.JWTStrategy.Generate(ctx, claims.ToMapClaims(), sess.IDTokenHeaders()) + if err != nil { + return err + } + + response.SetAccessToken(token) + response.SetTokenType("N_A") + response.SetExpiresIn(time.Duration(claims.ExpiresAt.UnixNano() - time.Now().UTC().UnixNano())) + return nil +} + +// type conversion according to jwt.MapClaims.toInt64 - ignore error +func toInt64(claim interface{}) int64 { + switch t := claim.(type) { + case float64: + return int64(t) + case int64: + return t + case json.Number: + v, err := t.Int64() + if err == nil { + return v + } + vf, err := t.Float64() + if err != nil { + return 0 + } + return int64(vf) + } + return 0 +} diff --git a/handler/rfc8693/flow_token_exchange.go b/handler/rfc8693/flow_token_exchange.go new file mode 100644 index 00000000..d334e150 --- /dev/null +++ b/handler/rfc8693/flow_token_exchange.go @@ -0,0 +1,222 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + + "github.com/ory/fosite" + "github.com/ory/x/errorsx" + "github.com/pkg/errors" +) + +var _ fosite.TokenEndpointHandler = (*TokenExchangeGrantHandler)(nil) + +// TokenExchangeGrantHandler is the grant handler for RFC8693 +type TokenExchangeGrantHandler struct { + Config fosite.Configurator +} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *TokenExchangeGrantHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + client := request.GetClient() + if client.IsPublic() { + return errors.WithStack(fosite.ErrInvalidGrant.WithHint("The OAuth 2.0 Client is marked as public and is thus not allowed to use authorization grant \"urn:ietf:params:oauth:grant-type:token-exchange\".")) + } + + // Check whether client is allowed to use token exchange + if !client.GetGrantTypes().Has("urn:ietf:params:oauth:grant-type:token-exchange") { + return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf( + "The OAuth 2.0 Client is not allowed to use authorization grant \"%s\".", "urn:ietf:params:oauth:grant-type:token-exchange")) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + form := request.GetRequestForm() + configTypesSupported := teConfig.GetTokenTypes(ctx) + var supportedSubjectTypes, supportedActorTypes, supportedRequestTypes fosite.Arguments + actorTokenRequired := false + if teClient, ok := client.(Client); ok { + supportedRequestTypes = fosite.Arguments(teClient.GetSupportedRequestTokenTypes()) + supportedActorTypes = fosite.Arguments(teClient.GetSupportedActorTokenTypes()) + supportedSubjectTypes = fosite.Arguments(teClient.GetSupportedSubjectTokenTypes()) + actorTokenRequired = teClient.ActorTokenRequired() + } + + // From https://tools.ietf.org/html/rfc8693#section-2.1: + // + // subject_token + // REQUIRED. A security token that represents the identity of the + // party on behalf of whom the request is being made. Typically, the + // subject of this token will be the subject of the security token + // issued in response to the request. + subjectToken := form.Get("subject_token") + if subjectToken == "" { + return errors.WithStack(fosite.ErrInvalidRequest.WithHintf("Mandatory parameter \"%s\" is missing.", "subject_token")) + } + + // From https://tools.ietf.org/html/rfc8693#section-2.1: + // + // subject_token_type + // REQUIRED. An identifier, as described in Section 3, that + // indicates the type of the security token in the "subject_token" + // parameter. + subjectTokenType := form.Get("subject_token_type") + if subjectTokenType == "" { + return errors.WithStack(fosite.ErrInvalidRequest.WithHintf("Mandatory parameter \"%s\" is missing.", "subject_token_type")) + } + + if tt := configTypesSupported[subjectTokenType]; tt == nil { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf("\"%s\" token type is not supported as a \"%s\".", subjectTokenType, "subject_token_type")) + } + + if len(supportedSubjectTypes) > 0 && !supportedSubjectTypes.Has(subjectTokenType) { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + "The OAuth 2.0 client is not allowed to use \"%s\" as \"%s\".", subjectTokenType, "subject_token_type")) + } + + // From https://tools.ietf.org/html/rfc8693#section-2.1: + // + // actor_token + // OPTIONAL . A security token that represents the identity of the acting party. + // Typically, this will be the party that is authorized to use the requested security + // token and act on behalf of the subject. + actorToken := form.Get("actor_token") + actorTokenType := form.Get("actor_token_type") + if actorToken != "" { + // From https://tools.ietf.org/html/rfc8693#section-2.1: + // + // actor_token_type + // An identifier, as described in Section 3, that indicates the type of the security token + // in the actor_token parameter. This is REQUIRED when the actor_token parameter is present + // in the request but MUST NOT be included otherwise. + if actorTokenType == "" { + return errors.WithStack(fosite.ErrInvalidRequest.WithHintf("\"actor_token_type\" is empty even though the \"actor_token\" is not empty.")) + } + + if tt := configTypesSupported[actorTokenType]; tt == nil { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + "\"%s\" token type is not supported as a \"%s\".", actorTokenType, "actor_token_type")) + } + + if len(supportedActorTypes) > 0 && !supportedActorTypes.Has(actorTokenType) { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + "The OAuth 2.0 client is not allowed to use \"%s\" as \"%s\".", actorTokenType, "actor_token_type")) + } + } else if actorTokenType != "" { + return errors.WithStack(fosite.ErrInvalidRequest.WithHintf("\"actor_token_type\" is not empty even though the \"actor_token\" is empty.")) + } else if actorTokenRequired { + return errors.WithStack(fosite.ErrInvalidRequest.WithHintf("The OAuth 2.0 client must provide an actor token.")) + } + + // check if supported + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + requestedTokenType = teConfig.GetDefaultRequestedTokenType(ctx) + } + + if tt := configTypesSupported[requestedTokenType]; tt == nil { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + "\"%s\" token type is not supported as a \"%s\".", requestedTokenType, "requested_token_type")) + } + + if len(supportedRequestTypes) > 0 && !supportedRequestTypes.Has(requestedTokenType) { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf("The OAuth 2.0 client is not allowed to use \"%s\" as \"%s\".", requestedTokenType, "requested_token_type")) + } + + // Check scope + openIDIndex := -1 + for i, scope := range request.GetRequestedScopes() { + if !c.Config.GetScopeStrategy(ctx)(client.GetScopes(), scope) { + return errors.WithStack(fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope)) + } + + // making an assumption here that scope=openid is only present once. + // scope=openid makes no sense in the token exchange flow, so we are going + // to remove it. + if scope == "openid" { + openIDIndex = i + } + } + + if openIDIndex > -1 { + requestedScopes := request.GetRequestedScopes() + requestedScopes[openIDIndex] = requestedScopes[len(requestedScopes)-1] + requestedScopes = requestedScopes[:len(requestedScopes)-1] + + request.SetRequestedScopes(requestedScopes) + } + + // Check audience + if err := c.Config.GetAudienceStrategy(ctx)(client.GetAudience(), request.GetRequestedAudience()); err != nil { + // TODO: Need to convert to using invalid_target + return err + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *TokenExchangeGrantHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + form := request.GetRequestForm() + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + requestedTokenType = teConfig.GetDefaultRequestedTokenType(ctx) + } + + configTypesSupported := teConfig.GetTokenTypes(ctx) + if tt := configTypesSupported[requestedTokenType]; tt == nil { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + "\"%s\" token type is not supported as a \"%s\".", requestedTokenType, "requested_token_type")) + } + + // chain `act` if necessary + subjectTokenObject := session.GetSubjectToken() + if mayAct, _ := subjectTokenObject["may_act"].(map[string]interface{}); mayAct != nil { + if subjectActor, _ := subjectTokenObject["act"].(map[string]interface{}); subjectActor != nil { + mayAct["act"] = subjectActor + } + + session.SetAct(mayAct) + } + + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *TokenExchangeGrantHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *TokenExchangeGrantHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} diff --git a/handler/rfc8693/id_token_type_handler.go b/handler/rfc8693/id_token_type_handler.go new file mode 100644 index 00000000..9b9bf9c1 --- /dev/null +++ b/handler/rfc8693/id_token_type_handler.go @@ -0,0 +1,148 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/token/jwt" + "github.com/ory/x/errorsx" +) + +type IDTokenTypeHandler struct { + Config fosite.Configurator + JWTStrategy jwt.Signer + IssueStrategy openid.OpenIDConnectTokenStrategy + ValidationStrategy openid.OpenIDConnectTokenValidationStrategy + Storage +} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *IDTokenTypeHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + form := request.GetRequestForm() + if form.Get("subject_token_type") != IDTokenType && form.Get("actor_token_type") != IDTokenType { + return nil + } + + if form.Get("actor_token_type") == IDTokenType { + token := form.Get("actor_token") + if unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + session.SetActorToken(unpacked) + } + } + + if form.Get("subject_token_type") == IDTokenType { + token := form.Get("subject_token") + if unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + // Get the subject and populate session + session.SetSubject(unpacked["sub"].(string)) + session.SetSubjectToken(unpacked) + } + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *IDTokenTypeHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + form := request.GetRequestForm() + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + if config, ok := c.Config.(fosite.RFC8693ConfigProvider); ok { + requestedTokenType = config.GetDefaultRequestedTokenType(ctx) + } + } + + if requestedTokenType != IDTokenType { + return nil + } + + if err := c.issue(ctx, request, responder); err != nil { + return err + } + + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *IDTokenTypeHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *IDTokenTypeHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + // Value MUST be set to "password". + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} + +func (c *IDTokenTypeHandler) validate(ctx context.Context, request fosite.AccessRequester, token string) (map[string]interface{}, error) { + + claims, err := c.ValidationStrategy.ValidateIDToken(ctx, request, token) + if err != nil { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Unable to parse the id_token").WithWrap(err).WithDebug(err.Error())) + } + + expectedIssuer := "" + if config, ok := c.Config.(fosite.AccessTokenIssuerProvider); ok { + expectedIssuer = config.GetAccessTokenIssuer(ctx) + } + + if !claims.VerifyIssuer(expectedIssuer, true) { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf("Claim 'iss' from token must match the '%s'.", expectedIssuer)) + } + + if _, ok := claims["sub"].(string); !ok { + return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Claim 'sub' is missing.")) + } + + return map[string]interface{}(claims), nil +} + +func (c *IDTokenTypeHandler) issue(ctx context.Context, request fosite.AccessRequester, response fosite.AccessResponder) error { + sess, ok := request.GetSession().(openid.Session) + if !ok { + return errorsx.WithStack(fosite.ErrServerError.WithDebug( + "Failed to generate id token because session must be of type fosite/handler/openid.Session.")) + } + + claims := sess.IDTokenClaims() + if claims.Subject == "" { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to generate id token because subject is an empty string.")) + } + + token, err := c.IssueStrategy.GenerateIDToken(ctx, c.Config.GetIDTokenLifespan(ctx), request) + if err != nil { + return err + } + + response.SetAccessToken(token) + response.SetTokenType("N_A") + + return nil +} diff --git a/handler/rfc8693/refresh_token_type_handler.go b/handler/rfc8693/refresh_token_type_handler.go new file mode 100644 index 00000000..6c46c890 --- /dev/null +++ b/handler/rfc8693/refresh_token_type_handler.go @@ -0,0 +1,184 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/storage" + "github.com/ory/x/errorsx" + "github.com/pkg/errors" +) + +var _ fosite.TokenEndpointHandler = (*RefreshTokenTypeHandler)(nil) + +type RefreshTokenTypeHandler struct { + Config fosite.Configurator + oauth2.CoreStrategy + Storage +} + +// HandleTokenEndpointRequest implements https://tools.ietf.org/html/rfc6749#section-4.3.2 +func (c *RefreshTokenTypeHandler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + form := request.GetRequestForm() + if form.Get("subject_token_type") != RefreshTokenType && form.Get("actor_token_type") != RefreshTokenType { + return nil + } + + if form.Get("actor_token_type") == RefreshTokenType { + token := form.Get("actor_token") + if _, unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + session.SetActorToken(unpacked) + } + } + + if form.Get("subject_token_type") == RefreshTokenType { + token := form.Get("subject_token") + if subjectTokenSession, unpacked, err := c.validate(ctx, request, token); err != nil { + return err + } else { + session.SetSubjectToken(unpacked) + session.SetSubject(subjectTokenSession.GetSubject()) + } + } + + return nil +} + +// PopulateTokenEndpointResponse implements https://tools.ietf.org/html/rfc6749#section-4.3.3 +func (c *RefreshTokenTypeHandler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, responder fosite.AccessResponder) error { + + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + session, _ := request.GetSession().(Session) + if session == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the session is not of the right type.")) + } + + teConfig, _ := c.Config.(fosite.RFC8693ConfigProvider) + if teConfig == nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to perform token exchange because the config is not of the right type.")) + } + + form := request.GetRequestForm() + requestedTokenType := form.Get("requested_token_type") + if requestedTokenType == "" { + requestedTokenType = teConfig.GetDefaultRequestedTokenType(ctx) + } + + if requestedTokenType != RefreshTokenType { + return nil + } + + if err := c.issue(ctx, request, responder); err != nil { + return err + } + + return nil +} + +// CanSkipClientAuth indicates if client auth can be skipped +func (c *RefreshTokenTypeHandler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return false +} + +// CanHandleTokenEndpointRequest indicates if the token endpoint request can be handled +func (c *RefreshTokenTypeHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + // grant_type REQUIRED. + // Value MUST be set to "password". + return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") +} + +func (c *RefreshTokenTypeHandler) validate(ctx context.Context, request fosite.AccessRequester, token string) ( + fosite.Session, map[string]interface{}, error) { + + session, _ := request.GetSession().(Session) + if session == nil { + return nil, nil, errorsx.WithStack(fosite.ErrServerError.WithDebug( + "Failed to perform token exchange because the session is not of the right type.")) + } + + client := request.GetClient() + + sig := c.CoreStrategy.RefreshTokenSignature(ctx, token) + or, err := c.Storage.GetRefreshTokenSession(ctx, sig, request.GetSession()) + if err != nil { + return nil, nil, errors.WithStack(fosite.ErrInvalidRequest.WithHint("Token is not valid or has expired.").WithDebug(err.Error())) + } else if err := c.CoreStrategy.ValidateRefreshToken(ctx, or, token); err != nil { + return nil, nil, err + } + + tokenClientID := or.GetClient().GetID() + // forbid original subjects client to exchange its own token + if client.GetID() == tokenClientID { + return nil, nil, errors.WithStack( + fosite.ErrRequestForbidden.WithHint("Clients are not allowed to perform a token exchange on their own tokens.")) + } + + // Check if the client is allowed to exchange this token + if subjectTokenClient, ok := or.GetClient().(Client); ok { + allowed := subjectTokenClient.TokenExchangeAllowed(client) + if !allowed { + return nil, nil, errors.WithStack(fosite.ErrRequestForbidden.WithHintf( + "The OAuth 2.0 client is not permitted to exchange a subject token issued to client %s", tokenClientID)) + } + } + + // Convert to flat session with only access token claims + tokenObject := session.AccessTokenClaimsMap() + tokenObject["client_id"] = or.GetClient().GetID() + tokenObject["scope"] = or.GetGrantedScopes() + tokenObject["aud"] = or.GetGrantedAudience() + + return or.GetSession(), tokenObject, nil +} + +func (c *RefreshTokenTypeHandler) issue(ctx context.Context, request fosite.AccessRequester, response fosite.AccessResponder) error { + request.GetSession().SetExpiresAt(fosite.RefreshToken, time.Now().UTC().Add(c.Config.GetRefreshTokenLifespan(ctx)).Round(time.Second)) + refresh, refreshSignature, err := c.CoreStrategy.GenerateRefreshToken(ctx, request) + if err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithDebug(err.Error())) + } + + if refreshSignature == "" { + return errorsx.WithStack(fosite.ErrServerError.WithDebug("Unable to generate the refresh token signature")) + } + + if err := c.Storage.CreateRefreshTokenSession(ctx, refreshSignature, request.Sanitize([]string{})); err != nil { + if rollBackTxnErr := storage.MaybeRollbackTx(ctx, c.Storage); rollBackTxnErr != nil { + err = rollBackTxnErr + } + return errors.WithStack(fosite.ErrServerError.WithDebug(err.Error())) + } + + response.SetAccessToken(refresh) + response.SetTokenType("N_A") + response.SetExpiresIn(c.getExpiresIn(request, fosite.RefreshToken, c.Config.GetRefreshTokenLifespan(ctx), time.Now().UTC())) + response.SetScopes(request.GetGrantedScopes()) + + return nil +} + +func (c *RefreshTokenTypeHandler) getExpiresIn(r fosite.Requester, key fosite.TokenType, defaultLifespan time.Duration, now time.Time) time.Duration { + if r.GetSession().GetExpiresAt(key).IsZero() { + return defaultLifespan + } + return time.Duration(r.GetSession().GetExpiresAt(key).UnixNano() - now.UnixNano()) +} diff --git a/handler/rfc8693/session.go b/handler/rfc8693/session.go new file mode 100644 index 00000000..d2538279 --- /dev/null +++ b/handler/rfc8693/session.go @@ -0,0 +1,65 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import "github.com/ory/fosite/handler/openid" + +// Session is required to support token exchange +type Session interface { + // SetSubject sets the session's subject. + SetSubject(subject string) + + SetActorToken(token map[string]interface{}) + + GetActorToken() map[string]interface{} + + SetSubjectToken(token map[string]interface{}) + + GetSubjectToken() map[string]interface{} + + SetAct(act map[string]interface{}) + + AccessTokenClaimsMap() map[string]interface{} +} + +type DefaultSession struct { + *openid.DefaultSession + + ActorToken map[string]interface{} `json:"-"` + SubjectToken map[string]interface{} `json:"-"` + Extra map[string]interface{} `json:"extra,omitempty"` +} + +func (s *DefaultSession) SetActorToken(token map[string]interface{}) { + s.ActorToken = token +} + +func (s *DefaultSession) GetActorToken() map[string]interface{} { + return s.ActorToken +} + +func (s *DefaultSession) SetSubjectToken(token map[string]interface{}) { + s.SubjectToken = token +} + +func (s *DefaultSession) GetSubjectToken() map[string]interface{} { + return s.SubjectToken +} + +func (s *DefaultSession) SetAct(act map[string]interface{}) { + s.Extra["act"] = act +} + +func (s *DefaultSession) AccessTokenClaimsMap() map[string]interface{} { + tokenObject := map[string]interface{}{ + "sub": s.GetSubject(), + "username": s.GetUsername(), + } + + for k, v := range s.Extra { + tokenObject[k] = v + } + + return tokenObject +} diff --git a/handler/rfc8693/storage.go b/handler/rfc8693/storage.go new file mode 100644 index 00000000..3626c413 --- /dev/null +++ b/handler/rfc8693/storage.go @@ -0,0 +1,26 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" +) + +type Storage interface { + oauth2.CoreStorage + + // SetTokenExchangeCustomJWT marks a JTI as known for the given + // expiry time. It should atomically check if the JTI + // already exists and fail the request, if found. + SetTokenExchangeCustomJWT(ctx context.Context, jti string, exp time.Time) error + + // GetSubjectForTokenExchange computes the session subject and is used for token types where there is no way + // to know the subject value. For some token types, such as access and refresh tokens, the subject is well-defined + // and this function is not called. + GetSubjectForTokenExchange(ctx context.Context, requester fosite.Requester, subjectToken map[string]interface{}) (string, error) +} diff --git a/handler/rfc8693/token_exchange_test.go b/handler/rfc8693/token_exchange_test.go new file mode 100644 index 00000000..b70c2ef5 --- /dev/null +++ b/handler/rfc8693/token_exchange_test.go @@ -0,0 +1,281 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693_test + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/fosite/internal/gen" + "github.com/ory/fosite/storage" + "github.com/ory/fosite/token/hmac" + "github.com/ory/fosite/token/jwt" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/rfc8693" + . "github.com/ory/fosite/handler/rfc8693" +) + +// expose key to verify id_token +var key = gen.MustRSAKey() + +func TestAccessTokenExchangeImpersonation(t *testing.T) { + store := storage.NewExampleStore() + jwtName := "urn:custom:jwt" + + jwtSigner := &jwt.DefaultSigner{ + GetPrivateKey: func(_ context.Context) (interface{}, error) { + return key, nil + }, + } + + customJWTType := &JWTType{ + Name: jwtName, + JWTValidationConfig: JWTValidationConfig{ + ValidateJTI: true, + ValidateFunc: jwt.Keyfunc(func(t *jwt.Token) (interface{}, error) { + return key.PublicKey, nil + }), + JWTLifetimeToleranceWindow: 15 * time.Minute, + }, + JWTIssueConfig: JWTIssueConfig{ + Audience: []string{"https://resource1.com"}, + }, + Issuer: "https://customory.com", + } + + config := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + GlobalSecret: []byte("some-secret-thats-random-some-secret-thats-random-"), + RFC8693TokenTypes: map[string]fosite.RFC8693TokenType{ + AccessTokenType: &DefaultTokenType{ + Name: AccessTokenType, + }, + IDTokenType: &DefaultTokenType{ + Name: IDTokenType, + }, + RefreshTokenType: &DefaultTokenType{ + Name: RefreshTokenType, + }, + customJWTType.GetName(nil): customJWTType, + }, + DefaultRequestedTokenType: AccessTokenType, + } + + coreStrategy := &oauth2.HMACSHAStrategyUnPrefixed{ + Enigma: &hmac.HMACStrategy{Config: config}, + Config: config, + } + + genericTEHandler := &TokenExchangeGrantHandler{ + Config: config, + } + + accessTokenHandler := &AccessTokenTypeHandler{ + Config: config, + CoreStrategy: coreStrategy, + Storage: store, + } + + customJWTHandler := &CustomJWTTypeHandler{ + Config: config, + JWTStrategy: &jwt.DefaultSigner{ + GetPrivateKey: func(_ context.Context) (interface{}, error) { + return key, nil + }, + }, + Storage: store, + } + + for _, c := range []struct { + handlers []fosite.TokenEndpointHandler + areq *fosite.AccessRequest + description string + expectErr error + expect func(t *testing.T, areq *fosite.AccessRequest, aresp *fosite.AccessResponse) + }{ + { + handlers: []fosite.TokenEndpointHandler{genericTEHandler, accessTokenHandler}, + areq: &fosite.AccessRequest{ + Request: fosite.Request{ + ID: uuid.New().String(), + Client: store.Clients["my-client"], + Form: url.Values{ + "subject_token_type": []string{rfc8693.AccessTokenType}, + "subject_token": []string{createAccessToken(context.Background(), coreStrategy, store, + store.Clients["custom-lifespan-client"])}, + }, + Session: &rfc8693.DefaultSession{ + DefaultSession: &openid.DefaultSession{}, + Extra: map[string]interface{}{}, + }, + }, + }, + description: "should pass because a valid access token is exchanged for another access token", + expect: func(t *testing.T, areq *fosite.AccessRequest, aresp *fosite.AccessResponse) { + assert.NotEmpty(t, aresp.AccessToken, "Access token is empty; %+v", aresp) + req, err := introspectAccessToken(context.Background(), aresp.AccessToken, coreStrategy, store) + require.NoError(t, err, "Error occurred during introspection; err=%v", err) + + assert.EqualValues(t, "peter", req.GetSession().GetSubject(), "Subject did not match the expected value") + }, + }, + { + handlers: []fosite.TokenEndpointHandler{genericTEHandler, accessTokenHandler, customJWTHandler}, + areq: &fosite.AccessRequest{ + Request: fosite.Request{ + ID: uuid.New().String(), + Client: store.Clients["my-client"], + Form: url.Values{ + "subject_token_type": []string{jwtName}, + "subject_token": []string{createJWT(context.Background(), jwtSigner, jwt.MapClaims{ + "subject": "peter_for_jwt", + "jti": uuid.New(), + "iss": "https://customory.com", + "sub": "peter", + "exp": time.Now().Add(15 * time.Minute).Unix(), + })}, + }, + Session: &rfc8693.DefaultSession{ + DefaultSession: &openid.DefaultSession{}, + Extra: map[string]interface{}{}, + }, + }, + }, + description: "should pass because a valid custom JWT is exchanged for access token", + expect: func(t *testing.T, areq *fosite.AccessRequest, aresp *fosite.AccessResponse) { + assert.NotEmpty(t, aresp.AccessToken, "Access token is empty; %+v", aresp) + req, err := introspectAccessToken(context.Background(), aresp.AccessToken, coreStrategy, store) + require.NoError(t, err, "Error occurred during introspection; err=%v", err) + + assert.EqualValues(t, "peter_for_jwt", req.GetSession().GetSubject(), "Subject did not match the expected value") + }, + }, + } { + t.Run("case="+c.description, func(t *testing.T) { + ctx := context.Background() + aresp := fosite.NewAccessResponse() + found := false + var err error + c.areq.Form.Set("grant_type", string(fosite.GrantTypeTokenExchange)) + c.areq.GrantTypes = fosite.Arguments{"urn:ietf:params:oauth:grant-type:token-exchange"} + c.areq.Client = store.Clients["my-client"] + for _, loader := range c.handlers { + // Is the loader responsible for handling the request? + if !loader.CanHandleTokenEndpointRequest(ctx, c.areq) { + continue + } + + // The handler **is** responsible! + found = true + + if err = loader.HandleTokenEndpointRequest(ctx, c.areq); err == nil { + continue + } else if errors.Is(err, fosite.ErrUnknownRequest) { + // This is a duplicate because it should already have been handled by + // `loader.CanHandleTokenEndpointRequest(accessRequest)` but let's keep it for sanity. + // + err = nil + continue + } else { + break + } + } + + if !found { + assert.Fail(t, "Unable to find a valid handler") + } + + // now execute the response + if err == nil { + for _, loader := range c.handlers { + // Is the loader responsible for handling the request? + if !loader.CanHandleTokenEndpointRequest(ctx, c.areq) { + continue + } + + // The handler **is** responsible! + + if err = loader.PopulateTokenEndpointResponse(ctx, c.areq, aresp); err == nil { + found = true + } else if errors.Is(err, fosite.ErrUnknownRequest) { + // This is a duplicate because it should already have been handled by + // `loader.CanHandleTokenEndpointRequest(accessRequest)` but let's keep it for sanity. + // + err = nil + continue + } else { + break + } + } + } + + var rfcerr *fosite.RFC6749Error + rfcerr, _ = err.(*fosite.RFC6749Error) + if rfcerr == nil { + rfcerr = fosite.ErrServerError + } + if c.expectErr != nil { + require.EqualError(t, err, c.expectErr.Error(), "Error received: %v, rfcerr=%s", err, rfcerr.GetDescription()) + } else { + require.NoError(t, err, "Error received: %v, rfcerr=%s", err, rfcerr.GetDescription()) + } + + if c.expect != nil { + c.expect(t, c.areq, aresp) + } + }) + } +} + +func createAccessToken(ctx context.Context, coreStrategy oauth2.CoreStrategy, storage oauth2.AccessTokenStorage, client fosite.Client) string { + request := &fosite.AccessRequest{ + GrantTypes: fosite.Arguments{"password"}, + Request: fosite.Request{ + Session: &fosite.DefaultSession{ + Username: "peter", + Subject: "peter", + ExpiresAt: map[fosite.TokenType]time.Time{ + fosite.AccessToken: time.Now().UTC().Add(10 * time.Minute), + }, + }, + Client: client, + }, + } + + token, signature, err := coreStrategy.GenerateAccessToken(ctx, request) + if err != nil { + panic(err.Error()) + } else if err := storage.CreateAccessTokenSession(ctx, signature, request.Sanitize([]string{})); err != nil { + panic(err.Error()) + } + + return token +} + +func createJWT(ctx context.Context, signer jwt.Signer, claims jwt.MapClaims) string { + token, _, err := signer.Generate(ctx, claims, &jwt.Headers{}) + if err != nil { + panic(err.Error()) + } + + return token +} + +func introspectAccessToken(ctx context.Context, token string, coreStrategy oauth2.CoreStrategy, storage oauth2.CoreStorage) ( + fosite.Requester, error) { + sig := coreStrategy.AccessTokenSignature(ctx, token) + or, err := storage.GetAccessTokenSession(ctx, sig, &fosite.DefaultSession{}) + return or, err +} diff --git a/handler/rfc8693/token_type.go b/handler/rfc8693/token_type.go new file mode 100644 index 00000000..85ec5efb --- /dev/null +++ b/handler/rfc8693/token_type.go @@ -0,0 +1,31 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" +) + +const ( + // AccessTokenType is the access token type issued by the same provider + AccessTokenType string = "urn:ietf:params:oauth:token-type:access_token" // #nosec G101 + // RefreshTokenType is the refresh token type issued by the same provider + RefreshTokenType string = "urn:ietf:params:oauth:token-type:refresh_token" // #nosec G101 + // IDTokenType is the id_token type issued by the same provider + IDTokenType string = "urn:ietf:params:oauth:token-type:id_token" // #nosec G101 + // JWTTokenType is the JWT type that may be issued by a different provider + JWTTokenType string = "urn:ietf:params:oauth:token-type:jwt" // #nosec G101 +) + +type DefaultTokenType struct { + Name string +} + +func (c *DefaultTokenType) GetName(ctx context.Context) string { + return c.Name +} + +func (c *DefaultTokenType) GetType(ctx context.Context) string { + return c.Name +} diff --git a/handler/rfc8693/token_type_jwt.go b/handler/rfc8693/token_type_jwt.go new file mode 100644 index 00000000..e00ca272 --- /dev/null +++ b/handler/rfc8693/token_type_jwt.go @@ -0,0 +1,37 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "time" + + "github.com/ory/fosite/token/jwt" +) + +type JWTType struct { + Name string `json:"name"` + Issuer string `json:"iss"` + JWTValidationConfig `json:"validate"` + JWTIssueConfig `json:"issue"` +} + +type JWTIssueConfig struct { + Audience []string `json:"aud"` + Expiry time.Duration `json:"exp"` +} + +type JWTValidationConfig struct { + ValidateJTI bool `json:"validate_jti"` + JWTLifetimeToleranceWindow time.Duration `json:"tolerance_window"` + ValidateFunc jwt.Keyfunc `json:"-"` +} + +func (c *JWTType) GetName(ctx context.Context) string { + return c.Name +} + +func (c *JWTType) GetType(ctx context.Context) string { + return JWTTokenType +} diff --git a/oauth2.go b/oauth2.go index 0827b8ed..c29e8f6c 100644 --- a/oauth2.go +++ b/oauth2.go @@ -32,8 +32,8 @@ const ( GrantTypePassword GrantType = "password" GrantTypeClientCredentials GrantType = "client_credentials" GrantTypeJWTBearer GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" //nolint:gosec // this is not a hardcoded credential - - BearerAccessToken string = "bearer" + GrantTypeTokenExchange GrantType = "urn:ietf:params:oauth:grant-type:token-exchange" + BearerAccessToken string = "bearer" ) // OAuth2Provider is an interface that enables you to write OAuth2 handlers with only a few lines of code. @@ -365,3 +365,9 @@ type G11NContext interface { // GetLang returns the current language in the context GetLang() language.Tag } + +type RFC8693TokenType interface { + GetName(ctx context.Context) string + + GetType(ctx context.Context) string +} diff --git a/storage/memory.go b/storage/memory.go index 9e9fd1ba..7fe92ee1 100644 --- a/storage/memory.go +++ b/storage/memory.go @@ -102,7 +102,7 @@ func NewExampleStore() *MemoryStore { RotatedSecrets: [][]byte{[]byte(`$2y$10$X51gLxUQJ.hGw1epgHTE5u0bt64xM0COU7K9iAp.OFg8p2pUd.1zC `)}, // = "foobaz", RedirectURIs: []string{"http://localhost:3846/callback"}, ResponseTypes: []string{"id_token", "code", "token", "id_token token", "code id_token", "code token", "code id_token token"}, - GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, + GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"}, Scopes: []string{"fosite", "openid", "photos", "offline"}, }, "custom-lifespan-client": &fosite.DefaultClientWithCustomTokenLifespans{ @@ -140,6 +140,7 @@ func NewExampleStore() *MemoryStore { RefreshTokens: map[string]StoreRefreshToken{}, PKCES: map[string]fosite.Requester{}, AccessTokenRequestIDs: map[string]string{}, + BlacklistedJTIs: map[string]time.Time{}, RefreshTokenRequestIDs: map[string]string{}, IssuerPublicKeys: map[string]IssuerPublicKeys{}, PARSessions: map[string]fosite.AuthorizeRequester{}, @@ -496,3 +497,20 @@ func (s *MemoryStore) DeletePARSession(ctx context.Context, requestURI string) ( delete(s.PARSessions, requestURI) return nil } + +func (s *MemoryStore) SetTokenExchangeCustomJWT(ctx context.Context, jti string, exp time.Time) error { + // the memory store implementation is generic, so just re-use + return s.SetClientAssertionJWT(ctx, jti, exp) +} + +// GetSubjectForTokenExchange computes the session subject and is used for token types where there is no way +// to know the subject value. For some token types, such as access and refresh tokens, the subject is well-defined +// and this function is not called. +func (s *MemoryStore) GetSubjectForTokenExchange(ctx context.Context, requester fosite.Requester, subjectToken map[string]interface{}) (string, error) { + sub, _ := subjectToken["subject"].(string) + if sub == "" { + return "", fosite.ErrInvalidRequest.WithHint("No subject found.") + } + + return sub, nil +}