feat: Token Exchange (RFC 8693) (#255)

This change implements OAuth2 Token Exchange in OP according to RFC 8693 (and client code)

Some implementation details:

- OP parses and verifies subject/actor tokens natively if they were issued by OP
- Third-party tokens verification is also possible by implementing additional storage interface
- Token exchange can issue only OP's native tokens (id_token, access_token and refresh_token) with static issuer
This commit is contained in:
Emil Bektimirov 2023-02-19 14:57:46 +01:00 committed by GitHub
parent 9291ca9908
commit 8e298791d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 961 additions and 59 deletions

View file

@ -267,7 +267,8 @@ func (o *Provider) GrantTypeRefreshTokenSupported() bool {
}
func (o *Provider) GrantTypeTokenExchangeSupported() bool {
return false
_, ok := o.storage.(TokenExchangeStorage)
return ok
}
func (o *Provider) GrantTypeJWTAuthorizationSupported() bool {

View file

@ -25,6 +25,8 @@ type AuthStorage interface {
//
// * *oidc.JWTTokenRequest from a JWT that is the assertion value of a JWT Profile
// Grant: https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
//
// * TokenExchangeRequest as returned by ValidateTokenExchangeRequest
CreateAccessToken(context.Context, TokenRequest) (accessTokenID string, expiration time.Time, err error)
// The TokenRequest parameter of CreateAccessAndRefreshTokens can be any of:
@ -36,6 +38,8 @@ type AuthStorage interface {
// * AuthRequest as by returned by the AuthRequestByID or AuthRequestByCode (above).
// Used for the authorization code flow which requested offline_access scope and
// registered the refresh_token grant type in advance
//
// * TokenExchangeRequest as returned by ValidateTokenExchangeRequest
CreateAccessAndRefreshTokens(ctx context.Context, request TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshTokenID string, expiration time.Time, err error)
TokenRequestByRefreshToken(ctx context.Context, refreshTokenID string) (RefreshTokenRequest, error)
@ -57,6 +61,45 @@ type ClientCredentialsStorage interface {
ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (TokenRequest, error)
}
type TokenExchangeStorage interface {
// ValidateTokenExchangeRequest will be called to validate parsed (including tokens) Token Exchange Grant request.
//
// Important validations can include:
// - permissions
// - set requested token type to some default value if it is empty (rfc 8693 allows it) using SetRequestedTokenType method.
// Depending on RequestedTokenType - the following tokens will be issued:
// - RefreshTokenType - both access and refresh tokens
// - AccessTokenType - only access token
// - IDTokenType - only id token
// - validation of subject's token type on possibility to be exchanged to the requested token type (according to your requirements)
// - scopes (and update them using SetCurrentScopes method)
// - set new subject if it differs from exchange subject (impersonation flow)
//
// Request will include subject's and/or actor's token claims if correspinding tokens are access/id_token issued by op
// or third party tokens parsed by TokenExchangeTokensVerifierStorage interface methods.
ValidateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error
// CreateTokenExchangeRequest will be called after parsing and validating token exchange request.
// Stored request is not accessed later by op - so it is up to implementer to decide
// should this method actually store the request or not (common use case - store for it for audit purposes)
CreateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error
// GetPrivateClaimsFromTokenExchangeRequest will be called during access token creation.
// Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc.
GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) (claims map[string]interface{}, err error)
// SetUserinfoFromTokenExchangeRequest will be called during id token creation.
// Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc.
SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request TokenExchangeRequest) error
}
// TokenExchangeTokensVerifierStorage is an optional interface used in token exchange process to verify tokens
// issued by third-party applications. If interface is not implemented - only tokens issued by op will be exchanged.
type TokenExchangeTokensVerifierStorage interface {
VerifyExchangeSubjectToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, subject string, tokenClaims map[string]interface{}, err error)
VerifyExchangeActorToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, actor string, tokenClaims map[string]interface{}, err error)
}
// CanRefreshTokenInfo is an optional additional interface that Storage can support.
// Supporting CanRefreshTokenInfo is required to be able to (revoke) a refresh token that
// is neither an encrypted string of <tokenID>:<userID> nor a JWT.

View file

@ -74,6 +74,8 @@ func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool
switch req := tokenRequest.(type) {
case AuthRequest:
return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
case TokenExchangeRequest:
return req.GetRequestedTokenType() == oidc.RefreshTokenType
case RefreshTokenRequest:
return true
default:
@ -107,7 +109,23 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex
claims := oidc.NewAccessTokenClaims(issuer, tokenRequest.GetSubject(), tokenRequest.GetAudience(), exp, id, client.GetID(), client.ClockSkew())
if client != nil {
restrictedScopes := client.RestrictAdditionalAccessTokenScopes()(tokenRequest.GetScopes())
privateClaims, err := storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes))
var (
privateClaims map[string]interface{}
err error
)
tokenExchangeRequest, okReq := tokenRequest.(TokenExchangeRequest)
teStorage, okStorage := storage.(TokenExchangeStorage)
if okReq && okStorage {
privateClaims, err = teStorage.GetPrivateClaimsFromTokenExchangeRequest(
ctx,
tokenExchangeRequest,
)
} else {
privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes))
}
if err != nil {
return "", err
}
@ -156,7 +174,17 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v
scopes = removeUserinfoScopes(scopes)
}
}
if len(scopes) > 0 {
tokenExchangeRequest, okReq := request.(TokenExchangeRequest)
teStorage, okStorage := storage.(TokenExchangeStorage)
if okReq && okStorage {
userInfo := oidc.NewUserInfo()
err := teStorage.SetUserinfoFromTokenExchangeRequest(ctx, userInfo, tokenExchangeRequest)
if err != nil {
return "", err
}
claims.SetUserinfo(userInfo)
} else if len(scopes) > 0 {
userInfo := oidc.NewUserInfo()
err := storage.SetUserinfoFromScopes(ctx, userInfo, request.GetSubject(), request.GetClientID(), scopes)
if err != nil {

View file

@ -1,11 +1,399 @@
package op
import (
"errors"
"context"
"net/http"
"net/url"
"strings"
"time"
httphelper "github.com/zitadel/oidc/v2/pkg/http"
"github.com/zitadel/oidc/v2/pkg/oidc"
)
// TokenExchange will handle the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange")
func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) {
RequestError(w, r, errors.New("unimplemented"))
type TokenExchangeRequest interface {
GetAMR() []string
GetAudience() []string
GetResourses() []string
GetAuthTime() time.Time
GetClientID() string
GetScopes() []string
GetSubject() string
GetRequestedTokenType() oidc.TokenType
GetExchangeSubject() string
GetExchangeSubjectTokenType() oidc.TokenType
GetExchangeSubjectTokenIDOrToken() string
GetExchangeSubjectTokenClaims() map[string]interface{}
GetExchangeActor() string
GetExchangeActorTokenType() oidc.TokenType
GetExchangeActorTokenIDOrToken() string
GetExchangeActorTokenClaims() map[string]interface{}
SetCurrentScopes(scopes []string)
SetRequestedTokenType(tt oidc.TokenType)
SetSubject(subject string)
}
type tokenExchangeRequest struct {
exchangeSubjectTokenIDOrToken string
exchangeSubjectTokenType oidc.TokenType
exchangeSubject string
exchangeSubjectTokenClaims map[string]interface{}
exchangeActorTokenIDOrToken string
exchangeActorTokenType oidc.TokenType
exchangeActor string
exchangeActorTokenClaims map[string]interface{}
resource []string
audience oidc.Audience
scopes oidc.SpaceDelimitedArray
requestedTokenType oidc.TokenType
clientID string
authTime time.Time
subject string
}
func (r *tokenExchangeRequest) GetAMR() []string {
return []string{}
}
func (r *tokenExchangeRequest) GetAudience() []string {
return r.audience
}
func (r *tokenExchangeRequest) GetResourses() []string {
return r.resource
}
func (r *tokenExchangeRequest) GetAuthTime() time.Time {
return r.authTime
}
func (r *tokenExchangeRequest) GetClientID() string {
return r.clientID
}
func (r *tokenExchangeRequest) GetScopes() []string {
return r.scopes
}
func (r *tokenExchangeRequest) GetRequestedTokenType() oidc.TokenType {
return r.requestedTokenType
}
func (r *tokenExchangeRequest) GetExchangeSubject() string {
return r.exchangeSubject
}
func (r *tokenExchangeRequest) GetExchangeSubjectTokenType() oidc.TokenType {
return r.exchangeSubjectTokenType
}
func (r *tokenExchangeRequest) GetExchangeSubjectTokenIDOrToken() string {
return r.exchangeSubjectTokenIDOrToken
}
func (r *tokenExchangeRequest) GetExchangeSubjectTokenClaims() map[string]interface{} {
return r.exchangeSubjectTokenClaims
}
func (r *tokenExchangeRequest) GetExchangeActor() string {
return r.exchangeActor
}
func (r *tokenExchangeRequest) GetExchangeActorTokenType() oidc.TokenType {
return r.exchangeActorTokenType
}
func (r *tokenExchangeRequest) GetExchangeActorTokenIDOrToken() string {
return r.exchangeActorTokenIDOrToken
}
func (r *tokenExchangeRequest) GetExchangeActorTokenClaims() map[string]interface{} {
return r.exchangeActorTokenClaims
}
func (r *tokenExchangeRequest) GetSubject() string {
return r.subject
}
func (r *tokenExchangeRequest) SetCurrentScopes(scopes []string) {
r.scopes = scopes
}
func (r *tokenExchangeRequest) SetRequestedTokenType(tt oidc.TokenType) {
r.requestedTokenType = tt
}
func (r *tokenExchangeRequest) SetSubject(subject string) {
r.subject = subject
}
// TokenExchange handles the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange")
func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) {
tokenExchangeReq, clientID, clientSecret, err := ParseTokenExchangeRequest(r, exchanger.Decoder())
if err != nil {
RequestError(w, r, err)
}
tokenExchangeRequest, client, err := ValidateTokenExchangeRequest(r.Context(), tokenExchangeReq, clientID, clientSecret, exchanger)
if err != nil {
RequestError(w, r, err)
return
}
resp, err := CreateTokenExchangeResponse(r.Context(), tokenExchangeRequest, client, exchanger)
if err != nil {
RequestError(w, r, err)
return
}
httphelper.MarshalJSON(w, resp)
}
// ParseTokenExchangeRequest parses the http request into oidc.TokenExchangeRequest
func ParseTokenExchangeRequest(r *http.Request, decoder httphelper.Decoder) (_ *oidc.TokenExchangeRequest, clientID, clientSecret string, err error) {
err = r.ParseForm()
if err != nil {
return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err)
}
request := new(oidc.TokenExchangeRequest)
err = decoder.Decode(request, r.Form)
if err != nil {
return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err)
}
var ok bool
if clientID, clientSecret, ok = r.BasicAuth(); ok {
clientID, err = url.QueryUnescape(clientID)
if err != nil {
return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err)
}
clientSecret, err = url.QueryUnescape(clientSecret)
if err != nil {
return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err)
}
}
return request, clientID, clientSecret, nil
}
// ValidateTokenExchangeRequest validates the token exchange request parameters including authorization check of the client,
// subject_token and actor_token
func ValidateTokenExchangeRequest(
ctx context.Context,
oidcTokenExchangeRequest *oidc.TokenExchangeRequest,
clientID, clientSecret string,
exchanger Exchanger,
) (TokenExchangeRequest, Client, error) {
if oidcTokenExchangeRequest.SubjectToken == "" {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token missing")
}
if oidcTokenExchangeRequest.SubjectTokenType == "" {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type missing")
}
storage := exchanger.Storage()
teStorage, ok := storage.(TokenExchangeStorage)
if !ok {
return nil, nil, oidc.ErrUnsupportedGrantType().WithDescription("token_exchange grant not supported")
}
client, err := AuthorizeTokenExchangeClient(ctx, clientID, clientSecret, exchanger)
if err != nil {
return nil, nil, err
}
if oidcTokenExchangeRequest.RequestedTokenType != "" && !oidcTokenExchangeRequest.RequestedTokenType.IsSupported() {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("requested_token_type is not supported")
}
if !oidcTokenExchangeRequest.SubjectTokenType.IsSupported() {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type is not supported")
}
if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsSupported() {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token_type is not supported")
}
exchangeSubjectTokenIDOrToken, exchangeSubject, exchangeSubjectTokenClaims, ok := GetTokenIDAndSubjectFromToken(ctx, exchanger,
oidcTokenExchangeRequest.SubjectToken, oidcTokenExchangeRequest.SubjectTokenType, false)
if !ok {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token is invalid")
}
var (
exchangeActorTokenIDOrToken, exchangeActor string
exchangeActorTokenClaims map[string]interface{}
)
if oidcTokenExchangeRequest.ActorToken != "" {
exchangeActorTokenIDOrToken, exchangeActor, exchangeActorTokenClaims, ok = GetTokenIDAndSubjectFromToken(ctx, exchanger,
oidcTokenExchangeRequest.ActorToken, oidcTokenExchangeRequest.ActorTokenType, true)
if !ok {
return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token is invalid")
}
}
req := &tokenExchangeRequest{
exchangeSubjectTokenIDOrToken: exchangeSubjectTokenIDOrToken,
exchangeSubjectTokenType: oidcTokenExchangeRequest.SubjectTokenType,
exchangeSubject: exchangeSubject,
exchangeSubjectTokenClaims: exchangeSubjectTokenClaims,
exchangeActorTokenIDOrToken: exchangeActorTokenIDOrToken,
exchangeActorTokenType: oidcTokenExchangeRequest.ActorTokenType,
exchangeActor: exchangeActor,
exchangeActorTokenClaims: exchangeActorTokenClaims,
subject: exchangeSubject,
resource: oidcTokenExchangeRequest.Resource,
audience: oidcTokenExchangeRequest.Audience,
scopes: oidcTokenExchangeRequest.Scopes,
requestedTokenType: oidcTokenExchangeRequest.RequestedTokenType,
clientID: client.GetID(),
authTime: time.Now(),
}
err = teStorage.ValidateTokenExchangeRequest(ctx, req)
if err != nil {
return nil, nil, err
}
err = teStorage.CreateTokenExchangeRequest(ctx, req)
if err != nil {
return nil, nil, err
}
return req, client, nil
}
func GetTokenIDAndSubjectFromToken(
ctx context.Context,
exchanger Exchanger,
token string,
tokenType oidc.TokenType,
isActor bool,
) (tokenIDOrToken, subject string, claims map[string]interface{}, ok bool) {
switch tokenType {
case oidc.AccessTokenType:
var accessTokenClaims oidc.AccessTokenClaims
tokenIDOrToken, subject, accessTokenClaims, ok = getTokenIDAndClaims(ctx, exchanger, token)
claims = accessTokenClaims.GetClaims()
case oidc.RefreshTokenType:
refreshTokenRequest, err := exchanger.Storage().TokenRequestByRefreshToken(ctx, token)
if err != nil {
break
}
tokenIDOrToken, subject, ok = token, refreshTokenRequest.GetSubject(), true
case oidc.IDTokenType:
idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier(ctx))
if err != nil {
break
}
tokenIDOrToken, subject, claims, ok = token, idTokenClaims.GetSubject(), idTokenClaims.GetClaims(), true
}
if !ok {
if verifier, ok := exchanger.Storage().(TokenExchangeTokensVerifierStorage); ok {
var err error
if isActor {
tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeActorToken(ctx, token, tokenType)
} else {
tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeSubjectToken(ctx, token, tokenType)
}
if err != nil {
return "", "", nil, false
}
return tokenIDOrToken, subject, claims, true
}
return "", "", nil, false
}
return tokenIDOrToken, subject, claims, true
}
// AuthorizeTokenExchangeClient authorizes a client by validating the client_id and client_secret
func AuthorizeTokenExchangeClient(ctx context.Context, clientID, clientSecret string, exchanger Exchanger) (client Client, err error) {
if err := AuthorizeClientIDSecret(ctx, clientID, clientSecret, exchanger.Storage()); err != nil {
return nil, err
}
client, err = exchanger.Storage().GetClientByClientID(ctx, clientID)
if err != nil {
return nil, oidc.ErrInvalidClient().WithParent(err)
}
return client, nil
}
func CreateTokenExchangeResponse(
ctx context.Context,
tokenExchangeRequest TokenExchangeRequest,
client Client,
creator TokenCreator,
) (_ *oidc.TokenExchangeResponse, err error) {
var (
token, refreshToken, tokenType string
validity time.Duration
)
switch tokenExchangeRequest.GetRequestedTokenType() {
case oidc.AccessTokenType, oidc.RefreshTokenType:
token, refreshToken, validity, err = CreateAccessToken(ctx, tokenExchangeRequest, client.AccessTokenType(), creator, client, "")
if err != nil {
return nil, err
}
tokenType = oidc.BearerToken
case oidc.IDTokenType:
token, err = CreateIDToken(ctx, IssuerFromContext(ctx), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), client)
if err != nil {
return nil, err
}
// not applicable (see https://datatracker.ietf.org/doc/html/rfc8693#section-2-2-1-2-6)
tokenType = "N_A"
default:
// oidc.JWTTokenType and other custom token types are not supported for issuing.
// In the future it can be considered to have custom tokens generation logic injected via op configuration
// or via expanding Storage interface
oidc.ErrInvalidRequest().WithDescription("requested_token_type is invalid")
}
exp := uint64(validity.Seconds())
return &oidc.TokenExchangeResponse{
AccessToken: token,
IssuedTokenType: tokenExchangeRequest.GetRequestedTokenType(),
TokenType: tokenType,
ExpiresIn: exp,
RefreshToken: refreshToken,
Scopes: tokenExchangeRequest.GetScopes(),
}, nil
}
func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, accessToken string) (string, string, oidc.AccessTokenClaims, bool) {
tokenIDSubject, err := userinfoProvider.Crypto().Decrypt(accessToken)
if err == nil {
splitToken := strings.Split(tokenIDSubject, ":")
if len(splitToken) != 2 {
return "", "", nil, false
}
return splitToken[0], splitToken[1], nil, true
}
accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx))
if err != nil {
return "", "", nil, false
}
return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), accessTokenClaims, true
}

View file

@ -19,6 +19,8 @@ type Exchanger interface {
GrantTypeTokenExchangeSupported() bool
GrantTypeJWTAuthorizationSupported() bool
GrantTypeClientCredentialsSupported() bool
AccessTokenVerifier(context.Context) AccessTokenVerifier
IDTokenHintVerifier(context.Context) IDTokenHintVerifier
}
func tokenHandler(exchanger Exchanger) func(w http.ResponseWriter, r *http.Request) {