From 6398349c245c58015ce30ac4273e05d1746cd701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 20 Mar 2024 12:18:46 +0200 Subject: [PATCH] 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 --- cmd/setup/24.go | 27 + cmd/setup/24.sql | 2 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + .../apps/app-create/app-create.component.ts | 1 + .../apps/app-detail/app-detail.component.ts | 1 + .../app/pages/projects/apps/authmethods.ts | 73 +++ console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 3 +- console/src/assets/i18n/de.json | 3 +- console/src/assets/i18n/en.json | 3 +- console/src/assets/i18n/es.json | 3 +- console/src/assets/i18n/fr.json | 3 +- console/src/assets/i18n/it.json | 3 +- console/src/assets/i18n/ja.json | 3 +- console/src/assets/i18n/mk.json | 3 +- console/src/assets/i18n/nl.json | 3 +- console/src/assets/i18n/pl.json | 3 +- console/src/assets/i18n/pt.json | 3 +- console/src/assets/i18n/ru.json | 3 +- console/src/assets/i18n/zh.json | 3 +- go.mod | 6 +- go.sum | 8 +- internal/api/authz/context.go | 2 +- internal/api/grpc/admin/feature.go | 7 + .../admin/iam_settings_integration_test.go | 8 + internal/api/grpc/feature/v2/converter.go | 4 + .../api/grpc/feature/v2/converter_test.go | 20 + internal/api/grpc/project/application.go | 4 + internal/api/oidc/access_token.go | 13 +- internal/api/oidc/amr.go | 36 +- internal/api/oidc/amr_test.go | 2 +- internal/api/oidc/auth_request.go | 33 +- internal/api/oidc/auth_request_converter.go | 4 + internal/api/oidc/client.go | 26 +- internal/api/oidc/client_converter.go | 2 + internal/api/oidc/client_integration_test.go | 6 +- internal/api/oidc/introspect.go | 23 +- internal/api/oidc/key.go | 2 +- internal/api/oidc/server.go | 7 - internal/api/oidc/token_exchange.go | 347 ++++++++++ internal/api/oidc/token_exchange_converter.go | 98 +++ .../oidc/token_exchange_integration_test.go | 590 ++++++++++++++++++ internal/api/oidc/userinfo.go | 8 +- internal/api/oidc/userinfo_test.go | 14 +- .../eventstore/token_verifier.go | 6 + internal/command/instance_features.go | 4 +- internal/command/instance_features_model.go | 5 + .../command/instance_policy_security_model.go | 3 + internal/command/main_test.go | 4 + internal/command/oidc_session.go | 11 +- internal/command/oidc_session_model.go | 4 + internal/command/oidc_session_test.go | 36 +- internal/command/system_features.go | 4 +- internal/command/system_features_model.go | 5 + internal/command/user.go | 69 +- .../command/user_human_access_token_model.go | 4 + internal/command/user_human_refresh_token.go | 62 +- .../command/user_human_refresh_token_model.go | 14 +- .../command/user_human_refresh_token_test.go | 85 +-- internal/command/user_test.go | 30 +- internal/domain/application_oidc.go | 1 + internal/domain/token.go | 21 + internal/domain/tokenreason_enumer.go | 116 ++++ internal/domain/user.go | 4 +- internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 32 +- internal/integration/oidc.go | 21 +- internal/integration/user.go | 57 ++ internal/query/access_token.go | 4 + internal/query/embed/oidc_client_by_id.sql | 7 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 5 + .../query/projection/instance_features.go | 4 + internal/query/projection/system_features.go | 4 + internal/query/system_features.go | 1 + internal/query/system_features_model.go | 3 + .../feature/feature_v2/eventstore.go | 2 + .../repository/feature/feature_v2/feature.go | 2 + .../repository/oidcsession/oidc_session.go | 12 +- internal/repository/user/eventstore.go | 1 + .../repository/user/human_refresh_token.go | 24 +- internal/repository/user/user.go | 65 +- internal/static/i18n/bg.yaml | 12 + internal/static/i18n/cs.yaml | 12 + internal/static/i18n/de.yaml | 12 + internal/static/i18n/en.yaml | 12 + internal/static/i18n/es.yaml | 12 + internal/static/i18n/fr.yaml | 12 + internal/static/i18n/it.yaml | 12 + internal/static/i18n/ja.yaml | 12 + internal/static/i18n/mk.yaml | 12 + internal/static/i18n/nl.yaml | 12 + internal/static/i18n/pl.yaml | 12 + internal/static/i18n/pt.yaml | 12 + internal/static/i18n/ru.yaml | 12 + internal/static/i18n/zh.yaml | 12 + internal/user/model/refresh_token_view.go | 1 + internal/user/model/token_view.go | 2 + .../repository/view/model/refresh_token.go | 3 + internal/user/repository/view/model/token.go | 38 ++ proto/zitadel/app.proto | 1 + proto/zitadel/feature/v2beta/instance.proto | 15 + proto/zitadel/feature/v2beta/system.proto | 15 + 104 files changed, 2149 insertions(+), 248 deletions(-) create mode 100644 cmd/setup/24.go create mode 100644 cmd/setup/24.sql create mode 100644 internal/api/oidc/token_exchange.go create mode 100644 internal/api/oidc/token_exchange_converter.go create mode 100644 internal/api/oidc/token_exchange_integration_test.go create mode 100644 internal/domain/tokenreason_enumer.go create mode 100644 internal/integration/user.go diff --git a/cmd/setup/24.go b/cmd/setup/24.go new file mode 100644 index 000000000..b90b0bde2 --- /dev/null +++ b/cmd/setup/24.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 24.sql + addTokenActor string +) + +type AddActorToAuthTokens struct { + dbClient *database.DB +} + +func (mig *AddActorToAuthTokens) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addTokenActor) + return err +} + +func (mig *AddActorToAuthTokens) String() string { + return "24_add_actor_col_to_auth_tokens" +} diff --git a/cmd/setup/24.sql b/cmd/setup/24.sql new file mode 100644 index 000000000..5fbe9d1d7 --- /dev/null +++ b/cmd/setup/24.sql @@ -0,0 +1,2 @@ +ALTER TABLE auth.tokens ADD COLUMN actor jsonb; +ALTER TABLE auth.refresh_tokens ADD COLUMN actor jsonb; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 07022cac4..59143d87d 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -102,6 +102,7 @@ type Steps struct { s21AddBlockFieldToLimits *AddBlockFieldToLimits s22ActiveInstancesIndex *ActiveInstanceEvents s23CorrectGlobalUniqueConstraints *CorrectGlobalUniqueConstraints + s24AddActorToAuthTokens *AddActorToAuthTokens } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 5dc8a0228..a493b2569 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -136,6 +136,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient} steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient} steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient} + steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -172,6 +173,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.s20AddByUserSessionIndex, steps.s22ActiveInstancesIndex, steps.s23CorrectGlobalUniqueConstraints, + steps.s24AddActorToAuthTokens, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/console/src/app/pages/projects/apps/app-create/app-create.component.ts b/console/src/app/pages/projects/apps/app-create/app-create.component.ts index ae1c5d8b7..f901c9dde 100644 --- a/console/src/app/pages/projects/apps/app-create/app-create.component.ts +++ b/console/src/app/pages/projects/apps/app-create/app-create.component.ts @@ -115,6 +115,7 @@ export class AppCreateComponent implements OnInit, OnDestroy { { type: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, checked: false, disabled: true }, { type: OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, checked: false, disabled: true }, { type: OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, checked: false, disabled: true }, + { type: OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, checked: false, disabled: true }, ]; public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts index 68f7996ee..7c1e57d3f 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts @@ -105,6 +105,7 @@ export class AppDetailComponent implements OnInit, OnDestroy { OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, + OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, ]; public oidcAppTypes: OIDCAppType[] = [ OIDCAppType.OIDC_APP_TYPE_WEB, diff --git a/console/src/app/pages/projects/apps/authmethods.ts b/console/src/app/pages/projects/apps/authmethods.ts index 566748bb0..8eebfc3b4 100644 --- a/console/src/app/pages/projects/apps/authmethods.ts +++ b/console/src/app/pages/projects/apps/authmethods.ts @@ -213,6 +213,22 @@ export function getAuthMethodFromPartialConfig(config: { OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC, ]); + const codeWithExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE].sort(), + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC, + ]); + + const codeWithRefreshAndExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [ + OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, + OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + ].sort(), + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC, + ]); + const pkce = JSON.stringify([ [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE], @@ -237,6 +253,22 @@ export function getAuthMethodFromPartialConfig(config: { OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, ]); + const postWithExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE].sort(), + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, + ]); + + const postWithRefreshAndExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [ + OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, + OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + ].sort(), + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, + ]); + const deviceCode = JSON.stringify([ [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], [OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE], @@ -263,6 +295,33 @@ export function getAuthMethodFromPartialConfig(config: { OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, ]); + const deviceCodeWithCodeAndExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [ + OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + ], + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, + ]); + + const deviceCodeWithCodeAndRefreshAndExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [ + OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, + OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, + OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + ], + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, + ]); + + const deviceCodeWithExchange = JSON.stringify([ + [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], + [OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, OIDCGrantType.OIDC_GRANT_TYPE_TOKEN_EXCHANGE], + OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, + ]); + const deviceCodeWithRefresh = JSON.stringify([ [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], [OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN], @@ -292,6 +351,10 @@ export function getAuthMethodFromPartialConfig(config: { return CODE_METHOD.key; case codeWithRefresh: return CODE_METHOD.key; + case codeWithExchange: + return CODE_METHOD.key; + case codeWithRefreshAndExchange: + return CODE_METHOD.key; case pkce: return PKCE_METHOD.key; @@ -302,6 +365,10 @@ export function getAuthMethodFromPartialConfig(config: { return POST_METHOD.key; case postWithRefresh: return POST_METHOD.key; + case postWithExchange: + return POST_METHOD.key; + case postWithRefreshAndExchange: + return POST_METHOD.key; case deviceCode: return DEVICE_CODE_METHOD.key; @@ -309,8 +376,14 @@ export function getAuthMethodFromPartialConfig(config: { return DEVICE_CODE_METHOD.key; case deviceCodeWithRefresh: return DEVICE_CODE_METHOD.key; + case deviceCodeWithExchange: + return DEVICE_CODE_METHOD.key; case deviceCodeWithCodeAndRefresh: return DEVICE_CODE_METHOD.key; + case deviceCodeWithCodeAndExchange: + return DEVICE_CODE_METHOD.key; + case deviceCodeWithCodeAndRefreshAndExchange: + return DEVICE_CODE_METHOD.key; case pkjwt: return PK_JWT_METHOD.key; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 6b260348f..acd9479a2 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -2270,7 +2270,8 @@ "0": "Код за оторизация", "1": "имплицитно", "2": "Опресняване на токена", - "3": "Код на устройството" + "3": "Код на устройството", + "4": "Размяна на токени" }, "AUTHMETHOD": { "0": "Основен", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 2f4e4d516..f75478bfd 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -2289,7 +2289,8 @@ "0": "Autorizační kód", "1": "Implicitní", "2": "Obnovovací Token", - "3": "Kód zařízení" + "3": "Kód zařízení", + "4": "Výměna tokenů" }, "AUTHMETHOD": { "0": "Základní", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index dca603dc5..502393328 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -2279,7 +2279,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 6fa1141f2..203871294 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -2298,7 +2298,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index c02e9b2b3..6fdc92099 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -2277,7 +2277,8 @@ "0": "Código de autorización", "1": "Implícito", "2": "Token de refresco", - "3": "Device Code" + "3": "Device Code", + "4": "Intercambio de tokens" }, "AUTHMETHOD": { "0": "Básico", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index bc1b53871..82e21c157 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -2280,7 +2280,8 @@ "0": "Code d'autorisation", "1": "Implicite", "2": "Rafraîchir le jeton", - "3": "Device Code" + "3": "Device Code", + "4": "Échange de jetons" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index e2c997ae3..18ad88a79 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -2280,7 +2280,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index dd17b2661..632d35869 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -2271,7 +2271,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 7951d0c4e..4949b04eb 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -2277,7 +2277,8 @@ "0": "Код на Овластување", "1": "Implicit", "2": "Токен за Освежување", - "3": "Код од Уред" + "3": "Код од Уред", + "4": "Размена на токени" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 5a1671d20..c463f6a5a 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -2298,7 +2298,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index dd9481bb5..6d1a36ce0 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -2280,7 +2280,8 @@ "0": "Kod autoryzacyjny", "1": "Implicite", "2": "Token odświeżający", - "3": "Device Code" + "3": "Device Code", + "4": "Wymiana tokenów" }, "AUTHMETHOD": { "0": "Podstawowy", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 3f52d16ee..9b4d65db4 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -2275,7 +2275,8 @@ "0": "Código de Autorização", "1": "Implícito", "2": "Token de Atualização", - "3": "Código do Dispositivo" + "3": "Código do Dispositivo", + "4": "Troca de tokens" }, "AUTHMETHOD": { "0": "Básico", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 8bbd2c7ed..9f0562523 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2393,7 +2393,8 @@ "0": "Код авторизации", "1": "Скрытый", "2": "Обновить токен", - "3": "Код устройства" + "3": "Код устройства", + "4": "Обмен токенов" }, "AUTHMETHOD": { "0": "Basic", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 946fdf0ea..0f15c48ed 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -2279,7 +2279,8 @@ "0": "Authorization Code", "1": "Implicit", "2": "Refresh Token", - "3": "Device Code" + "3": "Device Code", + "4": "Token Exchange" }, "AUTHMETHOD": { "0": "Basic", diff --git a/go.mod b/go.mod index fa7b51de0..5d91dec21 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/zitadel/zitadel go 1.21.1 // https://go.dev/doc/toolchain -toolchain go1.21.5 +toolchain go1.21.8 require ( cloud.google.com/go/storage v1.39.0 @@ -24,7 +24,7 @@ require ( github.com/drone/envsubst v1.0.3 github.com/envoyproxy/protoc-gen-validate v1.0.4 github.com/fatih/color v1.16.0 - github.com/go-jose/go-jose/v3 v3.0.2 + github.com/go-jose/go-jose/v3 v3.0.3 github.com/go-ldap/ldap/v3 v3.4.6 github.com/go-webauthn/webauthn v0.10.1 github.com/gorilla/csrf v1.7.2 @@ -62,7 +62,7 @@ require ( github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 github.com/ttacon/libphonenumber v1.2.1 github.com/zitadel/logging v0.6.0 - github.com/zitadel/oidc/v3 v3.14.0 + github.com/zitadel/oidc/v3 v3.18.0 github.com/zitadel/passwap v0.5.0 github.com/zitadel/saml v0.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 diff --git a/go.sum b/go.sum index 2932e4fdf..07da31224 100644 --- a/go.sum +++ b/go.sum @@ -218,8 +218,8 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v3 v3.0.2 h1:2Edjn8Nrb44UvTdp84KU0bBPs1cO7noRCybtS3eJEUQ= -github.com/go-jose/go-jose/v3 v3.0.2/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= @@ -779,8 +779,8 @@ github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= -github.com/zitadel/oidc/v3 v3.14.0 h1:k2tXbWsWiAb0/KqbrZzg6V0zHHpNAQbkq5SV0OVFHm8= -github.com/zitadel/oidc/v3 v3.14.0/go.mod h1:eFpGSMDvzdpq+ru2Dr3gWWdZCT6BqPk2QbY3qA7rU9I= +github.com/zitadel/oidc/v3 v3.18.0 h1:NGdxLIYbuvaIqc/Na1fu61wBXIbqufp7LsFNV1bXOQw= +github.com/zitadel/oidc/v3 v3.18.0/go.mod h1:tY75hMcm07McpPXzvgvFTNPefPYDnHRYZQZVn9gtAps= github.com/zitadel/passwap v0.5.0 h1:kFMoRyo0GnxtOz7j9+r/CsRwSCjHGRaAKoUe69NwPvs= github.com/zitadel/passwap v0.5.0/go.mod h1:uqY7D3jqdTFcKsW0Q3Pcv5qDMmSHpVTzUZewUKC1KZA= github.com/zitadel/saml v0.1.3 h1:LI4DOCVyyU1qKPkzs3vrGcA5J3H4pH3+CL9zr9ShkpM= diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index 3a1168494..329cbe7fc 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -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 } diff --git a/internal/api/grpc/admin/feature.go b/internal/api/grpc/admin/feature.go index c581ba9e9..a63825171 100644 --- a/internal/api/grpc/admin/feature.go +++ b/internal/api/grpc/admin/feature.go @@ -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 diff --git a/internal/api/grpc/admin/iam_settings_integration_test.go b/internal/api/grpc/admin/iam_settings_integration_test.go index 10e24ef8a..6d39abb74 100644 --- a/internal/api/grpc/admin/iam_settings_integration_test.go +++ b/internal/api/grpc/admin/iam_settings_integration_test.go @@ -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 diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 2ded702f7..55ab38978 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -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), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 3b90ee15e..baac093b6 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -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) diff --git a/internal/api/grpc/project/application.go b/internal/api/grpc/project/application.go index c1a5a88f3..25274eeb1 100644 --- a/internal/api/grpc/project/application.go +++ b/internal/api/grpc/project/application.go @@ -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 diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 05cb81d7f..b4aca5f92 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -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") diff --git a/internal/api/oidc/amr.go b/internal/api/oidc/amr.go index e53db8b7c..4f773f175 100644 --- a/internal/api/oidc/amr.go +++ b/internal/api/oidc/amr.go @@ -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 +} diff --git a/internal/api/oidc/amr_test.go b/internal/api/oidc/amr_test.go index d4468022f..4ea70bdfd 100644 --- a/internal/api/oidc/amr_test.go +++ b/internal/api/oidc/amr_test.go @@ -22,7 +22,7 @@ func TestAMR(t *testing.T) { args{ nil, }, - []string{}, + nil, }, { "pw checked", diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index 093d0d91b..08602538f 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -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 } } diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 4cb3d60a8..bbe3b1892 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -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) +} diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 9af51bff6..ee1f3199a 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -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 } diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index b8334b578..74d983c81 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -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 } diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go index 3faa930c4..96812120d 100644 --- a/internal/api/oidc/client_integration_test.go +++ b/internal/api/oidc/client_integration_test.go @@ -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 diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index f2d557e67..04934e0dc 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -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 diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 19b444fed..a5e0422a7 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -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 }) diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 32d88975b..fe10db321 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -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) }() diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go new file mode 100644 index 000000000..77c9b46fa --- /dev/null +++ b/internal/api/oidc/token_exchange.go @@ -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 +} diff --git a/internal/api/oidc/token_exchange_converter.go b/internal/api/oidc/token_exchange_converter.go new file mode 100644 index 000000000..98bddc1bb --- /dev/null +++ b/internal/api/oidc/token_exchange_converter.go @@ -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, + } +} diff --git a/internal/api/oidc/token_exchange_integration_test.go b/internal/api/oidc/token_exchange_integration_test.go new file mode 100644 index 000000000..54ad8808d --- /dev/null +++ b/internal/api/oidc/token_exchange_integration_test.go @@ -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()) +} diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index b1ccac395..138b381ea 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -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) } } diff --git a/internal/api/oidc/userinfo_test.go b/internal/api/oidc/userinfo_test.go index f0bc05e8c..d241f0d86 100644 --- a/internal/api/oidc/userinfo_test.go +++ b/internal/api/oidc/userinfo_test.go @@ -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", }, }, }, diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index 2ddcc3cf9..d1f22eb21 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -112,6 +112,9 @@ func (repo *TokenVerifierRepo) verifyAccessTokenV1(ctx context.Context, tokenID, _, tokenSpan := tracing.NewNamedSpan(ctx, "token") token, err := repo.tokenByID(ctx, tokenID, subject) + if token.Actor != nil { + return "", "", "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-wai8O", "Errors.TokenExchange.Token.NotForAPI") + } tokenSpan.EndWithError(err) if err != nil { return "", "", "", "", "", zerrors.ThrowUnauthenticated(err, "APP-BxUSiL", "invalid token") @@ -136,6 +139,9 @@ func (repo *TokenVerifierRepo) verifyAccessTokenV2(ctx context.Context, token, v if err != nil { return "", "", "", err } + if activeToken.Actor != nil { + return "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-Shi0J", "Errors.TokenExchange.Token.NotForAPI") + } if err = verifyAudience(activeToken.Audience, verifierClientID, projectID); err != nil { return "", "", "", err } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 72ded6663..abcdb439b 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -16,13 +16,15 @@ type InstanceFeatures struct { TriggerIntrospectionProjections *bool LegacyIntrospection *bool UserSchema *bool + TokenExchange *bool } func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && m.LegacyIntrospection == nil && - m.UserSchema == nil + m.UserSchema == nil && + m.TokenExchange == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 7938429c4..02c9266c6 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -55,6 +55,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, + feature_v2.InstanceTokenExchangeEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -64,6 +65,7 @@ func (m *InstanceFeaturesWriteModel) reduceReset() { m.TriggerIntrospectionProjections = nil m.LegacyIntrospection = nil m.UserSchema = nil + m.TokenExchange = nil } func (m *InstanceFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { @@ -80,6 +82,8 @@ func (m *InstanceFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEven m.TriggerIntrospectionProjections = &event.Value case feature.KeyLegacyIntrospection: m.LegacyIntrospection = &event.Value + case feature.KeyTokenExchange: + m.TokenExchange = &event.Value case feature.KeyUserSchema: m.UserSchema = &event.Value } @@ -92,6 +96,7 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) return cmds } diff --git a/internal/command/instance_policy_security_model.go b/internal/command/instance_policy_security_model.go index 75d212685..be14bc159 100644 --- a/internal/command/instance_policy_security_model.go +++ b/internal/command/instance_policy_security_model.go @@ -35,6 +35,9 @@ func (wm *InstanceSecurityPolicyWriteModel) Reduce() error { if e.AllowedOrigins != nil { wm.AllowedOrigins = *e.AllowedOrigins } + if e.EnableImpersonation != nil { + wm.EnableImpersonation = *e.EnableImpersonation + } } } return wm.WriteModel.Reduce() diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 26a6d479d..cbfcc4238 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -23,6 +23,7 @@ import ( type expect func(mockRepository *mock.MockRepository) +// Deprecated: use expectEventstore func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { m := mock.NewRepo(t) for _, e := range expects { @@ -37,6 +38,9 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { return es } +// expectEventstore defines expectations for the Eventstore and is initialized within the scope of a (sub) test. +// This allows proper reporting of the test name, instead of reporting on the top-level +// of the Test function being run. func expectEventstore(expects ...expect) func(*testing.T) *eventstore.Eventstore { return func(t *testing.T) *eventstore.Eventstore { return eventstoreExpect(t, expects...) diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index afc4c5b5a..90d852d87 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/repository/authrequest" @@ -35,7 +36,7 @@ func (c *Commands) AddOIDCSessionAccessToken(ctx context.Context, authRequestID return "", time.Time{}, err } cmd.AddSession(ctx) - if err = cmd.AddAccessToken(ctx, cmd.authRequestWriteModel.Scope); err != nil { + if err = cmd.AddAccessToken(ctx, cmd.authRequestWriteModel.Scope, domain.TokenReasonAuthRequest, nil); err != nil { return "", time.Time{}, err } cmd.SetAuthRequestSuccessful(ctx) @@ -52,7 +53,7 @@ func (c *Commands) AddOIDCSessionRefreshAndAccessToken(ctx context.Context, auth return "", "", time.Time{}, err } cmd.AddSession(ctx) - if err = cmd.AddAccessToken(ctx, cmd.authRequestWriteModel.Scope); err != nil { + if err = cmd.AddAccessToken(ctx, cmd.authRequestWriteModel.Scope, domain.TokenReasonAuthRequest, nil); err != nil { return "", "", time.Time{}, err } if err = cmd.AddRefreshToken(ctx); err != nil { @@ -69,7 +70,7 @@ func (c *Commands) ExchangeOIDCSessionRefreshAndAccessToken(ctx context.Context, if err != nil { return "", "", time.Time{}, err } - if err = cmd.AddAccessToken(ctx, scope); err != nil { + if err = cmd.AddAccessToken(ctx, scope, domain.TokenReasonRefresh, nil); err != nil { return "", "", time.Time{}, err } if err = cmd.RenewRefreshToken(ctx); err != nil { @@ -290,13 +291,13 @@ func (c *OIDCSessionEvents) SetAuthRequestSuccessful(ctx context.Context) { c.events = append(c.events, authrequest.NewSucceededEvent(ctx, c.authRequestWriteModel.aggregate)) } -func (c *OIDCSessionEvents) AddAccessToken(ctx context.Context, scope []string) error { +func (c *OIDCSessionEvents) AddAccessToken(ctx context.Context, scope []string, reason domain.TokenReason, actor *domain.TokenActor) error { accessTokenID, err := c.idGenerator.Next() if err != nil { return err } c.accessTokenID = AccessTokenPrefix + accessTokenID - c.events = append(c.events, oidcsession.NewAccessTokenAddedEvent(ctx, c.oidcSessionWriteModel.aggregate, c.accessTokenID, scope, c.accessTokenLifetime)) + c.events = append(c.events, oidcsession.NewAccessTokenAddedEvent(ctx, c.oidcSessionWriteModel.aggregate, c.accessTokenID, scope, c.accessTokenLifetime, reason, actor)) return nil } diff --git a/internal/command/oidc_session_model.go b/internal/command/oidc_session_model.go index 64f9d5490..a561fc416 100644 --- a/internal/command/oidc_session_model.go +++ b/internal/command/oidc_session_model.go @@ -23,6 +23,8 @@ type OIDCSessionWriteModel struct { AccessTokenID string AccessTokenCreation time.Time AccessTokenExpiration time.Time + AccessTokenReason domain.TokenReason + AccessTokenActor *domain.TokenActor RefreshTokenID string RefreshToken string RefreshTokenExpiration time.Time @@ -100,6 +102,8 @@ func (wm *OIDCSessionWriteModel) reduceAdded(e *oidcsession.AddedEvent) { func (wm *OIDCSessionWriteModel) reduceAccessTokenAdded(e *oidcsession.AccessTokenAddedEvent) { wm.AccessTokenID = e.ID wm.AccessTokenExpiration = e.CreationDate().Add(e.Lifetime) + wm.AccessTokenReason = e.Reason + wm.AccessTokenActor = e.Actor } func (wm *OIDCSessionWriteModel) reduceAccessTokenRevoked(e *oidcsession.AccessTokenRevokedEvent) { diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index d43aab0d8..6dcd96c62 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -196,7 +196,7 @@ func TestCommands_AddOIDCSessionAccessToken(t *testing.T) { oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid"}, time.Hour), + "at_accessTokenID", []string{"openid"}, time.Hour, domain.TokenReasonAuthRequest, nil), authrequest.NewSucceededEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), ), ), @@ -397,7 +397,7 @@ func TestCommands_AddOIDCSessionRefreshAndAccessToken(t *testing.T) { oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), authrequest.NewSucceededEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), @@ -441,7 +441,7 @@ func TestCommands_AddOIDCSessionRefreshAndAccessToken(t *testing.T) { func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator defaultAccessTokenLifetime time.Duration defaultRefreshTokenLifetime time.Duration @@ -469,7 +469,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { { "invalid refresh token format error", fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args{ @@ -484,7 +484,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { { "inactive session error", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter(), ), keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), @@ -501,7 +501,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { { "invalid refresh token error", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -509,7 +509,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { ), eventFromEventPusher( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), ), ), @@ -527,7 +527,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { { "expired refresh token error", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -535,7 +535,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { ), eventFromEventPusher( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusher( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -557,7 +557,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { { "refresh successful", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -565,7 +565,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { ), eventFromEventPusherWithCreationDateNow( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusherWithCreationDateNow( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -575,7 +575,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { expectFilter(), // token lifetime expectPush( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonRefresh, nil), oidcsession.NewRefreshTokenRenewedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "rt_refreshTokenID2", 24*time.Hour), ), @@ -602,7 +602,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, defaultAccessTokenLifetime: tt.fields.defaultAccessTokenLifetime, defaultRefreshTokenLifetime: tt.fields.defaultRefreshTokenLifetime, @@ -682,7 +682,7 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { ), eventFromEventPusher( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), ), ), @@ -707,7 +707,7 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { ), eventFromEventPusher( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusher( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -736,7 +736,7 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { ), eventFromEventPusherWithCreationDateNow( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusherWithCreationDateNow( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -892,7 +892,7 @@ func TestCommands_RevokeOIDCSessionToken(t *testing.T) { ), eventFromEventPusherWithCreationDateNow( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusherWithCreationDateNow( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -969,7 +969,7 @@ func TestCommands_RevokeOIDCSessionToken(t *testing.T) { ), eventFromEventPusherWithCreationDateNow( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), ), eventFromEventPusherWithCreationDateNow( oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, diff --git a/internal/command/system_features.go b/internal/command/system_features.go index 4311c3b23..70ec66dbe 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -12,6 +12,7 @@ type SystemFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool LegacyIntrospection *bool + TokenExchange *bool UserSchema *bool } @@ -19,7 +20,8 @@ func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && m.LegacyIntrospection == nil && - m.UserSchema == nil + m.UserSchema == nil && + m.TokenExchange == nil } func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 5057f55f3..9b223d7f0 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -50,6 +50,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, + feature_v2.SystemTokenExchangeEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -58,6 +59,7 @@ func (m *SystemFeaturesWriteModel) reduceReset() { m.LoginDefaultOrg = nil m.TriggerIntrospectionProjections = nil m.LegacyIntrospection = nil + m.TokenExchange = nil m.UserSchema = nil } @@ -77,6 +79,8 @@ func (m *SystemFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[ m.LegacyIntrospection = &event.Value case feature.KeyUserSchema: m.UserSchema = &event.Value + case feature.KeyTokenExchange: + m.TokenExchange = &event.Value } return nil } @@ -88,6 +92,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) return cmds } diff --git a/internal/command/user.go b/internal/command/user.go index 19047cf2b..9376f011f 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -232,16 +232,29 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, return writeModelToObjectDetails(&existingUser.WriteModel), nil } -func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, userID string, audience, scopes []string, lifetime time.Duration) (*domain.Token, error) { +func (c *Commands) AddUserToken( + ctx context.Context, + orgID, + agentID, + clientID, + userID string, + audience, + scopes, + authMethodsReferences []string, + lifetime time.Duration, + authTime time.Time, + reason domain.TokenReason, + actor *domain.TokenActor, +) (*domain.Token, error) { if userID == "" { //do not check for empty orgID (JWT Profile requests won't provide it, so service user requests fail) return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Dbge4", "Errors.IDMissing") } userWriteModel := NewUserWriteModel(userID, orgID) - event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, "", audience, scopes, lifetime) + cmds, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, "", audience, scopes, authMethodsReferences, lifetime, authTime, reason, actor) if err != nil { return nil, err } - _, err = c.eventstore.Push(ctx, event) + _, err = c.eventstore.Push(ctx, cmds...) if err != nil { return nil, err } @@ -264,7 +277,7 @@ func (c *Commands) RevokeAccessToken(ctx context.Context, userID, orgID, tokenID return writeModelToObjectDetails(&accessTokenWriteModel.WriteModel), nil } -func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID, refreshTokenID string, audience, scopes []string, lifetime time.Duration) (*user.UserTokenAddedEvent, *domain.Token, error) { +func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID, refreshTokenID string, audience, scopes, authMethodsReferences []string, lifetime time.Duration, authTime time.Time, reason domain.TokenReason, actor *domain.TokenActor) ([]eventstore.Command, *domain.Token, error) { err := c.eventstore.FilterToQueryReducer(ctx, userWriteModel) if err != nil { return nil, nil, err @@ -273,10 +286,24 @@ func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteMo return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound") } + //nolint:contextcheck + userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel) + + var cmds []eventstore.Command + if reason == domain.TokenReasonImpersonation { + if err := c.checkPermission(ctx, "impersonation", userWriteModel.ResourceOwner, userWriteModel.AggregateID); err != nil { + return nil, nil, err + } + cmds = append(cmds, user.NewUserImpersonatedEvent(ctx, userAgg, clientID, actor)) + } + audience = domain.AddAudScopeToAudience(ctx, audience, scopes) preferredLanguage := "" existingHuman, err := c.getHumanWriteModelByID(ctx, userWriteModel.AggregateID, userWriteModel.ResourceOwner) + if err != nil { + return nil, nil, err + } if existingHuman != nil { preferredLanguage = existingHuman.PreferredLanguage.String() } @@ -286,21 +313,25 @@ func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteMo return nil, nil, err } - userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel) - return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, refreshTokenID, audience, scopes, expiration), - &domain.Token{ - ObjectRoot: models.ObjectRoot{ - AggregateID: userWriteModel.AggregateID, - }, - TokenID: tokenID, - UserAgentID: agentID, - ApplicationID: clientID, - RefreshTokenID: refreshTokenID, - Audience: audience, - Scopes: scopes, - Expiration: expiration, - PreferredLanguage: preferredLanguage, - }, nil + cmds = append(cmds, + user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, refreshTokenID, audience, scopes, authMethodsReferences, authTime, expiration, reason, actor), + ) + + return cmds, &domain.Token{ + ObjectRoot: models.ObjectRoot{ + AggregateID: userWriteModel.AggregateID, + }, + TokenID: tokenID, + UserAgentID: agentID, + ApplicationID: clientID, + RefreshTokenID: refreshTokenID, + Audience: audience, + Scopes: scopes, + Expiration: expiration, + PreferredLanguage: preferredLanguage, + Reason: reason, + Actor: actor, + }, nil } func (c *Commands) removeAccessToken(ctx context.Context, userID, orgID, tokenID string) (*user.UserTokenRemovedEvent, *UserAccessTokenWriteModel, error) { diff --git a/internal/command/user_human_access_token_model.go b/internal/command/user_human_access_token_model.go index 63f0b3887..5256e81c0 100644 --- a/internal/command/user_human_access_token_model.go +++ b/internal/command/user_human_access_token_model.go @@ -19,6 +19,8 @@ type UserAccessTokenWriteModel struct { Scopes []string Expiration time.Time PreferredLanguage string + Reason domain.TokenReason + Actor *domain.TokenActor UserState domain.UserState } @@ -71,6 +73,8 @@ func (wm *UserAccessTokenWriteModel) Reduce() error { wm.Expiration = e.Expiration wm.PreferredLanguage = e.PreferredLanguage wm.UserState = domain.UserStateActive + wm.Reason = e.Reason + wm.Actor = e.Actor if e.Expiration.Before(time.Now()) { wm.UserState = domain.UserStateDeleted } diff --git a/internal/command/user_human_refresh_token.go b/internal/command/user_human_refresh_token.go index 9449221dc..61ec2da7b 100644 --- a/internal/command/user_human_refresh_token.go +++ b/internal/command/user_human_refresh_token.go @@ -24,11 +24,13 @@ func (c *Commands) AddAccessAndRefreshToken( refreshIdleExpiration, refreshExpiration time.Duration, authTime time.Time, + reason domain.TokenReason, + actor *domain.TokenActor, ) (accessToken *domain.Token, newRefreshToken string, err error) { if refreshToken == "" { - return c.AddNewRefreshTokenAndAccessToken(ctx, userID, orgID, agentID, clientID, audience, scopes, authMethodsReferences, refreshExpiration, accessLifetime, refreshIdleExpiration, authTime) + return c.AddNewRefreshTokenAndAccessToken(ctx, userID, orgID, agentID, clientID, audience, scopes, authMethodsReferences, refreshExpiration, accessLifetime, refreshIdleExpiration, authTime, reason, actor) } - return c.RenewRefreshTokenAndAccessToken(ctx, userID, orgID, refreshToken, agentID, clientID, audience, scopes, refreshIdleExpiration, accessLifetime) + return c.RenewRefreshTokenAndAccessToken(ctx, userID, orgID, refreshToken, agentID, clientID, audience, scopes, refreshIdleExpiration, accessLifetime, actor) } func (c *Commands) AddNewRefreshTokenAndAccessToken( @@ -44,6 +46,8 @@ func (c *Commands) AddNewRefreshTokenAndAccessToken( accessLifetime, refreshIdleExpiration time.Duration, authTime time.Time, + reason domain.TokenReason, + actor *domain.TokenActor, ) (accessToken *domain.Token, newRefreshToken string, err error) { if userID == "" || clientID == "" { return nil, "", zerrors.ThrowInvalidArgument(nil, "COMMAND-adg4r", "Errors.IDMissing") @@ -53,15 +57,16 @@ func (c *Commands) AddNewRefreshTokenAndAccessToken( if err != nil { return nil, "", err } - accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, refreshTokenID, audience, scopes, accessLifetime) + cmds, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, refreshTokenID, audience, scopes, authMethodsReferences, accessLifetime, authTime, reason, actor) if err != nil { return nil, "", err } - refreshTokenEvent, newRefreshToken, err := c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration) + refreshTokenEvent, newRefreshToken, err := c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration, actor) if err != nil { return nil, "", err } - _, err = c.eventstore.Push(ctx, accessTokenEvent, refreshTokenEvent) + cmds = append(cmds, refreshTokenEvent) + _, err = c.eventstore.Push(ctx, cmds...) if err != nil { return nil, "", err } @@ -79,17 +84,18 @@ func (c *Commands) RenewRefreshTokenAndAccessToken( scopes []string, idleExpiration, accessLifetime time.Duration, + actor *domain.TokenActor, ) (accessToken *domain.Token, newRefreshToken string, err error) { - refreshTokenEvent, refreshTokenID, newRefreshToken, err := c.renewRefreshToken(ctx, userID, orgID, refreshToken, idleExpiration) + renewed, err := c.renewRefreshToken(ctx, userID, orgID, refreshToken, idleExpiration) if err != nil { return nil, "", err } userWriteModel := NewUserWriteModel(userID, orgID) - accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, refreshTokenID, audience, scopes, accessLifetime) + cmds, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, renewed.tokenID, audience, scopes, renewed.authMethodsReferences, accessLifetime, renewed.authTime, domain.TokenReasonRefresh, actor) if err != nil { return nil, "", err } - _, err = c.eventstore.Push(ctx, accessTokenEvent, refreshTokenEvent) + _, err = c.eventstore.Push(ctx, append(cmds, renewed.event)...) if err != nil { return nil, "", err } @@ -128,7 +134,7 @@ func (c *Commands) RevokeRefreshTokens(ctx context.Context, userID, orgID string return err } -func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration) (*user.HumanRefreshTokenAddedEvent, string, error) { +func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration, actor *domain.TokenActor) (*user.HumanRefreshTokenAddedEvent, string, error) { refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, accessToken.RefreshTokenID, c.keyAlgorithm) if err != nil { return nil, "", err @@ -136,46 +142,60 @@ func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Toke refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(accessToken.AggregateID, accessToken.ResourceOwner, accessToken.RefreshTokenID) userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) return user.NewHumanRefreshTokenAddedEvent(ctx, userAgg, accessToken.RefreshTokenID, accessToken.ApplicationID, accessToken.UserAgentID, - accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration), + accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration, actor), refreshToken, nil } -func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (event *user.HumanRefreshTokenRenewedEvent, refreshTokenID, newRefreshToken string, err error) { +type renewedRefreshToken struct { + event *user.HumanRefreshTokenRenewedEvent + authTime time.Time + authMethodsReferences []string + tokenID string + token string +} + +func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (*renewedRefreshToken, error) { if refreshToken == "" { - return nil, "", "", zerrors.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing") } tokenUserID, tokenID, token, err := domain.FromRefreshToken(refreshToken, c.keyAlgorithm) if err != nil { - return nil, "", "", err + return nil, err } if tokenUserID != userID { - return nil, "", "", zerrors.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid") } refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID) err = c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel) if err != nil { - return nil, "", "", err + return nil, err } if refreshTokenWriteModel.UserState != domain.UserStateActive { - return nil, "", "", zerrors.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid") } if refreshTokenWriteModel.RefreshToken != token || refreshTokenWriteModel.IdleExpiration.Before(time.Now()) || refreshTokenWriteModel.Expiration.Before(time.Now()) { - return nil, "", "", zerrors.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid") } newToken, err := c.idGenerator.Next() if err != nil { - return nil, "", "", err + return nil, err } - newRefreshToken, err = domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm) + newRefreshToken, err := domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm) if err != nil { - return nil, "", "", err + return nil, err } userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) - return user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), tokenID, newRefreshToken, nil + return &renewedRefreshToken{ + event: user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), + authTime: refreshTokenWriteModel.AuthTime, + authMethodsReferences: refreshTokenWriteModel.AuthMethodsReferences, + tokenID: tokenID, + token: newRefreshToken, + }, nil } func (c *Commands) removeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*user.HumanRefreshTokenRemovedEvent, *HumanRefreshTokenWriteModel, error) { diff --git a/internal/command/user_human_refresh_token_model.go b/internal/command/user_human_refresh_token_model.go index dd49cdf47..e731ad2f6 100644 --- a/internal/command/user_human_refresh_token_model.go +++ b/internal/command/user_human_refresh_token_model.go @@ -15,10 +15,13 @@ type HumanRefreshTokenWriteModel struct { TokenID string RefreshToken string - UserState domain.UserState - IdleExpiration time.Time - Expiration time.Time - UserAgentID string + UserState domain.UserState + AuthTime time.Time + IdleExpiration time.Time + Expiration time.Time + UserAgentID string + AuthMethodsReferences []string + Actor *domain.TokenActor } func NewHumanRefreshTokenWriteModel(userID, resourceOwner, tokenID string) *HumanRefreshTokenWriteModel { @@ -64,7 +67,10 @@ func (wm *HumanRefreshTokenWriteModel) Reduce() error { wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration) wm.Expiration = e.CreationDate().Add(e.Expiration) wm.UserState = domain.UserStateActive + wm.AuthTime = e.AuthTime wm.UserAgentID = e.UserAgentID + wm.AuthMethodsReferences = e.AuthMethodsReferences + wm.Actor = e.Actor case *user.HumanRefreshTokenRenewedEvent: if wm.UserState == domain.UserStateActive { wm.RefreshToken = e.RefreshToken diff --git a/internal/command/user_human_refresh_token_test.go b/internal/command/user_human_refresh_token_test.go index d3c310db7..ca9279ac1 100644 --- a/internal/command/user_human_refresh_token_test.go +++ b/internal/command/user_human_refresh_token_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/oidc" "go.uber.org/mock/gomock" @@ -40,6 +41,8 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { authTime time.Time refreshIdleExpiration time.Duration refreshExpiration time.Duration + reason domain.TokenReason + actor *domain.TokenActor } type res struct { token *domain.Token @@ -135,6 +138,7 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( context.Background(), @@ -173,6 +177,7 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { time.Now(), -1*time.Hour, 24*time.Hour, + nil, )), ), ), @@ -292,7 +297,7 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { keyAlgorithm: tt.fields.keyAlgorithm, } got, gotRefresh, err := c.AddAccessAndRefreshToken(tt.args.ctx, tt.args.orgID, tt.args.agentID, tt.args.clientID, tt.args.userID, tt.args.refreshToken, - tt.args.audience, tt.args.scopes, tt.args.authMethodsReferences, tt.args.lifetime, tt.args.refreshIdleExpiration, tt.args.refreshExpiration, tt.args.authTime) + tt.args.audience, tt.args.scopes, tt.args.authMethodsReferences, tt.args.lifetime, tt.args.refreshIdleExpiration, tt.args.refreshExpiration, tt.args.authTime, tt.args.reason, tt.args.actor) if tt.res.err == nil { assert.NoError(t, err) } @@ -372,6 +377,7 @@ func TestCommands_RevokeRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectPushFailed(zerrors.ThrowInternal(nil, "ERROR", "internal"), @@ -411,6 +417,7 @@ func TestCommands_RevokeRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectPush( @@ -506,6 +513,7 @@ func TestCommands_RevokeRefreshTokens(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectFilter(), @@ -539,6 +547,7 @@ func TestCommands_RevokeRefreshTokens(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectFilter( @@ -555,6 +564,7 @@ func TestCommands_RevokeRefreshTokens(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectPushFailed(zerrors.ThrowInternal(nil, "ERROR", "internal"), @@ -599,6 +609,7 @@ func TestCommands_RevokeRefreshTokens(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectFilter( @@ -615,6 +626,7 @@ func TestCommands_RevokeRefreshTokens(t *testing.T) { time.Now(), 1*time.Hour, 10*time.Hour, + nil, )), ), expectPush( @@ -691,6 +703,7 @@ func TestCommands_addRefreshToken(t *testing.T) { authTime time.Time idleExpiration time.Duration expiration time.Duration + actor *domain.TokenActor } type res struct { event *user.HumanRefreshTokenAddedEvent @@ -745,6 +758,7 @@ func TestCommands_addRefreshToken(t *testing.T) { authTime, 1*time.Hour, 10*time.Hour, + nil, ), refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:refreshTokenID:refreshTokenID")), }, @@ -756,7 +770,7 @@ func TestCommands_addRefreshToken(t *testing.T) { eventstore: tt.fields.eventstore, keyAlgorithm: tt.fields.keyAlgorithm, } - gotEvent, gotRefreshToken, err := c.addRefreshToken(tt.args.ctx, tt.args.accessToken, tt.args.authMethodsReferences, tt.args.authTime, tt.args.idleExpiration, tt.args.expiration) + gotEvent, gotRefreshToken, err := c.addRefreshToken(tt.args.ctx, tt.args.accessToken, tt.args.authMethodsReferences, tt.args.authTime, tt.args.idleExpiration, tt.args.expiration, tt.args.actor) if tt.res.err == nil { assert.NoError(t, err) } @@ -788,13 +802,13 @@ func TestCommands_renewRefreshToken(t *testing.T) { event *user.HumanRefreshTokenRenewedEvent refreshTokenID string newRefreshToken string - err func(error) bool } tests := []struct { - name string - fields fields - args args - res res + name string + fields fields + args args + want *renewedRefreshToken + wantErr func(error) bool }{ { name: "empty token, error", @@ -804,9 +818,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { args: args{ ctx: context.Background(), }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "invalid token, error", @@ -818,9 +830,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { ctx: context.Background(), refreshToken: "invalid", }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "invalid token (invalid userID), error", @@ -834,9 +844,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { orgID: "orgID", refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID2:tokenID:token")), }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "token inactive, error", @@ -856,6 +864,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( context.Background(), @@ -872,9 +881,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { orgID: "orgID", refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:token")), }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "token expired, error", @@ -894,6 +901,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), ), ), @@ -905,9 +913,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { orgID: "orgID", refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "user deactivated, error", @@ -927,6 +933,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), eventFromEventPusher( user.NewUserDeactivatedEvent( @@ -945,9 +952,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), idleExpiration: 1 * time.Hour, }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "user signedout, error", @@ -967,6 +972,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), eventFromEventPusher( user.NewHumanSignedOutEvent( @@ -986,9 +992,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), idleExpiration: 1 * time.Hour, }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, + wantErr: zerrors.IsErrorInvalidArgument, }, { name: "token renewed, ok", @@ -1008,6 +1012,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { time.Now(), 1*time.Hour, 24*time.Hour, + nil, )), ), ), @@ -1021,7 +1026,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), idleExpiration: 1 * time.Hour, }, - res: res{ + want: &renewedRefreshToken{ event: user.NewHumanRefreshTokenRenewedEvent( context.Background(), &user.NewAggregate("userID", "orgID").Aggregate, @@ -1029,8 +1034,9 @@ func TestCommands_renewRefreshToken(t *testing.T) { "refreshToken1", 1*time.Hour, ), - refreshTokenID: "tokenID", - newRefreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:refreshToken1")), + authMethodsReferences: []string{"password"}, + tokenID: "tokenID", + token: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:refreshToken1")), }, }, } @@ -1041,17 +1047,16 @@ func TestCommands_renewRefreshToken(t *testing.T) { idGenerator: tt.fields.idGenerator, keyAlgorithm: tt.fields.keyAlgorithm, } - gotEvent, gotRefreshTokenID, gotNewRefreshToken, err := c.renewRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.refreshToken, tt.args.idleExpiration) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { + got, err := c.renewRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.refreshToken, tt.args.idleExpiration) + if tt.wantErr != nil && !tt.wantErr(err) { t.Errorf("got wrong err: %v ", err) } - if tt.res.err == nil { - assert.Equal(t, tt.res.event, gotEvent) - assert.Equal(t, tt.res.refreshTokenID, gotRefreshTokenID) - assert.Equal(t, tt.res.newRefreshToken, gotNewRefreshToken) + if tt.wantErr == nil { + require.NoError(t, err) + assert.Equal(t, tt.want.event, got.event) + assert.Equal(t, tt.want.authMethodsReferences, got.authMethodsReferences) + assert.Equal(t, tt.want.tokenID, got.tokenID) + assert.Equal(t, tt.want.token, got.token) } }) } diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 9f2244a45..6538e0066 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -1440,14 +1440,18 @@ func TestCommandSide_AddUserToken(t *testing.T) { } type ( args struct { - ctx context.Context - orgID string - agentID string - clientID string - userID string - audience []string - scopes []string - lifetime time.Duration + ctx context.Context + orgID string + agentID string + clientID string + userID string + audience []string + scopes []string + authMethodsReferences []string + lifetime time.Duration + authTime time.Time + reason domain.TokenReason + actor *domain.TokenActor } ) type res struct { @@ -1500,7 +1504,7 @@ func TestCommandSide_AddUserToken(t *testing.T) { eventstore: tt.fields.eventstore, idGenerator: tt.fields.idGenerator, } - got, err := r.AddUserToken(tt.args.ctx, tt.args.orgID, tt.args.agentID, tt.args.clientID, tt.args.userID, tt.args.audience, tt.args.scopes, tt.args.lifetime) + got, err := r.AddUserToken(tt.args.ctx, tt.args.orgID, tt.args.agentID, tt.args.clientID, tt.args.userID, tt.args.audience, tt.args.scopes, tt.args.authMethodsReferences, tt.args.lifetime, tt.args.authTime, tt.args.reason, tt.args.actor) if tt.res.err == nil { assert.NoError(t, err) } @@ -1565,7 +1569,11 @@ func TestCommands_RevokeAccessToken(t *testing.T) { "refreshTokenID", []string{"clientID"}, []string{"openid"}, + []string{"password"}, time.Now(), + time.Now(), + domain.TokenReasonAuthRequest, + nil, ), ), ), @@ -1597,7 +1605,11 @@ func TestCommands_RevokeAccessToken(t *testing.T) { "refreshTokenID", []string{"clientID"}, []string{"openid"}, + []string{"password"}, + time.Now(), time.Now().Add(5*time.Hour), + domain.TokenReasonAuthRequest, + nil, ), ), ), diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 248100095..0f981197f 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -91,6 +91,7 @@ const ( OIDCGrantTypeImplicit OIDCGrantTypeRefreshToken OIDCGrantTypeDeviceCode + OIDCGrantTypeTokenExchange ) type OIDCApplicationType int32 diff --git a/internal/domain/token.go b/internal/domain/token.go index 66106504e..1c12efc28 100644 --- a/internal/domain/token.go +++ b/internal/domain/token.go @@ -20,6 +20,8 @@ type Token struct { Expiration time.Time Scopes []string PreferredLanguage string + Reason TokenReason + Actor *TokenActor } func AddAudScopeToAudience(ctx context.Context, audience, scopes []string) []string { @@ -44,3 +46,22 @@ func addProjectID(audience []string, projectID string) []string { } return append(audience, projectID) } + +//go:generate enumer -type TokenReason -transform snake -trimprefix TokenReason -json +type TokenReason int + +const ( + TokenReasonUnspecified TokenReason = iota + TokenReasonAuthRequest + TokenReasonRefresh + TokenReasonJWTProfile + TokenReasonClientCredentials + TokenReasonExchange + TokenReasonImpersonation +) + +type TokenActor struct { + Actor *TokenActor `json:"actor,omitempty"` + UserID string `json:"user_id,omitempty"` + Issuer string `json:"issuer,omitempty"` +} diff --git a/internal/domain/tokenreason_enumer.go b/internal/domain/tokenreason_enumer.go new file mode 100644 index 000000000..5e8f32d3e --- /dev/null +++ b/internal/domain/tokenreason_enumer.go @@ -0,0 +1,116 @@ +// Code generated by "enumer -type TokenReason -transform snake -trimprefix TokenReason -json"; DO NOT EDIT. + +package domain + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _TokenReasonName = "unspecifiedauth_requestrefreshjwt_profileclient_credentialsexchangeimpersonation" + +var _TokenReasonIndex = [...]uint8{0, 11, 23, 30, 41, 59, 67, 80} + +const _TokenReasonLowerName = "unspecifiedauth_requestrefreshjwt_profileclient_credentialsexchangeimpersonation" + +func (i TokenReason) String() string { + if i < 0 || i >= TokenReason(len(_TokenReasonIndex)-1) { + return fmt.Sprintf("TokenReason(%d)", i) + } + return _TokenReasonName[_TokenReasonIndex[i]:_TokenReasonIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _TokenReasonNoOp() { + var x [1]struct{} + _ = x[TokenReasonUnspecified-(0)] + _ = x[TokenReasonAuthRequest-(1)] + _ = x[TokenReasonRefresh-(2)] + _ = x[TokenReasonJWTProfile-(3)] + _ = x[TokenReasonClientCredentials-(4)] + _ = x[TokenReasonExchange-(5)] + _ = x[TokenReasonImpersonation-(6)] +} + +var _TokenReasonValues = []TokenReason{TokenReasonUnspecified, TokenReasonAuthRequest, TokenReasonRefresh, TokenReasonJWTProfile, TokenReasonClientCredentials, TokenReasonExchange, TokenReasonImpersonation} + +var _TokenReasonNameToValueMap = map[string]TokenReason{ + _TokenReasonName[0:11]: TokenReasonUnspecified, + _TokenReasonLowerName[0:11]: TokenReasonUnspecified, + _TokenReasonName[11:23]: TokenReasonAuthRequest, + _TokenReasonLowerName[11:23]: TokenReasonAuthRequest, + _TokenReasonName[23:30]: TokenReasonRefresh, + _TokenReasonLowerName[23:30]: TokenReasonRefresh, + _TokenReasonName[30:41]: TokenReasonJWTProfile, + _TokenReasonLowerName[30:41]: TokenReasonJWTProfile, + _TokenReasonName[41:59]: TokenReasonClientCredentials, + _TokenReasonLowerName[41:59]: TokenReasonClientCredentials, + _TokenReasonName[59:67]: TokenReasonExchange, + _TokenReasonLowerName[59:67]: TokenReasonExchange, + _TokenReasonName[67:80]: TokenReasonImpersonation, + _TokenReasonLowerName[67:80]: TokenReasonImpersonation, +} + +var _TokenReasonNames = []string{ + _TokenReasonName[0:11], + _TokenReasonName[11:23], + _TokenReasonName[23:30], + _TokenReasonName[30:41], + _TokenReasonName[41:59], + _TokenReasonName[59:67], + _TokenReasonName[67:80], +} + +// TokenReasonString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func TokenReasonString(s string) (TokenReason, error) { + if val, ok := _TokenReasonNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _TokenReasonNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to TokenReason values", s) +} + +// TokenReasonValues returns all values of the enum +func TokenReasonValues() []TokenReason { + return _TokenReasonValues +} + +// TokenReasonStrings returns a slice of all String values of the enum +func TokenReasonStrings() []string { + strs := make([]string, len(_TokenReasonNames)) + copy(strs, _TokenReasonNames) + return strs +} + +// IsATokenReason returns "true" if the value is listed in the enum definition. "false" otherwise +func (i TokenReason) IsATokenReason() bool { + for _, v := range _TokenReasonValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for TokenReason +func (i TokenReason) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for TokenReason +func (i *TokenReason) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("TokenReason should be a string, got %s", data) + } + + var err error + *i, err = TokenReasonString(s) + return err +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 24427b2d5..8677a997f 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -42,6 +42,7 @@ const ( UserAuthMethodTypeIDP UserAuthMethodTypeOTPSMS UserAuthMethodTypeOTPEmail + UserAuthMethodTypeOTP // generic OTP when parsing AMR from OIDC userAuthMethodTypeCount ) @@ -59,7 +60,8 @@ func HasMFA(methods []UserAuthMethodType) bool { UserAuthMethodTypeTOTP, UserAuthMethodTypeOTPSMS, UserAuthMethodTypeOTPEmail, - UserAuthMethodTypeIDP: + UserAuthMethodTypeIDP, + UserAuthMethodTypeOTP: factors++ case UserAuthMethodTypeUnspecified, userAuthMethodTypeCount: diff --git a/internal/feature/feature.go b/internal/feature/feature.go index e85413efb..d497f626b 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,6 +9,7 @@ const ( KeyTriggerIntrospectionProjections KeyLegacyIntrospection KeyUserSchema + KeyTokenExchange ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -29,4 +30,5 @@ type Features struct { TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` LegacyIntrospection bool `json:"legacy_introspection,omitempty"` UserSchema bool `json:"user_schema,omitempty"` + TokenExchange bool `json:"token_exchange,omitempty"` } diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index eaa434064..06a052ee8 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schema" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchange" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92} +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schema" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchange" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -29,21 +29,24 @@ func _KeyNoOp() { _ = x[KeyTriggerIntrospectionProjections-(2)] _ = x[KeyLegacyIntrospection-(3)] _ = x[KeyUserSchema-(4)] + _ = x[KeyTokenExchange-(5)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange} var _KeyNameToValueMap = map[string]Key{ - _KeyName[0:11]: KeyUnspecified, - _KeyLowerName[0:11]: KeyUnspecified, - _KeyName[11:28]: KeyLoginDefaultOrg, - _KeyLowerName[11:28]: KeyLoginDefaultOrg, - _KeyName[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, - _KeyName[61:81]: KeyLegacyIntrospection, - _KeyLowerName[61:81]: KeyLegacyIntrospection, - _KeyName[81:92]: KeyUserSchema, - _KeyLowerName[81:92]: KeyUserSchema, + _KeyName[0:11]: KeyUnspecified, + _KeyLowerName[0:11]: KeyUnspecified, + _KeyName[11:28]: KeyLoginDefaultOrg, + _KeyLowerName[11:28]: KeyLoginDefaultOrg, + _KeyName[28:61]: KeyTriggerIntrospectionProjections, + _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, + _KeyName[61:81]: KeyLegacyIntrospection, + _KeyLowerName[61:81]: KeyLegacyIntrospection, + _KeyName[81:92]: KeyUserSchema, + _KeyLowerName[81:92]: KeyUserSchema, + _KeyName[92:106]: KeyTokenExchange, + _KeyLowerName[92:106]: KeyTokenExchange, } var _KeyNames = []string{ @@ -52,6 +55,7 @@ var _KeyNames = []string{ _KeyName[28:61], _KeyName[61:81], _KeyName[81:92], + _KeyName[92:106], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 6421758ad..765da68b4 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -23,13 +23,16 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user" ) -func (s *Tester) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool) (*management.AddOIDCAppResponse, error) { +func (s *Tester) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) { + if len(grantTypes) == 0 { + grantTypes = []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN} + } return s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ ProjectId: projectID, Name: fmt.Sprintf("app-%d", time.Now().UnixNano()), RedirectUris: []string{redirectURI}, ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, - GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN}, + GrantTypes: grantTypes, AppType: appType, AuthMethodType: authMethod, PostLogoutRedirectUris: []string{logoutRedirectURI}, @@ -53,8 +56,8 @@ func (s *Tester) CreateOIDCWebClientBasic(ctx context.Context, redirectURI, logo return s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, false) } -func (s *Tester) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (client *management.AddOIDCAppResponse, keyData []byte, err error) { - client, err = s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, false) +func (s *Tester) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, grantTypes ...app.OIDCGrantType) (client *management.AddOIDCAppResponse, keyData []byte, err error) { + client, err = s.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, false, grantTypes...) if err != nil { return nil, nil, err } @@ -113,6 +116,16 @@ func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI s }) } +func (s *Tester) CreateOIDCTokenExchangeClient(ctx context.Context) (client *management.AddOIDCAppResponse, keyData []byte, err error) { + project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ + Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), + }) + if err != nil { + return nil, nil, err + } + return s.CreateOIDCWebClientJWT(ctx, "", "", project.GetId(), app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN) +} + func (s *Tester) CreateProject(ctx context.Context) (*management.AddProjectResponse, error) { return s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), diff --git a/internal/integration/user.go b/internal/integration/user.go new file mode 100644 index 000000000..60a6e4131 --- /dev/null +++ b/internal/integration/user.go @@ -0,0 +1,57 @@ +package integration + +import ( + "context" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/pkg/grpc/admin" + "github.com/zitadel/zitadel/pkg/grpc/management" +) + +func (s *Tester) CreateMachineUserPATWithMembership(ctx context.Context, roles ...string) (id, pat string, err error) { + user := s.CreateMachineUser(ctx) + + patResp, err := s.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{ + UserId: user.GetUserId(), + ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour)), + }) + if err != nil { + return "", "", err + } + + orgRoles := make([]string, 0, len(roles)) + iamRoles := make([]string, 0, len(roles)) + + for _, role := range roles { + if strings.HasPrefix(role, "ORG_") { + orgRoles = append(orgRoles, role) + } + if strings.HasPrefix(role, "IAM_") { + iamRoles = append(iamRoles, role) + } + } + + if len(orgRoles) > 0 { + _, err := s.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{ + UserId: user.GetUserId(), + Roles: orgRoles, + }) + if err != nil { + return "", "", err + } + } + if len(iamRoles) > 0 { + _, err := s.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{ + UserId: user.GetUserId(), + Roles: iamRoles, + }) + if err != nil { + return "", "", err + } + } + + return user.GetUserId(), patResp.GetToken(), nil +} diff --git a/internal/query/access_token.go b/internal/query/access_token.go index 7ed46d85e..2b927cf7b 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -28,6 +28,8 @@ type OIDCSessionAccessTokenReadModel struct { AccessTokenID string AccessTokenCreation time.Time AccessTokenExpiration time.Time + Reason domain.TokenReason + Actor *domain.TokenActor } func newOIDCSessionAccessTokenReadModel(id string) *OIDCSessionAccessTokenReadModel { @@ -84,6 +86,8 @@ func (wm *OIDCSessionAccessTokenReadModel) reduceAccessTokenAdded(e *oidcsession wm.AccessTokenID = e.ID wm.AccessTokenCreation = e.CreationDate() wm.AccessTokenExpiration = e.CreationDate().Add(e.Lifetime) + wm.Reason = e.Reason + wm.Actor = e.Actor } func (wm *OIDCSessionAccessTokenReadModel) reduceTokenRevoked(e eventstore.Event) { diff --git a/internal/query/embed/oidc_client_by_id.sql b/internal/query/embed/oidc_client_by_id.sql index c2fa73c3e..8759b513d 100644 --- a/internal/query/embed/oidc_client_by_id.sql +++ b/internal/query/embed/oidc_client_by_id.sql @@ -29,7 +29,12 @@ keys as ( group by identifier ), settings as ( - select instance_id, json_build_object('access_token_lifetime', access_token_lifetime, 'id_token_lifetime', id_token_lifetime) as settings + select instance_id, json_build_object( + 'access_token_lifetime', access_token_lifetime, + 'id_token_lifetime', id_token_lifetime, + 'refresh_token_idle_expiration', refresh_token_idle_expiration, + 'refresh_token_expiration', refresh_token_expiration + ) as settings from projections.oidc_settings2 where aggregate_id = $1 and instance_id = $1 diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 62e67b46b..ed806f355 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -12,6 +12,7 @@ type InstanceFeatures struct { TriggerIntrospectionProjections FeatureSource[bool] LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index ddbd3529b..50beb1e36 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -61,6 +61,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, + feature_v2.InstanceTokenExchangeEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -73,6 +74,7 @@ func (m *InstanceFeaturesReadModel) reduceReset() { m.instance.TriggerIntrospectionProjections = FeatureSource[bool]{} m.instance.LegacyIntrospection = FeatureSource[bool]{} m.instance.UserSchema = FeatureSource[bool]{} + m.instance.TokenExchange = FeatureSource[bool]{} } func (m *InstanceFeaturesReadModel) populateFromSystem() bool { @@ -83,6 +85,7 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections m.instance.LegacyIntrospection = m.system.LegacyIntrospection m.instance.UserSchema = m.system.UserSchema + m.instance.TokenExchange = m.system.TokenExchange return true } @@ -104,6 +107,8 @@ func (m *InstanceFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent dst = &m.instance.LegacyIntrospection case feature.KeyUserSchema: dst = &m.instance.UserSchema + case feature.KeyTokenExchange: + dst = &m.instance.TokenExchange } *dst = FeatureSource[bool]{ Level: level, diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index cfe803778..22e47cbfe 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -75,6 +75,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], }, + { + Event: feature_v2.InstanceTokenExchangeEventType, + Reduce: reduceInstanceSetFeature[bool], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 7eccfa793..ee3772a19 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -67,6 +67,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], }, + { + Event: feature_v2.SystemTokenExchangeEventType, + Reduce: reduceSystemSetFeature[bool], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 6f87d7c69..0c18219e6 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -19,6 +19,7 @@ type SystemFeatures struct { TriggerIntrospectionProjections FeatureSource[bool] LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index d5e7b3263..4067b9453 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -49,6 +49,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, + feature_v2.SystemTokenExchangeEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -75,6 +76,8 @@ func (m *SystemFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent[b dst = &m.system.LegacyIntrospection case feature.KeyUserSchema: dst = &m.system.UserSchema + case feature.KeyTokenExchange: + dst = &m.system.TokenExchange } *dst = FeatureSource[bool]{ diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index c1d2c6122..7b8d7e2a0 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -10,9 +10,11 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 314abcdb4..1c6ee9e3e 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -16,12 +16,14 @@ var ( SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) + SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) + InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) ) const ( diff --git a/internal/repository/oidcsession/oidc_session.go b/internal/repository/oidcsession/oidc_session.go index 4993a972e..ff127b369 100644 --- a/internal/repository/oidcsession/oidc_session.go +++ b/internal/repository/oidcsession/oidc_session.go @@ -71,9 +71,11 @@ func NewAddedEvent(ctx context.Context, type AccessTokenAddedEvent struct { eventstore.BaseEvent `json:"-"` - ID string `json:"id"` - Scope []string `json:"scope"` - Lifetime time.Duration `json:"lifetime"` + ID string `json:"id,omitempty"` + Scope []string `json:"scope,omitempty"` + Lifetime time.Duration `json:"lifetime,omitempty"` + Reason domain.TokenReason `json:"reason,omitempty"` + Actor *domain.TokenActor `json:"actor,omitempty"` } func (e *AccessTokenAddedEvent) Payload() interface{} { @@ -94,6 +96,8 @@ func NewAccessTokenAddedEvent( id string, scope []string, lifetime time.Duration, + reason domain.TokenReason, + actor *domain.TokenActor, ) *AccessTokenAddedEvent { return &AccessTokenAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -104,6 +108,8 @@ func NewAccessTokenAddedEvent( ID: id, Scope: scope, Lifetime: lifetime, + Reason: reason, + Actor: actor, } } diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index e326a3fdd..d9e88c427 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -42,6 +42,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, UserReactivatedType, UserReactivatedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserRemovedType, UserRemovedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserTokenAddedType, UserTokenAddedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, UserImpersonatedType, eventstore.GenericEventMapper[UserImpersonatedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UserTokenRemovedType, UserTokenRemovedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserDomainClaimedType, DomainClaimedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserDomainClaimedSentType, DomainClaimedSentEventMapper) diff --git a/internal/repository/user/human_refresh_token.go b/internal/repository/user/human_refresh_token.go index 2de3a2ca4..a0ddbc0d1 100644 --- a/internal/repository/user/human_refresh_token.go +++ b/internal/repository/user/human_refresh_token.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -18,16 +19,17 @@ const ( type HumanRefreshTokenAddedEvent struct { eventstore.BaseEvent `json:"-"` - TokenID string `json:"tokenId"` - ClientID string `json:"clientId"` - UserAgentID string `json:"userAgentId"` - Audience []string `json:"audience"` - Scopes []string `json:"scopes"` - AuthMethodsReferences []string `json:"authMethodReferences"` - AuthTime time.Time `json:"authTime"` - IdleExpiration time.Duration `json:"idleExpiration"` - Expiration time.Duration `json:"expiration"` - PreferredLanguage string `json:"preferredLanguage"` + TokenID string `json:"tokenId"` + ClientID string `json:"clientId"` + UserAgentID string `json:"userAgentId"` + Audience []string `json:"audience"` + Scopes []string `json:"scopes"` + AuthMethodsReferences []string `json:"authMethodReferences"` + AuthTime time.Time `json:"authTime"` + IdleExpiration time.Duration `json:"idleExpiration"` + Expiration time.Duration `json:"expiration"` + PreferredLanguage string `json:"preferredLanguage"` + Actor *domain.TokenActor `json:"actor,omitempty"` } func (e *HumanRefreshTokenAddedEvent) Payload() interface{} { @@ -55,6 +57,7 @@ func NewHumanRefreshTokenAddedEvent( authTime time.Time, idleExpiration, expiration time.Duration, + actor *domain.TokenActor, ) *HumanRefreshTokenAddedEvent { return &HumanRefreshTokenAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -72,6 +75,7 @@ func NewHumanRefreshTokenAddedEvent( IdleExpiration: idleExpiration, Expiration: expiration, PreferredLanguage: preferredLanguage, + Actor: actor, } } diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index 260dab135..ef77e8e66 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -20,6 +20,7 @@ const ( UserRemovedType = userEventTypePrefix + "removed" UserTokenAddedType = userEventTypePrefix + "token.added" UserTokenRemovedType = userEventTypePrefix + "token.removed" + UserImpersonatedType = userEventTypePrefix + "impersonated" UserDomainClaimedType = userEventTypePrefix + "domain.claimed" UserDomainClaimedSentType = userEventTypePrefix + "domain.claimed.sent" UserUserNameChangedType = userEventTypePrefix + "username.changed" @@ -209,14 +210,18 @@ func UserRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { type UserTokenAddedEvent struct { eventstore.BaseEvent `json:"-"` - TokenID string `json:"tokenId"` - ApplicationID string `json:"applicationId"` - UserAgentID string `json:"userAgentId"` - RefreshTokenID string `json:"refreshTokenID,omitempty"` - Audience []string `json:"audience"` - Scopes []string `json:"scopes"` - Expiration time.Time `json:"expiration"` - PreferredLanguage string `json:"preferredLanguage"` + TokenID string `json:"tokenId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + UserAgentID string `json:"userAgentId,omitempty"` + RefreshTokenID string `json:"refreshTokenID,omitempty"` + Audience []string `json:"audience,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AuthMethodsReferences []string `json:"authMethodsReferences,omitempty"` + AuthTime time.Time `json:"authTime,omitempty"` + Expiration time.Time `json:"expiration,omitempty"` + PreferredLanguage string `json:"preferredLanguage,omitempty"` + Reason domain.TokenReason `json:"reason,omitempty"` + Actor *domain.TokenActor `json:"actor,omitempty"` } func (e *UserTokenAddedEvent) Payload() interface{} { @@ -236,8 +241,12 @@ func NewUserTokenAddedEvent( preferredLanguage, refreshTokenID string, audience, - scopes []string, + scopes, + authMethodsReferences []string, + authTime, expiration time.Time, + reason domain.TokenReason, + actor *domain.TokenActor, ) *UserTokenAddedEvent { return &UserTokenAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -253,6 +262,8 @@ func NewUserTokenAddedEvent( Scopes: scopes, Expiration: expiration, PreferredLanguage: preferredLanguage, + Reason: reason, + Actor: actor, } } @@ -268,6 +279,42 @@ func UserTokenAddedEventMapper(event eventstore.Event) (eventstore.Event, error) return tokenAdded, nil } +type UserImpersonatedEvent struct { + eventstore.BaseEvent `json:"-"` + + ApplicationID string `json:"applicationId,omitempty"` + Actor *domain.TokenActor `json:"actor,omitempty"` +} + +func (e *UserImpersonatedEvent) Payload() interface{} { + return e +} + +func (e *UserImpersonatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *UserImpersonatedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewUserImpersonatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + applicationID string, + actor *domain.TokenActor, +) *UserTokenAddedEvent { + return &UserTokenAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + UserImpersonatedType, + ), + ApplicationID: applicationID, + Actor: actor, + } +} + type UserTokenRemovedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 9e6ca104a..c2f66bd7b 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -573,6 +573,17 @@ Errors: NotActive: Потребителската схема не е активна NotInactive: Потребителската схема не е неактивна NotExists: Потребителската схема не съществува + TokenExchange: + FeatureDisabled: Функцията Token Exchange е деактивирана за вашето копие. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Токенът липсва + Invalid: Токенът е невалиден + TypeMissing: Липсва тип токен + TypeNotAllowed: Типът токен не е разрешен + TypeNotSupported: Типът токен не се поддържа + NotForAPI: Имитирани токени не са разрешени за API + Impersonation: + PolicyDisabled: Имитирането е деактивирано в политиката за сигурност на екземпляра AggregateTypes: action: Действие @@ -609,6 +620,7 @@ EventTypes: token: added: Токенът за достъп е създаден removed: Токенът за достъп е премахнат + impersonated: Имитиран потребител username: reserved: Потребителското име е запазено released: Потребителското име е освободено diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 80d6d31ff..2c9cef25b 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -553,6 +553,17 @@ Errors: NotActive: Uživatelské schéma není aktivní NotInactive: Uživatelské schéma není neaktivní NotExists: Uživatelské schéma neexistuje + TokenExchange: + FeatureDisabled: Funkce Token Exchange je pro vaši instanci zakázána. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Token chybí + Invalid: Token je neplatný + TypeMissing: Chybí typ tokenu + TypeNotAllowed: Typ tokenu není povolen + TypeNotSupported: Typ tokenu není podporován + NotForAPI: Zosobněné tokeny nejsou pro API povoleny + Impersonation: + PolicyDisabled: Zosobnění je zakázáno v zásadách zabezpečení instance AggregateTypes: action: Akce @@ -589,6 +600,7 @@ EventTypes: token: added: Přístupový token vytvořen removed: Přístupový token odstraněn + impersonated: Usuario suplantado username: reserved: Uživatelské jméno rezervováno released: Uživatelské jméno uvolněno diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 8387d34be..83251fc34 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -556,6 +556,17 @@ Errors: NotActive: Benutzerschema nicht aktiv NotInactive: Benutzerschema nicht inaktiv NotExists: Benutzerschema existiert nicht + TokenExchange: + FeatureDisabled: Die Token-Austauschfunktion ist für Ihre Instanz deaktiviert. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Token fehlt + Invalid: Token ist ungültig + TypeMissing: Der Tokentyp fehlt + TypeNotAllowed: Der Tokentyp ist nicht zulässig + TypeNotSupported: Der Tokentyp wird nicht unterstützt + NotForAPI: Imitierte Token sind für die API nicht zulässig + Impersonation: + PolicyDisabled: Der Identitätswechsel ist in der Sicherheitsrichtlinie der Instanz deaktiviert AggregateTypes: action: Action @@ -592,6 +603,7 @@ EventTypes: token: added: Access Token ausgestellt removed: Access Token gelöscht + impersonated: Benutzer hat sich als Benutzer ausgegeben username: reserved: Benutzername reserviert released: Benutzername freigegeben diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index e5534b357..603cbac27 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -556,6 +556,17 @@ Errors: NotActive: User Schema not active NotInactive: User Schema not inactive NotExists: User Schema does not exist + TokenExchange: + FeatureDisabled: Token Exchange feature is disabled for your instance. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Token is missing + Invalid: Token is invalid + TypeMissing: Token type is missing + TypeNotAllowed: Token type is not allowed + TypeNotSupported: Token type is not supported + NotForAPI: Impersonated tokens not allowed for API + Impersonation: + PolicyDisabled: Impersonation is disabled in the instance security policy AggregateTypes: action: Action @@ -592,6 +603,7 @@ EventTypes: token: added: Access Token created removed: Access Token removed + impersonated: User impersonated username: reserved: Username reserved released: Username released diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index c02216ba1..3eea6faf6 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -556,6 +556,17 @@ Errors: NotActive: Esquema de usuario no activo NotInactive: Esquema de usuario no inactivo NotExists: El esquema de usuario no existe + TokenExchange: + FeatureDisabled: La función de intercambio de tokens está deshabilitada para su instancia. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Falta la ficha + Invalid: El token no es válido + TypeMissing: Falta el tipo de token + TypeNotAllowed: El tipo de token no está permitido + TypeNotSupported: El tipo de token no es compatible + NotForAPI: Tokens suplantados no permitidos para API + Impersonation: + PolicyDisabled: La suplantación está deshabilitada en la política de seguridad de la instancia. AggregateTypes: action: Acción @@ -592,6 +603,7 @@ EventTypes: token: added: Token de acceso creado removed: Token de acceso eliminado + impersonated: Usuario suplantado username: reserved: Nombre de usuario reservado released: Nombre de usuario liberado diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index a0875f98b..90b14892a 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -556,6 +556,17 @@ Errors: NotActive: Schéma utilisateur non actif NotInactive: Le schéma utilisateur n'est pas inactif NotExists: Le schéma utilisateur n'existe pas + TokenExchange: + FeatureDisabled: La fonctionnalité Token Exchange est désactivée pour votre instance. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Le jeton est manquant + Invalid: Le jeton n'est pas valide + TypeMissing: Le type de jeton est manquantg + TypeNotAllowed: Le type de jeton n'est pas autorisé + TypeNotSupported: Le type de jeton n'est pas pris en charge + NotForAPI: Les jetons usurpés d'identité ne sont pas autorisés pour l'API + Impersonation: + PolicyDisabled: L'usurpation d'identité est désactivée dans la politique de sécurité de l'instance AggregateTypes: action: Action @@ -591,6 +602,7 @@ EventTypes: failed: La vérification de l'initialisation a échoué token: added: Jeton d'accès créé + impersonated: Utilisateur usurpé l'identité username: reserved: Nom d'utilisateur réservé released: Nom d'utilisateur libéré diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index a206c5dce..08e38a2d8 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -557,6 +557,17 @@ Errors: NotActive: Schema utente non attivo NotInactive: Schema utente non inattivo NotExists: Lo schema utente non esiste + TokenExchange: + FeatureDisabled: La funzionalità di scambio token è disabilitata per la tua istanza. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Manca il gettone + Invalid: Il token non è valido + TypeMissing: Manca il tipo di token + TypeNotAllowed: Il tipo di token non è consentito + TypeNotSupported: Il tipo di token non è supportato + NotForAPI: Token rappresentati non consentiti per l'API + Impersonation: + PolicyDisabled: La rappresentazione è disabilitata nella policy di sicurezza dell'istanza AggregateTypes: action: Azione @@ -592,6 +603,7 @@ EventTypes: failed: Controllo dell'inizializzazione fallito token: added: Access Token creato + impersonated: Utente impersonificato username: reserved: Nome utente riservato released: Nome utente rilasciato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 000ddf0cc..26fbf5373 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -545,6 +545,17 @@ Errors: NotActive: ユーザースキーマがアクティブではありません NotInactive: ユーザースキーマが非アクティブではありません NotExists: ユーザースキーマが存在しません + TokenExchange: + FeatureDisabled: インスタンスではトークン交換機能が無効になっています。 https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: トークンがありません + Invalid: トークンが無効です + TypeMissing: トークンの種類がありません + TypeNotAllowed: トークンの種類は許可されていません + TypeNotSupported: トークンタイプはサポートされていません + NotForAPI: 偽装されたトークンは API では許可されません + Impersonation: + PolicyDisabled: インスタンスのセキュリティ ポリシーで偽装が無効になっています AggregateTypes: action: アクション @@ -581,6 +592,7 @@ EventTypes: token: added: アクセストークンの作成 removed: アクセストークンの削除 + impersonated: ユーザーがなりすました username: reserved: ユーザー名の予約 released: ユーザー名の解放 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index f8de9b335..2737da7f2 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -555,6 +555,17 @@ Errors: NotActive: Корисничката шема не е активна NotInactive: Корисничката шема не е неактивна NotExists: Корисничката шема не постои + TokenExchange: + FeatureDisabled: Функцијата за размена на токени е оневозможена на вашиот пример. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Недостасува токен + Invalid: Токенот е неважечки + TypeMissing: Недостасува тип на токен + TypeNotAllowed: Типот на токен не е дозволен + TypeNotSupported: Типот на токен не е поддржан + NotForAPI: Имитирани токени не се дозволени за API + Impersonation: + PolicyDisabled: Имитирањето е оневозможено во политиката за безбедност на примерот AggregateTypes: action: Акција @@ -591,6 +602,7 @@ EventTypes: token: added: Креиран е токен за пристап removed: Токенот за пристап е отстранет + impersonated: Корисникот имитиран username: reserved: Корисничкото име е резервирано released: Корисничкото име е ослободено diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 5c2d634dd..5337ff3f4 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -556,6 +556,17 @@ Errors: NotActive: Gebruikersschema niet actief NotInactive: Gebruikersschema niet inactief NotExists: Gebruikersschema bestaat niet + TokenExchange: + FeatureDisabled: De Token Exchange-functie is uitgeschakeld voor uw instantie. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Token ontbreekt + Invalid: Token is ongeldig + TypeMissing: Tokentype ontbreekt + TypeNotAllowed: Tokentype is niet toegestaan + TypeNotSupported: Tokentype wordt niet ondersteund + NotForAPI: Nagebootste tokens zijn niet toegestaan voor API + Impersonation: + PolicyDisabled: Nabootsing van identiteit is uitgeschakeld in het beveiligingsbeleid van de instantie. AggregateTypes: action: Actie @@ -592,6 +603,7 @@ EventTypes: token: added: Toegangstoken aangemaakt removed: Toegangstoken verwijderd + impersonated: Gebruiker nagebootst username: reserved: Gebruikersnaam gereserveerd released: Gebruikersnaam vrijgegeven diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 572a8b110..3b1771218 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -556,6 +556,17 @@ Errors: NotActive: Schemat użytkownika nieaktywny NotInactive: Schemat użytkownika nie jest nieaktywny NotExists: Schemat użytkownika nie istnieje + TokenExchange: + FeatureDisabled: Funkcja wymiany tokenów jest wyłączona dla Twojej instancji. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Brak tokena + Invalid: Token jest nieprawidłowy + TypeMissing: Brak typu tokena + TypeNotAllowed: Typ tokenu jest niedozwolony + TypeNotSupported: Typ tokena nie jest obsługiwany + NotForAPI: Podrabiane tokeny nie są dozwolone w interfejsie API + Impersonation: + PolicyDisabled: Podszywanie się jest wyłączone w polityce bezpieczeństwa instancji AggregateTypes: action: Działanie @@ -592,6 +603,7 @@ EventTypes: token: added: Token dostępu utworzony removed: Token dostępu usunięty + impersonated: Użytkownik podszywał się pod użytkownika username: reserved: Nazwa użytkownika zarezerwowana released: Nazwa użytkownika zwolniona diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 47b71c3f8..8c6442aea 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -550,6 +550,17 @@ Errors: NotActive: Esquema do usuário não ativo NotInactive: Esquema do usuário não inativo NotExists: O esquema do usuário não existe + TokenExchange: + FeatureDisabled: O recurso Token Exchange está desabilitado para sua instância. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: O token está faltando + Invalid: O token é inválido + TypeMissing: O tipo de token está faltando + TypeNotAllowed: O tipo de token não é permitido + TypeNotSupported: O tipo de token não é compatível + NotForAPI: Tokens personificados não permitidos para API + Impersonation: + PolicyDisabled: A representação está desativada na política de segurança da instância AggregateTypes: action: Ação @@ -586,6 +597,7 @@ EventTypes: token: added: Token de acesso criado removed: Token de acesso removido + impersonated: Usuário personificado username: reserved: Nome de usuário reservado released: Nome de usuário liberado diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 67733b7a2..6bf62864d 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -544,6 +544,17 @@ Errors: NotActive: Пользовательская схема не активна NotInactive: Пользовательская схема не неактивна NotExists: Пользовательская схема не существует + TokenExchange: + FeatureDisabled: Функция обмена токенами отключена для вашего экземпляра. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: Токен отсутствует + Invalid: Токен недействителен + TypeMissing: Тип токена отсутствует + TypeNotAllowed: Тип токена недопустим. + TypeNotSupported: Тип токена не поддерживается + NotForAPI: Олицетворенные токены не разрешены для API. + Impersonation: + PolicyDisabled: Олицетворение отключено в политике безопасности экземпляра. AggregateTypes: action: Действие @@ -580,6 +591,7 @@ EventTypes: token: added: Токен доступа создан removed: Токен доступа удалён + impersonated: Пользователь олицетворяет себя username: reserved: Имя пользователя зарезервировано released: Имя пользователя опубликовано diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 6de8ddfc8..a6fbb3eba 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -556,6 +556,17 @@ Errors: NotActive: 用户架构未激活 NotInactive: 用户架构未处于非活动状态 NotExists: 用户架构不存在 + TokenExchange: + FeatureDisabled: 您的实例已禁用令牌交换功能。 https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: 令牌丢失 + Invalid: 令牌无效 + TypeMissing: 缺少令牌类型 + TypeNotAllowed: 不允许的令牌类型 + TypeNotSupported: 不支持令牌类型 + NotForAPI: API 不允许使用模拟令牌 + Impersonation: + PolicyDisabled: 实例安全策略中禁用模拟 AggregateTypes: action: 动作 @@ -591,6 +602,7 @@ EventTypes: failed: 初始化检查失败 token: added: 已创建访问令牌 + impersonated: 用户冒充 username: reserved: 保留用户名 released: 用户名已发布 diff --git a/internal/user/model/refresh_token_view.go b/internal/user/model/refresh_token_view.go index 1e1da3999..5f5f5c899 100644 --- a/internal/user/model/refresh_token_view.go +++ b/internal/user/model/refresh_token_view.go @@ -23,6 +23,7 @@ type RefreshTokenView struct { Scopes []string Sequence uint64 Token string + Actor *domain.TokenActor } type RefreshTokenSearchRequest struct { diff --git a/internal/user/model/token_view.go b/internal/user/model/token_view.go index c2f82afaa..9ee00c87e 100644 --- a/internal/user/model/token_view.go +++ b/internal/user/model/token_view.go @@ -22,6 +22,8 @@ type TokenView struct { PreferredLanguage string RefreshTokenID string IsPAT bool + Reason domain.TokenReason + Actor *domain.TokenActor } type TokenSearchRequest struct { diff --git a/internal/user/repository/view/model/refresh_token.go b/internal/user/repository/view/model/refresh_token.go index a45300ca8..06b446362 100644 --- a/internal/user/repository/view/model/refresh_token.go +++ b/internal/user/repository/view/model/refresh_token.go @@ -39,6 +39,7 @@ type RefreshTokenView struct { Expiration time.Time `json:"-" gorm:"column:expiration"` Sequence uint64 `json:"-" gorm:"column:sequence"` InstanceID string `json:"instanceID" gorm:"column:instance_id;primary_key"` + Actor TokenActor `json:"actor" gorm:"column:actor"` } func RefreshTokenViewsToModel(tokens []*RefreshTokenView) []*usr_model.RefreshTokenView { @@ -66,6 +67,7 @@ func RefreshTokenViewToModel(token *RefreshTokenView) *usr_model.RefreshTokenVie IdleExpiration: token.IdleExpiration, Expiration: token.Expiration, Sequence: token.Sequence, + Actor: token.Actor.TokenActor, } } @@ -135,6 +137,7 @@ func (t *RefreshTokenView) appendAddedEvent(event eventstore.Event) error { t.Scopes = e.Scopes t.Token = e.TokenID t.UserAgentID = e.UserAgentID + t.Actor = TokenActor{e.Actor} return nil } diff --git a/internal/user/repository/view/model/token.go b/internal/user/repository/view/model/token.go index 5b01ecd5b..ebe156473 100644 --- a/internal/user/repository/view/model/token.go +++ b/internal/user/repository/view/model/token.go @@ -1,11 +1,14 @@ package model import ( + "database/sql/driver" + "encoding/json" "time" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" user_repo "github.com/zitadel/zitadel/internal/repository/user" usr_model "github.com/zitadel/zitadel/internal/user/model" @@ -40,6 +43,40 @@ type TokenView struct { IsPAT bool `json:"-" gorm:"is_pat"` Deactivated bool `json:"-" gorm:"-"` InstanceID string `json:"instanceID" gorm:"column:instance_id;primary_key"` + Actor TokenActor `json:"actor" gorm:"column:actor"` +} + +type TokenActor struct { + *domain.TokenActor +} + +func (a *TokenActor) Scan(value any) error { + var data []byte + switch v := value.(type) { + case nil: + a.TokenActor = nil + case string: + data = []byte(v) + case []byte: + data = v + default: + return zerrors.ThrowInternalf(nil, "MODEL-yo8Ae", "cannot scan type %T into %T", v, a) + } + if err := json.Unmarshal(data, &a.TokenActor); err != nil { + return zerrors.ThrowInternal(nil, "MODEL-yo8Ae", "cannot unmarshal token actor") + } + return nil +} + +func (a TokenActor) Value() (driver.Value, error) { + if a.TokenActor == nil { + return nil, nil + } + data, err := json.Marshal(a.TokenActor) + if err != nil { + return nil, zerrors.ThrowInternal(nil, "MODEL-oD2mi", "cannot marshal token actor") + } + return data, nil } func TokenViewToModel(token *TokenView) *usr_model.TokenView { @@ -58,6 +95,7 @@ func TokenViewToModel(token *TokenView) *usr_model.TokenView { PreferredLanguage: token.PreferredLanguage, RefreshTokenID: token.RefreshTokenID, IsPAT: token.IsPAT, + Actor: token.Actor.TokenActor, } } diff --git a/proto/zitadel/app.proto b/proto/zitadel/app.proto index dcd5da5c2..58735e7c9 100644 --- a/proto/zitadel/app.proto +++ b/proto/zitadel/app.proto @@ -181,6 +181,7 @@ enum OIDCGrantType{ OIDC_GRANT_TYPE_IMPLICIT = 1; OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; OIDC_GRANT_TYPE_DEVICE_CODE = 3; + OIDC_GRANT_TYPE_TOKEN_EXCHANGE = 4; } enum OIDCAppType { diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 1c886efd0..f2df465c6 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -29,12 +29,20 @@ message SetInstanceFeaturesRequest{ description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; } ]; + optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; } ]; + + optional bool oidc_token_exchange = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; + } + ]; } message SetInstanceFeaturesResponse { @@ -85,4 +93,11 @@ message GetInstanceFeaturesResponse { description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; } ]; + + FeatureFlag oidc_token_exchange = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; + } + ]; } diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index d4bb39630..0091bcbd3 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -38,6 +38,14 @@ message SetSystemFeaturesRequest{ description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; } ]; + + optional bool oidc_token_exchange = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; + } + ]; + } message SetSystemFeaturesResponse { @@ -81,4 +89,11 @@ message GetSystemFeaturesResponse { description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; } ]; + + FeatureFlag oidc_token_exchange = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; + } + ]; }