From 50d2b26a28656d5919247b07099a249e0958baf4 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 19 Dec 2024 10:37:46 +0100 Subject: [PATCH] feat: specify login UI version on instance and apps (#9071) # Which Problems Are Solved To be able to migrate or test the new login UI, admins might want to (temporarily) switch individual apps. At a later point admin might want to make sure all applications use the new login UI. # How the Problems Are Solved - Added a feature flag `` on instance level to require all apps to use the new login and provide an optional base url. - if the flag is enabled, all (OIDC) applications will automatically use the v2 login. - if disabled, applications can decide based on their configuration - Added an option on OIDC apps to use the new login UI and an optional base url. - Removed the requirement to use `x-zitadel-login-client` to be redirected to the login V2 and retrieve created authrequest and link them to SSO sessions. - Added a new "IAM_LOGIN_CLIENT" role to allow management of users, sessions, grants and more without `x-zitadel-login-client`. # Additional Changes None # Additional Context closes https://github.com/zitadel/zitadel/issues/8702 --- cmd/defaults.yaml | 38 ++ cmd/setup/42.go | 27 ++ cmd/setup/42.sql | 2 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + .../apps/app-detail/app-detail.component.html | 16 + .../apps/app-detail/app-detail.component.ts | 33 +- console/src/assets/i18n/bg.json | 4 + console/src/assets/i18n/cs.json | 4 + console/src/assets/i18n/de.json | 4 + console/src/assets/i18n/en.json | 4 + console/src/assets/i18n/es.json | 4 + console/src/assets/i18n/fr.json | 4 + console/src/assets/i18n/hu.json | 4 + console/src/assets/i18n/id.json | 4 + console/src/assets/i18n/it.json | 4 + console/src/assets/i18n/ja.json | 4 + console/src/assets/i18n/mk.json | 4 + console/src/assets/i18n/nl.json | 4 + console/src/assets/i18n/pl.json | 4 + console/src/assets/i18n/pt.json | 4 + console/src/assets/i18n/ru.json | 4 + console/src/assets/i18n/sv.json | 4 + console/src/assets/i18n/zh.json | 4 + internal/api/grpc/admin/import.go | 10 +- .../admin/integration_test/iam_member_test.go | 1 + internal/api/grpc/feature/v2/converter.go | 57 ++- .../api/grpc/feature/v2/converter_test.go | 47 +- internal/api/grpc/feature/v2/feature.go | 12 +- .../idp/v2/integration_test/server_test.go | 2 +- .../grpc/management/project_application.go | 12 +- .../project_application_converter.go | 20 +- .../oidc/v2/integration_test/oidc_test.go | 162 ++++++- .../oidc/v2beta/integration_test/oidc_test.go | 2 +- .../grpc/org/v2/integration_test/org_test.go | 2 +- internal/api/grpc/project/application.go | 30 ++ .../v3alpha/integration_test/email_test.go | 6 +- .../v3alpha/integration_test/phone_test.go | 6 +- .../v3alpha/integration_test/user_test.go | 14 +- .../v2/integration_test/settings_test.go | 2 +- .../user/v2/integration_test/user_test.go | 2 +- .../user/v2beta/integration_test/user_test.go | 2 +- internal/api/oidc/auth_request.go | 86 +++- internal/api/oidc/client_converter.go | 19 +- .../integration_test/auth_request_test.go | 446 +++++++++++------- .../api/oidc/integration_test/oidc_test.go | 46 +- internal/command/auth_request.go | 4 +- internal/command/auth_request_test.go | 147 ++++-- internal/command/instance_domain_test.go | 2 + internal/command/instance_features.go | 4 +- internal/command/instance_features_model.go | 10 + internal/command/instance_test.go | 4 + internal/command/project_application_oidc.go | 8 + .../command/project_application_oidc_model.go | 18 + .../command/project_application_oidc_test.go | 73 ++- internal/command/project_converter.go | 2 + internal/command/system_features.go | 4 +- internal/command/system_features_model.go | 10 + internal/domain/application.go | 8 + internal/domain/application_oidc.go | 2 + internal/domain/permission.go | 2 + internal/feature/feature.go | 12 +- internal/feature/key_enumer.go | 12 +- internal/integration/instance.go | 14 +- internal/integration/oidc.go | 60 ++- internal/integration/usertype_enumer.go | 12 +- .../integration_test/telemetry_pusher_test.go | 2 +- internal/query/app.go | 71 +++ internal/query/app_test.go | 75 +++ internal/query/auth_request.go | 6 +- internal/query/auth_request_test.go | 57 ++- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 6 + internal/query/oidc_client.go | 32 +- internal/query/oidc_client_by_id.sql | 3 +- internal/query/projection/app.go | 14 +- internal/query/projection/app_test.go | 22 +- .../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 | 8 + .../feature/feature_v2/eventstore.go | 2 + .../repository/feature/feature_v2/feature.go | 2 + internal/repository/project/oidc_config.go | 28 +- proto/zitadel/app.proto | 19 + proto/zitadel/feature/v2/feature.proto | 17 + proto/zitadel/feature/v2/instance.proto | 13 + proto/zitadel/feature/v2/system.proto | 13 + proto/zitadel/management.proto | 10 + 89 files changed, 1670 insertions(+), 321 deletions(-) create mode 100644 cmd/setup/42.go create mode 100644 cmd/setup/42.sql diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 08973cee64..b63c84eb0b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1289,6 +1289,7 @@ InternalAuthZ: - "project.grant.member.delete" - "events.read" - "milestones.read" + - "session.read" - "session.delete" - "action.target.read" - "action.target.write" @@ -1481,6 +1482,43 @@ InternalAuthZ: - "project.grant.member.write" - "project.grant.member.delete" - "session.delete" + - Role: "IAM_LOGIN_CLIENT" + Permissions: + - "iam.read" + - "iam.policy.read" + - "iam.member.read" + - "iam.member.write" + - "iam.idp.read" + - "iam.feature.read" + - "iam.restrictions.read" + - "org.read" + - "org.member.read" + - "org.member.write" + - "org.idp.read" + - "org.feature.read" + - "user.read" + - "user.write" + - "user.grant.read" + - "user.grant.write" + - "user.membership.read" + - "user.credential.write" + - "user.passkey.write" + - "user.feature.read" + - "policy.read" + - "project.read" + - "project.member.read" + - "project.member.write" + - "project.role.read" + - "project.app.read" + - "project.member.read" + - "project.member.write" + - "project.grant.read" + - "project.grant.member.read" + - "project.grant.member.write" + - "session.read" + - "session.link" + - "session.delete" + - "userschema.read" - Role: "ORG_USER_MANAGER" Permissions: - "org.read" diff --git a/cmd/setup/42.go b/cmd/setup/42.go new file mode 100644 index 0000000000..6251b1290b --- /dev/null +++ b/cmd/setup/42.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 42.sql + addOIDCAppLoginVersion string +) + +type Apps7OIDCConfigsLoginVersion struct { + dbClient *database.DB +} + +func (mig *Apps7OIDCConfigsLoginVersion) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addOIDCAppLoginVersion) + return err +} + +func (mig *Apps7OIDCConfigsLoginVersion) String() string { + return "40_apps7_oidc_configs_login_version" +} diff --git a/cmd/setup/42.sql b/cmd/setup/42.sql new file mode 100644 index 0000000000..12f3b62b19 --- /dev/null +++ b/cmd/setup/42.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS login_version SMALLINT; +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS login_base_uri TEXT; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 966b5777a7..ae62728c95 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -127,6 +127,7 @@ type Steps struct { s37Apps7OIDConfigsBackChannelLogoutURI *Apps7OIDConfigsBackChannelLogoutURI s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart s40InitPushFunc *InitPushFunc + s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 7c789c399a..497457ba8f 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -170,6 +170,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient} steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} + steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -240,6 +241,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s32AddAuthSessionID, steps.s33SMSConfigs3TwilioAddVerifyServiceSid, steps.s37Apps7OIDConfigsBackChannelLogoutURI, + steps.s42Apps7OIDCConfigsLoginVersion, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html index c034af991e..4c2d2cfb82 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html @@ -147,6 +147,22 @@ > {{ 'APP.OIDC.REFRESHTOKEN' | translate }} + + + {{ 'APP.LOGINV2.USEV2' | translate }} + + + + {{ 'APP.LOGINV2.BASEURL' | translate }} + + 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 638a1e56b2..df08000b53 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 @@ -1,6 +1,6 @@ import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { Location } from '@angular/common'; -import { Component, OnDestroy, OnInit, ViewEncapsulation, signal } from '@angular/core'; +import { Component, OnDestroy, OnInit, signal } from '@angular/core'; import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; @@ -21,6 +21,9 @@ import { APIConfig, App, AppState, + LoginV1, + LoginV2, + LoginVersion, OIDCAppType, OIDCAuthMethodType, OIDCConfig, @@ -50,8 +53,8 @@ import { getAuthMethodFromPartialConfig, getPartialConfigFromAuthMethod, IMPLICIT_METHOD, - PKCE_METHOD, PK_JWT_METHOD, + PKCE_METHOD, POST_METHOD, } from '../authmethods'; import { AuthMethodDialogComponent } from './auth-method-dialog/auth-method-dialog.component'; @@ -182,6 +185,7 @@ export class AppDetailComponent implements OnInit, OnDestroy { public currentSetting: string | undefined = this.settingsList[0].id; public isNew = signal(false); + constructor( private envSvc: EnvironmentService, public translate: TranslateService, @@ -203,6 +207,8 @@ export class AppDetailComponent implements OnInit, OnDestroy { grantTypesList: [{ value: [], disabled: true }], appType: [{ value: '', disabled: true }], authMethodType: [{ value: '', disabled: true }], + loginV2: [{ value: false, disabled: true }], + loginV2BaseURL: [{ value: '', disabled: true }], }); this.oidcTokenForm = this.fb.group({ @@ -430,6 +436,12 @@ export class AppDetailComponent implements OnInit, OnDestroy { const inSecs = this.app.oidcConfig?.clockSkew.seconds + this.app.oidcConfig?.clockSkew.nanos / 100000; this.oidcTokenForm.controls['clockSkewSeconds'].setValue(inSecs); } + if (this.app.oidcConfig?.loginVersion?.loginV1) { + this.oidcForm.controls['loginV2'].setValue(false); + } else if (this.app.oidcConfig?.loginVersion?.loginV2) { + this.oidcForm.controls['loginV2'].setValue(true); + this.oidcForm.controls['loginV2BaseURL'].setValue(this.app.oidcConfig.loginVersion.loginV2.baseUri); + } if (this.app.oidcConfig) { this.oidcForm.patchValue(this.app.oidcConfig); this.oidcTokenForm.patchValue(this.app.oidcConfig); @@ -655,6 +667,15 @@ export class AppDetailComponent implements OnInit, OnDestroy { req.setAuthMethodType(this.app.oidcConfig.authMethodType); req.setGrantTypesList(this.app.oidcConfig.grantTypesList); req.setAppType(this.app.oidcConfig.appType); + const login = new LoginVersion(); + if (this.loginV2?.value) { + const loginV2 = new LoginV2(); + loginV2.setBaseUri(this.loginV2BaseURL?.value); + login.setLoginV2(loginV2); + } else { + login.setLoginV1(new LoginV1()); + } + req.setLoginVersion(login); // token req.setAccessTokenType(this.app.oidcConfig.accessTokenType); @@ -839,6 +860,14 @@ export class AppDetailComponent implements OnInit, OnDestroy { return this.oidcForm.get('authMethodType'); } + public get loginV2(): FormControl | null { + return this.oidcForm.get('loginV2') as FormControl; + } + + public get loginV2BaseURL(): AbstractControl | null { + return this.oidcForm.get('loginV2BaseURL'); + } + public get apiAuthMethodType(): AbstractControl | null { return this.apiForm.get('authMethodType') as UntypedFormControl; } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 9402ae5bb2..7c39fb22b2 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -2537,6 +2537,10 @@ "CLIENTSECRETREGENERATED": "генерирана клиентска тайна.", "DELETED": "Приложението е изтрито.", "CONFIGCHANGED": "Открити са промени!" + }, + "LOGINV2": { + "USEV2": "Използвайте новия интерфейс за вход", + "BASEURL": "Персонализиран основен URL адрес за новия интерфейс за вход" } }, "GENDERS": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 2f34468cd2..09b9ac2b9f 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -2550,6 +2550,10 @@ "CLIENTSECRETREGENERATED": "klient tajemství regenerováno.", "DELETED": "Aplikace smazána.", "CONFIGCHANGED": "Zjištěny změny!" + }, + "LOGINV2": { + "USEV2": "Použít nové uživatelské rozhraní pro přihlášení", + "BASEURL": "Vlastní základní adresa URL pro nové uživatelské rozhraní pro přihlášení" } }, "GENDERS": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 4adf55be3e..1a9efe8c97 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -2541,6 +2541,10 @@ "CLIENTSECRETREGENERATED": "Client Secret generiert.", "DELETED": "App gelöscht.", "CONFIGCHANGED": "Konfigurationsänderung entdeckt." + }, + "LOGINV2": { + "USEV2": "Neue Login-Benutzeroberfläche verwenden", + "BASEURL": "Benutzerdefinierte Basis-URL für die neue Login-Benutzeroberfläche" } }, "GENDERS": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 4e7d2e13d9..488ae68d7c 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -2566,6 +2566,10 @@ "CLIENTSECRETREGENERATED": "client secret generated.", "DELETED": "App deleted.", "CONFIGCHANGED": "Changes detected!" + }, + "LOGINV2": { + "USEV2": "Use new Login UI", + "BASEURL": "Custom base URL for the new Login UI" } }, "GENDERS": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 06532cc849..09cb87a5e6 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -2538,6 +2538,10 @@ "CLIENTSECRETREGENERATED": "secreto del cliente generado.", "DELETED": "App borrada.", "CONFIGCHANGED": "¡Cambios detectados!" + }, + "LOGINV2": { + "USEV2": "Usar la nueva interfaz de usuario de inicio de sesión", + "BASEURL": "URL base personalizada para la nueva interfaz de usuario de inicio de sesión" } }, "GENDERS": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 2814abdc97..c5ae8d767c 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -2542,6 +2542,10 @@ "CLIENTSECRETREGENERATED": "secret client généré.", "DELETED": "Application supprimée.", "CONFIGCHANGED": "Changements détectés !" + }, + "LOGINV2": { + "USEV2": "Utiliser la nouvelle interface utilisateur de connexion", + "BASEURL": "URL de base personnalisée pour la nouvelle interface utilisateur de connexion" } }, "GENDERS": { diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 0ffa6b92b6..e74e9eab32 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -2564,6 +2564,10 @@ "CLIENTSECRETREGENERATED": "Az ügyfél titok generálva.", "DELETED": "Az app törölve.", "CONFIGCHANGED": "Változások észlelve!" + }, + "LOGINV2": { + "USEV2": "Új bejelentkezési felhasználói felület használata", + "BASEURL": "Egyéni alapértelmezett URL az új bejelentkezési felhasználói felülethez" } }, "GENDERS": { diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index c12fbbe555..d5dbd48128 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -2255,6 +2255,10 @@ "CLIENTSECRETREGENERATED": "rahasia klien dihasilkan.", "DELETED": "Aplikasi dihapus.", "CONFIGCHANGED": "Perubahan terdeteksi!" + }, + "LOGINV2": { + "USEV2": "Gunakan UI Login baru", + "BASEURL": "URL dasar kustom untuk UI Login baru" } }, "GENDERS": { "0": "Tidak dikenal", "1": "Perempuan", "2": "Pria", "3": "Lainnya" }, diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index d21396991c..1910146698 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -2542,6 +2542,10 @@ "CLIENTSECRETREGENERATED": "Client secret generato.", "DELETED": "App rimossa con successo.", "CONFIGCHANGED": "Modifiche alla configurazione rilevate" + }, + "LOGINV2": { + "USEV2": "Utilizza la nuova interfaccia utente di accesso", + "BASEURL": "URL base personalizzato per la nuova interfaccia utente di accesso" } }, "GENDERS": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 936262d132..3e75cd94a2 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -2532,6 +2532,10 @@ "CLIENTSECRETREGENERATED": "クライアントシークレットが生成されました。", "DELETED": "アプリが削除されました。", "CONFIGCHANGED": "変更を検出しました!" + }, + "LOGINV2": { + "USEV2": "新しいログインUIを使用する", + "BASEURL": "新しいログインUIのカスタムベースURL" } }, "GENDERS": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index ab0481b6bf..78d231ade7 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -2538,6 +2538,10 @@ "CLIENTSECRETREGENERATED": "Клиентската тајна е генерирана.", "DELETED": "Апликацијата е избришана.", "CONFIGCHANGED": "Детектирани промени!" + }, + "LOGINV2": { + "USEV2": "Користете нов интерфејс за најава", + "BASEURL": "Прилагоден основен URL за новиот интерфејс за најава" } }, "GENDERS": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index efc5513e68..cb1a66469a 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -2557,6 +2557,10 @@ "CLIENTSECRETREGENERATED": "client geheim gegenereerd.", "DELETED": "App verwijderd.", "CONFIGCHANGED": "Wijzigingen gedetecteerd!" + }, + "LOGINV2": { + "USEV2": "Nieuwe login-gebruikersinterface gebruiken", + "BASEURL": "Aangepaste basis-URL voor de nieuwe login-gebruikersinterface" } }, "GENDERS": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 0443b89a89..934e569d9a 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -2541,6 +2541,10 @@ "CLIENTSECRETREGENERATED": "Sekret klienta został wygenerowany.", "DELETED": "Aplikacja została usunięta.", "CONFIGCHANGED": "Wykryto zmiany!" + }, + "LOGINV2": { + "USEV2": "Użyj nowego interfejsu użytkownika logowania", + "BASEURL": "Niestandardowy podstawowy adres URL dla nowego interfejsu użytkownika logowania" } }, "GENDERS": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 3bbb4e9c9b..f6fbc826dd 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -2537,6 +2537,10 @@ "CLIENTSECRETREGENERATED": "segredo do cliente gerado.", "DELETED": "Aplicativo excluído.", "CONFIGCHANGED": "Alterações detectadas!" + }, + "LOGINV2": { + "USEV2": "Usar a nova interface de usuário de login", + "BASEURL": "URL base personalizado para a nova interface de usuário de login" } }, "GENDERS": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index cdbb49d708..8a3ca0c1de 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2649,6 +2649,10 @@ "CLIENTSECRETREGENERATED": "Клиентский ключ сгенерирован.", "DELETED": "Приложение удалено.", "CONFIGCHANGED": "Обнаружены изменения!" + }, + "LOGINV2": { + "USEV2": "Использовать новый интерфейс входа", + "BASEURL": "Настраиваемый базовый URL для нового интерфейса входа" } }, "GENDERS": { diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 93ed8ac72b..fd698478fb 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -2570,6 +2570,10 @@ "CLIENTSECRETREGENERATED": "Klienthemlighet genererad.", "DELETED": "App raderad.", "CONFIGCHANGED": "Ändringar upptäckta!" + }, + "LOGINV2": { + "USEV2": "Använd nya inloggningsgränssnittet", + "BASEURL": "Anpassad bas-URL för det nya inloggningsgränssnittet" } }, "GENDERS": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 4f1b1d1d46..99bf053225 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -2541,6 +2541,10 @@ "CLIENTSECRETREGENERATED": "客户端秘钥已生成。", "DELETED": "应用已删除。", "CONFIGCHANGED": "检测到变化!" + }, + "LOGINV2": { + "USEV2": "使用新的登录UI", + "BASEURL": "新的登录UI的自定义基本URL" } }, "GENDERS": { diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 338d90b0bb..7f7443fef7 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -644,7 +644,15 @@ func importOIDCApps(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDa } for _, app := range org.GetOidcApps() { logging.Debugf("import oidcapplication: %s", app.GetAppId()) - _, err := s.command.AddOIDCApplicationWithID(ctx, management.AddOIDCAppRequestToDomain(app.App), org.GetOrgId(), app.GetAppId()) + oidcApp, err := management.AddOIDCAppRequestToDomain(app.App) + if err != nil { + *errors = append(*errors, &admin_pb.ImportDataError{Type: "oidc_app", Id: app.GetAppId(), Message: err.Error()}) + if isCtxTimeout(ctx) { + return err + } + continue + } + _, err = s.command.AddOIDCApplicationWithID(ctx, oidcApp, org.GetOrgId(), app.GetAppId()) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "oidc_app", Id: app.GetAppId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/admin/integration_test/iam_member_test.go b/internal/api/grpc/admin/integration_test/iam_member_test.go index ff8d2715d7..035cfa9f70 100644 --- a/internal/api/grpc/admin/integration_test/iam_member_test.go +++ b/internal/api/grpc/admin/integration_test/iam_member_test.go @@ -25,6 +25,7 @@ var iamRoles = []string{ "IAM_USER_MANAGER", "IAM_ADMIN_IMPERSONATOR", "IAM_END_USER_IMPERSONATOR", + "IAM_LOGIN_CLIENT", } func TestServer_ListIAMMemberRoles(t *testing.T) { diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 7d951f789a..109d2d1e53 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -1,6 +1,10 @@ package feature import ( + "net/url" + + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/feature" @@ -8,7 +12,11 @@ import ( feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) -func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { +func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command.SystemFeatures, error) { + loginV2, err := loginV2ToDomain(req.GetLoginV2()) + if err != nil { + return nil, err + } return &command.SystemFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, @@ -20,7 +28,8 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, - } + LoginV2: loginV2, + }, nil } func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { @@ -36,10 +45,15 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), } } -func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { +func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*command.InstanceFeatures, error) { + loginV2, err := loginV2ToDomain(req.GetLoginV2()) + if err != nil { + return nil, err + } return &command.InstanceFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, @@ -53,7 +67,8 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, DisableUserTokenEvent: req.DisableUserTokenEvent, EnableBackChannelLogout: req.EnableBackChannelLogout, - } + LoginV2: loginV2, + }, nil } func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { @@ -71,6 +86,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), } } @@ -81,6 +97,39 @@ func featureSourceToImprovedPerformanceFlagPb(fs *query.FeatureSource[[]feature. } } +func loginV2ToDomain(loginV2 *feature_pb.LoginV2) (_ *feature.LoginV2, err error) { + if loginV2 == nil { + return nil, nil + } + var baseURI *url.URL + if loginV2.GetBaseUri() != "" { + baseURI, err = url.Parse(loginV2.GetBaseUri()) + if err != nil { + return nil, err + } + } + return &feature.LoginV2{ + Required: loginV2.GetRequired(), + BaseURI: baseURI, + }, nil +} + +func loginV2ToLoginV2FlagPb(f query.FeatureSource[*feature.LoginV2]) *feature_pb.LoginV2FeatureFlag { + var required bool + var baseURI *string + if f.Value != nil { + required = f.Value.Required + if f.Value.BaseURI != nil && f.Value.BaseURI.String() != "" { + baseURI = gu.Ptr(f.Value.BaseURI.String()) + } + } + return &feature_pb.LoginV2FeatureFlag{ + Required: required, + BaseUri: baseURI, + Source: featureLevelToSourcePb(f.Level), + } +} + func featureSourceToFlagPb(fs *query.FeatureSource[bool]) *feature_pb.FeatureFlag { return &feature_pb.FeatureFlag{ Enabled: fs.Value, diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 43a848e3a6..f8b2c0006f 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -1,6 +1,7 @@ package feature import ( + "net/url" "testing" "time" @@ -26,6 +27,10 @@ func Test_systemFeaturesToCommand(t *testing.T) { OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OidcSingleV1SessionTermination: gu.Ptr(true), + LoginV2: &feature_pb.LoginV2{ + Required: true, + BaseUri: gu.Ptr("https://login.com"), + }, } want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), @@ -36,9 +41,14 @@ func Test_systemFeaturesToCommand(t *testing.T) { TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginV2: &feature.LoginV2{ + Required: true, + BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, + }, } - got := systemFeaturesToCommand(arg) + got, err := systemFeaturesToCommand(arg) assert.Equal(t, want, got) + assert.NoError(t, err) } func Test_systemFeaturesToPb(t *testing.T) { @@ -84,6 +94,13 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, + LoginV2: query.FeatureSource[*feature.LoginV2]{ + Level: feature.LevelSystem, + Value: &feature.LoginV2{ + Required: true, + BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, + }, + }, } want := &feature_pb.GetSystemFeaturesResponse{ Details: &object.Details{ @@ -131,6 +148,11 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, + LoginV2: &feature_pb.LoginV2FeatureFlag{ + Required: true, + BaseUri: gu.Ptr("https://login.com"), + Source: feature_pb.Source_SOURCE_SYSTEM, + }, } got := systemFeaturesToPb(arg) assert.Equal(t, want, got) @@ -149,6 +171,10 @@ func Test_instanceFeaturesToCommand(t *testing.T) { DebugOidcParentError: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), + LoginV2: &feature_pb.LoginV2{ + Required: true, + BaseUri: gu.Ptr("https://login.com"), + }, } want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), @@ -162,9 +188,14 @@ func Test_instanceFeaturesToCommand(t *testing.T) { DebugOIDCParentError: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), + LoginV2: &feature.LoginV2{ + Required: true, + BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, + }, } - got := instanceFeaturesToCommand(arg) + got, err := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) + assert.NoError(t, err) } func Test_instanceFeaturesToPb(t *testing.T) { @@ -214,6 +245,13 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelInstance, Value: true, }, + LoginV2: query.FeatureSource[*feature.LoginV2]{ + Level: feature.LevelInstance, + Value: &feature.LoginV2{ + Required: true, + BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, + }, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -269,6 +307,11 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, }, + LoginV2: &feature_pb.LoginV2FeatureFlag{ + Required: true, + BaseUri: gu.Ptr("https://login.com"), + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go index 9125dea518..f4527689fc 100644 --- a/internal/api/grpc/feature/v2/feature.go +++ b/internal/api/grpc/feature/v2/feature.go @@ -11,7 +11,11 @@ import ( ) func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { - details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req)) + features, err := systemFeaturesToCommand(req) + if err != nil { + return nil, err + } + details, err := s.command.SetSystemFeatures(ctx, features) if err != nil { return nil, err } @@ -39,7 +43,11 @@ func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFe } func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { - details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req)) + features, err := instanceFeaturesToCommand(req) + if err != nil { + return nil, err + } + details, err := s.command.SetInstanceFeatures(ctx, features) if err != nil { return nil, err } diff --git a/internal/api/grpc/idp/v2/integration_test/server_test.go b/internal/api/grpc/idp/v2/integration_test/server_test.go index c285ddfe69..b7252685d8 100644 --- a/internal/api/grpc/idp/v2/integration_test/server_test.go +++ b/internal/api/grpc/idp/v2/integration_test/server_test.go @@ -27,7 +27,7 @@ func TestMain(m *testing.M) { Instance = integration.NewInstance(ctx) - UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) Client = Instance.Client.IDPv2 diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index 730b6ff22f..15e057c1bd 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -80,7 +80,11 @@ func (s *Server) ListAppChanges(ctx context.Context, req *mgmt_pb.ListAppChanges } func (s *Server) AddOIDCApp(ctx context.Context, req *mgmt_pb.AddOIDCAppRequest) (*mgmt_pb.AddOIDCAppResponse, error) { - app, err := s.command.AddOIDCApplication(ctx, AddOIDCAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + oidcApp, err := AddOIDCAppRequestToDomain(req) + if err != nil { + return nil, err + } + app, err := s.command.AddOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -128,7 +132,11 @@ func (s *Server) UpdateApp(ctx context.Context, req *mgmt_pb.UpdateAppRequest) ( } func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOIDCAppConfigRequest) (*mgmt_pb.UpdateOIDCAppConfigResponse, error) { - config, err := s.command.ChangeOIDCApplication(ctx, UpdateOIDCAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + oidcApp, err := UpdateOIDCAppConfigRequestToDomain(req) + if err != nil { + return nil, err + } + config, err := s.command.ChangeOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index ea2f45fd0d..787470d9c1 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -36,7 +36,11 @@ func ListAppsRequestToModel(req *mgmt_pb.ListAppsRequest) (*query.AppSearchQueri }, nil } -func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) *domain.OIDCApp { +func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := app_grpc.LoginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: req.ProjectId, @@ -58,7 +62,9 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) *domain.OIDCApp { AdditionalOrigins: req.AdditionalOrigins, SkipNativeAppSuccessPage: req.SkipNativeAppSuccessPage, BackChannelLogoutURI: req.GetBackChannelLogoutUri(), - } + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil } func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) *domain.SAMLApp { @@ -89,7 +95,11 @@ func UpdateAppRequestToDomain(app *mgmt_pb.UpdateAppRequest) domain.Application } } -func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) *domain.OIDCApp { +func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := app_grpc.LoginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: app.ProjectId, @@ -110,7 +120,9 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) AdditionalOrigins: app.AdditionalOrigins, SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, BackChannelLogoutURI: app.BackChannelLogoutUri, - } + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil } func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) *domain.SAMLApp { diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index 4c079476b1..b81abc9fd6 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -16,15 +16,18 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/object/v2" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) var ( - CTX context.Context - Instance *integration.Instance - Client oidc_pb.OIDCServiceClient + CTX context.Context + CTXLoginClient context.Context + Instance *integration.Instance + Client oidc_pb.OIDCServiceClient + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} ) const ( @@ -42,6 +45,7 @@ func TestMain(m *testing.M) { Client = Instance.Client.OIDCv2 CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) return m.Run() }()) } @@ -51,29 +55,58 @@ func TestServer_GetAuthRequest(t *testing.T) { require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) - require.NoError(t, err) + now := time.Now() tests := []struct { name string AuthRequestID string + ctx context.Context want *oidc_pb.GetAuthRequestResponse wantErr bool }{ { name: "Not found", AuthRequestID: "123", + ctx: CTX, wantErr: true, }, { - name: "success", - AuthRequestID: authRequestID, + name: "success", + AuthRequestID: func() string { + authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + ctx: CTX, + }, + { + name: "without login client, no permission", + AuthRequestID: func() string { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + ctx: CTX, + wantErr: true, + }, + { + name: "without login client, with permission", + AuthRequestID: func() string { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + ctx: CTXLoginClient, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.GetAuthRequest(CTX, &oidc_pb.GetAuthRequestRequest{ + got, err := Client.GetAuthRequest(tt.ctx, &oidc_pb.GetAuthRequestRequest{ AuthRequestId: tt.AuthRequestID, }) if tt.wantErr { @@ -83,7 +116,7 @@ func TestServer_GetAuthRequest(t *testing.T) { require.NoError(t, err) authRequest := got.GetAuthRequest() assert.NotNil(t, authRequest) - assert.Equal(t, authRequestID, authRequest.GetId()) + assert.Equal(t, tt.AuthRequestID, authRequest.GetId()) assert.WithinRange(t, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) assert.Contains(t, authRequest.GetScope(), "openid") }) @@ -95,6 +128,8 @@ func TestServer_CreateCallback(t *testing.T) { require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) + clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ @@ -108,6 +143,7 @@ func TestServer_CreateCallback(t *testing.T) { tests := []struct { name string + ctx context.Context req *oidc_pb.CreateCallbackRequest AuthError string want *oidc_pb.CreateCallbackResponse @@ -116,6 +152,7 @@ func TestServer_CreateCallback(t *testing.T) { }{ { name: "Not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: "123", CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -129,6 +166,7 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) @@ -146,6 +184,7 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session token invalid", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) @@ -163,6 +202,7 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "fail callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) @@ -186,8 +226,35 @@ func TestServer_CreateCallback(t *testing.T) { }, wantErr: false, }, + { + name: "fail callback, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Error{ + Error: &oidc_pb.AuthorizationError{ + Error: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED, + ErrorDescription: gu.Ptr("nope"), + ErrorUri: gu.Ptr("https://example.com/docs"), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: regexp.QuoteMeta(`oidcintegrationtest://callback?error=access_denied&error_description=nope&error_uri=https%3A%2F%2Fexample.com%2Fdocs&state=state`), + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, { name: "code callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) @@ -211,10 +278,54 @@ func TestServer_CreateCallback(t *testing.T) { wantErr: false, }, { - name: "implicit", + name: "code callback, no login client header, no permission, error", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "code callback, no login client header, with permission", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "implicit", + ctx: CTX, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURIImplicit) require.NoError(t, err) @@ -236,10 +347,37 @@ func TestServer_CreateCallback(t *testing.T) { }, wantErr: false, }, + { + name: "implicit, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, loginV2) + require.NoError(t, err) + authRequestID, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `http:\/\/localhost:9999\/callback#access_token=(.*)&expires_in=(.*)&id_token=(.*)&state=state&token_type=Bearer`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.CreateCallback(CTX, tt.req) + got, err := Client.CreateCallback(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go index 650a0dac30..1c83b504dd 100644 --- a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go @@ -214,7 +214,7 @@ func TestServer_CreateCallback(t *testing.T) { name: "implicit", req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURIImplicit) require.NoError(t, err) diff --git a/internal/api/grpc/org/v2/integration_test/org_test.go b/internal/api/grpc/org/v2/integration_test/org_test.go index 8de5e40bb5..2bca4b9349 100644 --- a/internal/api/grpc/org/v2/integration_test/org_test.go +++ b/internal/api/grpc/org/v2/integration_test/org_test.go @@ -35,7 +35,7 @@ func TestMain(m *testing.M) { CTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) OwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) User = Instance.CreateHumanUser(CTX) return m.Run() }()) diff --git a/internal/api/grpc/project/application.go b/internal/api/grpc/project/application.go index e70554ce64..573156e637 100644 --- a/internal/api/grpc/project/application.go +++ b/internal/api/grpc/project/application.go @@ -1,6 +1,8 @@ package project import ( + "net/url" + "google.golang.org/protobuf/types/known/durationpb" object_grpc "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -62,10 +64,24 @@ func AppOIDCConfigToPb(app *query.OIDCApp) *app_pb.App_OidcConfig { AllowedOrigins: app.AllowedOrigins, SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, BackChannelLogoutUri: app.BackChannelLogoutURI, + LoginVersion: loginVersionToPb(app.LoginVersion, app.LoginBaseURI), }, } } +func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app_pb.LoginVersion { + switch version { + case domain.LoginVersionUnspecified: + return nil + case domain.LoginVersion1: + return &app_pb.LoginVersion{Version: &app_pb.LoginVersion_LoginV1{LoginV1: &app_pb.LoginV1{}}} + case domain.LoginVersion2: + return &app_pb.LoginVersion{Version: &app_pb.LoginVersion_LoginV2{LoginV2: &app_pb.LoginV2{BaseUri: baseURI}}} + default: + return nil + } +} + func AppSAMLConfigToPb(app *query.SAMLApp) app_pb.AppConfig { return &app_pb.App_SamlConfig{ SamlConfig: &app_pb.SAMLConfig{ @@ -311,3 +327,17 @@ func AppQueryToModel(appQuery *app_pb.AppQuery) (query.SearchQuery, error) { return nil, zerrors.ThrowInvalidArgument(nil, "APP-Add46", "List.Query.Invalid") } } + +func LoginVersionToDomain(version *app_pb.LoginVersion) (domain.LoginVersion, string, error) { + switch v := version.GetVersion().(type) { + case nil: + return domain.LoginVersionUnspecified, "", nil + case *app_pb.LoginVersion_LoginV1: + return domain.LoginVersion1, "", nil + case *app_pb.LoginVersion_LoginV2: + _, err := url.Parse(v.LoginV2.GetBaseUri()) + return domain.LoginVersion2, v.LoginV2.GetBaseUri(), err + default: + return domain.LoginVersionUnspecified, "", nil + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go index f64bcebe38..44c5bd3185 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go @@ -69,7 +69,7 @@ func TestServer_SetContactEmail(t *testing.T) { }, { name: "email patch, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.SetContactEmailRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -412,7 +412,7 @@ func TestServer_VerifyContactEmail(t *testing.T) { }, { name: "email verify, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.VerifyContactEmailRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -601,7 +601,7 @@ func TestServer_ResendContactEmailCode(t *testing.T) { }, { name: "email resend, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.ResendContactEmailCodeRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go index fbd5805f16..da72dd23ce 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go @@ -68,7 +68,7 @@ func TestServer_SetContactPhone(t *testing.T) { }, { name: "phone patch, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.SetContactPhoneRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -340,7 +340,7 @@ func TestServer_VerifyContactPhone(t *testing.T) { }, { name: "phone verify, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.VerifyContactPhoneRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -530,7 +530,7 @@ func TestServer_ResendContactPhoneCode(t *testing.T) { }, { name: "phone resend, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.ResendContactPhoneCodeRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go index 1bc35a5390..94076be015 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go @@ -94,7 +94,7 @@ func TestServer_CreateUser(t *testing.T) { }, { name: "user create, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -294,7 +294,7 @@ func TestServer_PatchUser(t *testing.T) { }, { name: "user patch, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.PatchUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -734,7 +734,7 @@ func TestServer_DeleteUser(t *testing.T) { }, { name: "user delete, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.DeleteUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -950,7 +950,7 @@ func TestServer_LockUser(t *testing.T) { }, { name: "user lock, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.LockUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -1152,7 +1152,7 @@ func TestServer_UnlockUser(t *testing.T) { }, { name: "user unlock, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.UnlockUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -1333,7 +1333,7 @@ func TestServer_DeactivateUser(t *testing.T) { }, { name: "user deactivate, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.DeactivateUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() @@ -1535,7 +1535,7 @@ func TestServer_ActivateUser(t *testing.T) { }, { name: "user activate, no permission", - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), dep: func(req *user.ActivateUserRequest) error { userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) req.Id = userResp.GetDetails().GetId() diff --git a/internal/api/grpc/settings/v2/integration_test/settings_test.go b/internal/api/grpc/settings/v2/integration_test/settings_test.go index daa800b0bd..3430eae5f8 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -237,7 +237,7 @@ func TestServer_GetActiveIdentityProviders(t *testing.T) { { name: "permission error", args: args{ - ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), req: &settings.GetActiveIdentityProvidersRequest{}, }, wantErr: true, diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index cf42e9291f..206183351e 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -43,7 +43,7 @@ func TestMain(m *testing.M) { Instance = integration.NewInstance(ctx) - UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) SystemCTX = integration.WithSystemAuthorization(ctx) CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 2e0abbb6b9..9cf59ae563 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -41,7 +41,7 @@ func TestMain(m *testing.M) { Instance = integration.NewInstance(ctx) - UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) SystemCTX = integration.WithSystemAuthorization(ctx) CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index 138035af58..14a97e49ec 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "net/http" + "net/url" "slices" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" @@ -26,7 +28,11 @@ import ( ) const ( - LoginClientHeader = "x-zitadel-login-client" + LoginClientHeader = "x-zitadel-login-client" + LoginPostLogoutRedirectParam = "post_logout_redirect" + LoginPath = "/login" + LogoutPath = "/logout" + LogoutDonePath = "/logout/done" ) func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, userID string) (_ op.AuthRequest, err error) { @@ -36,12 +42,34 @@ func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest span.EndWithError(err) }() + // for backwards compatibility we pass the login client if set headers, _ := http_utils.HeadersFromCtx(ctx) - if loginClient := headers.Get(LoginClientHeader); loginClient != "" { + loginClient := headers.Get(LoginClientHeader) + + // if the instance requires the v2 login, use it no matter what the application configured + if authz.GetFeatures(ctx).LoginV2.Required { return o.createAuthRequestLoginClient(ctx, req, userID, loginClient) } - return o.createAuthRequest(ctx, req, userID) + version, err := o.query.OIDCClientLoginVersion(ctx, req.ClientID) + if err != nil { + return nil, err + } + + switch version { + case domain.LoginVersion1: + return o.createAuthRequest(ctx, req, userID) + case domain.LoginVersion2: + return o.createAuthRequestLoginClient(ctx, req, userID, loginClient) + case domain.LoginVersionUnspecified: + fallthrough + default: + // if undefined, use the v2 login if the header is sent, to retain the current behavior + if loginClient != "" { + return o.createAuthRequestLoginClient(ctx, req, userID, loginClient) + } + return o.createAuthRequest(ctx, req, userID) + } } func (o *OPStorage) createAuthRequestScopeAndAudience(ctx context.Context, clientID string, reqScope []string) (scope, audience []string, err error) { @@ -240,18 +268,35 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR // check for the login client header headers, _ := http_utils.HeadersFromCtx(ctx) - // in case there is no id_token_hint, redirect to the UI and let it decide which session to terminate - if headers.Get(LoginClientHeader) != "" && endSessionRequest.IDTokenHintClaims == nil { - return o.defaultLogoutURLV2 + endSessionRequest.RedirectURI, nil + + // V2: + // In case there is no id_token_hint and login V2 is either required by feature + // or requested via header (backwards compatibility), + // we'll redirect to the UI (V2) and let it decide which session to terminate + // + // If there's no id_token_hint and for v1 logins, we handle them separately + if endSessionRequest.IDTokenHintClaims == nil && + (authz.GetFeatures(ctx).LoginV2.Required || headers.Get(LoginClientHeader) != "") { + redirectURI := v2PostLogoutRedirectURI(endSessionRequest.RedirectURI) + // if no base uri is set, fallback to the default configured in the runtime config + if authz.GetFeatures(ctx).LoginV2.BaseURI == nil || authz.GetFeatures(ctx).LoginV2.BaseURI.String() == "" { + return o.defaultLogoutURLV2 + redirectURI, nil + } + return buildLoginV2LogoutURL(authz.GetFeatures(ctx).LoginV2.BaseURI, redirectURI), nil } - // If there is no login client header and no id_token_hint or the id_token_hint does not have a session ID, - // do a v1 Terminate session (which terminates all sessions of the user agent, identified by cookie). + // V1: + // We check again for the id_token_hint param and if a session is set in it. + // All explicit V2 sessions with empty id_token_hint are handled above and all V2 session contain a sessionID + // So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession, + // which terminates all sessions of the user agent, identified by cookie. if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" { return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID) } - // If the sessionID is prefixed by V1, we also terminate a v1 session. + // V1: + // If the sessionID is prefixed by V1, we also terminate a v1 session, but based on the SingleV1SessionTermination feature flag, + // we either terminate all sessions of the user agent or only the specific session if strings.HasPrefix(endSessionRequest.IDTokenHintClaims.SessionID, handler.IDPrefixV1) { err = o.terminateV1Session(ctx, endSessionRequest.UserID, endSessionRequest.IDTokenHintClaims.SessionID) if err != nil { @@ -260,12 +305,31 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR return endSessionRequest.RedirectURI, nil } - // terminate the v2 session of the id_token_hint + // V2: + // Terminate the v2 session of the id_token_hint _, err = o.command.TerminateSessionWithoutTokenCheck(ctx, endSessionRequest.IDTokenHintClaims.SessionID) if err != nil { return "", err } - return endSessionRequest.RedirectURI, nil + return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil +} + +func buildLoginV2LogoutURL(baseURI *url.URL, redirectURI string) string { + baseURI.JoinPath(LogoutPath) + q := baseURI.Query() + q.Set(LoginPostLogoutRedirectParam, redirectURI) + baseURI.RawQuery = q.Encode() + return baseURI.String() +} + +// v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins. +// The default value set by the [op.SessionEnder] only handles V1 logins. In case the redirect_uri is set to the default +// we'll return the path for the v2 login. +func v2PostLogoutRedirectURI(redirectURI string) string { + if redirectURI != login.DefaultLoggedOutPath { + return redirectURI + } + return LogoutDonePath } // terminateV1Session terminates "v1" sessions created through the login UI. diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index c84049d2ad..ec254e2fce 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -15,6 +15,10 @@ import ( "github.com/zitadel/zitadel/internal/query" ) +const ( + LoginAuthRequestParam = "authRequest" +) + type Client struct { client *query.OIDCClient defaultLoginURL string @@ -49,10 +53,21 @@ func (c *Client) GetID() string { } func (c *Client) LoginURL(id string) string { - if strings.HasPrefix(id, command.IDPrefixV2) { + // if the authRequest does not have the v2 prefix, it was created for login V1 + if !strings.HasPrefix(id, command.IDPrefixV2) { + return c.defaultLoginURL + id + } + // any v2 login without a specific base uri will be sent to the configured login v2 UI + // this way we're also backwards compatible + if c.client.LoginBaseURI == nil || c.client.LoginBaseURI.URL().String() == "" { return c.defaultLoginURLV2 + id } - return c.defaultLoginURL + id + // for clients with a specific URI (internal or external) we only need to add the auth request id + uri := c.client.LoginBaseURI.URL().JoinPath(LoginPath) + q := uri.Query() + q.Set(LoginAuthRequestParam, id) + uri.RawQuery = q.Encode() + return uri.String() } func (c *Client) RedirectURIs() []string { diff --git a/internal/api/oidc/integration_test/auth_request_test.go b/internal/api/oidc/integration_test/auth_request_test.go index 7ac0e24694..ad78184a04 100644 --- a/internal/api/oidc/integration_test/auth_request_test.go +++ b/internal/api/oidc/integration_test/auth_request_test.go @@ -29,157 +29,255 @@ var ( func TestOPStorage_CreateAuthRequest(t *testing.T) { clientID, _ := createClient(t, Instance) + clientIDV2, _ := createClientLoginV2(t, Instance) id := createAuthRequest(t, Instance, clientID, redirectURI) require.Contains(t, id, command.IDPrefixV2) + + id2 := createAuthRequestNoLoginClientHeader(t, Instance, clientIDV2, redirectURI) + require.Contains(t, id2, command.IDPrefixV2) } func TestOPStorage_CreateAccessToken_code(t *testing.T) { - clientID, _ := createClient(t, Instance) - authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + tests := []struct { + name string + clientID string + authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string + }{ + { + name: "login header", + clientID: func() string { + clientID, _ := createClient(t, Instance) + return clientID + }(), + authRequestID: createAuthRequest, }, - }) - require.NoError(t, err) - - // test code exchange - code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) - require.NoError(t, err) - assertTokens(t, tokens, false) - assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) - - // callback on a succeeded request must fail - linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + { + name: "login v2 config", + clientID: func() string { + clientID, _ := createClientLoginV2(t, Instance) + return clientID + }(), + authRequestID: createAuthRequestNoLoginClientHeader, }, - }) - require.Error(t, err) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authRequestID := tt.authRequestID(t, Instance, tt.clientID, redirectURI) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) - // exchange with a used code must fail - _, err = exchangeTokens(t, Instance, clientID, code, redirectURI) - require.Error(t, err) + // test code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, Instance, tt.clientID, code, redirectURI) + require.NoError(t, err) + assertTokens(t, tokens, false) + assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + + // callback on a succeeded request must fail + linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.Error(t, err) + + // exchange with a used code must fail + _, err = exchangeTokens(t, Instance, tt.clientID, code, redirectURI) + require.Error(t, err) + }) + } } func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { - clientID := createImplicitClient(t) - authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit) - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + tests := []struct { + name string + clientID string + authRequestID func(t testing.TB, clientID, redirectURI string, scope ...string) string + }{ + { + name: "login header", + clientID: createImplicitClient(t), + authRequestID: createAuthRequestImplicit, }, - }) - require.NoError(t, err) - - // test implicit callback - callback, err := url.Parse(linkResp.GetCallbackUrl()) - require.NoError(t, err) - values, err := url.ParseQuery(callback.Fragment) - require.NoError(t, err) - accessToken := values.Get("access_token") - idToken := values.Get("id_token") - refreshToken := values.Get("refresh_token") - assert.NotEmpty(t, accessToken) - assert.NotEmpty(t, idToken) - assert.Empty(t, refreshToken) - assert.NotEmpty(t, values.Get("expires_in")) - assert.Equal(t, oidc.BearerToken, values.Get("token_type")) - assert.Equal(t, "state", values.Get("state")) - - // check id_token / claims - provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURIImplicit) - require.NoError(t, err) - claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier()) - require.NoError(t, err) - assertIDTokenClaims(t, claims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) - - // callback on a succeeded request must fail - linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + { + name: "login v2 config", + clientID: createImplicitClientNoLoginClientHeader(t), + authRequestID: createAuthRequestImplicitNoLoginClientHeader, }, - }) - require.Error(t, err) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authRequestID := tt.authRequestID(t, tt.clientID, redirectURIImplicit) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // test implicit callback + callback, err := url.Parse(linkResp.GetCallbackUrl()) + require.NoError(t, err) + values, err := url.ParseQuery(callback.Fragment) + require.NoError(t, err) + accessToken := values.Get("access_token") + idToken := values.Get("id_token") + refreshToken := values.Get("refresh_token") + assert.NotEmpty(t, accessToken) + assert.NotEmpty(t, idToken) + assert.Empty(t, refreshToken) + assert.NotEmpty(t, values.Get("expires_in")) + assert.Equal(t, oidc.BearerToken, values.Get("token_type")) + assert.Equal(t, "state", values.Get("state")) + + // check id_token / claims + provider, err := Instance.CreateRelyingParty(CTX, tt.clientID, redirectURIImplicit) + require.NoError(t, err) + claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier()) + require.NoError(t, err) + assertIDTokenClaims(t, claims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + + // callback on a succeeded request must fail + linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.Error(t, err) + }) + } } func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { - clientID, _ := createClient(t, Instance) - authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + tests := []struct { + name string + clientID string + authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string + }{ + { + name: "login header", + clientID: func() string { + clientID, _ := createClient(t, Instance) + return clientID + }(), + authRequestID: createAuthRequest, }, - }) - require.NoError(t, err) + { + name: "login v2 config", + clientID: func() string { + clientID, _ := createClientLoginV2(t, Instance) + return clientID + }(), + authRequestID: createAuthRequestNoLoginClientHeader, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authRequestID := tt.authRequestID(t, Instance, tt.clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) - // test code exchange (expect refresh token to be returned) - code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) - require.NoError(t, err) - assertTokens(t, tokens, true) - assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + // test code exchange (expect refresh token to be returned) + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, Instance, tt.clientID, code, redirectURI) + require.NoError(t, err) + assertTokens(t, tokens, true) + assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + }) + } } func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { - clientID, _ := createClient(t, Instance) - provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) - require.NoError(t, err) - authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + tests := []struct { + name string + clientID string + authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string + }{ + { + name: "login header", + clientID: func() string { + clientID, _ := createClient(t, Instance) + return clientID + }(), + authRequestID: createAuthRequest, }, - }) - require.NoError(t, err) + { + name: "login v2 config", + clientID: func() string { + clientID, _ := createClientLoginV2(t, Instance) + return clientID + }(), + authRequestID: createAuthRequestNoLoginClientHeader, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := Instance.CreateRelyingParty(CTX, tt.clientID, redirectURI) + require.NoError(t, err) + authRequestID := tt.authRequestID(t, Instance, tt.clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) - // code exchange - code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) - require.NoError(t, err) - assertTokens(t, tokens, true) - assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, Instance, tt.clientID, code, redirectURI) + require.NoError(t, err) + assertTokens(t, tokens, true) + assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) - // test actual refresh grant - newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken) - require.NoError(t, err) - assertTokens(t, newTokens, true) - // auth time must still be the initial - assertIDTokenClaims(t, newTokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + // test actual refresh grant + newTokens, err := refreshTokens(t, tt.clientID, tokens.RefreshToken) + require.NoError(t, err) + assertTokens(t, newTokens, true) + // auth time must still be the initial + assertIDTokenClaims(t, newTokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) - // refresh with an old refresh_token must fail - _, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "") - require.Error(t, err) + // refresh with an old refresh_token must fail + _, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "") + require.Error(t, err) + }) + } } func TestOPStorage_RevokeToken_access_token(t *testing.T) { @@ -454,47 +552,75 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { } func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { - clientID, _ := createClient(t, Instance) - provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) - require.NoError(t, err) - authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ - AuthRequestId: authRequestID, - CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ - Session: &oidc_pb.Session{ - SessionId: sessionID, - SessionToken: sessionToken, - }, + tests := []struct { + name string + clientID string + authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string + logoutURL string + }{ + { + name: "login header", + clientID: func() string { + clientID, _ := createClient(t, Instance) + return clientID + }(), + authRequestID: createAuthRequest, + logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state", }, - }) - require.NoError(t, err) + { + name: "login v2 config", + clientID: func() string { + clientID, _ := createClientLoginV2(t, Instance) + return clientID + }(), + authRequestID: createAuthRequestNoLoginClientHeader, + logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := Instance.CreateRelyingParty(CTX, tt.clientID, redirectURI) + require.NoError(t, err) + authRequestID := tt.authRequestID(t, Instance, tt.clientID, redirectURI) + sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) - // test code exchange - code := assertCodeResponse(t, linkResp.GetCallbackUrl()) - tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) - require.NoError(t, err) - assertTokens(t, tokens, false) - assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) + // test code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, Instance, tt.clientID, code, redirectURI) + require.NoError(t, err) + assertTokens(t, tokens, false) + assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID) - postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state") - require.NoError(t, err) - assert.Equal(t, http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure)+Instance.Config.LogoutURLV2+logoutRedirectURI+"?state=state", postLogoutRedirect.String()) + postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state") + require.NoError(t, err) + assert.Equal(t, tt.logoutURL, postLogoutRedirect.String()) - // userinfo must not fail until login UI terminated session - _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) - require.NoError(t, err) + // userinfo must not fail until login UI terminated session + _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) + require.NoError(t, err) - // simulate termination by login UI - _, err = Instance.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{ - SessionId: sessionID, - SessionToken: gu.Ptr(sessionToken), - }) - require.NoError(t, err) + // simulate termination by login UI + _, err = Instance.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{ + SessionId: sessionID, + SessionToken: gu.Ptr(sessionToken), + }) + require.NoError(t, err) - // userinfo must fail - _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) - require.Error(t, err) + // userinfo must fail + _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) + require.Error(t, err) + }) + } } func exchangeTokens(t testing.TB, instance *integration.Instance, clientID, code, redirectURI string) (*oidc.Tokens[*oidc.IDTokenClaims], error) { diff --git a/internal/api/oidc/integration_test/oidc_test.go b/internal/api/oidc/integration_test/oidc_test.go index 86754aab0e..302c818c36 100644 --- a/internal/api/oidc/integration_test/oidc_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/auth" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" @@ -394,16 +395,27 @@ func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) { func createClient(t testing.TB, instance *integration.Instance) (clientID, projectID string) { return createClientWithOpts(t, instance, clientOpts{ - redirectURI: redirectURI, - logoutURI: logoutRedirectURI, - devMode: false, + redirectURI: redirectURI, + logoutURI: logoutRedirectURI, + devMode: false, + LoginVersion: nil, + }) +} + +func createClientLoginV2(t testing.TB, instance *integration.Instance) (clientID, projectID string) { + return createClientWithOpts(t, instance, clientOpts{ + redirectURI: redirectURI, + logoutURI: logoutRedirectURI, + devMode: false, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}}, }) } type clientOpts struct { - redirectURI string - logoutURI string - devMode bool + redirectURI string + logoutURI string + devMode bool + LoginVersion *app.LoginVersion } func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) { @@ -411,13 +423,19 @@ func createClientWithOpts(t testing.TB, instance *integration.Instance, opts cli project, err := instance.CreateProject(ctx) require.NoError(t, err) - app, err := instance.CreateOIDCNativeClient(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode) + app, err := instance.CreateOIDCClientLoginVersion(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, opts.devMode, opts.LoginVersion) require.NoError(t, err) return app.GetClientId(), project.GetId() } func createImplicitClient(t testing.TB) string { - app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) + require.NoError(t, err) + return app.GetClientId() +} + +func createImplicitClientNoLoginClientHeader(t testing.TB) string { + app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}}) require.NoError(t, err) return app.GetClientId() } @@ -428,12 +446,24 @@ func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, r return redURL } +func createAuthRequestNoLoginClientHeader(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { + redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, redirectURI, "", scope...) + require.NoError(t, err) + return redURL +} + func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string { redURL, err := Instance.CreateOIDCAuthRequestImplicit(CTX, clientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) require.NoError(t, err) return redURL } +func createAuthRequestImplicitNoLoginClientHeader(t testing.TB, clientID, redirectURI string, scope ...string) string { + redURL, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientID, redirectURI, scope...) + require.NoError(t, err) + return redURL +} + func assertOIDCTime(t *testing.T, actual oidc.Time, expected time.Time) { assertOIDCTimeRange(t, actual, expected, expected) } diff --git a/internal/command/auth_request.go b/internal/command/auth_request.go index efd57da240..91705acedf 100644 --- a/internal/command/auth_request.go +++ b/internal/command/auth_request.go @@ -92,7 +92,9 @@ func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Sx208nt", "Errors.AuthRequest.AlreadyHandled") } if checkLoginClient && authz.GetCtxData(ctx).UserID != writeModel.LoginClient { - return nil, nil, zerrors.ThrowPermissionDenied(nil, "COMMAND-rai9Y", "Errors.AuthRequest.WrongLoginClient") + if err := c.checkPermission(ctx, domain.PermissionSessionLink, writeModel.ResourceOwner, ""); err != nil { + return nil, nil, err + } } sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) diff --git a/internal/command/auth_request_test.go b/internal/command/auth_request_test.go index d097e4f381..3668b6563b 100644 --- a/internal/command/auth_request_test.go +++ b/internal/command/auth_request_test.go @@ -25,7 +25,7 @@ import ( func TestCommands_AddAuthRequest(t *testing.T) { mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator } type args struct { @@ -42,7 +42,7 @@ func TestCommands_AddAuthRequest(t *testing.T) { { "already exists error", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -78,7 +78,7 @@ func TestCommands_AddAuthRequest(t *testing.T) { { "added", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter(), expectPush( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -158,7 +158,7 @@ func TestCommands_AddAuthRequest(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, } got, err := c.AddAuthRequest(tt.args.ctx, tt.args.request) @@ -171,8 +171,9 @@ func TestCommands_AddAuthRequest(t *testing.T) { func TestCommands_LinkSessionToAuthRequest(t *testing.T) { mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") type fields struct { - eventstore *eventstore.Eventstore - tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + eventstore func(*testing.T) *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -195,7 +196,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "authRequest not found", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter(), ), tokenVerifier: newMockTokenVerifierValid(), @@ -212,7 +213,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "authRequest not existing", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("id", "instanceID").Aggregate, @@ -252,9 +253,9 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { }, }, { - "wrong login client", + "wrong login client / not permitted", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("id", "instanceID").Aggregate, @@ -278,7 +279,8 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { ), ), ), - tokenVerifier: newMockTokenVerifierValid(), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckNotAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "orgID", "wrongLoginClient"), @@ -288,13 +290,13 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { checkLoginClient: true, }, res{ - wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-rai9Y", "Errors.AuthRequest.WrongLoginClient"), + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), }, }, { "session not existing", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -333,7 +335,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "session expired", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -395,7 +397,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "invalid session token", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -446,7 +448,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "linked", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -534,7 +536,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { { "linked with login client check", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -620,12 +622,103 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { }, }, }, + { + "linked with permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "otherLoginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + nil, + nil, + nil, + nil, + nil, + nil, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + authrequest.NewSessionLinkedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentAuthRequest{ + AuthRequest: &AuthRequest{ + ID: "V2_id", + LoginClient: "otherLoginClient", + ClientID: "clientID", + RedirectURI: "redirectURI", + State: "state", + Nonce: "nonce", + Scope: []string{"openid"}, + Audience: []string{"audience"}, + ResponseType: domain.OIDCResponseTypeCode, + ResponseMode: domain.OIDCResponseModeQuery, + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), sessionTokenVerifier: tt.fields.tokenVerifier, + checkPermission: tt.fields.checkPermission, } details, got, err := c.LinkSessionToAuthRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) require.ErrorIs(t, err, tt.res.wantErr) @@ -642,7 +735,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { func TestCommands_FailAuthRequest(t *testing.T) { mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -663,7 +756,7 @@ func TestCommands_FailAuthRequest(t *testing.T) { { "authRequest not existing", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -679,7 +772,7 @@ func TestCommands_FailAuthRequest(t *testing.T) { { "failed", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, @@ -735,7 +828,7 @@ func TestCommands_FailAuthRequest(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), } details, got, err := c.FailAuthRequest(tt.args.ctx, tt.args.id, tt.args.reason) require.ErrorIs(t, err, tt.res.wantErr) @@ -748,7 +841,7 @@ func TestCommands_FailAuthRequest(t *testing.T) { func TestCommands_AddAuthRequestCode(t *testing.T) { mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -764,7 +857,7 @@ func TestCommands_AddAuthRequestCode(t *testing.T) { { "empty code error", fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args{ ctx: mockCtx, @@ -776,7 +869,7 @@ func TestCommands_AddAuthRequestCode(t *testing.T) { { "no session linked error", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate, @@ -814,7 +907,7 @@ func TestCommands_AddAuthRequestCode(t *testing.T) { { "success", fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate, @@ -864,7 +957,7 @@ func TestCommands_AddAuthRequestCode(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), } err := c.AddAuthRequestCode(tt.args.ctx, tt.args.id, tt.args.code) assert.ErrorIs(t, tt.wantErr, err) diff --git a/internal/command/instance_domain_test.go b/internal/command/instance_domain_test.go index adaa59ec05..ff56505ab6 100644 --- a/internal/command/instance_domain_test.go +++ b/internal/command/instance_domain_test.go @@ -156,6 +156,8 @@ func TestCommandSide_AddInstanceDomain(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), ), ), diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index e4509ae130..44f122e98f 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -28,6 +28,7 @@ type InstanceFeatures struct { OIDCSingleV1SessionTermination *bool DisableUserTokenEvent *bool EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 } func (m *InstanceFeatures) isEmpty() bool { @@ -43,7 +44,8 @@ func (m *InstanceFeatures) isEmpty() bool { m.DebugOIDCParentError == nil && m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && - m.EnableBackChannelLogout == nil + m.EnableBackChannelLogout == nil && + m.LoginV2 == 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 f6c5f39898..8fa52318db 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -41,6 +41,12 @@ func (m *InstanceFeaturesWriteModel) Reduce() (err error) { return err } reduceInstanceFeature(&m.InstanceFeatures, key, e.Value) + case *feature_v2.SetEvent[*feature.LoginV2]: + _, key, err := e.FeatureInfo() + if err != nil { + return err + } + reduceInstanceFeature(&m.InstanceFeatures, key, e.Value) case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]: _, key, err := e.FeatureInfo() if err != nil { @@ -72,6 +78,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, + feature_v2.InstanceLoginVersion, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -120,6 +127,8 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyEnableBackChannelLogout: v := value.(bool) features.EnableBackChannelLogout = &v + case feature.KeyLoginV2: + features.LoginV2 = value.(*feature.LoginV2) } } @@ -138,5 +147,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.InstanceLoginVersion) return cmds } diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 301077b268..fd21e0e704 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -128,6 +128,8 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str nil, false, "", + domain.LoginVersionUnspecified, + "", ), } } @@ -441,6 +443,8 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain nil, false, "", + domain.LoginVersionUnspecified, + "", ), ), expectFilter( diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index ac486b2e18..257cdeaec4 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -32,6 +32,8 @@ type addOIDCApp struct { AdditionalOrigins []string SkipSuccessPageForNativeApp bool BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI string ClientID string ClientSecret string @@ -110,6 +112,8 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { trimStringSliceWhiteSpaces(app.AdditionalOrigins), app.SkipSuccessPageForNativeApp, app.BackChannelLogoutURI, + app.LoginVersion, + app.LoginBaseURI, ), }, nil }, nil @@ -202,6 +206,8 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), oidcApp.SkipNativeAppSuccessPage, strings.TrimSpace(oidcApp.BackChannelLogoutURI), + oidcApp.LoginVersion, + strings.TrimSpace(oidcApp.LoginBaseURI), )) addedApplication.AppID = oidcApp.AppID @@ -260,6 +266,8 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA trimStringSliceWhiteSpaces(oidc.AdditionalOrigins), oidc.SkipNativeAppSuccessPage, strings.TrimSpace(oidc.BackChannelLogoutURI), + oidc.LoginVersion, + strings.TrimSpace(oidc.LoginBaseURI), ) if err != nil { return nil, err diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 1ab0ad00d6..9471df3760 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -37,6 +37,8 @@ type OIDCApplicationWriteModel struct { AdditionalOrigins []string SkipNativeAppSuccessPage bool BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI string oidc bool } @@ -167,6 +169,8 @@ func (wm *OIDCApplicationWriteModel) appendAddOIDCEvent(e *project.OIDCConfigAdd wm.AdditionalOrigins = e.AdditionalOrigins wm.SkipNativeAppSuccessPage = e.SkipNativeAppSuccessPage wm.BackChannelLogoutURI = e.BackChannelLogoutURI + wm.LoginVersion = e.LoginVersion + wm.LoginBaseURI = e.LoginBaseURI } func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfigChangedEvent) { @@ -218,6 +222,12 @@ func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfig if e.BackChannelLogoutURI != nil { wm.BackChannelLogoutURI = *e.BackChannelLogoutURI } + if e.LoginVersion != nil { + wm.LoginVersion = *e.LoginVersion + } + if e.LoginBaseURI != nil { + wm.LoginBaseURI = *e.LoginBaseURI + } } func (wm *OIDCApplicationWriteModel) Query() *eventstore.SearchQueryBuilder { @@ -260,6 +270,8 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( additionalOrigins []string, skipNativeAppSuccessPage bool, backChannelLogoutURI string, + loginVersion domain.LoginVersion, + loginBaseURI string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error @@ -312,6 +324,12 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( if wm.BackChannelLogoutURI != backChannelLogoutURI { changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) } + if wm.LoginVersion != loginVersion { + changes = append(changes, project.ChangeLoginVersion(loginVersion)) + } + if wm.LoginBaseURI != loginBaseURI { + changes = append(changes, project.ChangeLoginBaseURI(loginBaseURI)) + } if len(changes) == 0 { return nil, false, nil diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 8c79d03f82..8b663afa57 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -176,6 +176,8 @@ func TestAddOIDCApp(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), }, }, @@ -242,6 +244,8 @@ func TestAddOIDCApp(t *testing.T) { nil, false, "", + domain.LoginVersionUnspecified, + "", ), }, }, @@ -308,6 +312,8 @@ func TestAddOIDCApp(t *testing.T) { nil, false, "", + domain.LoginVersionUnspecified, + "", ), }, }, @@ -374,6 +380,8 @@ func TestAddOIDCApp(t *testing.T) { nil, false, "", + domain.LoginVersionUnspecified, + "", ), }, }, @@ -521,6 +529,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { []string{"https://sub.test.ch"}, true, "https://test.ch/backchannel", + domain.LoginVersion2, + "https://login.test.ch", ), ), ), @@ -549,6 +559,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AdditionalOrigins: []string{" https://sub.test.ch "}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: " https://test.ch/backchannel ", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: " https://login.test.ch ", }, resourceOwner: "org1", }, @@ -578,6 +590,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -622,6 +636,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { []string{"https://sub.test.ch"}, true, "https://test.ch/backchannel", + domain.LoginVersion2, + "https://login.test.ch", ), ), ), @@ -650,6 +666,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", }, resourceOwner: "org1", }, @@ -679,6 +697,8 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -712,7 +732,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { func TestCommandSide_ChangeOIDCApplication(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -732,9 +752,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "invalid app, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -753,9 +771,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -777,9 +793,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "missing aggregateid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -801,8 +815,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -826,8 +839,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -858,6 +870,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { []string{"https://sub.test.ch"}, true, "https://test.ch/backchannel", + domain.LoginVersion2, + "https://login.test.ch", ), ), ), @@ -887,6 +901,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", }, resourceOwner: "org1", }, @@ -897,8 +913,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "no changes whitespaces are ignored, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -929,6 +944,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { []string{"https://sub.test.ch"}, true, "https://test.ch/backchannel", + domain.LoginVersion2, + "https://login.test.ch", ), ), ), @@ -958,6 +975,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AdditionalOrigins: []string{" https://sub.test.ch "}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: " https://test.ch/backchannel ", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: " https://login.test.ch ", }, resourceOwner: "org1", }, @@ -968,8 +987,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { { name: "change oidc app, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -1000,6 +1018,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { []string{"https://sub.test.ch"}, true, "https://test.ch/backchannel", + domain.LoginVersion1, + "", ), ), ), @@ -1035,6 +1055,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", }, resourceOwner: "org1", }, @@ -1063,6 +1085,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "https://test.ch/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://login.test.ch", Compliance: &domain.Compliance{}, State: domain.AppStateActive, }, @@ -1072,7 +1096,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ChangeOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { @@ -1188,6 +1212,8 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -1232,6 +1258,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { AdditionalOrigins: []string{"https://sub.test.ch"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "", + LoginVersion: domain.LoginVersionUnspecified, State: domain.AppStateActive, }, }, @@ -1270,6 +1297,8 @@ func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner project.ChangeIDTokenRoleAssertion(false), project.ChangeIDTokenUserinfoAssertion(false), project.ChangeClockSkew(time.Second * 2), + project.ChangeLoginVersion(domain.LoginVersion2), + project.ChangeLoginBaseURI("https://login.test.ch"), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, @@ -1347,6 +1376,8 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -1383,6 +1414,8 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -1418,6 +1451,8 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) { []string{"https://sub.test.ch"}, false, "", + domain.LoginVersionUnspecified, + "", ), ), ), diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 079dc85654..59343aa762 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -48,6 +48,8 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O AdditionalOrigins: writeModel.AdditionalOrigins, SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage, BackChannelLogoutURI: writeModel.BackChannelLogoutURI, + LoginVersion: writeModel.LoginVersion, + LoginBaseURI: writeModel.LoginBaseURI, } } diff --git a/internal/command/system_features.go b/internal/command/system_features.go index f089ada207..eb10bba553 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -20,6 +20,7 @@ type SystemFeatures struct { OIDCSingleV1SessionTermination *bool DisableUserTokenEvent *bool EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 } func (m *SystemFeatures) isEmpty() bool { @@ -33,7 +34,8 @@ func (m *SystemFeatures) isEmpty() bool { m.ImprovedPerformance == nil && m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && - m.EnableBackChannelLogout == nil + m.EnableBackChannelLogout == nil && + m.LoginV2 == 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 d3fca66fea..d656a6e266 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -34,6 +34,12 @@ func (m *SystemFeaturesWriteModel) Reduce() error { return err } reduceSystemFeature(&m.SystemFeatures, key, e.Value) + case *feature_v2.SetEvent[*feature.LoginV2]: + _, key, err := e.FeatureInfo() + if err != nil { + return err + } + reduceSystemFeature(&m.SystemFeatures, key, e.Value) case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]: _, key, err := e.FeatureInfo() if err != nil { @@ -63,6 +69,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemOIDCSingleV1SessionTerminationEventType, feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, + feature_v2.SystemLoginVersion, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -104,6 +111,8 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyEnableBackChannelLogout: v := value.(bool) features.EnableBackChannelLogout = &v + case feature.KeyLoginV2: + features.LoginV2 = value.(*feature.LoginV2) } } @@ -120,6 +129,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.SystemOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.SystemEnableBackChannelLogout) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.SystemLoginVersion) return cmds } diff --git a/internal/domain/application.go b/internal/domain/application.go index 4d7e32b999..8c6efb2511 100644 --- a/internal/domain/application.go +++ b/internal/domain/application.go @@ -39,3 +39,11 @@ func (a *ChangeApp) GetApplicationName() string { func (a *ChangeApp) GetState() AppState { return a.State } + +type LoginVersion int32 + +const ( + LoginVersionUnspecified LoginVersion = iota + LoginVersion1 + LoginVersion2 +) diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 617b889561..5d466c689d 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -46,6 +46,8 @@ type OIDCApp struct { AdditionalOrigins []string SkipNativeAppSuccessPage bool BackChannelLogoutURI string + LoginVersion LoginVersion + LoginBaseURI string State AppState } diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 7e0cfcfc89..bf24c09e53 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -32,6 +32,8 @@ const ( PermissionUserDelete = "user.delete" PermissionUserCredentialWrite = "user.credential.write" PermissionSessionWrite = "session.write" + PermissionSessionRead = "session.read" + PermissionSessionLink = "session.link" PermissionSessionDelete = "session.delete" PermissionOrgRead = "org.read" PermissionIDPRead = "iam.idp.read" diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 1d619b25d8..09fdf2ff52 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -1,6 +1,9 @@ package feature -import "slices" +import ( + "net/url" + "slices" +) //go:generate enumer -type Key -transform snake -trimprefix Key type Key int @@ -19,6 +22,7 @@ const ( KeyOIDCSingleV1SessionTermination KeyDisableUserTokenEvent KeyEnableBackChannelLogout + KeyLoginV2 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -47,6 +51,7 @@ type Features struct { OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` + LoginV2 LoginV2 `json:"login_v2,omitempty"` } type ImprovedPerformanceType int32 @@ -63,3 +68,8 @@ const ( func (f Features) ShouldUseImprovedPerformance(typ ImprovedPerformanceType) bool { return slices.Contains(f.ImprovedPerformance, typ) } + +type LoginV2 struct { + Required bool `json:"required,omitempty"` + BaseURI *url.URL `json:"base_uri,omitempty"` +} diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index db3cf4161e..462b751e6c 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_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logout" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247} +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logout" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -37,9 +37,10 @@ func _KeyNoOp() { _ = x[KeyOIDCSingleV1SessionTermination-(10)] _ = x[KeyDisableUserTokenEvent-(11)] _ = x[KeyEnableBackChannelLogout-(12)] + _ = x[KeyLoginV2-(13)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -68,6 +69,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[197:221]: KeyDisableUserTokenEvent, _KeyName[221:247]: KeyEnableBackChannelLogout, _KeyLowerName[221:247]: KeyEnableBackChannelLogout, + _KeyName[247:255]: KeyLoginV2, + _KeyLowerName[247:255]: KeyLoginV2, } var _KeyNames = []string{ @@ -84,6 +87,7 @@ var _KeyNames = []string{ _KeyName[163:197], _KeyName[197:221], _KeyName[221:247], + _KeyName[247:255], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/instance.go b/internal/integration/instance.go index c6e0b9737c..6113bf0e37 100644 --- a/internal/integration/instance.go +++ b/internal/integration/instance.go @@ -49,6 +49,7 @@ const ( UserTypeIAMOwner UserTypeOrgOwner UserTypeLogin + UserTypeNoPermission ) const ( @@ -196,6 +197,7 @@ func (i *Instance) setupInstance(ctx context.Context, token string) { i.createMachineUserInstanceOwner(ctx, token) i.createMachineUserOrgOwner(ctx) i.createLoginClient(ctx) + i.createMachineUserNoPermission(ctx) i.createWebAuthNClient() } @@ -238,7 +240,17 @@ func (i *Instance) createMachineUserOrgOwner(ctx context.Context) { } func (i *Instance) createLoginClient(ctx context.Context) { - i.createMachineUser(ctx, UserTypeLogin) + _, err := i.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{ + UserId: i.createMachineUser(ctx, UserTypeLogin), + Roles: []string{"IAM_LOGIN_CLIENT"}, + }) + if err != nil { + panic(err) + } +} + +func (i *Instance) createMachineUserNoPermission(ctx context.Context) { + i.createMachineUser(ctx, UserTypeNoPermission) } func (i *Instance) setClient(ctx context.Context) { diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index a581abe91b..04cf951048 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -25,7 +25,7 @@ import ( user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) { +func (i *Instance) CreateOIDCClientLoginVersion(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, loginVersion *app.LoginVersion, 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} } @@ -47,6 +47,7 @@ func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedi ClockSkew: nil, AdditionalOrigins: nil, SkipNativeAppSuccessPage: false, + LoginVersion: loginVersion, }) if err != nil { return nil, err @@ -66,6 +67,10 @@ func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedi }) } +func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) { + return i.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, projectID, appType, authMethod, devMode, nil, grantTypes...) +} + func (i *Instance) CreateOIDCNativeClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, devMode bool) (*management.AddOIDCAppResponse, error) { return i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, devMode) } @@ -128,7 +133,7 @@ func (i *Instance) CreateOIDCInactivateProjectClient(ctx context.Context, redire return client, err } -func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) { +func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string, loginVersion *app.LoginVersion) (*management.AddOIDCAppResponse, error) { project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), }) @@ -153,6 +158,7 @@ func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI ClockSkew: nil, AdditionalOrigins: nil, SkipNativeAppSuccessPage: false, + LoginVersion: loginVersion, }) if err != nil { return nil, err @@ -209,15 +215,28 @@ const CodeVerifier = "codeVerifier" func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { return i.CreateOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, loginClient, redirectURI, scope...) } + +func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (authRequestID string, err error) { + return i.createOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, redirectURI, "", loginBaseURI, scope...) +} + func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { - provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, scope...) + return i.createOIDCAuthRequestWithDomain(ctx, domain, clientID, redirectURI, loginClient, "", scope...) +} + +func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (authRequestID string, err error) { + provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, loginClient, scope...) if err != nil { return "", fmt.Errorf("create relying party: %w", err) } codeChallenge := oidc.NewSHACodeChallenge(CodeVerifier) authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge)) - req, err := GetRequest(authURL, map[string]string{oidc_internal.LoginClientHeader: loginClient}) + var headers map[string]string + if loginClient != "" { + headers = map[string]string{oidc_internal.LoginClientHeader: loginClient} + } + req, err := GetRequest(authURL, headers) if err != nil { return "", fmt.Errorf("get request: %w", err) } @@ -227,14 +246,24 @@ func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, return "", fmt.Errorf("check redirect: %w", err) } - prefixWithHost := provider.Issuer() + i.Config.LoginURLV2 - if !strings.HasPrefix(loc.String(), prefixWithHost) { - return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) + if loginBaseURI == "" { + loginBaseURI = provider.Issuer() + i.Config.LoginURLV2 } - return strings.TrimPrefix(loc.String(), prefixWithHost), nil + if !strings.HasPrefix(loc.String(), loginBaseURI) { + return "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) + } + return strings.TrimPrefix(loc.String(), loginBaseURI), nil +} + +func (i *Instance) CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (authRequestID string, err error) { + return i.createOIDCAuthRequestImplicit(ctx, clientID, redirectURI, nil, scope...) } func (i *Instance) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { + return i.createOIDCAuthRequestImplicit(ctx, clientID, redirectURI, map[string]string{oidc_internal.LoginClientHeader: loginClient}, scope...) +} + +func (i *Instance) createOIDCAuthRequestImplicit(ctx context.Context, clientID, redirectURI string, headers map[string]string, scope ...string) (authRequestID string, err error) { provider, err := i.CreateRelyingParty(ctx, clientID, redirectURI, scope...) if err != nil { return "", err @@ -249,7 +278,7 @@ func (i *Instance) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, parsed.RawQuery = queries.Encode() authURL = parsed.String() - req, err := GetRequest(authURL, map[string]string{oidc_internal.LoginClientHeader: loginClient}) + req, err := GetRequest(authURL, headers) if err != nil { return "", err } @@ -271,14 +300,21 @@ func (i *Instance) OIDCIssuer() string { } func (i *Instance) CreateRelyingParty(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { - return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, scope...) + return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, i.Users.Get(UserTypeLogin).Username, scope...) } -func (i *Instance) CreateRelyingPartyForDomain(ctx context.Context, domain, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { +func (i *Instance) CreateRelyingPartyWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { + return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, "", scope...) +} + +func (i *Instance) CreateRelyingPartyForDomain(ctx context.Context, domain, clientID, redirectURI, loginClientUsername string, scope ...string) (rp.RelyingParty, error) { if len(scope) == 0 { scope = []string{oidc.ScopeOpenID} } - loginClient := &http.Client{Transport: &loginRoundTripper{http.DefaultTransport, i.Users.Get(UserTypeLogin).Username}} + if loginClientUsername == "" { + return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, i.Config.Port, i.Config.Secure), clientID, "", redirectURI, scope) + } + loginClient := &http.Client{Transport: &loginRoundTripper{http.DefaultTransport, loginClientUsername}} return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, i.Config.Port, i.Config.Secure), clientID, "", redirectURI, scope, rp.WithHTTPClient(loginClient)) } diff --git a/internal/integration/usertype_enumer.go b/internal/integration/usertype_enumer.go index 66d49ced4d..e305d7cacc 100644 --- a/internal/integration/usertype_enumer.go +++ b/internal/integration/usertype_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _UserTypeName = "unspecifiediam_ownerorg_ownerlogin" +const _UserTypeName = "unspecifiediam_ownerorg_ownerloginno_permission" -var _UserTypeIndex = [...]uint8{0, 11, 20, 29, 34} +var _UserTypeIndex = [...]uint8{0, 11, 20, 29, 34, 47} -const _UserTypeLowerName = "unspecifiediam_ownerorg_ownerlogin" +const _UserTypeLowerName = "unspecifiediam_ownerorg_ownerloginno_permission" func (i UserType) String() string { if i < 0 || i >= UserType(len(_UserTypeIndex)-1) { @@ -28,9 +28,10 @@ func _UserTypeNoOp() { _ = x[UserTypeIAMOwner-(1)] _ = x[UserTypeOrgOwner-(2)] _ = x[UserTypeLogin-(3)] + _ = x[UserTypeNoPermission-(4)] } -var _UserTypeValues = []UserType{UserTypeUnspecified, UserTypeIAMOwner, UserTypeOrgOwner, UserTypeLogin} +var _UserTypeValues = []UserType{UserTypeUnspecified, UserTypeIAMOwner, UserTypeOrgOwner, UserTypeLogin, UserTypeNoPermission} var _UserTypeNameToValueMap = map[string]UserType{ _UserTypeName[0:11]: UserTypeUnspecified, @@ -41,6 +42,8 @@ var _UserTypeNameToValueMap = map[string]UserType{ _UserTypeLowerName[20:29]: UserTypeOrgOwner, _UserTypeName[29:34]: UserTypeLogin, _UserTypeLowerName[29:34]: UserTypeLogin, + _UserTypeName[34:47]: UserTypeNoPermission, + _UserTypeLowerName[34:47]: UserTypeNoPermission, } var _UserTypeNames = []string{ @@ -48,6 +51,7 @@ var _UserTypeNames = []string{ _UserTypeName[11:20], _UserTypeName[20:29], _UserTypeName[29:34], + _UserTypeName[34:47], } // UserTypeString retrieves an enum value from the enum constants string name. diff --git a/internal/notification/handlers/integration_test/telemetry_pusher_test.go b/internal/notification/handlers/integration_test/telemetry_pusher_test.go index 0779df7b34..9252790263 100644 --- a/internal/notification/handlers/integration_test/telemetry_pusher_test.go +++ b/internal/notification/handlers/integration_test/telemetry_pusher_test.go @@ -89,7 +89,7 @@ func loginToClient(t *testing.T, instance *integration.Instance, clientID, redir }}, }) require.NoError(t, err) - provider, err := instance.CreateRelyingPartyForDomain(iamOwnerCtx, instance.Domain, clientID, redirectURI) + provider, err := instance.CreateRelyingPartyForDomain(iamOwnerCtx, instance.Domain, clientID, redirectURI, instance.Users.Get(integration.UserTypeLogin).Username) require.NoError(t, err) callbackURL, err := url.Parse(callback.GetCallbackUrl()) require.NoError(t, err) diff --git a/internal/query/app.go b/internal/query/app.go index fc0101bf06..adf676bc38 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -60,6 +60,8 @@ type OIDCApp struct { AllowedOrigins database.TextArray[string] SkipNativeAppSuccessPage bool BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI *string } type SAMLApp struct { @@ -180,6 +182,10 @@ var ( name: projection.AppOIDCConfigColumnAppID, table: appOIDCConfigsTable, } + AppOIDCConfigColumnInstanceID = Column{ + name: projection.AppOIDCConfigColumnInstanceID, + table: appOIDCConfigsTable, + } AppOIDCConfigColumnVersion = Column{ name: projection.AppOIDCConfigColumnVersion, table: appOIDCConfigsTable, @@ -248,6 +254,14 @@ var ( name: projection.AppOIDCConfigColumnBackChannelLogoutURI, table: appOIDCConfigsTable, } + AppOIDCConfigColumnLoginVersion = Column{ + name: projection.AppOIDCConfigColumnLoginVersion, + table: appOIDCConfigsTable, + } + AppOIDCConfigColumnLoginBaseURI = Column{ + name: projection.AppOIDCConfigColumnLoginBaseURI, + table: appOIDCConfigsTable, + } ) func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bool, projectID, appID string) (app *App, err error) { @@ -501,6 +515,30 @@ func (q *Queries) SearchClientIDs(ctx context.Context, queries *AppSearchQueries return ids, nil } +func (q *Queries) OIDCClientLoginVersion(ctx context.Context, clientID string) (loginVersion domain.LoginVersion, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareLoginVersionByClientID(ctx, q.client) + eq := sq.Eq{ + AppOIDCConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppOIDCConfigColumnClientID.identifier(): clientID, + } + stmt, args, err := query.Where(eq).ToSql() + if err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInvalidArgument(err, "QUERY-WEh31", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + loginVersion, err = scan(row) + return err + }, stmt, args...) + if err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-W2gsa", "Errors.Internal") + } + return loginVersion, nil +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } @@ -542,6 +580,8 @@ func prepareAppQuery(ctx context.Context, db prepareDatabase, activeOnly bool) ( AppOIDCConfigColumnAdditionalOrigins.identifier(), AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), AppOIDCConfigColumnBackChannelLogoutURI.identifier(), + AppOIDCConfigColumnLoginVersion.identifier(), + AppOIDCConfigColumnLoginBaseURI.identifier(), AppSAMLConfigColumnAppID.identifier(), AppSAMLConfigColumnEntityID.identifier(), @@ -607,6 +647,8 @@ func scanApp(row *sql.Row) (*App, error) { &oidcConfig.additionalOrigins, &oidcConfig.skipNativeAppSuccessPage, &oidcConfig.backChannelLogoutURI, + &oidcConfig.loginVersion, + &oidcConfig.loginBaseURI, &samlConfig.appID, &samlConfig.entityID, @@ -657,6 +699,8 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { AppOIDCConfigColumnAdditionalOrigins.identifier(), AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), AppOIDCConfigColumnBackChannelLogoutURI.identifier(), + AppOIDCConfigColumnLoginVersion.identifier(), + AppOIDCConfigColumnLoginBaseURI.identifier(), ).From(appsTable.identifier()). Join(join(AppOIDCConfigColumnAppID, AppColumnID)). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { @@ -694,6 +738,8 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { &oidcConfig.additionalOrigins, &oidcConfig.skipNativeAppSuccessPage, &oidcConfig.backChannelLogoutURI, + &oidcConfig.loginVersion, + &oidcConfig.loginBaseURI, ) if err != nil { @@ -906,6 +952,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder AppOIDCConfigColumnAdditionalOrigins.identifier(), AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), AppOIDCConfigColumnBackChannelLogoutURI.identifier(), + AppOIDCConfigColumnLoginVersion.identifier(), + AppOIDCConfigColumnLoginBaseURI.identifier(), AppSAMLConfigColumnAppID.identifier(), AppSAMLConfigColumnEntityID.identifier(), @@ -959,6 +1007,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder &oidcConfig.additionalOrigins, &oidcConfig.skipNativeAppSuccessPage, &oidcConfig.backChannelLogoutURI, + &oidcConfig.loginVersion, + &oidcConfig.loginBaseURI, &samlConfig.appID, &samlConfig.entityID, @@ -1013,6 +1063,21 @@ func prepareClientIDsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu } } +func prepareLoginVersionByClientID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) { + return sq.Select( + AppOIDCConfigColumnLoginVersion.identifier(), + ).From(appOIDCConfigsTable.identifier()). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (domain.LoginVersion, error) { + var loginVersion sql.NullInt16 + if err := row.Scan( + &loginVersion, + ); err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-KL2io", "Errors.Internal") + } + return domain.LoginVersion(loginVersion.Int16), nil + } +} + type sqlOIDCConfig struct { appID sql.NullString version sql.NullInt32 @@ -1032,6 +1097,8 @@ type sqlOIDCConfig struct { grantTypes database.NumberArray[domain.OIDCGrantType] skipNativeAppSuccessPage sql.NullBool backChannelLogoutURI sql.NullString + loginVersion sql.NullInt16 + loginBaseURI sql.NullString } func (c sqlOIDCConfig) set(app *App) { @@ -1056,6 +1123,10 @@ func (c sqlOIDCConfig) set(app *App) { GrantTypes: c.grantTypes, SkipNativeAppSuccessPage: c.skipNativeAppSuccessPage.Bool, BackChannelLogoutURI: c.backChannelLogoutURI.String, + LoginVersion: domain.LoginVersion(c.loginVersion.Int16), + } + if c.loginBaseURI.Valid { + app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String } compliance := domain.GetOIDCCompliance(app.OIDCConfig.Version, app.OIDCConfig.AppType, app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, app.OIDCConfig.AuthMethodType, app.OIDCConfig.RedirectURIs) app.OIDCConfig.ComplianceProblems = compliance.Problems diff --git a/internal/query/app_test.go b/internal/query/app_test.go index 990ff943f0..ea9444f665 100644 --- a/internal/query/app_test.go +++ b/internal/query/app_test.go @@ -11,6 +11,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" @@ -49,6 +50,8 @@ var ( ` projections.apps7_oidc_configs.additional_origins,` + ` projections.apps7_oidc_configs.skip_native_app_success_page,` + ` projections.apps7_oidc_configs.back_channel_logout_uri,` + + ` projections.apps7_oidc_configs.login_version,` + + ` projections.apps7_oidc_configs.login_base_uri,` + //saml config ` projections.apps7_saml_configs.app_id,` + ` projections.apps7_saml_configs.entity_id,` + @@ -93,6 +96,8 @@ var ( ` projections.apps7_oidc_configs.additional_origins,` + ` projections.apps7_oidc_configs.skip_native_app_success_page,` + ` projections.apps7_oidc_configs.back_channel_logout_uri,` + + ` projections.apps7_oidc_configs.login_version,` + + ` projections.apps7_oidc_configs.login_base_uri,` + //saml config ` projections.apps7_saml_configs.app_id,` + ` projections.apps7_saml_configs.entity_id,` + @@ -166,6 +171,8 @@ var ( "additional_origins", "skip_native_app_success_page", "back_channel_logout_uri", + "login_version", + "login_base_uri", //saml config "app_id", "entity_id", @@ -238,6 +245,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config nil, nil, @@ -305,6 +314,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config nil, nil, @@ -375,6 +386,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config "app-id", "https://test.com/saml/metadata", @@ -447,6 +460,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -490,6 +505,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -535,6 +552,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -578,6 +597,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -623,6 +644,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -666,6 +689,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -711,6 +736,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -754,6 +781,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -799,6 +828,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -842,6 +873,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -887,6 +920,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, true, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -930,6 +965,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: true, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -975,6 +1012,8 @@ func Test_AppsPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersion2, + "https://login.ch/", // saml config nil, nil, @@ -1013,6 +1052,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config nil, nil, @@ -1051,6 +1092,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config "saml-app-id", "https://test.com/saml/metadata", @@ -1094,6 +1137,8 @@ func Test_AppsPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.ch/"), }, }, { @@ -1228,6 +1273,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config nil, nil, @@ -1289,6 +1336,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config nil, nil, @@ -1355,6 +1404,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1393,6 +1444,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1438,6 +1491,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1476,6 +1531,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1521,6 +1578,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, // saml config "app-id", "https://test.com/saml/metadata", @@ -1588,6 +1647,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1626,6 +1687,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1671,6 +1734,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1709,6 +1774,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1754,6 +1821,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1792,6 +1861,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1837,6 +1908,8 @@ func Test_AppPrepare(t *testing.T) { database.TextArray[string]{"additional.origin"}, false, "back.channel.logout.ch", + domain.LoginVersionUnspecified, + nil, // saml config nil, nil, @@ -1875,6 +1948,8 @@ func Test_AppPrepare(t *testing.T) { AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, SkipNativeAppSuccessPage: false, BackChannelLogoutURI: "back.channel.logout.ch", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, diff --git a/internal/query/auth_request.go b/internal/query/auth_request.go index c0554778ab..20ac0f5abd 100644 --- a/internal/query/auth_request.go +++ b/internal/query/auth_request.go @@ -34,9 +34,9 @@ type AuthRequest struct { HintUserID *string } -func (a *AuthRequest) checkLoginClient(ctx context.Context) error { +func (a *AuthRequest) checkLoginClient(ctx context.Context, permissionCheck domain.PermissionCheck) error { if uid := authz.GetCtxData(ctx).UserID; uid != a.LoginClient { - return zerrors.ThrowPermissionDenied(nil, "OIDCv2-aL0ag", "Errors.AuthRequest.WrongLoginClient") + return permissionCheck(ctx, domain.PermissionSessionRead, authz.GetInstance(ctx).InstanceID(), "") } return nil } @@ -89,7 +89,7 @@ func (q *Queries) AuthRequestByID(ctx context.Context, shouldTriggerBulk bool, i dst.UiLocales = locales if checkLoginClient { - if err = dst.checkLoginClient(ctx); err != nil { + if err = dst.checkLoginClient(ctx, q.checkPermission); err != nil { return nil, err } } diff --git a/internal/query/auth_request_test.go b/internal/query/auth_request_test.go index 9ada3d13f3..479282f9f7 100644 --- a/internal/query/auth_request_test.go +++ b/internal/query/auth_request_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" _ "embed" @@ -45,11 +46,12 @@ func TestQueries_AuthRequestByID(t *testing.T) { checkLoginClient bool } tests := []struct { - name string - args args - expect sqlExpectation - want *AuthRequest - wantErr error + name string + args args + expect sqlExpectation + permissionCheck domain.PermissionCheck + want *AuthRequest + wantErr error }{ { name: "success, all values", @@ -138,7 +140,7 @@ func TestQueries_AuthRequestByID(t *testing.T) { wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ou8ue", "Errors.Internal"), }, { - name: "wrong login client", + name: "wrong login client / not permitted", args: args{ shouldTriggerBulk: false, id: "123", @@ -157,7 +159,47 @@ func TestQueries_AuthRequestByID(t *testing.T) { nil, nil, }, "123", "instanceID"), - wantErr: zerrors.ThrowPermissionDeniedf(nil, "OIDCv2-aL0ag", "Errors.AuthRequest.WrongLoginClient"), + permissionCheck: func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return zerrors.ThrowPermissionDenied(nil, "id", "not permitted") + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "id", "not permitted"), + }, + { + name: "other login client / permitted", + args: args{ + shouldTriggerBulk: false, + id: "123", + checkLoginClient: true, + }, + expect: mockQuery(expQuery, cols, []driver.Value{ + "id", + testNow, + "otherLoginClient", + "clientID", + database.TextArray[string]{"a", "b", "c"}, + "example.com", + database.NumberArray[domain.Prompt]{domain.PromptLogin, domain.PromptConsent}, + database.TextArray[string]{"en", "fi"}, + nil, + nil, + nil, + }, "123", "instanceID"), + permissionCheck: func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return nil + }, + want: &AuthRequest{ + ID: "id", + CreationDate: testNow, + LoginClient: "otherLoginClient", + ClientID: "clientID", + Scope: []string{"a", "b", "c"}, + RedirectURI: "example.com", + Prompt: []domain.Prompt{domain.PromptLogin, domain.PromptConsent}, + UiLocales: []string{"en", "fi"}, + LoginHint: nil, + MaxAge: nil, + HintUserID: nil, + }, }, } for _, tt := range tests { @@ -168,6 +210,7 @@ func TestQueries_AuthRequestByID(t *testing.T) { DB: db, Database: &prepareDB{}, }, + checkPermission: tt.permissionCheck, } ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index fed6d851df..4f06577a6d 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -21,6 +21,7 @@ type InstanceFeatures struct { OIDCSingleV1SessionTermination FeatureSource[bool] DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] } 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 5192f7dfc5..c7f273a24a 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -42,6 +42,8 @@ func (m *InstanceFeaturesReadModel) Reduce() (err error) { ) case *feature_v2.SetEvent[bool]: err = reduceInstanceFeatureSet(m.instance, e) + case *feature_v2.SetEvent[*feature.LoginV2]: + err = reduceInstanceFeatureSet(m.instance, e) case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]: err = reduceInstanceFeatureSet(m.instance, e) } @@ -72,6 +74,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, feature_v2.InstanceEnableBackChannelLogout, + feature_v2.InstanceLoginVersion, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -98,6 +101,7 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { m.instance.OIDCSingleV1SessionTermination = m.system.OIDCSingleV1SessionTermination m.instance.DisableUserTokenEvent = m.system.DisableUserTokenEvent m.instance.EnableBackChannelLogout = m.system.EnableBackChannelLogout + m.instance.LoginV2 = m.system.LoginV2 return true } @@ -133,6 +137,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.DisableUserTokenEvent.set(level, event.Value) case feature.KeyEnableBackChannelLogout: features.EnableBackChannelLogout.set(level, event.Value) + case feature.KeyLoginV2: + features.LoginV2.set(level, event.Value) } return nil } diff --git a/internal/query/oidc_client.go b/internal/query/oidc_client.go index 8790c9737a..7da4e0476d 100644 --- a/internal/query/oidc_client.go +++ b/internal/query/oidc_client.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" _ "embed" + "encoding/json" "errors" + "net/url" "time" "github.com/zitadel/zitadel/internal/api/authz" @@ -39,10 +41,32 @@ type OIDCClient struct { PublicKeys map[string][]byte `json:"public_keys,omitempty"` ProjectID string `json:"project_id,omitempty"` ProjectRoleAssertion bool `json:"project_role_assertion,omitempty"` + LoginVersion domain.LoginVersion `json:"login_version,omitempty"` + LoginBaseURI *URL `json:"login_base_uri,omitempty"` ProjectRoleKeys []string `json:"project_role_keys,omitempty"` Settings *OIDCSettings `json:"settings,omitempty"` } +type URL url.URL + +func (c *URL) URL() *url.URL { + return (*url.URL)(c) +} + +func (c *URL) UnmarshalJSON(src []byte) error { + var s string + err := json.Unmarshal(src, &s) + if err != nil { + return err + } + u, err := url.Parse(s) + if err != nil { + return err + } + *c = URL(*u) + return nil +} + //go:embed oidc_client_by_id.sql var oidcClientQuery string @@ -59,7 +83,13 @@ func (q *Queries) ActiveOIDCClientByID(ctx context.Context, clientID string, get if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-ieR7R", "Errors.Internal") } - if authz.GetInstance(ctx).ConsoleClientID() == clientID { + instance := authz.GetInstance(ctx) + loginV2 := instance.Features().LoginV2 + if loginV2.Required { + client.LoginVersion = domain.LoginVersion2 + client.LoginBaseURI = (*URL)(loginV2.BaseURI) + } + if instance.ConsoleClientID() == clientID { client.RedirectURIs = append(client.RedirectURIs, http_util.DomainContext(ctx).Origin()+path.RedirectPath) client.PostLogoutRedirectURIs = append(client.PostLogoutRedirectURIs, http_util.DomainContext(ctx).Origin()+path.PostLogoutPath) } diff --git a/internal/query/oidc_client_by_id.sql b/internal/query/oidc_client_by_id.sql index 201705c6bf..7d783bbb5e 100644 --- a/internal/query/oidc_client_by_id.sql +++ b/internal/query/oidc_client_by_id.sql @@ -3,7 +3,8 @@ with client as ( c.app_id, a.state, c.client_id, c.back_channel_logout_uri, c.client_secret, c.redirect_uris, c.response_types, c.grant_types, c.application_type, c.auth_method_type, c.post_logout_redirect_uris, c.is_dev_mode, c.access_token_type, c.access_token_role_assertion, c.id_token_role_assertion, - c.id_token_userinfo_assertion, c.clock_skew, c.additional_origins, a.project_id, p.project_role_assertion + c.id_token_userinfo_assertion, c.clock_skew, c.additional_origins, a.project_id, p.project_role_assertion, + c.login_version, c.login_base_uri from projections.apps7_oidc_configs c join projections.apps7 a on a.id = c.app_id and a.instance_id = c.instance_id and a.state = 1 join projections.projects4 p on p.id = a.project_id and p.instance_id = a.instance_id and p.state = 1 diff --git a/internal/query/projection/app.go b/internal/query/projection/app.go index 7b810c3a97..14053cc8dc 100644 --- a/internal/query/projection/app.go +++ b/internal/query/projection/app.go @@ -59,6 +59,8 @@ const ( AppOIDCConfigColumnAdditionalOrigins = "additional_origins" AppOIDCConfigColumnSkipNativeAppSuccessPage = "skip_native_app_success_page" AppOIDCConfigColumnBackChannelLogoutURI = "back_channel_logout_uri" + AppOIDCConfigColumnLoginVersion = "login_version" + AppOIDCConfigColumnLoginBaseURI = "login_base_uri" appSAMLTableSuffix = "saml_configs" AppSAMLConfigColumnAppID = "app_id" @@ -127,6 +129,8 @@ func (*appProjection) Init() *old_handler.Check { handler.NewColumn(AppOIDCConfigColumnAdditionalOrigins, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(AppOIDCConfigColumnSkipNativeAppSuccessPage, handler.ColumnTypeBool, handler.Default(false)), handler.NewColumn(AppOIDCConfigColumnBackChannelLogoutURI, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnLoginVersion, handler.ColumnTypeEnum, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnLoginBaseURI, handler.ColumnTypeText, handler.Nullable()), }, handler.NewPrimaryKey(AppOIDCConfigColumnInstanceID, AppOIDCConfigColumnAppID), appOIDCTableSuffix, @@ -503,6 +507,8 @@ func (p *appProjection) reduceOIDCConfigAdded(event eventstore.Event) (*handler. handler.NewCol(AppOIDCConfigColumnAdditionalOrigins, database.TextArray[string](e.AdditionalOrigins)), handler.NewCol(AppOIDCConfigColumnSkipNativeAppSuccessPage, e.SkipNativeAppSuccessPage), handler.NewCol(AppOIDCConfigColumnBackChannelLogoutURI, e.BackChannelLogoutURI), + handler.NewCol(AppOIDCConfigColumnLoginVersion, e.LoginVersion), + handler.NewCol(AppOIDCConfigColumnLoginBaseURI, e.LoginBaseURI), }, handler.WithTableSuffix(appOIDCTableSuffix), ), @@ -525,7 +531,7 @@ func (p *appProjection) reduceOIDCConfigChanged(event eventstore.Event) (*handle return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-GNHU1", "reduce.wrong.event.type %s", project.OIDCConfigChangedType) } - cols := make([]handler.Column, 0, 16) + cols := make([]handler.Column, 0, 18) if e.Version != nil { cols = append(cols, handler.NewCol(AppOIDCConfigColumnVersion, *e.Version)) } @@ -574,6 +580,12 @@ func (p *appProjection) reduceOIDCConfigChanged(event eventstore.Event) (*handle if e.BackChannelLogoutURI != nil { cols = append(cols, handler.NewCol(AppOIDCConfigColumnBackChannelLogoutURI, *e.BackChannelLogoutURI)) } + if e.LoginVersion != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnLoginVersion, *e.LoginVersion)) + } + if e.LoginBaseURI != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnLoginBaseURI, *e.LoginBaseURI)) + } if len(cols) == 0 { return handler.NewNoOpStatement(e), nil diff --git a/internal/query/projection/app_test.go b/internal/query/projection/app_test.go index 74e4e39847..8177fe7d1e 100644 --- a/internal/query/projection/app_test.go +++ b/internal/query/projection/app_test.go @@ -559,7 +559,9 @@ func TestAppProjection_reduces(t *testing.T) { "clockSkew": 1000, "additionalOrigins": ["origin.one.ch", "origin.two.ch"], "skipNativeAppSuccessPage": true, - "backChannelLogoutURI": "back.channel.one.ch" + "backChannelLogoutURI": "back.channel.one.ch", + "loginVersion": 2, + "loginBaseURI": "https://login.ch/" }`), ), project.OIDCConfigAddedEventMapper), }, @@ -570,7 +572,7 @@ func TestAppProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", + expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)", expectedArgs: []interface{}{ "app-id", "instance-id", @@ -592,6 +594,8 @@ func TestAppProjection_reduces(t *testing.T) { database.TextArray[string]{"origin.one.ch", "origin.two.ch"}, true, "back.channel.one.ch", + domain.LoginVersion2, + "https://login.ch/", }, }, { @@ -633,7 +637,9 @@ func TestAppProjection_reduces(t *testing.T) { "clockSkew": 1000, "additionalOrigins": ["origin.one.ch", "origin.two.ch"], "skipNativeAppSuccessPage": true, - "backChannelLogoutURI": "back.channel.one.ch" + "backChannelLogoutURI": "back.channel.one.ch", + "loginVersion": 2, + "loginBaseURI": "https://login.ch/" }`), ), project.OIDCConfigAddedEventMapper), }, @@ -644,7 +650,7 @@ func TestAppProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", + expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)", expectedArgs: []interface{}{ "app-id", "instance-id", @@ -666,6 +672,8 @@ func TestAppProjection_reduces(t *testing.T) { database.TextArray[string]{"origin.one.ch", "origin.two.ch"}, true, "back.channel.one.ch", + domain.LoginVersion2, + "https://login.ch/", }, }, { @@ -705,7 +713,8 @@ func TestAppProjection_reduces(t *testing.T) { "clockSkew": 1000, "additionalOrigins": ["origin.one.ch", "origin.two.ch"], "skipNativeAppSuccessPage": true, - "backChannelLogoutURI": "back.channel.one.ch" + "backChannelLogoutURI": "back.channel.one.ch", + "loginVersion": 2 }`), ), project.OIDCConfigChangedEventMapper), }, @@ -716,7 +725,7 @@ func TestAppProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.apps7_oidc_configs SET (version, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri) = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) WHERE (app_id = $17) AND (instance_id = $18)", + expectedStmt: "UPDATE projections.apps7_oidc_configs SET (version, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version) = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) WHERE (app_id = $18) AND (instance_id = $19)", expectedArgs: []interface{}{ domain.OIDCVersionV1, database.TextArray[string]{"redirect.one.ch", "redirect.two.ch"}, @@ -734,6 +743,7 @@ func TestAppProjection_reduces(t *testing.T) { database.TextArray[string]{"origin.one.ch", "origin.two.ch"}, true, "back.channel.one.ch", + domain.LoginVersion2, "app-id", "instance-id", }, diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 45e360c6db..2479203d09 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -108,6 +108,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceEnableBackChannelLogout, Reduce: reduceInstanceSetFeature[bool], }, + { + Event: feature_v2.InstanceLoginVersion, + Reduce: reduceInstanceSetFeature[*feature.LoginV2], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 65e72fa394..410234c27c 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -88,6 +88,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemEnableBackChannelLogout, Reduce: reduceSystemSetFeature[bool], }, + { + Event: feature_v2.SystemLoginVersion, + Reduce: reduceSystemSetFeature[*feature.LoginV2], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index cae68d5fbb..e696f6bf6f 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -30,6 +30,7 @@ type SystemFeatures struct { OIDCSingleV1SessionTermination FeatureSource[bool] DisableUserTokenEvent FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] } 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 119aaa4ea1..f486e1ba4a 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -32,6 +32,11 @@ func (m *SystemFeaturesReadModel) Reduce() error { if err != nil { return err } + case *feature_v2.SetEvent[*feature.LoginV2]: + err := reduceSystemFeatureSet(m.system, e) + if err != nil { + return err + } case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]: err := reduceSystemFeatureSet(m.system, e) if err != nil { @@ -60,6 +65,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemOIDCSingleV1SessionTerminationEventType, feature_v2.SystemDisableUserTokenEvent, feature_v2.SystemEnableBackChannelLogout, + feature_v2.SystemLoginVersion, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -97,6 +103,8 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S features.DisableUserTokenEvent.set(level, event.Value) case feature.KeyEnableBackChannelLogout: features.EnableBackChannelLogout.set(level, event.Value) + case feature.KeyLoginV2: + features.LoginV2.set(level, event.Value) } return nil } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 9288f0a675..d4d2617aea 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -17,6 +17,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) @@ -31,4 +32,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 3fc180a814..0255203bdd 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -22,6 +22,7 @@ var ( SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) + SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) @@ -36,6 +37,7 @@ var ( InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) + InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) ) const ( diff --git a/internal/repository/project/oidc_config.go b/internal/repository/project/oidc_config.go index 498f3233e2..09a5601cfc 100644 --- a/internal/repository/project/oidc_config.go +++ b/internal/repository/project/oidc_config.go @@ -44,6 +44,8 @@ type OIDCConfigAddedEvent struct { AdditionalOrigins []string `json:"additionalOrigins,omitempty"` SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage,omitempty"` BackChannelLogoutURI string `json:"backChannelLogoutURI,omitempty"` + LoginVersion domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI string `json:"loginBaseURI,omitempty"` } func (e *OIDCConfigAddedEvent) Payload() interface{} { @@ -76,6 +78,8 @@ func NewOIDCConfigAddedEvent( additionalOrigins []string, skipNativeAppSuccessPage bool, backChannelLogoutURI string, + loginVersion domain.LoginVersion, + loginBaseURI string, ) *OIDCConfigAddedEvent { return &OIDCConfigAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -102,6 +106,8 @@ func NewOIDCConfigAddedEvent( AdditionalOrigins: additionalOrigins, SkipNativeAppSuccessPage: skipNativeAppSuccessPage, BackChannelLogoutURI: backChannelLogoutURI, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, } } @@ -190,7 +196,13 @@ func (e *OIDCConfigAddedEvent) Validate(cmd eventstore.Command) bool { if e.SkipNativeAppSuccessPage != c.SkipNativeAppSuccessPage { return false } - return e.BackChannelLogoutURI == c.BackChannelLogoutURI + if e.BackChannelLogoutURI != c.BackChannelLogoutURI { + return false + } + if e.LoginVersion != c.LoginVersion { + return false + } + return e.LoginBaseURI == c.LoginBaseURI } func OIDCConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { @@ -226,6 +238,8 @@ type OIDCConfigChangedEvent struct { AdditionalOrigins *[]string `json:"additionalOrigins,omitempty"` SkipNativeAppSuccessPage *bool `json:"skipNativeAppSuccessPage,omitempty"` BackChannelLogoutURI *string `json:"backChannelLogoutURI,omitempty"` + LoginVersion *domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI *string `json:"loginBaseURI,omitempty"` } func (e *OIDCConfigChangedEvent) Payload() interface{} { @@ -358,6 +372,18 @@ func ChangeBackChannelLogoutURI(backChannelLogoutURI string) func(event *OIDCCon } } +func ChangeLoginVersion(loginVersion domain.LoginVersion) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + e.LoginVersion = &loginVersion + } +} + +func ChangeLoginBaseURI(loginBaseURI string) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + e.LoginBaseURI = &loginBaseURI + } +} + func OIDCConfigChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { e := &OIDCConfigChangedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(event), diff --git a/proto/zitadel/app.proto b/proto/zitadel/app.proto index d18168f2b9..999e71cabf 100644 --- a/proto/zitadel/app.proto +++ b/proto/zitadel/app.proto @@ -174,6 +174,11 @@ message OIDCConfig { description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; } ]; + LoginVersion login_version = 22 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } enum OIDCResponseType { @@ -239,3 +244,17 @@ message APIConfig { } ]; } + +message LoginVersion { + oneof version { + LoginV1 login_v1 = 1; + LoginV2 login_v2 = 2; + } +} + +message LoginV1 {} + +message LoginV2 { + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 1; +} \ No newline at end of file diff --git a/proto/zitadel/feature/v2/feature.proto b/proto/zitadel/feature/v2/feature.proto index 3249248735..1748706fae 100644 --- a/proto/zitadel/feature/v2/feature.proto +++ b/proto/zitadel/feature/v2/feature.proto @@ -49,6 +49,16 @@ message ImprovedPerformanceFeatureFlag { ]; } +message LoginV2FeatureFlag { + bool required = 1; + optional string base_uri = 2; + Source source = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The source where the setting of the feature was defined. The source may be the resource itself or a resource owner through inheritance."; + } + ]; +} + enum ImprovedPerformance { IMPROVED_PERFORMANCE_UNSPECIFIED = 0; // Uses the eventstore to query the org by id @@ -65,4 +75,11 @@ enum ImprovedPerformance { // users are checked against verified domains // from other organizations. IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED = 5; +} + +message LoginV2 { + // Require that all users must use the new login UI. If enabled, all users will be redirected to the login V2 regardless of the application's preference. + bool required = 1; + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 2; } \ No newline at end of file diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 6717e397ea..385ce5a4d0 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -93,6 +93,12 @@ message SetInstanceFeaturesRequest{ description: "If the flag is enabled, you'll be able to use the OIDC Back-Channel Logout to be notified in your application about terminated user sessions."; } ]; + + optional LoginV2 login_v2 = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the login UI for all users and applications regardless of their preference."; + } + ]; } message SetInstanceFeaturesResponse { @@ -199,4 +205,11 @@ message GetInstanceFeaturesResponse { description: "If the flag is enabled, you'll be able to use the OIDC Back-Channel Logout to be notified in your application about terminated user sessions."; } ]; + + LoginV2FeatureFlag login_v2 = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; + } + ]; } diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index cd8d7cc201..cac8fe774f 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -82,6 +82,12 @@ message SetSystemFeaturesRequest{ description: "If the flag is enabled, you'll be able to use the OIDC Back-Channel Logout to be notified in your application about terminated user sessions."; } ]; + + optional LoginV2 login_v2 = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the login UI for all users and applications regardless of their preference."; + } + ]; } message SetSystemFeaturesResponse { @@ -167,4 +173,11 @@ message GetSystemFeaturesResponse { description: "If the flag is enabled, you'll be able to use the OIDC Back-Channel Logout to be notified in your application about terminated user sessions."; } ]; + + LoginV2FeatureFlag login_v2 = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "If the flag is set, all users will be redirected to the login V2 regardless of the application's preference."; + } + ]; } diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 0ff2ad7b75..94de141a65 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -9808,6 +9808,11 @@ message AddOIDCAppRequest { description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; } ]; + zitadel.app.v1.LoginVersion login_version = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } message AddOIDCAppResponse { @@ -9989,6 +9994,11 @@ message UpdateOIDCAppConfigRequest { description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; } ]; + zitadel.app.v1.LoginVersion login_version = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } message UpdateOIDCAppConfigResponse {