feat(oidc): token exchange impersonation (#7516)

* add token exchange feature flag

* allow setting reason and actor to access tokens

* impersonation

* set token types and scopes in response

* upgrade oidc to working draft state

* fix tests

* audience and scope validation

* id toke and jwt as input

* return id tokens

* add grant type  token exchange to app config

* add integration tests

* check and deny actors in api calls

* fix instance setting tests by triggering projection on write and cleanup

* insert sleep statements again

* solve linting issues

* add translations

* pin oidc v3.15.0

* resolve comments, add event translation

* fix refreshtoken test

* use ValidateAuthReqScopes from oidc

* apparently the linter can't make up its mind

* persist actor thru refresh tokens and check in tests

* remove unneeded triggers
This commit is contained in:
Tim Möhlmann
2024-03-20 12:18:46 +02:00
committed by GitHub
parent b338171585
commit 6398349c24
104 changed files with 2149 additions and 248 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/user/model"
"github.com/zitadel/zitadel/internal/zerrors"
@@ -19,13 +20,17 @@ import (
type accessToken struct {
tokenID string
userID string
resourceOwner string
subject string
clientID string
audience []string
scope []string
authMethods []domain.UserAuthMethodType
authTime time.Time
tokenCreation time.Time
tokenExpiration time.Time
isPAT bool
actor *domain.TokenActor
}
var ErrInvalidTokenFormat = errors.New("invalid token format")
@@ -67,6 +72,7 @@ func accessTokenV1(tokenID, subject string, token *model.TokenView) *accessToken
return &accessToken{
tokenID: tokenID,
userID: token.UserID,
resourceOwner: token.ResourceOwner,
subject: subject,
clientID: token.ApplicationID,
audience: token.Audience,
@@ -74,6 +80,7 @@ func accessTokenV1(tokenID, subject string, token *model.TokenView) *accessToken
tokenCreation: token.CreationDate,
tokenExpiration: token.Expiration,
isPAT: token.IsPAT,
actor: token.Actor,
}
}
@@ -81,17 +88,21 @@ func accessTokenV2(tokenID, subject string, token *query.OIDCSessionAccessTokenR
return &accessToken{
tokenID: tokenID,
userID: token.UserID,
resourceOwner: token.ResourceOwner,
subject: subject,
clientID: token.ClientID,
audience: token.Audience,
scope: token.Scope,
authMethods: token.AuthMethods,
authTime: token.AuthTime,
tokenCreation: token.AccessTokenCreation,
tokenExpiration: token.AccessTokenExpiration,
actor: token.Actor,
}
}
func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToken, clientID, projectID string) error {
token.audience = append(token.audience, clientID)
token.audience = append(token.audience, clientID, projectID)
projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID)
if err != nil {
return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal")

View File

@@ -21,6 +21,9 @@ const (
//
// [RFC 8176, section 2]: https://datatracker.ietf.org/doc/html/rfc8176#section-2
func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string {
if methodTypes == nil {
return nil // make sure amr is omitted when not provided / supported
}
amr := make([]string, 0, 4)
var factors, otp int
for _, methodType := range methodTypes {
@@ -34,7 +37,8 @@ func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string {
case domain.UserAuthMethodTypeU2F:
amr = append(amr, UserPresence)
factors++
case domain.UserAuthMethodTypeTOTP,
case domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
domain.UserAuthMethodTypeOTPSMS,
domain.UserAuthMethodTypeOTPEmail:
// a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry
@@ -55,3 +59,33 @@ func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string {
}
return amr
}
func AMRToAuthMethodTypes(amr []string) []domain.UserAuthMethodType {
authMethods := make([]domain.UserAuthMethodType, 0, len(amr))
var (
userPresence bool
mfa bool
)
for _, entry := range amr {
switch entry {
case Password, PWD:
authMethods = append(authMethods, domain.UserAuthMethodTypePassword)
case OTP:
authMethods = append(authMethods, domain.UserAuthMethodTypeOTP)
case UserPresence:
userPresence = true
case MFA:
mfa = true
}
}
if userPresence {
if mfa {
authMethods = append(authMethods, domain.UserAuthMethodTypePasswordless)
} else {
authMethods = append(authMethods, domain.UserAuthMethodTypeU2F)
}
}
return authMethods
}

View File

@@ -22,7 +22,7 @@ func TestAMR(t *testing.T) {
args{
nil,
},
[]string{},
nil,
},
{
"pw checked",

View File

@@ -207,27 +207,18 @@ func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest)
err = oidcError(err)
span.EndWithError(err)
}()
var userAgentID, applicationID, userOrgID string
switch authReq := req.(type) {
case *AuthRequest:
userAgentID = authReq.AgentID
applicationID = authReq.ApplicationID
userOrgID = authReq.UserOrgID
case *AuthRequestV2:
// trigger activity log for authentication for user
if authReq, ok := req.(*AuthRequestV2); ok {
activity.Trigger(ctx, "", authReq.CurrentAuthRequest.UserID, activity.OIDCAccessToken, o.eventstore.FilterToQueryReducer)
return o.command.AddOIDCSessionAccessToken(setContextUserSystem(ctx), authReq.GetID())
case op.IDTokenRequest:
applicationID = authReq.GetClientID()
}
userAgentID, applicationID, userOrgID, authTime, amr, reason, actor := getInfoFromRequest(req)
accessTokenLifetime, _, _, _, err := o.getOIDCSettings(ctx)
if err != nil {
return "", time.Time{}, err
}
resp, err := o.command.AddUserToken(setContextUserSystem(ctx), userOrgID, userAgentID, applicationID, req.GetSubject(), req.GetAudience(), req.GetScopes(), accessTokenLifetime) //PLANNED: lifetime from client
resp, err := o.command.AddUserToken(setContextUserSystem(ctx), userOrgID, userAgentID, applicationID, req.GetSubject(), req.GetAudience(), req.GetScopes(), amr, accessTokenLifetime, authTime, reason, actor)
if err != nil {
return "", time.Time{}, err
}
@@ -256,7 +247,7 @@ func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.Tok
return o.command.ExchangeOIDCSessionRefreshAndAccessToken(setContextUserSystem(ctx), tokenReq.OIDCSessionWriteModel.AggregateID, refreshToken, tokenReq.RequestedScopes)
}
userAgentID, applicationID, userOrgID, authTime, authMethodsReferences := getInfoFromRequest(req)
userAgentID, applicationID, userOrgID, authTime, authMethodsReferences, reason, actor := getInfoFromRequest(req)
scopes, err := o.assertProjectRoleScopes(ctx, applicationID, req.GetScopes())
if err != nil {
return "", "", time.Time{}, zerrors.ThrowPreconditionFailed(err, "OIDC-Df2fq", "Errors.Internal")
@@ -272,7 +263,7 @@ func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.Tok
resp, token, err := o.command.AddAccessAndRefreshToken(setContextUserSystem(ctx), userOrgID, userAgentID, applicationID, req.GetSubject(),
refreshToken, req.GetAudience(), scopes, authMethodsReferences, accessTokenLifetime,
refreshTokenIdleExpiration, refreshTokenExpiration, authTime) //PLANNED: lifetime from client
refreshTokenIdleExpiration, refreshTokenExpiration, authTime, reason, actor) //PLANNED: lifetime from client
if err != nil {
if zerrors.IsErrorInvalidArgument(err) {
err = oidc.ErrInvalidGrant().WithParent(err)
@@ -285,16 +276,20 @@ func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.Tok
return resp.TokenID, token, resp.Expiration, nil
}
func getInfoFromRequest(req op.TokenRequest) (string, string, string, time.Time, []string) {
func getInfoFromRequest(req op.TokenRequest) (agentID string, clientID string, userOrgID string, authTime time.Time, amr []string, reason domain.TokenReason, actor *domain.TokenActor) {
switch r := req.(type) {
case *AuthRequest:
return r.AgentID, r.ApplicationID, r.UserOrgID, r.AuthTime, r.GetAMR()
return r.AgentID, r.ApplicationID, r.UserOrgID, r.AuthTime, r.GetAMR(), domain.TokenReasonAuthRequest, nil
case *RefreshTokenRequest:
return r.UserAgentID, r.ClientID, "", r.AuthTime, r.AuthMethodsReferences
return r.UserAgentID, r.ClientID, "", r.AuthTime, r.AuthMethodsReferences, domain.TokenReasonRefresh, r.Actor
case op.IDTokenRequest:
return "", r.GetClientID(), "", r.GetAuthTime(), r.GetAMR()
return "", r.GetClientID(), "", r.GetAuthTime(), r.GetAMR(), domain.TokenReasonAuthRequest, nil
case *oidc.JWTTokenRequest:
return "", "", "", r.GetAuthTime(), nil, domain.TokenReasonJWTProfile, nil
case *clientCredentialsRequest:
return "", "", "", time.Time{}, nil, domain.TokenReasonClientCredentials, nil
default:
return "", "", "", time.Time{}, nil
return "", "", "", time.Time{}, nil, domain.TokenReasonAuthRequest, nil
}
}

View File

@@ -308,3 +308,7 @@ func (r *RefreshTokenRequest) GetSubject() string {
func (r *RefreshTokenRequest) SetCurrentScopes(scopes []string) {
r.Scopes = scopes
}
func (r *RefreshTokenRequest) GetActor() *oidc.ActorClaims {
return actorDomainToClaims(r.Actor)
}

View File

@@ -27,15 +27,17 @@ import (
)
const (
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
ClaimUserMetaData = ScopeUserMetaData
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
ClaimResourceOwner = ScopeResourceOwner + ":"
ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log"
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
ClaimUserMetaData = ScopeUserMetaData
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
ClaimResourceOwnerID = ScopeResourceOwner + ":id"
ClaimResourceOwnerName = ScopeResourceOwner + ":name"
ClaimResourceOwnerPrimaryDomain = ScopeResourceOwner + ":primary_domain"
ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log"
oidcCtx = "oidc"
)
@@ -868,9 +870,9 @@ func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string)
return nil, err
}
return map[string]string{
ClaimResourceOwner + "id": resourceOwner.ID,
ClaimResourceOwner + "name": resourceOwner.Name,
ClaimResourceOwner + "primary_domain": resourceOwner.Domain,
ClaimResourceOwnerID: resourceOwner.ID,
ClaimResourceOwnerName: resourceOwner.Name,
ClaimResourceOwnerPrimaryDomain: resourceOwner.Domain,
}, nil
}

View File

@@ -215,6 +215,8 @@ func grantTypeToOIDC(grantType domain.OIDCGrantType) oidc.GrantType {
return oidc.GrantTypeRefreshToken
case domain.OIDCGrantTypeDeviceCode:
return oidc.GrantTypeDeviceCode
case domain.OIDCGrantTypeTokenExchange:
return oidc.GrantTypeTokenExchange
default:
return oidc.GrantTypeCode
}

View File

@@ -213,9 +213,9 @@ func assertIntrospection(
assertOIDCTime(t, introspection.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
require.NotNil(t, introspection.Claims)
assert.Equal(t, User.Details.ResourceOwner, introspection.Claims[oidc_api.ClaimResourceOwner+"id"])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwner+"name"])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwner+"primary_domain"])
assert.Equal(t, User.Details.ResourceOwner, introspection.Claims[oidc_api.ClaimResourceOwnerID])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerName])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
}
// TestServer_VerifyClient tests verification by running code flow tests

View File

@@ -106,16 +106,19 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
return nil, err
}
introspectionResp := &oidc.IntrospectionResponse{
Active: true,
Scope: token.scope,
ClientID: token.clientID,
TokenType: oidc.BearerToken,
Expiration: oidc.FromTime(token.tokenExpiration),
IssuedAt: oidc.FromTime(token.tokenCreation),
NotBefore: oidc.FromTime(token.tokenCreation),
Audience: token.audience,
Issuer: op.IssuerFromContext(ctx),
JWTID: token.tokenID,
Active: true,
Scope: token.scope,
ClientID: token.clientID,
TokenType: oidc.BearerToken,
Expiration: oidc.FromTime(token.tokenExpiration),
IssuedAt: oidc.FromTime(token.tokenCreation),
AuthTime: oidc.FromTime(token.authTime),
NotBefore: oidc.FromTime(token.tokenCreation),
Audience: token.audience,
AuthenticationMethodsReferences: AuthMethodTypesToAMR(token.authMethods),
Issuer: op.IssuerFromContext(ctx),
JWTID: token.tokenID,
Actor: actorDomainToClaims(token.actor),
}
introspectionResp.SetUserInfo(userInfo)
return op.NewResponse(introspectionResp), nil

View File

@@ -311,7 +311,7 @@ func (o *OPStorage) SigningKey(ctx context.Context) (key op.SigningKey, err erro
return err
}
if key == nil {
return zerrors.ThrowInternal(nil, "test", "test")
return zerrors.ThrowNotFound(nil, "OIDC-ve4Qu", "Errors.Internal")
}
return nil
})

View File

@@ -174,13 +174,6 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr
return s.LegacyServer.JWTProfile(ctx, r)
}
func (s *Server) TokenExchange(ctx context.Context, r *op.ClientRequest[oidc.TokenExchangeRequest]) (_ *op.Response, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
return s.LegacyServer.TokenExchange(ctx, r)
}
func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequest[oidc.ClientCredentialsRequest]) (_ *op.Response, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()

View File

@@ -0,0 +1,347 @@
package oidc
import (
"context"
"slices"
"time"
"github.com/zitadel/oidc/v3/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
UserIDTokenType oidc.TokenType = "urn:zitadel:params:oauth:token-type:user_id"
// TokenTypeNA is set when the returned Token Exchange access token value can't be used as an access token.
// For example, when it is an ID Token.
// See [RFC 8693, section 2.2.1, token_type](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.1)
TokenTypeNA = "N_A"
)
func init() {
oidc.AllTokenTypes = append(oidc.AllTokenTypes, UserIDTokenType)
}
func (s *Server) TokenExchange(ctx context.Context, r *op.ClientRequest[oidc.TokenExchangeRequest]) (_ *op.Response, err error) {
resp, err := s.tokenExchange(ctx, r)
if err != nil {
return nil, oidcError(err)
}
return resp, nil
}
func (s *Server) tokenExchange(ctx context.Context, r *op.ClientRequest[oidc.TokenExchangeRequest]) (_ *op.Response, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if !authz.GetFeatures(ctx).TokenExchange {
return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-oan4I", "Errors.TokenExchange.FeatureDisabled")
}
if len(r.Data.Resource) > 0 {
return nil, oidc.ErrInvalidTarget().WithDescription("resource parameter not supported")
}
client, ok := r.Client.(*Client)
if !ok {
// not supposed to happen, but just preventing a panic if it does.
return nil, zerrors.ThrowInternal(nil, "OIDC-eShi5", "Error.Internal")
}
subjectToken, err := s.verifyExchangeToken(ctx, client, r.Data.SubjectToken, r.Data.SubjectTokenType, oidc.AllTokenTypes...)
if err != nil {
return nil, oidc.ErrInvalidRequest().WithParent(err).WithDescription("subject_token invalid")
}
actorToken := subjectToken // see [createExchangeTokens] comment.
if subjectToken.tokenType == UserIDTokenType || subjectToken.tokenType == oidc.JWTTokenType || r.Data.ActorToken != "" {
if !authz.GetInstance(ctx).EnableImpersonation() {
return nil, zerrors.ThrowPermissionDenied(nil, "OIDC-Fae5w", "Errors.TokenExchange.Impersonation.PolicyDisabled")
}
actorToken, err = s.verifyExchangeToken(ctx, client, r.Data.ActorToken, r.Data.ActorTokenType, oidc.AccessTokenType, oidc.IDTokenType, oidc.RefreshTokenType)
if err != nil {
return nil, oidc.ErrInvalidRequest().WithParent(err).WithDescription("actor_token invalid")
}
ctx = authz.SetCtxData(ctx, authz.CtxData{
UserID: actorToken.userID,
OrgID: actorToken.resourceOwner,
})
}
audience, err := validateTokenExchangeAudience(r.Data.Audience, subjectToken.audience, actorToken.audience)
if err != nil {
return nil, err
}
scopes, err := validateTokenExchangeScopes(client, r.Data.Scopes, subjectToken.scopes, actorToken.scopes)
if err != nil {
return nil, err
}
resp, err := s.createExchangeTokens(ctx, r.Data.RequestedTokenType, client, subjectToken, actorToken, audience, scopes)
if err != nil {
return nil, err
}
return op.NewResponse(resp), nil
}
// verifyExchangeToken verifies the passed token based on the token type. It is safe to pass both from the request as-is.
// A list of allowed token types must be passed to determine which types are trusted at a particular stage of the token exchange.
func (s *Server) verifyExchangeToken(ctx context.Context, client *Client, token string, tokenType oidc.TokenType, allowed ...oidc.TokenType) (*exchangeToken, error) {
if token == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-lei0O", "Errors.TokenExchange.Token.Missing")
}
if tokenType == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-sei9V", "Errors.TokenExchange.Token.TypeMissing")
}
if !slices.Contains(allowed, tokenType) {
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-OZ1ie", "Errors.TokenExchange.Token.TypeNotAllowed")
}
switch tokenType {
case oidc.AccessTokenType:
token, err := s.verifyAccessToken(ctx, token)
if err != nil {
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Osh3t", "Errors.TokenExchange.Token.Invalid")
}
if token.isPAT {
if err = s.assertClientScopesForPAT(ctx, token, client.GetID(), client.client.ProjectID); err != nil {
return nil, err
}
}
return accessToExchangeToken(token, op.IssuerFromContext(ctx)), nil
case oidc.IDTokenType:
verifier := op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), s.idTokenHintKeySet)
claims, err := op.VerifyIDTokenHint[*oidc.IDTokenClaims](ctx, token, verifier)
if err != nil {
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Rei0f", "Errors.TokenExchange.Token.Invalid")
}
resourceOwner, ok := claims.Claims[ClaimResourceOwnerID].(string)
if !ok || resourceOwner == "" {
user, err := s.query.GetUserByID(ctx, false, token)
if err != nil {
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-aD0Oo", "Errors.TokenExchange.Token.Invalid")
}
resourceOwner = user.ResourceOwner
}
return idTokenClaimsToExchangeToken(claims, resourceOwner), nil
case oidc.JWTTokenType:
resourceOwner := new(string)
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, client.client.ClockSkew, s.jwtProfileUserCheck(ctx, resourceOwner))
jwt, err := op.VerifyJWTAssertion(ctx, token, verifier)
if err != nil {
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-eiS6o", "Errors.TokenExchange.Token.Invalid")
}
return jwtToExchangeToken(jwt, *resourceOwner), nil
case UserIDTokenType:
user, err := s.query.GetUserByID(ctx, false, token)
if err != nil {
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Nee6r", "Errors.TokenExchange.Token.Invalid")
}
return userToExchangeToken(user), nil
case oidc.RefreshTokenType:
fallthrough
default:
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-oda4R", "Errors.TokenExchange.Token.TypeNotSupported")
}
}
func (s *Server) jwtProfileUserCheck(ctx context.Context, resourceOwner *string) op.JWTProfileVerifierOption {
return op.SubjectCheck(func(request *oidc.JWTTokenRequest) error {
user, err := s.query.GetUserByID(ctx, false, request.Subject)
if err != nil {
return zerrors.ThrowPermissionDenied(err, "OIDC-Nee6r", "Errors.TokenExchange.Token.Invalid")
}
*resourceOwner = user.ResourceOwner
return nil
})
}
func validateTokenExchangeScopes(client *Client, requestedScopes, subjectScopes, actorScopes []string) ([]string, error) {
// Scope always has 1 empty string is the space delimited array was an empty string.
scopes := slices.DeleteFunc(requestedScopes, func(s string) bool {
return s == ""
})
if len(scopes) == 0 {
scopes = subjectScopes
}
if len(scopes) == 0 {
scopes = actorScopes
}
return op.ValidateAuthReqScopes(client, scopes)
}
func validateTokenExchangeAudience(requestedAudience, subjectAudience, actorAudience []string) ([]string, error) {
if len(requestedAudience) == 0 {
if len(subjectAudience) > 0 {
return subjectAudience, nil
}
if len(actorAudience) > 0 {
return actorAudience, nil
}
}
if slices.Equal(requestedAudience, subjectAudience) || slices.Equal(requestedAudience, actorAudience) {
return requestedAudience, nil
}
//nolint:gocritic
allowedAudience := append(subjectAudience, actorAudience...)
for _, a := range requestedAudience {
if !slices.Contains(allowedAudience, a) {
return nil, oidc.ErrInvalidTarget().WithDescription("audience %q not found in subject or actor token", a)
}
}
return requestedAudience, nil
}
// createExchangeTokens prepares the final tokens to be returned to the client.
// The subjectToken is used to set the new token's subject and resource owner.
// The actorToken is used to set the new token's auth time AMR and actor.
// Both tokens may point to the same object (subjectToken) in case of a regular Token Exchange.
// When the subject and actor Tokens point to different objects, the new tokens will be for impersonation / delegation.
func (s *Server) createExchangeTokens(ctx context.Context, tokenType oidc.TokenType, client *Client, subjectToken, actorToken *exchangeToken, audience, scopes []string) (_ *oidc.TokenExchangeResponse, err error) {
var (
userInfo *oidc.UserInfo
signingKey op.SigningKey
)
if slices.Contains(scopes, oidc.ScopeOpenID) || tokenType == oidc.JWTTokenType || tokenType == oidc.IDTokenType {
projectID := client.client.ProjectID
userInfo, err = s.userInfo(ctx, subjectToken.userID, projectID, scopes, []string{projectID})
if err != nil {
return nil, err
}
signingKey, err = s.Provider().Storage().SigningKey(ctx)
if err != nil {
return nil, err
}
}
resp := &oidc.TokenExchangeResponse{
Scopes: scopes,
}
reason := domain.TokenReasonExchange
actor := actorToken.actor
if subjectToken != actorToken {
reason = domain.TokenReasonImpersonation
actor = actorToken.nestedActor()
}
switch tokenType {
case oidc.AccessTokenType, "":
resp.AccessToken, resp.RefreshToken, resp.ExpiresIn, err = s.createExchangeAccessToken(ctx, client, subjectToken.resourceOwner, subjectToken.userID, audience, scopes, actorToken.authMethods, actorToken.authTime, reason, actor)
resp.TokenType = oidc.BearerToken
resp.IssuedTokenType = oidc.AccessTokenType
case oidc.JWTTokenType:
resp.AccessToken, resp.RefreshToken, resp.ExpiresIn, err = s.createExchangeJWT(ctx, signingKey, client, subjectToken.resourceOwner, subjectToken.userID, audience, scopes, actorToken.authMethods, actorToken.authTime, reason, actor, userInfo.Claims)
resp.TokenType = oidc.BearerToken
resp.IssuedTokenType = oidc.JWTTokenType
case oidc.IDTokenType:
resp.AccessToken, resp.ExpiresIn, err = s.createExchangeIDToken(ctx, signingKey, client, subjectToken.userID, "", audience, userInfo, actorToken.authMethods, actorToken.authTime, reason, actor)
resp.TokenType = TokenTypeNA
resp.IssuedTokenType = oidc.IDTokenType
case oidc.RefreshTokenType, UserIDTokenType:
fallthrough
default:
err = zerrors.ThrowInvalidArgument(nil, "OIDC-wai5E", "Errors.TokenExchange.Token.TypeNotSupported")
}
if err != nil {
return nil, err
}
if slices.Contains(scopes, oidc.ScopeOpenID) && tokenType != oidc.IDTokenType {
resp.IDToken, _, err = s.createExchangeIDToken(ctx, signingKey, client, subjectToken.userID, resp.AccessToken, audience, userInfo, actorToken.authMethods, actorToken.authTime, reason, actor)
if err != nil {
return nil, err
}
}
return resp, nil
}
func (s *Server) createExchangeAccessToken(ctx context.Context, client *Client, resourceOwner, userID string, audience, scopes []string, authMethods []domain.UserAuthMethodType, authTime time.Time, reason domain.TokenReason, actor *domain.TokenActor) (accessToken string, refreshToken string, exp uint64, err error) {
tokenInfo, refreshToken, err := s.createAccessTokenCommands(ctx, client, resourceOwner, userID, audience, scopes, authMethods, authTime, reason, actor)
if err != nil {
return "", "", 0, err
}
accessToken, err = op.CreateBearerToken(tokenInfo.TokenID, userID, s.Provider().Crypto())
if err != nil {
return "", "", 0, err
}
return accessToken, refreshToken, timeToOIDCExpiresIn(tokenInfo.Expiration), nil
}
func (s *Server) createExchangeJWT(ctx context.Context, signingKey op.SigningKey, client *Client, resourceOwner, userID string, audience, scopes []string, authMethods []domain.UserAuthMethodType, authTime time.Time, reason domain.TokenReason, actor *domain.TokenActor, privateClaims map[string]any) (accessToken string, refreshToken string, exp uint64, err error) {
tokenInfo, refreshToken, err := s.createAccessTokenCommands(ctx, client, resourceOwner, userID, audience, scopes, authMethods, authTime, reason, actor)
if err != nil {
return "", "", 0, err
}
expTime := tokenInfo.Expiration.Add(client.ClockSkew())
claims := oidc.NewAccessTokenClaims(op.IssuerFromContext(ctx), userID, tokenInfo.Audience, expTime, tokenInfo.TokenID, client.GetID(), client.ClockSkew())
claims.Actor = actorDomainToClaims(tokenInfo.Actor)
claims.Claims = privateClaims
signer, err := op.SignerFromKey(signingKey)
if err != nil {
return "", "", 0, err
}
accessToken, err = crypto.Sign(claims, signer)
if err != nil {
return "", "", 0, nil
}
return accessToken, refreshToken, timeToOIDCExpiresIn(expTime), nil
}
func (s *Server) createExchangeIDToken(ctx context.Context, signingKey op.SigningKey, client *Client, userID, accessToken string, audience []string, userInfo *oidc.UserInfo, authMethods []domain.UserAuthMethodType, authTime time.Time, reason domain.TokenReason, actor *domain.TokenActor) (idToken string, exp uint64, err error) {
expTime := time.Now().Add(client.IDTokenLifetime()).Add(client.ClockSkew())
claims := oidc.NewIDTokenClaims(op.IssuerFromContext(ctx), userID, audience, expTime, authTime, "", "", AuthMethodTypesToAMR(authMethods), client.GetID(), client.ClockSkew())
claims.Actor = actorDomainToClaims(actor)
claims.SetUserInfo(userInfo)
if accessToken != "" {
claims.AccessTokenHash, err = oidc.ClaimHash(accessToken, signingKey.SignatureAlgorithm())
if err != nil {
return "", 0, err
}
}
signer, err := op.SignerFromKey(signingKey)
if err != nil {
return "", 0, err
}
idToken, err = crypto.Sign(claims, signer)
return idToken, timeToOIDCExpiresIn(expTime), err
}
func timeToOIDCExpiresIn(exp time.Time) uint64 {
return uint64(time.Until(exp) / time.Second)
}
func (s *Server) createAccessTokenCommands(ctx context.Context, client *Client, resourceOwner, userID string, audience, scopes []string, authMethods []domain.UserAuthMethodType, authTime time.Time, reason domain.TokenReason, actor *domain.TokenActor) (tokenInfo *domain.Token, refreshToken string, err error) {
settings := client.client.Settings
if slices.Contains(scopes, oidc.ScopeOfflineAccess) {
return s.command.AddAccessAndRefreshToken(
ctx, resourceOwner, "", client.GetID(), userID, "", audience, scopes, AuthMethodTypesToAMR(authMethods),
settings.AccessTokenLifetime, settings.RefreshTokenIdleExpiration, settings.RefreshTokenExpiration,
authTime, reason, actor,
)
}
tokenInfo, err = s.command.AddUserToken(
ctx, resourceOwner, "", client.GetID(), userID, audience, scopes, AuthMethodTypesToAMR(authMethods),
settings.AccessTokenLifetime,
authTime, reason, actor,
)
return tokenInfo, "", err
}

View File

@@ -0,0 +1,98 @@
package oidc
import (
"time"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
type exchangeToken struct {
tokenType oidc.TokenType
userID string
issuer string
resourceOwner string
authTime time.Time
authMethods []domain.UserAuthMethodType
actor *domain.TokenActor
audience []string
scopes []string
}
func (et *exchangeToken) nestedActor() *domain.TokenActor {
return &domain.TokenActor{
Actor: et.actor,
UserID: et.userID,
Issuer: et.issuer,
}
}
func accessToExchangeToken(token *accessToken, issuer string) *exchangeToken {
return &exchangeToken{
tokenType: oidc.AccessTokenType,
userID: token.userID,
issuer: issuer,
resourceOwner: token.resourceOwner,
authMethods: token.authMethods,
actor: token.actor,
audience: token.audience,
scopes: token.scope,
}
}
func idTokenClaimsToExchangeToken(claims *oidc.IDTokenClaims, resourceOwner string) *exchangeToken {
return &exchangeToken{
tokenType: oidc.IDTokenType,
userID: claims.Subject,
issuer: claims.Issuer,
resourceOwner: resourceOwner,
authTime: claims.GetAuthTime(),
authMethods: AMRToAuthMethodTypes(claims.AuthenticationMethodsReferences),
actor: actorClaimsToDomain(claims.Actor),
audience: claims.Audience,
}
}
func actorClaimsToDomain(actor *oidc.ActorClaims) *domain.TokenActor {
if actor == nil {
return nil
}
return &domain.TokenActor{
Actor: actorClaimsToDomain(actor.Actor),
UserID: actor.Subject,
Issuer: actor.Issuer,
}
}
func actorDomainToClaims(actor *domain.TokenActor) *oidc.ActorClaims {
if actor == nil {
return nil
}
return &oidc.ActorClaims{
Actor: actorDomainToClaims(actor.Actor),
Subject: actor.UserID,
Issuer: actor.Issuer,
}
}
func jwtToExchangeToken(jwt *oidc.JWTTokenRequest, resourceOwner string) *exchangeToken {
return &exchangeToken{
tokenType: oidc.JWTTokenType,
userID: jwt.Subject,
issuer: jwt.Issuer,
resourceOwner: resourceOwner,
scopes: jwt.Scopes,
authTime: jwt.IssuedAt.AsTime(),
// audience omitted as we don't thrust audiences not signed by us
}
}
func userToExchangeToken(user *query.User) *exchangeToken {
return &exchangeToken{
tokenType: UserIDTokenType,
userID: user.ID,
resourceOwner: user.ResourceOwner,
}
}

View File

@@ -0,0 +1,590 @@
//go:build integration
package oidc_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/client/rs"
"github.com/zitadel/oidc/v3/pkg/client/tokenexchange"
"github.com/zitadel/oidc/v3/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/oidc"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/admin"
feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
func setTokenExchangeFeature(t *testing.T, value bool) {
iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner)
_, err := Tester.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{
OidcTokenExchange: proto.Bool(value),
})
require.NoError(t, err)
time.Sleep(time.Second)
}
func resetFeatures(t *testing.T) {
iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner)
_, err := Tester.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{})
require.NoError(t, err)
time.Sleep(time.Second)
}
func setImpersonationPolicy(t *testing.T, value bool) {
iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner)
policy, err := Tester.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
require.NoError(t, err)
if policy.GetPolicy().GetEnableImpersonation() != value {
_, err = Tester.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
EnableImpersonation: value,
})
require.NoError(t, err)
}
time.Sleep(time.Second)
}
func createMachineUserPATWithMembership(t *testing.T, roles ...string) (userID, pat string) {
iamCTX := Tester.WithAuthorization(CTX, integration.IAMOwner)
userID, pat, err := Tester.CreateMachineUserPATWithMembership(iamCTX, roles...)
require.NoError(t, err)
return userID, pat
}
func accessTokenVerifier(ctx context.Context, server rs.ResourceServer, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
resp, err := rs.Introspect[*oidc.IntrospectionResponse](ctx, server, token)
require.NoError(t, err)
assert.True(t, resp.Active)
if subject != "" {
assert.Equal(t, subject, resp.Subject)
}
if actorSubject != "" {
require.NotNil(t, resp.Actor)
assert.Equal(t, actorSubject, resp.Actor.Subject)
}
}
}
func idTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
verifier := provider.IDTokenVerifier()
resp, err := rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, token, verifier)
require.NoError(t, err)
if subject != "" {
assert.Equal(t, subject, resp.Subject)
}
if actorSubject != "" {
require.NotNil(t, resp.Actor)
assert.Equal(t, actorSubject, resp.Actor.Subject)
}
}
}
func refreshTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
clientAssertion, err := client.SignedJWTProfileAssertion(provider.OAuthConfig().ClientID, []string{provider.Issuer()}, time.Hour, provider.Signer())
require.NoError(t, err)
tokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, provider, token, clientAssertion, oidc.ClientAssertionTypeJWTAssertion)
require.NoError(t, err)
if subject != "" {
assert.Equal(t, subject, tokens.IDTokenClaims.Subject)
}
if actorSubject != "" {
require.NotNil(t, tokens.IDTokenClaims.Actor)
assert.Equal(t, actorSubject, tokens.IDTokenClaims.Actor.Subject)
}
}
}
func TestServer_TokenExchange(t *testing.T) {
t.Cleanup(func() {
resetFeatures(t)
setImpersonationPolicy(t, false)
})
client, keyData, err := Tester.CreateOIDCTokenExchangeClient(CTX)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Tester.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
time.Sleep(time.Second)
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
orgUserID, orgImpersonatorPAT := createMachineUserPATWithMembership(t, "ORG_ADMIN_IMPERSONATOR")
serviceUserID, noPermPAT := createMachineUserPATWithMembership(t)
// exchange some tokens for later use
setTokenExchangeFeature(t, true)
teResp, err := tokenexchange.ExchangeToken(CTX, exchanger, noPermPAT, oidc.AccessTokenType, "", "", nil, nil, nil, oidc.AccessTokenType)
require.NoError(t, err)
patScopes := oidc.SpaceDelimitedArray{"openid", "profile", "urn:zitadel:iam:user:metadata", "urn:zitadel:iam:user:resourceowner"}
relyingParty, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), client.GetClientId(), "", "", []string{"openid"}, rp.WithJWTProfile(rp.SignerFromKeyFile(keyData)))
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyData)
require.NoError(t, err)
type settings struct {
tokenExchangeFeature bool
impersonationPolicy bool
}
type args struct {
SubjectToken string
SubjectTokenType oidc.TokenType
ActorToken string
ActorTokenType oidc.TokenType
Resource []string
Audience []string
Scopes []string
RequestedTokenType oidc.TokenType
}
type result struct {
issuedTokenType oidc.TokenType
tokenType string
expiresIn uint64
scopes oidc.SpaceDelimitedArray
verifyAccessToken func(t *testing.T, token string)
verifyRefreshToken func(t *testing.T, token string)
verifyIDToken func(t *testing.T, token string)
}
tests := []struct {
name string
settings settings
args args
want result
wantErr bool
}{
{
name: "feature disabled error",
settings: settings{
tokenExchangeFeature: false,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "unsupported resource parameter",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
Resource: []string{"https://example.com"},
},
wantErr: true,
},
{
name: "invalid subject token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: "foo",
SubjectTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: access token to default",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to access token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to JWT",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.JWTTokenType,
},
want: result{
issuedTokenType: oidc.JWTTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to ID Token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.IDTokenType,
},
want: result{
issuedTokenType: oidc.IDTokenType,
tokenType: "N_A",
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
verifyIDToken: func(t *testing.T, token string) {
assert.Empty(t, token)
},
},
},
{
name: "EXCHANGE: refresh token not allowed",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: teResp.RefreshToken,
SubjectTokenType: oidc.RefreshTokenType,
RequestedTokenType: oidc.IDTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: alternate scope for refresh token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, "", ""),
},
},
{
name: "EXCHANGE: access token, requested token type not supported error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.RefreshTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: access token, invalid audience",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
Audience: []string{"foo", "bar"},
},
wantErr: true,
},
{
name: "IMPERSONATION: subject: userID, actor: access token, policy disabled error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "IMPERSONATION: subject: userID, actor: access token, membership not found error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: noPermPAT,
ActorTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "IAM IMPERSONATION: subject: userID, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: iamImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), iamUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), iamUserID),
},
},
{
name: "ORG IMPERSONATION: subject: userID, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: access token, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.AccessToken,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: ID token, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.IDToken,
SubjectTokenType: oidc.IDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: JWT, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: func() string {
token, err := crypto.Sign(&oidc.JWTTokenRequest{
Issuer: client.GetClientId(),
Subject: User.GetUserId(),
Audience: oidc.Audience{Tester.OIDCIssuer()},
ExpiresAt: oidc.FromTime(time.Now().Add(time.Hour)),
IssuedAt: oidc.FromTime(time.Now().Add(-time.Second)),
}, signer)
require.NoError(t, err)
return token
}(),
SubjectTokenType: oidc.JWTTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: access token, actor: access token, with refresh token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.AccessToken,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setTokenExchangeFeature(t, tt.settings.tokenExchangeFeature)
setImpersonationPolicy(t, tt.settings.impersonationPolicy)
got, err := tokenexchange.ExchangeToken(CTX, exchanger, tt.args.SubjectToken, tt.args.SubjectTokenType, tt.args.ActorToken, tt.args.ActorTokenType, tt.args.Resource, tt.args.Audience, tt.args.Scopes, tt.args.RequestedTokenType)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want.issuedTokenType, got.IssuedTokenType)
assert.Equal(t, tt.want.tokenType, got.TokenType)
assert.Greater(t, got.ExpiresIn, tt.want.expiresIn)
assert.Equal(t, tt.want.scopes, got.Scopes)
if tt.want.verifyAccessToken != nil {
tt.want.verifyAccessToken(t, got.AccessToken)
}
if tt.want.verifyRefreshToken != nil {
tt.want.verifyRefreshToken(t, got.RefreshToken)
}
if tt.want.verifyIDToken != nil {
tt.want.verifyIDToken(t, got.IDToken)
}
})
}
}
// This test tries to call the zitadel API with an impersonated token,
// which should fail.
func TestImpersonation_API_Call(t *testing.T) {
client, keyData, err := Tester.CreateOIDCTokenExchangeClient(CTX)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Tester.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyData)
require.NoError(t, err)
setTokenExchangeFeature(t, true)
setImpersonationPolicy(t, true)
t.Cleanup(func() {
resetFeatures(t)
setImpersonationPolicy(t, false)
})
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
iamOwner := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.IAMOwner)
// impersonating the IAM owner!
resp, err := tokenexchange.ExchangeToken(CTX, exchanger, iamOwner.Token, oidc.AccessTokenType, iamImpersonatorPAT, oidc.AccessTokenType, nil, nil, nil, oidc.AccessTokenType)
require.NoError(t, err)
accessTokenVerifier(CTX, resourceServer, iamOwner.ID, iamUserID)
impersonatedCTX := Tester.WithAuthorizationToken(CTX, resp.AccessToken)
_, err = Tester.Client.Admin.GetAllowedLanguages(impersonatedCTX, &admin.GetAllowedLanguagesRequest{})
status := status.Convert(err)
assert.Equal(t, codes.PermissionDenied, status.Code())
assert.Equal(t, "Errors.TokenExchange.Token.NotForAPI (APP-wai8O)", status.Message())
}

View File

@@ -29,7 +29,7 @@ func (s *Server) userInfo(ctx context.Context, userID, projectID string, scope,
return userInfo, s.userinfoFlows(ctx, qu, userInfo)
}
// prepareRoles scans the requested scopes, appends to roleAudiendce and returns the requestedRoles.
// prepareRoles scans the requested scopes, appends to roleAudience and returns the requestedRoles.
//
// When [ScopeProjectsRoles] is present and roleAudience was empty,
// project IDs with the [domain.ProjectIDScope] prefix are added to the roleAudience.
@@ -153,9 +153,9 @@ func setUserInfoMetadata(metadata []query.UserMetadata, out *oidc.UserInfo) {
func setUserInfoOrgClaims(user *query.OIDCUserInfo, out *oidc.UserInfo) {
if org := user.Org; org != nil {
out.AppendClaims(ClaimResourceOwner+"id", org.ID)
out.AppendClaims(ClaimResourceOwner+"name", org.Name)
out.AppendClaims(ClaimResourceOwner+"primary_domain", org.PrimaryDomain)
out.AppendClaims(ClaimResourceOwnerID, org.ID)
out.AppendClaims(ClaimResourceOwnerName, org.Name)
out.AppendClaims(ClaimResourceOwnerPrimaryDomain, org.PrimaryDomain)
}
}

View File

@@ -349,9 +349,9 @@ func Test_userInfoToOIDC(t *testing.T) {
},
want: &oidc.UserInfo{
Claims: map[string]any{
ClaimResourceOwner + "id": "orgID",
ClaimResourceOwner + "name": "orgName",
ClaimResourceOwner + "primary_domain": "orgDomain",
ClaimResourceOwnerID: "orgID",
ClaimResourceOwnerName: "orgName",
ClaimResourceOwnerPrimaryDomain: "orgDomain",
},
},
},
@@ -377,10 +377,10 @@ func Test_userInfoToOIDC(t *testing.T) {
},
want: &oidc.UserInfo{
Claims: map[string]any{
domain.OrgIDClaim: "orgID",
ClaimResourceOwner + "id": "orgID",
ClaimResourceOwner + "name": "orgName",
ClaimResourceOwner + "primary_domain": "orgDomain",
domain.OrgIDClaim: "orgID",
ClaimResourceOwnerID: "orgID",
ClaimResourceOwnerName: "orgName",
ClaimResourceOwnerPrimaryDomain: "orgDomain",
},
},
},