mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 01:47:33 +00:00
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:
@@ -182,7 +182,7 @@ func checkOrigin(ctx context.Context, origins []string) error {
|
||||
func extractBearerToken(token string) (part string, err error) {
|
||||
parts := strings.Split(token, BearerPrefix)
|
||||
if len(parts) != 2 {
|
||||
return "", zerrors.ThrowUnauthenticated(nil, "AUTH-7fs1e", "invalid auth header")
|
||||
return "", zerrors.ThrowUnauthenticated(nil, "AUTH-toLo1", "invalid auth header")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
@@ -5,8 +5,12 @@ import (
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
object_pb "github.com/zitadel/zitadel/internal/api/grpc/object"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||
)
|
||||
|
||||
@@ -17,6 +21,9 @@ func (s *Server) ActivateFeatureLoginDefaultOrg(ctx context.Context, _ *admin_pb
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = projection.InstanceFeatureProjection.Trigger(ctx, handler.WithAwaitRunning())
|
||||
logging.OnError(err).Warn("trigger instance feature projection")
|
||||
|
||||
return &admin_pb.ActivateFeatureLoginDefaultOrgResponse{
|
||||
Details: object_pb.DomainToChangeDetailsPb(details),
|
||||
}, nil
|
||||
|
@@ -23,6 +23,14 @@ func TestServer_GetSecurityPolicy(t *testing.T) {
|
||||
EnableImpersonation: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, err := Client.SetSecurityPolicy(AdminCTX, &admin_pb.SetSecurityPolicyRequest{
|
||||
EnableIframeEmbedding: false,
|
||||
AllowedOrigins: []string{},
|
||||
EnableImpersonation: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@@ -14,6 +14,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.
|
||||
TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections,
|
||||
LegacyIntrospection: req.OidcLegacyIntrospection,
|
||||
UserSchema: req.UserSchema,
|
||||
TokenExchange: req.OidcTokenExchange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe
|
||||
OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections),
|
||||
OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection),
|
||||
UserSchema: featureSourceToFlagPb(&f.UserSchema),
|
||||
OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm
|
||||
TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections,
|
||||
LegacyIntrospection: req.OidcLegacyIntrospection,
|
||||
UserSchema: req.UserSchema,
|
||||
TokenExchange: req.OidcTokenExchange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +46,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat
|
||||
OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections),
|
||||
OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection),
|
||||
UserSchema: featureSourceToFlagPb(&f.UserSchema),
|
||||
OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange),
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -22,12 +22,14 @@ func Test_systemFeaturesToCommand(t *testing.T) {
|
||||
OidcTriggerIntrospectionProjections: gu.Ptr(false),
|
||||
OidcLegacyIntrospection: nil,
|
||||
UserSchema: gu.Ptr(true),
|
||||
OidcTokenExchange: gu.Ptr(true),
|
||||
}
|
||||
want := &command.SystemFeatures{
|
||||
LoginDefaultOrg: gu.Ptr(true),
|
||||
TriggerIntrospectionProjections: gu.Ptr(false),
|
||||
LegacyIntrospection: nil,
|
||||
UserSchema: gu.Ptr(true),
|
||||
TokenExchange: gu.Ptr(true),
|
||||
}
|
||||
got := systemFeaturesToCommand(arg)
|
||||
assert.Equal(t, want, got)
|
||||
@@ -56,6 +58,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
|
||||
Level: feature.LevelSystem,
|
||||
Value: true,
|
||||
},
|
||||
TokenExchange: query.FeatureSource[bool]{
|
||||
Level: feature.LevelSystem,
|
||||
Value: false,
|
||||
},
|
||||
}
|
||||
want := &feature_pb.GetSystemFeaturesResponse{
|
||||
Details: &object.Details{
|
||||
@@ -79,6 +85,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
|
||||
Enabled: true,
|
||||
Source: feature_pb.Source_SOURCE_SYSTEM,
|
||||
},
|
||||
OidcTokenExchange: &feature_pb.FeatureFlag{
|
||||
Enabled: false,
|
||||
Source: feature_pb.Source_SOURCE_SYSTEM,
|
||||
},
|
||||
}
|
||||
got := systemFeaturesToPb(arg)
|
||||
assert.Equal(t, want, got)
|
||||
@@ -90,12 +100,14 @@ func Test_instanceFeaturesToCommand(t *testing.T) {
|
||||
OidcTriggerIntrospectionProjections: gu.Ptr(false),
|
||||
OidcLegacyIntrospection: nil,
|
||||
UserSchema: gu.Ptr(true),
|
||||
OidcTokenExchange: gu.Ptr(true),
|
||||
}
|
||||
want := &command.InstanceFeatures{
|
||||
LoginDefaultOrg: gu.Ptr(true),
|
||||
TriggerIntrospectionProjections: gu.Ptr(false),
|
||||
LegacyIntrospection: nil,
|
||||
UserSchema: gu.Ptr(true),
|
||||
TokenExchange: gu.Ptr(true),
|
||||
}
|
||||
got := instanceFeaturesToCommand(arg)
|
||||
assert.Equal(t, want, got)
|
||||
@@ -124,6 +136,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
|
||||
Level: feature.LevelInstance,
|
||||
Value: true,
|
||||
},
|
||||
TokenExchange: query.FeatureSource[bool]{
|
||||
Level: feature.LevelSystem,
|
||||
Value: false,
|
||||
},
|
||||
}
|
||||
want := &feature_pb.GetInstanceFeaturesResponse{
|
||||
Details: &object.Details{
|
||||
@@ -147,6 +163,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
|
||||
Enabled: true,
|
||||
Source: feature_pb.Source_SOURCE_INSTANCE,
|
||||
},
|
||||
OidcTokenExchange: &feature_pb.FeatureFlag{
|
||||
Enabled: false,
|
||||
Source: feature_pb.Source_SOURCE_SYSTEM,
|
||||
},
|
||||
}
|
||||
got := instanceFeaturesToPb(arg)
|
||||
assert.Equal(t, want, got)
|
||||
|
@@ -138,6 +138,8 @@ func OIDCGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app_pb.OIDCGra
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN
|
||||
case domain.OIDCGrantTypeDeviceCode:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE
|
||||
case domain.OIDCGrantTypeTokenExchange:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
@@ -158,6 +160,8 @@ func OIDCGrantTypesToDomain(grantTypes []app_pb.OIDCGrantType) []domain.OIDCGran
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeTokenExchange
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -22,7 +22,7 @@ func TestAMR(t *testing.T) {
|
||||
args{
|
||||
nil,
|
||||
},
|
||||
[]string{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"pw checked",
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
})
|
||||
|
@@ -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) }()
|
||||
|
347
internal/api/oidc/token_exchange.go
Normal file
347
internal/api/oidc/token_exchange.go
Normal 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
|
||||
}
|
98
internal/api/oidc/token_exchange_converter.go
Normal file
98
internal/api/oidc/token_exchange_converter.go
Normal 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,
|
||||
}
|
||||
}
|
590
internal/api/oidc/token_exchange_integration_test.go
Normal file
590
internal/api/oidc/token_exchange_integration_test.go
Normal 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())
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Reference in New Issue
Block a user