From 42a965796b0220f48893f271949742014ff2b20d Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Tue, 20 Dec 2022 01:41:58 +0100 Subject: [PATCH 1/5] feat: token exchange in op (rfc 6749) --- pkg/oidc/token.go | 15 ++ pkg/oidc/token_request.go | 40 +++- pkg/op/op.go | 5 +- pkg/op/storage.go | 43 +++++ pkg/op/token.go | 28 ++- pkg/op/token_exchange.go | 394 +++++++++++++++++++++++++++++++++++++- pkg/op/token_request.go | 2 + 7 files changed, 512 insertions(+), 15 deletions(-) diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 784684d8..f6b2000e 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -31,6 +31,7 @@ type AccessTokenClaims interface { GetSubject() string GetTokenID() string SetPrivateClaims(map[string]interface{}) + GetClaims() map[string]interface{} } type IDTokenClaims interface { @@ -151,6 +152,11 @@ func (a *accessTokenClaims) SetPrivateClaims(claims map[string]interface{}) { a.claims = claims } +// GetClaims implements the AccessTokenClaims interface +func (a *accessTokenClaims) GetClaims() map[string]interface{} { + return a.claims +} + func (a *accessTokenClaims) MarshalJSON() ([]byte, error) { type Alias accessTokenClaims s := &struct { @@ -612,3 +618,12 @@ func GenerateJWTProfileToken(assertion JWTProfileAssertionClaims) (string, error } return signedAssertion.CompactSerialize() } + +type TokenExchangeResponse struct { + AccessToken string `json:"access_token"` // Can be access token or ID token + IssuedTokenType TokenType `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn uint64 `json:"expires_in,omitempty"` + Scopes SpaceDelimitedArray `json:"scope,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index ec11057d..a3a9eaaa 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -40,6 +40,29 @@ var AllGrantTypes = []GrantType{ type GrantType string +const ( + AccessTokenType TokenType = "urn:ietf:params:oauth:token-type:access_token" + RefreshTokenType TokenType = "urn:ietf:params:oauth:token-type:refresh_token" + IDTokenType TokenType = "urn:ietf:params:oauth:token-type:id_token" + JWTTokenType TokenType = "urn:ietf:params:oauth:token-type:jwt" +) + +var AllTokenTypes = []TokenType{ + AccessTokenType, RefreshTokenType, IDTokenType, JWTTokenType, +} + +type TokenType string + +func (t TokenType) IsValid() bool { + for _, tt := range AllTokenTypes { + if t == tt { + return true + } + } + + return false +} + type TokenRequest interface { // GrantType GrantType `schema:"grant_type"` GrantType() GrantType @@ -203,14 +226,15 @@ func (j *JWTTokenRequest) GetScopes() []string { } type TokenExchangeRequest struct { - subjectToken string `schema:"subject_token"` - subjectTokenType string `schema:"subject_token_type"` - actorToken string `schema:"actor_token"` - actorTokenType string `schema:"actor_token_type"` - resource []string `schema:"resource"` - audience Audience `schema:"audience"` - Scope SpaceDelimitedArray `schema:"scope"` - requestedTokenType string `schema:"requested_token_type"` + GrantType GrantType `schema:"grant_type"` + SubjectToken string `schema:"subject_token"` + SubjectTokenType TokenType `schema:"subject_token_type"` + ActorToken string `schema:"actor_token"` + ActorTokenType TokenType `schema:"actor_token_type"` + Resource []string `schema:"resource"` + Audience Audience `schema:"audience"` + Scopes SpaceDelimitedArray `schema:"scope"` + RequestedTokenType TokenType `schema:"requested_token_type"` } type ClientCredentialsRequest struct { diff --git a/pkg/op/op.go b/pkg/op/op.go index 8a0dc465..804a9f0c 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -190,7 +190,7 @@ type openidProvider struct { interceptors []HttpInterceptor timer <-chan time.Time accessTokenVerifierOpts []AccessTokenVerifierOpt - idTokenHintVerifierOpts []IDTokenHintVerifierOpt + idTokenHintVerifierOpts []IDTokenHintVerifierOpt } func (o *openidProvider) Issuer() string { @@ -246,7 +246,8 @@ func (o *openidProvider) GrantTypeRefreshTokenSupported() bool { } func (o *openidProvider) GrantTypeTokenExchangeSupported() bool { - return false + _, ok := o.storage.(TokenExchangeStorage) + return ok } func (o *openidProvider) GrantTypeJWTAuthorizationSupported() bool { diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 2b3c93f5..aaeba8c3 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -24,6 +24,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 CreateAccessToken(context.Context, TokenRequest) (accessTokenID string, expiration time.Time, err error) // The TokenRequest parameter of CreateAccessAndRefreshTokens can be any of: @@ -35,6 +37,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 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) @@ -54,6 +58,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) +} + type OPStorage interface { GetClientByClientID(ctx context.Context, clientID string) (Client, error) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error diff --git a/pkg/op/token.go b/pkg/op/token.go index 3a72261a..790bea7a 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -69,6 +69,8 @@ func needsRefreshToken(tokenRequest TokenRequest, client Client) 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: @@ -102,7 +104,20 @@ 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 + ) + if tokenExchangeRequest, ok := tokenRequest.(TokenExchangeRequest); ok { + privateClaims, err = storage.(TokenExchangeStorage).GetPrivateClaimsFromTokenExchangeRequest( + ctx, + tokenExchangeRequest, + ) + } else { + privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + } + if err != nil { return "", err } @@ -139,7 +154,16 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v scopes = removeUserinfoScopes(scopes) } } - if len(scopes) > 0 { + + if tokenExchangeRequest, ok := request.(TokenExchangeRequest); ok { + userInfo := oidc.NewUserInfo() + teStorage := storage.(TokenExchangeStorage) + 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 { diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index 7bb6e42f..fc2db303 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -1,11 +1,399 @@ package op import ( - "errors" + "context" "net/http" + "net/url" + "strings" + "time" + + httphelper "github.com/zitadel/oidc/pkg/http" + "github.com/zitadel/oidc/pkg/oidc" ) -// TokenExchange will handle the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange") +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) { - RequestError(w, r, errors.New("unimplemented")) + 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.IsValid() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("requested_token_type is not supported") + } + + if !oidcTokenExchangeRequest.SubjectTokenType.IsValid() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type is not supported") + } + + if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsValid() { + 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()) + 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, creator.Issuer(), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), creator.Signer(), 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()) + if err != nil { + return "", "", nil, false + } + + return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), accessTokenClaims, true } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 6ccd489a..ef96faea 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -21,6 +21,8 @@ type Exchanger interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool + AccessTokenVerifier() AccessTokenVerifier + IDTokenHintVerifier() IDTokenHintVerifier } func tokenHandler(exchanger Exchanger) func(w http.ResponseWriter, r *http.Request) { From 69e8cb434da870550564b9bf7526c4bd03182d19 Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Tue, 20 Dec 2022 01:49:13 +0100 Subject: [PATCH 2/5] docs: add token exchange to examples --- example/client/app/app.go | 25 ++++++ example/server/storage/client.go | 8 +- example/server/storage/oidc.go | 3 + example/server/storage/storage.go | 135 +++++++++++++++++++++++++++++- example/server/storage/user.go | 15 ++++ 5 files changed, 178 insertions(+), 8 deletions(-) diff --git a/example/client/app/app.go b/example/client/app/app.go index 34c89d27..dff12c86 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -80,6 +80,31 @@ func main() { // w.Write(data) //} + // you can also try token exchange flow + // + // requestTokenExchange := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { + // data := make(url.Values) + // data.Set("grant_type", string(oidc.GrantTypeTokenExchange)) + // data.Set("requested_token_type", string(oidc.IDTokenType)) + // data.Set("subject_token", tokens.RefreshToken) + // data.Set("subject_token_type", string(oidc.RefreshTokenType)) + // data.Add("scope", "profile custom_scope:impersonate:id2") + + // client := &http.Client{} + // r2, _ := http.NewRequest(http.MethodPost, issuer+"/oauth/token", strings.NewReader(data.Encode())) + // // r2.Header.Add("Authorization", "Basic "+"d2ViOnNlY3JldA==") + // r2.Header.Add("Content-Type", "application/x-www-form-urlencoded") + // r2.SetBasicAuth("web", "secret") + + // resp, _ := client.Do(r2) + // fmt.Println(resp.Status) + + // b, _ := io.ReadAll(resp.Body) + // resp.Body.Close() + + // w.Write(b) + // } + // register the CodeExchangeHandler at the callbackPath // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function // with the returned tokens from the token endpoint diff --git a/example/server/storage/client.go b/example/server/storage/client.go index 0f3a703a..1b83d12c 100644 --- a/example/server/storage/client.go +++ b/example/server/storage/client.go @@ -113,14 +113,14 @@ func (c *Client) IsScopeAllowed(scope string) bool { // IDTokenUserinfoClaimsAssertion allows specifying if claims of scope profile, email, phone and address are asserted into the id_token // even if an access token if issued which violates the OIDC Core spec -//(5.4. Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) +// (5.4. Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) // some clients though require that e.g. email is always in the id_token when requested even if an access_token is issued func (c *Client) IDTokenUserinfoClaimsAssertion() bool { return c.idTokenUserinfoClaimsAssertion } // ClockSkew enables clients to instruct the OP to apply a clock skew on the various times and expirations -//(subtract from issued_at, add to expiration, ...) +// (subtract from issued_at, add to expiration, ...) func (c *Client) ClockSkew() time.Duration { return c.clockSkew } @@ -158,7 +158,7 @@ func NativeClient(id string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, @@ -184,7 +184,7 @@ func WebClient(id, secret string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 91afd908..4a8681bb 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -17,6 +17,9 @@ const ( // CustomClaim is an example for how to return custom claims with this library CustomClaim = "custom_claim" + + // CustomScopeImpersonatePrefix is an example scope prefix for passing user id to impersonate using token exchage + CustomScopeImpersonatePrefix = "custom_scope:impersonate:" ) type AuthRequest struct { diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 130822e4..cf860d79 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -4,8 +4,10 @@ import ( "context" "crypto/rand" "crypto/rsa" + "errors" "fmt" "math/big" + "strings" "sync" "time" @@ -181,11 +183,14 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error { // it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...) func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) { var applicationID string - // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token - authReq, ok := request.(*AuthRequest) - if ok { - applicationID = authReq.ApplicationID + switch req := request.(type) { + case *AuthRequest: + // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token + applicationID = req.ApplicationID + case op.TokenExchangeRequest: + applicationID = req.GetClientID() } + token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes()) if err != nil { return "", time.Time{}, err @@ -196,6 +201,11 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest // CreateAccessAndRefreshTokens implements the op.Storage interface // it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request) func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + // generate tokens via token exchange flow if request is relevant + if teReq, ok := request.(op.TokenExchangeRequest); ok { + return s.exchangeRefreshToken(ctx, teReq) + } + // get the information depending on the request type / implementation applicationID, authTime, amr := getInfoFromRequest(request) @@ -226,6 +236,24 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T return accessToken.ID, refreshToken, accessToken.Expiration, nil } +func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + applicationID := request.GetClientID() + authTime := request.GetAuthTime() + + refreshTokenID := uuid.NewString() + accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) + if err != nil { + return "", "", time.Time{}, err + } + + refreshToken, err := s.createRefreshToken(accessToken, nil, authTime) + if err != nil { + return "", "", time.Time{}, err + } + + return accessToken.ID, refreshToken, accessToken.Expiration, nil +} + // TokenRequestByRefreshToken implements the op.Storage interface // it will be called after parsing and validation of the refresh token request func (s *Storage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) { @@ -424,6 +452,10 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o // GetPrivateClaimsFromScopes implements the op.Storage interface // it will be called for the creation of a JWT access token to assert claims for custom scopes func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { + return s.getPrivateClaimsFromScopes(ctx, userID, clientID, scopes) +} + +func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { for _, scope := range scopes { switch scope { case CustomScope: @@ -560,6 +592,101 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, return nil } +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// it will be called to validate parsed Token Exchange Grant request +func (s *Storage) ValidateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + if request.GetRequestedTokenType() == "" { + request.SetRequestedTokenType(oidc.RefreshTokenType) + } + + // Just an example, some use cases might need this use case + if request.GetExchangeSubjectTokenType() == oidc.IDTokenType && request.GetRequestedTokenType() == oidc.RefreshTokenType { + return errors.New("exchanging id_token to refresh_token is not supported") + } + + // Check impersonation permissions + if request.GetExchangeActor() == "" && !s.userStore.GetUserByID(request.GetExchangeSubject()).IsAdmin { + return errors.New("user doesn't have impersonation permission") + } + + allowedScopes := make([]string, 0) + for _, scope := range request.GetScopes() { + if scope == oidc.ScopeAddress { + continue + } + + if strings.HasPrefix(scope, CustomScopeImpersonatePrefix) { + subject := strings.TrimPrefix(scope, CustomScopeImpersonatePrefix) + request.SetSubject(subject) + } + + allowedScopes = append(allowedScopes, scope) + } + + request.SetCurrentScopes(allowedScopes) + + return nil +} + +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// Common use case is to store request for audit purposes. For this example we skip the storing. +func (s *Storage) CreateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + return nil +} + +// GetPrivateClaimsFromScopesForTokenExchange implements the op.TokenExchangeStorage interface +// it will be called for the creation of an exchanged JWT access token to assert claims for custom scopes +// plus adding token exchange specific claims related to delegation or impersonation +func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}, err error) { + claims, err = s.getPrivateClaimsFromScopes(ctx, "", request.GetClientID(), request.GetScopes()) + if err != nil { + return nil, err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + claims = appendClaim(claims, k, v) + } + + return claims, nil +} + +// SetUserinfoFromScopesForTokenExchange implements the op.TokenExchangeStorage interface +// it will be called for the creation of an id_token - we are using the same private function as for other flows, +// plus adding token exchange specific claims related to delegation or impersonation +func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request op.TokenExchangeRequest) error { + err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes()) + if err != nil { + return err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + userinfo.AppendClaims(k, v) + } + + return nil +} + +func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}) { + for _, scope := range request.GetScopes() { + switch { + case strings.HasPrefix(scope, CustomScopeImpersonatePrefix) && request.GetExchangeActor() == "": + // Set actor subject claim for impersonation flow + claims = appendClaim(claims, "act", map[string]interface{}{ + "sub": request.GetExchangeSubject(), + }) + } + } + + // Set actor subject claim for delegation flow + // if request.GetExchangeActor() != "" { + // claims = appendClaim(claims, "act", map[string]interface{}{ + // "sub": request.GetExchangeActor(), + // }) + // } + + return claims +} + // getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) { authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access) diff --git a/example/server/storage/user.go b/example/server/storage/user.go index 423af593..f464c72d 100644 --- a/example/server/storage/user.go +++ b/example/server/storage/user.go @@ -17,6 +17,7 @@ type User struct { Phone string PhoneVerified bool PreferredLanguage language.Tag + IsAdmin bool } type Service struct { @@ -47,6 +48,20 @@ func NewUserStore() UserStore { Phone: "", PhoneVerified: false, PreferredLanguage: language.German, + IsAdmin: true, + }, + "id2": { + ID: "id2", + Username: "test-user2", + Password: "verysecure", + FirstName: "Test", + LastName: "User2", + Email: "test-user2@zitadel.ch", + EmailVerified: true, + Phone: "", + PhoneVerified: false, + PreferredLanguage: language.German, + IsAdmin: false, }, }, } From cf8128a60b7e02b2724e21bd0eb5f1ce9af384bd Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Tue, 20 Dec 2022 01:49:55 +0100 Subject: [PATCH 3/5] feat: add token exchange to client --- pkg/client/client.go | 19 +++ pkg/client/{rp => }/integration_test.go | 157 ++++++++++++++++------ pkg/client/rs/resource_server.go | 5 + pkg/client/tokenexchange/tokenexchange.go | 127 +++++++++++++++++ 4 files changed, 270 insertions(+), 38 deletions(-) rename pkg/client/{rp => }/integration_test.go (73%) create mode 100644 pkg/client/tokenexchange/tokenexchange.go diff --git a/pkg/client/client.go b/pkg/client/client.go index 344e26b8..243fe6d2 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -90,6 +90,9 @@ func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndS return http.ErrUseLastResponse } resp, err := client.Do(req) + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { // TODO: switch to io.ReadAll when go1.15 support is retired @@ -150,6 +153,22 @@ func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCa return nil } +func CallTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { + return callTokenExchangeEndpoint(request, authFn, caller) +} + +func callTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { + req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn) + if err != nil { + return nil, err + } + tokenRes := new(oidc.TokenExchangeResponse) + if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil { + return nil, err + } + return tokenRes, nil +} + func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) { privateKey, err := crypto.BytesToPrivateKey(key) if err != nil { diff --git a/pkg/client/rp/integration_test.go b/pkg/client/integration_test.go similarity index 73% rename from pkg/client/rp/integration_test.go rename to pkg/client/integration_test.go index e08e2eb8..75f8c7ee 100644 --- a/pkg/client/rp/integration_test.go +++ b/pkg/client/integration_test.go @@ -1,4 +1,4 @@ -package rp_test +package client_test import ( "bytes" @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/oidc/pkg/client/rp" + "github.com/zitadel/oidc/pkg/client/rs" + "github.com/zitadel/oidc/pkg/client/tokenexchange" httphelper "github.com/zitadel/oidc/pkg/http" "github.com/zitadel/oidc/pkg/oidc" ) @@ -35,13 +37,119 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("auth server at %s", opServer.URL) dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) + clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, "secret") + + t.Log("------- refresh tokens ------") + + newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") + require.NoError(t, err, "refresh token") + assert.NotNil(t, newTokens, "access token") + t.Logf("new access token %s", newTokens.AccessToken) + t.Logf("new refresh token %s", newTokens.RefreshToken) + t.Logf("new token type %s", newTokens.TokenType) + t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) + require.NotEmpty(t, newTokens.AccessToken, "new accessToken") + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------ attempt refresh again (should fail) ------") + t.Log("trying original refresh token", refreshToken) + _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") + assert.Errorf(t, err, "refresh with original") + if newTokens.RefreshToken != "" { + t.Log("trying replacement refresh token", newTokens.RefreshToken) + _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") + assert.Errorf(t, err, "refresh with replacement") + } +} + +func TestResourceServerTokenExchange(t *testing.T) { + t.Log("------- start example OP ------") + ctx := context.Background() + exampleStorage := storage.NewStorage(storage.NewUserStore()) + var dh deferredHandler + opServer := httptest.NewServer(&dh) + defer opServer.Close() + t.Logf("auth server at %s", opServer.URL) + dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) + + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) + clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) + clientSecret := "secret" + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret) + + resourceServer, err := rs.NewResourceServerClientCredentials(opServer.URL, clientID, clientSecret) + require.NoError(t, err, "new resource server") + + t.Log("------- exchage refresh tokens (impersonation) ------") + + tokenExchangeResponse, err := tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.NoError(t, err, "refresh token") + require.NotNil(t, tokenExchangeResponse, "token exchange response") + assert.Equal(t, tokenExchangeResponse.IssuedTokenType, oidc.RefreshTokenType) + assert.NotEmpty(t, tokenExchangeResponse.AccessToken, "access token") + assert.NotEmpty(t, tokenExchangeResponse.RefreshToken, "refresh token") + assert.Equal(t, []string(tokenExchangeResponse.Scopes), []string{"profile", "custom_scope:impersonate:id2"}) + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------- attempt exchage again (should fail) ------") + + tokenExchangeResponse, err = tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.Error(t, err, "refresh token") + assert.Contains(t, err.Error(), "subject_token is invalid") + require.Nil(t, tokenExchangeResponse, "token exchange response") + +} + +func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, accessToken, refreshToken, idToken string) { targetURL := "http://local-site" localURL, err := url.Parse(targetURL + "/login?requestID=1234") require.NoError(t, err, "local url") - seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) - clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) - client := storage.WebClient(clientID, "secret", targetURL) + client := storage.WebClient(clientID, clientSecret, targetURL) storage.RegisterClients(client) jar, err := cookiejar.New(nil) @@ -57,10 +165,10 @@ func TestRelyingPartySession(t *testing.T) { t.Log("------- create RP ------") key := []byte("test1234test1234") cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) - provider, err := rp.NewRelyingPartyOIDC( + provider, err = rp.NewRelyingPartyOIDC( opServer.URL, clientID, - "secret", + clientSecret, targetURL, []string{"openid", "email", "profile", "offline_access"}, rp.WithPKCE(cookieHandler), @@ -69,8 +177,10 @@ func TestRelyingPartySession(t *testing.T) { rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"), ), ) + require.NoError(t, err, "new rp") t.Log("------- get redirect from local client (rp) to OP ------") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) state := "state-" + strconv.FormatInt(seed.Int63(), 25) capturedW := httptest.NewRecorder() get := httptest.NewRequest("GET", localURL.String(), nil) @@ -124,7 +234,7 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("setting cookie %s", cookie) } - var accessToken, refreshToken, idToken, email string + var email string redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { require.NotNil(t, tokens, "tokens") require.NotNil(t, info, "info") @@ -137,7 +247,7 @@ func TestRelyingPartySession(t *testing.T) { refreshToken = tokens.RefreshToken idToken = tokens.IDToken email = info.GetEmail() - http.Redirect(w, r, targetURL, 302) + http.Redirect(w, r, targetURL, http.StatusFound) } rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get) @@ -162,36 +272,7 @@ func TestRelyingPartySession(t *testing.T) { assert.NotEmpty(t, accessToken, "access token") assert.NotEmpty(t, email, "email") - t.Log("------- refresh tokens ------") - - newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") - require.NoError(t, err, "refresh token") - assert.NotNil(t, newTokens, "access token") - t.Logf("new access token %s", newTokens.AccessToken) - t.Logf("new refresh token %s", newTokens.RefreshToken) - t.Logf("new token type %s", newTokens.TokenType) - t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) - require.NotEmpty(t, newTokens.AccessToken, "new accessToken") - - t.Log("------ end session (logout) ------") - - newLoc, err := rp.EndSession(provider, idToken, "", "") - require.NoError(t, err, "logout") - if newLoc != nil { - t.Logf("redirect to %s", newLoc) - } else { - t.Logf("no redirect") - } - - t.Log("------ attempt refresh again (should fail) ------") - t.Log("trying original refresh token", refreshToken) - _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") - assert.Errorf(t, err, "refresh with original") - if newTokens.RefreshToken != "" { - t.Log("trying replacement refresh token", newTokens.RefreshToken) - _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") - assert.Errorf(t, err, "refresh with replacement") - } + return provider, accessToken, refreshToken, idToken } type deferredHandler struct { diff --git a/pkg/client/rs/resource_server.go b/pkg/client/rs/resource_server.go index b1bc47ea..6536c7fc 100644 --- a/pkg/client/rs/resource_server.go +++ b/pkg/client/rs/resource_server.go @@ -13,6 +13,7 @@ import ( type ResourceServer interface { IntrospectionURL() string + TokenEndpoint() string HttpClient() *http.Client AuthFn() (interface{}, error) } @@ -29,6 +30,10 @@ func (r *resourceServer) IntrospectionURL() string { return r.introspectURL } +func (r *resourceServer) TokenEndpoint() string { + return r.tokenURL +} + func (r *resourceServer) HttpClient() *http.Client { return r.httpClient } diff --git a/pkg/client/tokenexchange/tokenexchange.go b/pkg/client/tokenexchange/tokenexchange.go new file mode 100644 index 00000000..15b5d40f --- /dev/null +++ b/pkg/client/tokenexchange/tokenexchange.go @@ -0,0 +1,127 @@ +package tokenexchange + +import ( + "errors" + "net/http" + + "github.com/zitadel/oidc/pkg/client" + httphelper "github.com/zitadel/oidc/pkg/http" + "github.com/zitadel/oidc/pkg/oidc" +) + +type TokenExchanger interface { + TokenEndpoint() string + HttpClient() *http.Client + AuthFn() (interface{}, error) +} + +type OAuthTokenExchange struct { + httpClient *http.Client + tokenEndpoint string + authFn func() (interface{}, error) +} + +func NewTokenExchanger(issuer string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + return newOAuthTokenExchange(issuer, nil, options...) +} + +func NewTokenExchangerClientCredentials(issuer, clientID, clientSecret string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + authorizer := func() (interface{}, error) { + return httphelper.AuthorizeBasic(clientID, clientSecret), nil + } + return newOAuthTokenExchange(issuer, authorizer, options...) +} + +func newOAuthTokenExchange(issuer string, authorizer func() (interface{}, error), options ...func(source *OAuthTokenExchange)) (*OAuthTokenExchange, error) { + te := &OAuthTokenExchange{ + httpClient: httphelper.DefaultHTTPClient, + } + for _, opt := range options { + opt(te) + } + + if te.tokenEndpoint == "" { + config, err := client.Discover(issuer, te.httpClient) + if err != nil { + return nil, err + } + + te.tokenEndpoint = config.TokenEndpoint + } + + if te.tokenEndpoint == "" { + return nil, errors.New("tokenURL is empty: please provide with either `WithStaticTokenEndpoint` or a discovery url") + } + + te.authFn = authorizer + + return te, nil +} + +func WithHTTPClient(client *http.Client) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.httpClient = client + } +} + +func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.tokenEndpoint = tokenEndpoint + } +} + +func (te *OAuthTokenExchange) TokenEndpoint() string { + return te.tokenEndpoint +} + +func (te *OAuthTokenExchange) HttpClient() *http.Client { + return te.httpClient +} + +func (te *OAuthTokenExchange) AuthFn() (interface{}, error) { + if te.authFn != nil { + return te.authFn() + } + + return nil, nil +} + +// ExchangeToken sends a token exchange request (rfc 8693) to te's token endpoint. +// SubjectToken and SubjectTokenType are required parameters. +func ExchangeToken( + te TokenExchanger, + SubjectToken string, + SubjectTokenType oidc.TokenType, + ActorToken string, + ActorTokenType oidc.TokenType, + Resource []string, + Audience []string, + Scopes []string, + RequestedTokenType oidc.TokenType, +) (*oidc.TokenExchangeResponse, error) { + if SubjectToken == "" { + return nil, errors.New("empty subject_token") + } + if SubjectTokenType == "" { + return nil, errors.New("empty subject_token_type") + } + + authFn, err := te.AuthFn() + if err != nil { + return nil, err + } + + request := oidc.TokenExchangeRequest{ + GrantType: oidc.GrantTypeTokenExchange, + SubjectToken: SubjectToken, + SubjectTokenType: SubjectTokenType, + ActorToken: ActorToken, + ActorTokenType: ActorTokenType, + Resource: Resource, + Audience: Audience, + Scopes: Scopes, + RequestedTokenType: RequestedTokenType, + } + + return client.CallTokenExchangeEndpoint(request, authFn, te) +} From ad5a422e073a3c75cd291340e25c2224a7766c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 16 Feb 2023 14:19:19 +0200 Subject: [PATCH 4/5] resolve some compatibility issues after merge --- pkg/client/integration_test.go | 3 ++- pkg/client/rp/mock/generate.go | 2 +- pkg/client/rp/mock/verifier.mock.go | 2 +- pkg/client/tokenexchange/tokenexchange.go | 6 +++--- pkg/op/token_exchange.go | 10 +++++----- pkg/op/token_request.go | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go index 105af6f0..e89004a5 100644 --- a/pkg/client/integration_test.go +++ b/pkg/client/integration_test.go @@ -80,7 +80,8 @@ func TestRelyingPartySession(t *testing.T) { func TestResourceServerTokenExchange(t *testing.T) { t.Log("------- start example OP ------") ctx := context.Background() - exampleStorage := storage.NewStorage(storage.NewUserStore()) + targetURL := "http://local-site" + exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) var dh deferredHandler opServer := httptest.NewServer(&dh) defer opServer.Close() diff --git a/pkg/client/rp/mock/generate.go b/pkg/client/rp/mock/generate.go index 1e057013..c13df638 100644 --- a/pkg/client/rp/mock/generate.go +++ b/pkg/client/rp/mock/generate.go @@ -1,3 +1,3 @@ package mock -//go:generate mockgen -package mock -destination ./verifier.mock.go github.com/zitadel/oidc/pkg/rp Verifier +//go:generate mockgen -package mock -destination ./verifier.mock.go github.com/zitadel/oidc/v2/pkg/rp Verifier diff --git a/pkg/client/rp/mock/verifier.mock.go b/pkg/client/rp/mock/verifier.mock.go index 9d1daa13..e62a1458 100644 --- a/pkg/client/rp/mock/verifier.mock.go +++ b/pkg/client/rp/mock/verifier.mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/rp (interfaces: Verifier) +// Source: github.com/zitadel/oidc/v2/pkg/rp (interfaces: Verifier) // Package mock is a generated GoMock package. package mock diff --git a/pkg/client/tokenexchange/tokenexchange.go b/pkg/client/tokenexchange/tokenexchange.go index 15b5d40f..1375f687 100644 --- a/pkg/client/tokenexchange/tokenexchange.go +++ b/pkg/client/tokenexchange/tokenexchange.go @@ -4,9 +4,9 @@ import ( "errors" "net/http" - "github.com/zitadel/oidc/pkg/client" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type TokenExchanger interface { diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index fc2db303..4ce12414 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -7,8 +7,8 @@ import ( "strings" "time" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type TokenExchangeRequest interface { @@ -291,7 +291,7 @@ func GetTokenIDAndSubjectFromToken( tokenIDOrToken, subject, ok = token, refreshTokenRequest.GetSubject(), true case oidc.IDTokenType: - idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier()) + idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier(ctx)) if err != nil { break } @@ -355,7 +355,7 @@ func CreateTokenExchangeResponse( tokenType = oidc.BearerToken case oidc.IDTokenType: - token, err = CreateIDToken(ctx, creator.Issuer(), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), creator.Signer(), client) + token, err = CreateIDToken(ctx, IssuerFromContext(ctx), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), client) if err != nil { return nil, err } @@ -390,7 +390,7 @@ func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, return splitToken[0], splitToken[1], nil, true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier()) + accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", nil, false } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 883b8177..3d65ea0c 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -19,8 +19,8 @@ type Exchanger interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool - AccessTokenVerifier() AccessTokenVerifier - IDTokenHintVerifier() IDTokenHintVerifier + AccessTokenVerifier(context.Context) AccessTokenVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier } func tokenHandler(exchanger Exchanger) func(w http.ResponseWriter, r *http.Request) { From 728dd90c1ecd6f5226a319dd68a39f2278171696 Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Sat, 18 Feb 2023 03:23:56 +0100 Subject: [PATCH 5/5] fix: review remarks --- pkg/client/client.go | 4 ---- pkg/oidc/token_request.go | 2 +- pkg/op/storage.go | 4 ++-- pkg/op/token.go | 12 ++++++++---- pkg/op/token_exchange.go | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index 24f73066..077baf2d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -152,10 +152,6 @@ func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCa } func CallTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { - return callTokenExchangeEndpoint(request, authFn, caller) -} - -func callTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn) if err != nil { return nil, err diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 3eeee37a..6d8f1860 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -53,7 +53,7 @@ var AllTokenTypes = []TokenType{ type TokenType string -func (t TokenType) IsValid() bool { +func (t TokenType) IsSupported() bool { for _, tt := range AllTokenTypes { if t == tt { return true diff --git a/pkg/op/storage.go b/pkg/op/storage.go index d7fac185..1e19c762 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -26,7 +26,7 @@ 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 + // * 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: @@ -39,7 +39,7 @@ type AuthStorage interface { // Used for the authorization code flow which requested offline_access scope and // registered the refresh_token grant type in advance // - // * TokenExchangeRequest + // * 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) diff --git a/pkg/op/token.go b/pkg/op/token.go index 22b50a16..3a350621 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -114,8 +114,11 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex privateClaims map[string]interface{} err error ) - if tokenExchangeRequest, ok := tokenRequest.(TokenExchangeRequest); ok { - privateClaims, err = storage.(TokenExchangeStorage).GetPrivateClaimsFromTokenExchangeRequest( + + tokenExchangeRequest, okReq := tokenRequest.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { + privateClaims, err = teStorage.GetPrivateClaimsFromTokenExchangeRequest( ctx, tokenExchangeRequest, ) @@ -172,9 +175,10 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v } } - if tokenExchangeRequest, ok := request.(TokenExchangeRequest); ok { + tokenExchangeRequest, okReq := request.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { userInfo := oidc.NewUserInfo() - teStorage := storage.(TokenExchangeStorage) err := teStorage.SetUserinfoFromTokenExchangeRequest(ctx, userInfo, tokenExchangeRequest) if err != nil { return "", err diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index 4ce12414..6b918b1b 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -208,15 +208,15 @@ func ValidateTokenExchangeRequest( return nil, nil, err } - if oidcTokenExchangeRequest.RequestedTokenType != "" && !oidcTokenExchangeRequest.RequestedTokenType.IsValid() { + if oidcTokenExchangeRequest.RequestedTokenType != "" && !oidcTokenExchangeRequest.RequestedTokenType.IsSupported() { return nil, nil, oidc.ErrInvalidRequest().WithDescription("requested_token_type is not supported") } - if !oidcTokenExchangeRequest.SubjectTokenType.IsValid() { + if !oidcTokenExchangeRequest.SubjectTokenType.IsSupported() { return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type is not supported") } - if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsValid() { + if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsSupported() { return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token_type is not supported") }