diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 181ec838fe..1ab5e6bafd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: with: node_version: "20" buf_version: "latest" - go_version: "1.22" + go_version: "1.23" console: uses: ./.github/workflows/console.yml @@ -43,7 +43,7 @@ jobs: needs: [core, console, version] uses: ./.github/workflows/compile.yml with: - go_version: "1.22" + go_version: "1.23" core_cache_key: ${{ needs.core.outputs.cache_key }} console_cache_key: ${{ needs.console.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} @@ -54,7 +54,7 @@ jobs: needs: core uses: ./.github/workflows/core-unit-test.yml with: - go_version: "1.22" + go_version: "1.23" core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} secrets: @@ -64,7 +64,7 @@ jobs: needs: core uses: ./.github/workflows/core-integration-test.yml with: - go_version: "1.22" + go_version: "1.23" core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} secrets: @@ -74,7 +74,7 @@ jobs: needs: [core, console] uses: ./.github/workflows/lint.yml with: - go_version: "1.22" + go_version: "1.23" node_version: "18" buf_version: "latest" go_lint_version: "v1.62.2" diff --git a/ADOPTERS.md b/ADOPTERS.md index bc7e8d69f5..da37b9ffb0 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -12,11 +12,16 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | ----------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | | Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | | Rawkode Academy | [@RawkodeAcademy](https://github.com/RawkodeAcademy) | Rawkode Academy Platform & Zulip use Zitadel for all user and M2M authentication | +| XPeditionist | [@XPeditionistTravel](https://github.com/XPeditionistTravel) | An innovative all-in-one travel solution use Zitadel as complete auth solution. | | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | +| Micromate | [@sschoeb](https://github.com/sschoeb) | Using Zitadel for authentication and authorization for learners and managers in our digital learning assistant as well as in the Micromate manage platform | +| Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | +|hirschengraben | [hirschengraben.io](hirschengraben.io) | Using Zitadel as IDP for a multitenant B2B dispatch app for bike messengers | | OpenAIP | [@openaip](https://github.com/openAIP) | Using Zitadel Cloud for everything related to user authentication. | | Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | +| roclub GmbH | [@holgerson97](https://github.com/holgerson97) | Roclub builds a telehealth application to enable remote MRI/CT examinations. | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 08ee1d9162..e993657123 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -576,6 +576,7 @@ OIDC: DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH @@ -1302,6 +1303,7 @@ InternalAuthZ: - "project.grant.member.delete" - "events.read" - "milestones.read" + - "session.read" - "session.delete" - "action.target.read" - "action.target.write" @@ -1494,6 +1496,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/cmd/start/start.go b/cmd/start/start.go index 4ef9cf9d6a..154c683481 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -49,6 +49,7 @@ import ( user_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/user/v3alpha" userschema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/userschema/v3alpha" "github.com/zitadel/zitadel/internal/api/grpc/resources/webkey/v3" + saml_v2 "github.com/zitadel/zitadel/internal/api/grpc/saml/v2" session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta" settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" @@ -532,7 +533,7 @@ func startAPIs( store, consolePath, oidcServer.AuthCallbackURL(), - provider.AuthCallbackURL(samlProvider), + samlProvider.AuthCallbackURL(), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcServer.IssuerFromRequest).Handler, @@ -558,6 +559,10 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { return nil, err } + // After SAML provider so that the callback endpoint can be used + if err := apis.RegisterService(ctx, saml_v2.CreateServer(commands, queries, samlProvider, config.ExternalSecure)); err != nil { + return nil, err + } // handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes apis.RouteGRPC() return apis, nil 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/docs/docs/apis/observability/health.md b/docs/docs/apis/observability/health.md index 30a63f7999..133c9603ef 100644 --- a/docs/docs/apis/observability/health.md +++ b/docs/docs/apis/observability/health.md @@ -1,6 +1,6 @@ --- -title: ZITADEL Ready and Health Enpoints -sidebar_label: Ready and Health Enpoints +title: ZITADEL Ready and Health Endpoints +sidebar_label: Ready and Health Endpoints --- ZITADEL exposes a `Ready`- and `Healthy` endpoint to allow external systems like load balancers, orchestration systems, uptime probes and others to check the status. diff --git a/docs/docs/guides/integrate/zitadel-apis/event-api.md b/docs/docs/guides/integrate/zitadel-apis/event-api.md index c79cb27e8e..1c9ec82373 100644 --- a/docs/docs/guides/integrate/zitadel-apis/event-api.md +++ b/docs/docs/guides/integrate/zitadel-apis/event-api.md @@ -142,12 +142,27 @@ curl --request POST \ }' ``` - +```bash +curl --request POST \ + --url $CUSTOM-DOMAIN/admin/v1/events/_search \ + --header "Authorization: Bearer $TOKEN" \ + --header 'Content-Type: application/json' \ + --data '{ + "asc": true, + "limit": 1000, + "eventTypes": [ + "saml_session.added", + "saml_session.saml_response.added" + ], + "aggregateTypes": [ + "saml_session" + ] +}' +``` ## Example: Get failed login attempt diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 2b9266c798..94d8f438dc 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: traefik: diff --git a/docs/docs/self-hosting/manage/configure/docker-compose.yaml b/docs/docs/self-hosting/manage/configure/docker-compose.yaml index 8e5c9fbc05..abd1818a7b 100644 --- a/docs/docs/self-hosting/manage/configure/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/configure/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.8" - services: zitadel: restart: "always" diff --git a/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx b/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx index debca2f4f5..1cacf076e5 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx +++ b/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx @@ -24,7 +24,7 @@ export const Description = ({mode, link}) => { } export const Commands = ({mode, name, lower, configfilename}) => { - let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt\n\n'; + let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt 2>/dev/null\n\n'; let connPort = "443" let connInsecureFlag = "--insecure " let connScheme = "https" @@ -42,16 +42,16 @@ export const Commands = ({mode, name, lower, configfilename}) => { {'# Download the configuration files.'}{'\n'} {'export ZITADEL_CONFIG_FILES=https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/manage/reverseproxy\n'} - {`wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml`}{'\n'} - {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml'}{'\n'} - {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{'\n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml --quiet \n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml --quiet \n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{' --quiet \n'} {'\n'} {genCert} {'# Run the database, ZITADEL and '}{name}{'.'}{'\n'} - {'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach proxy-'}{mode}{'-tls'}{'\n'} + {'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach --wait db zitadel-init zitadel-'}{mode}{'-tls proxy-'}{mode}{'-tls'}{'\n'} {'\n'} {'# Test that gRPC and HTTP APIs work. Empty brackets like {} means success.\n'} - {'sleep 3\n'} + {'# Make sure you have the grpcurl cli installed on your machine https://github.com/fullstorydev/grpcurl?tab=readme-ov-file#installation\n'} {'grpcurl '}{connInsecureFlag}{grpcPlainTextFlag}{'127.0.0.1.sslip.io:'}{connPort}{' zitadel.admin.v1.AdminService/Healthz\n'} {'curl '}{connInsecureFlag}{connScheme}{'://127.0.0.1.sslip.io:'}{connPort}{'/admin/v1/healthz\n'} diff --git a/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml index aa4b7f6869..c5fad6ab7b 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml index d7d929fa44..989b620fef 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel-disabled-tls: @@ -17,7 +15,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: @@ -43,16 +41,16 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: - 'zitadel' depends_on: - zitadel-init: - condition: 'service_completed_successfully' db: condition: 'service_healthy' + zitadel-init: + condition: 'service_completed_successfully' zitadel-enabled-tls: extends: @@ -71,7 +69,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable volumes: @@ -109,7 +107,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: @@ -125,10 +123,9 @@ services: restart: 'always' image: postgres:16-alpine environment: - PGUSER: root POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"] + test: ["CMD-SHELL", "pg_isready"] interval: 5s timeout: 60s retries: 10 diff --git a/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml index 72e06b976f..8757758dc3 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml index 21b3361979..524d50fc30 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml index aee5cf891d..a2dfab075b 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/e2e/config/host.docker.internal/docker-compose.yaml b/e2e/config/host.docker.internal/docker-compose.yaml index 8c9d755b02..80ea33b364 100644 --- a/e2e/config/host.docker.internal/docker-compose.yaml +++ b/e2e/config/host.docker.internal/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: diff --git a/e2e/config/localhost/docker-compose.yaml b/e2e/config/localhost/docker-compose.yaml index a14c0dd603..040cbc81c0 100644 --- a/e2e/config/localhost/docker-compose.yaml +++ b/e2e/config/localhost/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel: user: '$UID' diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.yaml index ffcfb65c4d..f03b1fcc46 100644 --- a/e2e/docker-compose.yaml +++ b/e2e/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel: extends: diff --git a/e2e/yarn.lock b/e2e/yarn.lock index 90befd4063..17bb06236d 100644 --- a/e2e/yarn.lock +++ b/e2e/yarn.lock @@ -346,9 +346,9 @@ core-util-is@1.0.2: integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== cross-spawn@^7.0.0: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" diff --git a/go.mod b/go.mod index cf5cbf919d..aa9fbb64a2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/zitadel/zitadel -go 1.22.2 +go 1.23.4 require ( cloud.google.com/go/profiler v0.4.1 @@ -67,7 +67,7 @@ require ( github.com/zitadel/logging v0.6.1 github.com/zitadel/oidc/v3 v3.32.0 github.com/zitadel/passwap v0.6.0 - github.com/zitadel/saml v0.2.0 + github.com/zitadel/saml v0.3.3 github.com/zitadel/schema v1.3.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 @@ -80,12 +80,12 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.29.0 go.opentelemetry.io/otel/trace v1.29.0 go.uber.org/mock v0.5.0 - golang.org/x/crypto v0.29.0 + golang.org/x/crypto v0.31.0 golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 - golang.org/x/net v0.31.0 + golang.org/x/net v0.33.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.9.0 - golang.org/x/text v0.20.0 + golang.org/x/sync v0.10.0 + golang.org/x/text v0.21.0 google.golang.org/api v0.187.0 google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd google.golang.org/grpc v1.65.0 @@ -107,7 +107,7 @@ require ( github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-webauthn/x v0.1.9 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -205,7 +205,7 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/sys v0.27.0 + golang.org/x/sys v0.28.0 gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index bacef90c1a..82ece80ab2 100644 --- a/go.sum +++ b/go.sum @@ -269,8 +269,8 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= @@ -743,8 +743,8 @@ github.com/zitadel/oidc/v3 v3.32.0 h1:Mw0EPZRC6h+OXAuT0Uk2BZIjJQNHLqUpaJCm6c3IBy github.com/zitadel/oidc/v3 v3.32.0/go.mod h1:DyE/XClysRK/ozFaZSqlYamKVnTh4l6Ln25ihSNI03w= github.com/zitadel/passwap v0.6.0 h1:m9F3epFC0VkBXu25rihSLGyHvWiNlCzU5kk8RoI+SXQ= github.com/zitadel/passwap v0.6.0/go.mod h1:kqAiJ4I4eZvm3Y6oAk6hlEqlZZOkjMHraGXF90GG7LI= -github.com/zitadel/saml v0.2.0 h1:vv7r+Xz43eAPCb+fImMaospD+TWRZQDkb78AbSJRcL4= -github.com/zitadel/saml v0.2.0/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g= +github.com/zitadel/saml v0.3.3 h1:Cn+1ZNeWlzMM7wxUxJfgNjXSW+Yu6UD4zWbpySA5GQM= +github.com/zitadel/saml v0.3.3/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -809,8 +809,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= @@ -873,8 +873,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -890,8 +890,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -934,8 +934,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -950,8 +950,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 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..93d4417cba 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) { @@ -34,7 +35,7 @@ func TestServer_ListIAMMemberRoles(t *testing.T) { } func TestServer_ListIAMMembers(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, @@ -115,7 +116,7 @@ func TestServer_ListIAMMembers(t *testing.T) { } func TestServer_AddIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context req *admin_pb.AddIAMMemberRequest @@ -189,7 +190,7 @@ func TestServer_AddIAMMember(t *testing.T) { } func TestServer_UpdateIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, @@ -270,7 +271,7 @@ func TestServer_UpdateIAMMember(t *testing.T) { } func TestServer_RemoveIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, 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/idp/v2/query.go b/internal/api/grpc/idp/v2/query.go index 476ac3dcdd..0b66175c14 100644 --- a/internal/api/grpc/idp/v2/query.go +++ b/internal/api/grpc/idp/v2/query.go @@ -96,7 +96,7 @@ func configToPb(config *query.IDPTemplate) *idp_pb.IDPConfig { IsCreationAllowed: config.IsCreationAllowed, IsAutoCreation: config.IsAutoCreation, IsAutoUpdate: config.IsAutoUpdate, - AutoLinking: autoLinkingOptionToPb(config.AutoLinking), + AutoLinking: AutoLinkingOptionToPb(config.AutoLinking), }, } if config.OAuthIDPTemplate != nil { @@ -150,7 +150,7 @@ func configToPb(config *query.IDPTemplate) *idp_pb.IDPConfig { return idpConfig } -func autoLinkingOptionToPb(linking domain.AutoLinkingOption) idp_pb.AutoLinkingOption { +func AutoLinkingOptionToPb(linking domain.AutoLinkingOption) idp_pb.AutoLinkingOption { switch linking { case domain.AutoLinkingOptionUnspecified: return idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED diff --git a/internal/api/grpc/management/integration_test/org_test.go b/internal/api/grpc/management/integration_test/org_test.go index 8288ceb9e9..46693f14d7 100644 --- a/internal/api/grpc/management/integration_test/org_test.go +++ b/internal/api/grpc/management/integration_test/org_test.go @@ -39,7 +39,7 @@ func TestServer_ListOrgMemberRoles(t *testing.T) { } func TestServer_ListOrgMembers(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles[1:], @@ -120,7 +120,7 @@ func TestServer_ListOrgMembers(t *testing.T) { } func TestServer_AddOrgMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context req *mgmt_pb.AddOrgMemberRequest @@ -194,7 +194,7 @@ func TestServer_AddOrgMember(t *testing.T) { } func TestServer_UpdateOrgMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: []string{"ORG_OWNER"}, @@ -275,7 +275,7 @@ func TestServer_UpdateOrgMember(t *testing.T) { } func TestServer_RemoveIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: []string{"ORG_OWNER"}, 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/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index fe8aba5d6e..8cf0d8b1fa 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user_pb "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { @@ -70,3 +71,105 @@ func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { return -1 } } + +func AuthMethodsToPb(mfas *query.AuthMethods) []*user_pb.AuthFactor { + factors := make([]*user_pb.AuthFactor, len(mfas.AuthMethods)) + for i, mfa := range mfas.AuthMethods { + factors[i] = AuthMethodToPb(mfa) + } + return factors +} + +func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { + factor := &user_pb.AuthFactor{ + State: MFAStateToPb(mfa.State), + } + switch mfa.Type { + case domain.UserAuthMethodTypeTOTP: + factor.Type = &user_pb.AuthFactor_Otp{ + Otp: &user_pb.AuthFactorOTP{}, + } + case domain.UserAuthMethodTypeU2F: + factor.Type = &user_pb.AuthFactor_U2F{ + U2F: &user_pb.AuthFactorU2F{ + Id: mfa.TokenID, + Name: mfa.Name, + }, + } + case domain.UserAuthMethodTypeOTPSMS: + factor.Type = &user_pb.AuthFactor_OtpSms{ + OtpSms: &user_pb.AuthFactorOTPSMS{}, + } + case domain.UserAuthMethodTypeOTPEmail: + factor.Type = &user_pb.AuthFactor_OtpEmail{ + OtpEmail: &user_pb.AuthFactorOTPEmail{}, + } + case domain.UserAuthMethodTypeUnspecified: + case domain.UserAuthMethodTypePasswordless: + case domain.UserAuthMethodTypePassword: + case domain.UserAuthMethodTypeIDP: + case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypePrivateKey: + } + return factor +} + +func AuthFactorsToPb(authFactors []user_pb.AuthFactors) []domain.UserAuthMethodType { + factors := make([]domain.UserAuthMethodType, len(authFactors)) + for i, authFactor := range authFactors { + factors[i] = AuthFactorToPb(authFactor) + } + return factors +} + +func AuthFactorToPb(authFactor user_pb.AuthFactors) domain.UserAuthMethodType { + switch authFactor { + case user_pb.AuthFactors_OTP: + return domain.UserAuthMethodTypeTOTP + case user_pb.AuthFactors_OTP_SMS: + return domain.UserAuthMethodTypeOTPSMS + case user_pb.AuthFactors_OTP_EMAIL: + return domain.UserAuthMethodTypeOTPEmail + case user_pb.AuthFactors_U2F: + return domain.UserAuthMethodTypeU2F + default: + return domain.UserAuthMethodTypeUnspecified + } +} + +func AuthFactorStatesToPb(authFactorStates []user_pb.AuthFactorState) []domain.MFAState { + factorStates := make([]domain.MFAState, len(authFactorStates)) + for i, authFactorState := range authFactorStates { + factorStates[i] = AuthFactorStateToPb(authFactorState) + } + return factorStates +} + +func AuthFactorStateToPb(authFactorState user_pb.AuthFactorState) domain.MFAState { + switch authFactorState { + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED: + return domain.MFAStateUnspecified + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY: + return domain.MFAStateNotReady + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY: + return domain.MFAStateReady + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_REMOVED: + return domain.MFAStateRemoved + default: + return domain.MFAStateUnspecified + } +} + +func MFAStateToPb(state domain.MFAState) user_pb.AuthFactorState { + switch state { + case domain.MFAStateNotReady: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY + case domain.MFAStateReady: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY + case domain.MFAStateUnspecified, domain.MFAStateRemoved: + // Handle all remaining cases so the linter succeeds + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + default: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + } +} 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/saml/v2/integration/saml_test.go b/internal/api/grpc/saml/v2/integration/saml_test.go new file mode 100644 index 0000000000..b70099fb20 --- /dev/null +++ b/internal/api/grpc/saml/v2/integration/saml_test.go @@ -0,0 +1,367 @@ +//go:build integration + +package saml_test + +import ( + "context" + "net/url" + "os" + "regexp" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/crewjam/saml" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +var ( + CTX context.Context + Instance *integration.Instance + Client saml_pb.SAMLServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SAMLv2 + + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + return m.Run() + }()) +} + +func TestServer_GetAuthRequest(t *testing.T) { + rootURL := "https://sp.example.com" + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + spMiddlewareRedirect, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPRedirectBinding) + require.NoError(t, err) + spMiddlewarePost, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPPostBinding) + require.NoError(t, err) + + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] + + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) + require.NoError(t, err) + + now := time.Now() + + tests := []struct { + name string + dep func() (string, error) + want *oidc_pb.GetAuthRequestResponse + wantErr bool + }{ + { + name: "Not found", + dep: func() (string, error) { + return "123", nil + }, + wantErr: true, + }, + { + name: "success, redirect binding", + dep: func() (string, error) { + return Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + }, + }, + { + name: "success, post binding", + dep: func() (string, error) { + return Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authRequestID, err := tt.dep() + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.GetSAMLRequest(CTX, &saml_pb.GetSAMLRequestRequest{ + SamlRequestId: authRequestID, + }) + if tt.wantErr { + assert.Error(ttt, err) + return + } + assert.NoError(ttt, err) + authRequest := got.GetSamlRequest() + assert.NotNil(ttt, authRequest) + assert.Equal(ttt, authRequestID, authRequest.GetId()) + assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) + }, retryDuration, tick, "timeout waiting for expected saml request result") + }) + } +} + +func TestServer_CreateResponse(t *testing.T) { + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + rootURLRedirect := "spredirect.example.com" + spMiddlewareRedirect, err := integration.CreateSAMLSP("https://"+rootURLRedirect, idpMetadata, saml.HTTPRedirectBinding) + require.NoError(t, err) + rootURLPost := "sppost.example.com" + spMiddlewarePost, err := integration.CreateSAMLSP("https://"+rootURLPost, idpMetadata, saml.HTTPPostBinding) + require.NoError(t, err) + + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] + + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) + require.NoError(t, err) + + sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: Instance.Users[integration.UserTypeOrgOwner].ID, + }, + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + req *saml_pb.CreateResponseRequest + AuthError string + want *saml_pb.CreateResponseResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "Not found", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: "123", + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "session not found", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: "foo", + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "session token invalid", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "fail callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`), + Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{ + RelayState: "notempty", + SamlResponse: "notempty", + }}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "fail callback, post, already failed", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + Instance.FailSAMLAuthRequest(CTX, authRequestID, saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + wantErr: true, + }, + { + name: "fail callback, redirect", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, redirect", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`), + Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{ + RelayState: "notempty", + SamlResponse: "notempty", + }}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + Instance.SuccessfulSAMLAuthRequest(CTX, Instance.Users[integration.UserTypeOrgOwner].ID, authRequestID) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreateResponse(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.Url), got.GetUrl()) + if tt.want.GetPost() != nil { + assert.NotEmpty(t, got.GetPost().GetRelayState()) + assert.NotEmpty(t, got.GetPost().GetSamlResponse()) + } + if tt.want.GetRedirect() != nil { + assert.NotNil(t, got.GetRedirect()) + } + } + }) + } +} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go new file mode 100644 index 0000000000..de4f3440ab --- /dev/null +++ b/internal/api/grpc/saml/v2/saml.go @@ -0,0 +1,112 @@ +package saml + +import ( + "context" + + "github.com/zitadel/logging" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +func (s *Server) GetAuthRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { + authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) + if err != nil { + logging.WithError(err).Error("query samlRequest by ID") + return nil, err + } + return &saml_pb.GetSAMLRequestResponse{ + SamlRequest: samlRequestToPb(authRequest), + }, nil +} + +func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { + return &saml_pb.SAMLRequest{ + Id: a.ID, + CreationDate: timestamppb.New(a.CreationDate), + } +} + +func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) { + switch v := req.GetResponseKind().(type) { + case *saml_pb.CreateResponseRequest_Error: + return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error) + case *saml_pb.CreateResponseRequest_Session: + return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v) + } +} + +func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) { + details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError())) + if err != nil { + return nil, err + } + authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar} + url, body, err := s.idp.CreateErrorResponse(authReq, errorReasonToDomain(ae.GetError()), ae.GetErrorDescription()) + if err != nil { + return nil, err + } + return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil +} + +func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { + details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true) + if err != nil { + return nil, err + } + authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar} + url, body, err := s.idp.CreateResponse(ctx, authReq) + if err != nil { + return nil, err + } + return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil +} + +func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse { + resp := &saml_pb.CreateResponseResponse{ + Details: object.DomainToDetailsPb(details), + Url: url, + } + + if body != "" { + resp.Binding = &saml_pb.CreateResponseResponse_Post{ + Post: &saml_pb.PostResponse{ + RelayState: relayState, + SamlResponse: body, + }, + } + } else { + resp.Binding = &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}} + } + return resp +} + +func errorReasonToDomain(errorReason saml_pb.ErrorReason) domain.SAMLErrorReason { + switch errorReason { + case saml_pb.ErrorReason_ERROR_REASON_UNSPECIFIED: + return domain.SAMLErrorReasonUnspecified + case saml_pb.ErrorReason_ERROR_REASON_VERSION_MISSMATCH: + return domain.SAMLErrorReasonVersionMissmatch + case saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED: + return domain.SAMLErrorReasonAuthNFailed + case saml_pb.ErrorReason_ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE: + return domain.SAMLErrorReasonInvalidAttrNameOrValue + case saml_pb.ErrorReason_ERROR_REASON_INVALID_NAMEID_POLICY: + return domain.SAMLErrorReasonInvalidNameIDPolicy + case saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED: + return domain.SAMLErrorReasonRequestDenied + case saml_pb.ErrorReason_ERROR_REASON_REQUEST_UNSUPPORTED: + return domain.SAMLErrorReasonRequestUnsupported + case saml_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_BINDING: + return domain.SAMLErrorReasonUnsupportedBinding + default: + return domain.SAMLErrorReasonUnspecified + } +} diff --git a/internal/api/grpc/saml/v2/server.go b/internal/api/grpc/saml/v2/server.go new file mode 100644 index 0000000000..62299d88c5 --- /dev/null +++ b/internal/api/grpc/saml/v2/server.go @@ -0,0 +1,59 @@ +package saml + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +var _ saml_pb.SAMLServiceServer = (*Server)(nil) + +type Server struct { + saml_pb.UnimplementedSAMLServiceServer + command *command.Commands + query *query.Queries + + idp *saml.Provider + externalSecure bool +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + idp *saml.Provider, + externalSecure bool, +) *Server { + return &Server{ + command: command, + query: query, + idp: idp, + externalSecure: externalSecure, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + saml_pb.RegisterSAMLServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return saml_pb.SAMLService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return saml_pb.SAMLService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return saml_pb.SAMLService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return saml_pb.RegisterSAMLServiceHandler +} 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 16942137c9..3430eae5f8 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -7,11 +7,15 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/idp" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) @@ -178,3 +182,281 @@ func TestServer_SetSecuritySettings(t *testing.T) { }) } } + +func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { + return &settings.IdentityProvider{ + Id: id, + Name: name, + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, + Options: &idp_pb.Options{ + IsLinkingAllowed: linking, + IsCreationAllowed: creation, + IsAutoCreation: autoCreation, + IsAutoUpdate: autoUpdate, + AutoLinking: autoLinking, + }, + } +} + +func TestServer_GetActiveIdentityProviders(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive + idpActiveName := gofakeit.AppName() + idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) + idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpLinkingDisallowedName := gofakeit.AppName() + idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) + idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpCreationDisallowedName := gofakeit.AppName() + idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) + idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoCreationName := gofakeit.AppName() + idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) + idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoLinkingName := gofakeit.AppName() + idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) + idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + + type args struct { + ctx context.Context + req *settings.GetActiveIdentityProvidersRequest + } + tests := []struct { + name string + args args + want *settings.GetActiveIdentityProvidersResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + wantErr: true, + }, + { + name: "success, all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 5, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpLinkingDisallowedResponse, + }, + }, + }, + { + name: "success, exclude creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpCreationDisallowedResponse, + }, + }, + }, + { + name: "success, auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, no auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, no auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + CreationAllowed: gu.Ptr(true), + AutoCreation: gu.Ptr(true), + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + for i, result := range tt.want.GetIdentityProviders() { + assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) + } + integration.AssertListDetails(ct, tt.want, got) + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 3e48ab0c04..8b9ab9b845 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -120,7 +120,12 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou } func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) + queries, err := activeIdentityProvidersToQuery(req) + if err != nil { + return nil, err + } + + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) if err != nil { return nil, err } @@ -131,6 +136,43 @@ func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.G }, nil } +func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, 0, 4) + if req.CreationAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.LinkingAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoCreation != nil { + creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoLinking != nil { + compare := query.NumberEquals + if *req.AutoLinking { + compare = query.NumberNotEquals + } + creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + return q, nil +} + func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { instance := authz.GetInstance(ctx) return &settings.GetGeneralSettingsResponse{ diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 222e548d1b..b1329a5973 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -5,9 +5,11 @@ import ( "google.golang.org/protobuf/types/known/durationpb" + idp_api "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) @@ -189,6 +191,13 @@ func identityProviderToPb(idp *query.IDPLoginPolicyLink) *settings.IdentityProvi Id: idp.IDPID, Name: domain.IDPName(idp.IDPName, idp.IDPType), Type: idpTypeToPb(idp.IDPType), + Options: &idp_pb.Options{ + IsLinkingAllowed: idp.IsLinkingAllowed, + IsCreationAllowed: idp.IsCreationAllowed, + IsAutoCreation: idp.IsAutoCreation, + IsAutoUpdate: idp.IsAutoUpdate, + AutoLinking: idp_api.AutoLinkingOptionToPb(idp.AutoLinking), + }, } } diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go index 40c381986a..937daf6712 100644 --- a/internal/api/grpc/settings/v2/settings_converter_test.go +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -16,6 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/idp/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) @@ -382,14 +383,24 @@ func Test_lockoutSettingsToPb(t *testing.T) { func Test_identityProvidersToPb(t *testing.T) { arg := []*query.IDPLoginPolicyLink{ { - IDPID: "1", - IDPName: "foo", - IDPType: domain.IDPTypeOIDC, + IDPID: "1", + IDPName: "foo", + IDPType: domain.IDPTypeOIDC, + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: domain.AutoLinkingOptionUsername, }, { - IDPID: "2", - IDPName: "bar", - IDPType: domain.IDPTypeGitHub, + IDPID: "2", + IDPName: "bar", + IDPType: domain.IDPTypeGitHub, + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: domain.AutoLinkingOptionEmail, }, } want := []*settings.IdentityProvider{ @@ -397,11 +408,25 @@ func Test_identityProvidersToPb(t *testing.T) { Id: "1", Name: "foo", Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC, + Options: &idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, + }, }, { Id: "2", Name: "bar", Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB, + Options: &idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_EMAIL, + }, }, } got := identityProvidersToPb(arg) diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 6d0871b26e..4b247ef10f 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -67,6 +67,33 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR }, nil } +func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeRequest) (resp *user.SendEmailCodeResponse, err error) { + var email *domain.Email + + switch v := req.GetVerification().(type) { + case *user.SendEmailCodeRequest_SendCode: + email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + case *user.SendEmailCodeRequest_ReturnCode: + email, err = s.command.SendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + case nil: + email, err = s.command.SendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method SendEmailCode not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: email.Sequence, + ChangeDate: timestamppb.New(email.ChangeDate), + ResourceOwner: email.ResourceOwner, + }, + VerificationCode: email.PlainCode, + }, nil +} + func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { details, err := s.command.VerifyUserEmail(ctx, req.GetUserId(), diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index 37d575016b..ad63c2ce5e 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -147,7 +147,7 @@ func TestServer_SetEmail(t *testing.T) { func TestServer_ResendEmailCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string @@ -249,6 +249,116 @@ func TestServer_ResendEmailCode(t *testing.T) { } } +func TestServer_SendEmailCode(t *testing.T) { + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() + + tests := []struct { + name string + req *user.SendEmailCodeRequest + want *user.SendEmailCodeResponse + wantErr bool + }{ + { + name: "user not existing", + req: &user.SendEmailCodeRequest{ + UserId: "xxx", + }, + wantErr: true, + }, + { + name: "user no code", + req: &user.SendEmailCodeRequest{ + UserId: verifiedUserID, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "resend", + req: &user.SendEmailCodeRequest{ + UserId: userID, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "custom url template", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "template error", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + wantErr: true, + }, + { + name: "return code", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SendEmailCode(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + integration.AssertDetails(t, tt.want, got) + if tt.want.GetVerificationCode() != "" { + assert.NotEmpty(t, got.GetVerificationCode()) + } else { + assert.Empty(t, got.GetVerificationCode()) + } + }) + } +} + func TestServer_VerifyEmail(t *testing.T) { userResp := Instance.CreateHumanUser(CTX) tests := []struct { diff --git a/internal/api/grpc/user/v2/integration_test/idp_link_test.go b/internal/api/grpc/user/v2/integration_test/idp_link_test.go index 116a095216..9d8160ab74 100644 --- a/internal/api/grpc/user/v2/integration_test/idp_link_test.go +++ b/internal/api/grpc/user/v2/integration_test/idp_link_test.go @@ -102,17 +102,17 @@ func TestServer_ListIDPLinks(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) - userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") require.NoError(t, err) ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) - userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email()) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") require.NoError(t, err) - userMultipleResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userMultipleResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpResp.Id, "externalUsername_multi") require.NoError(t, err) _, err = Instance.CreateUserIDPlink(ctxOrg, userMultipleResp.GetUserId(), "external_multi", orgIdpResp.Id, "externalUsername_multi") @@ -256,17 +256,17 @@ func TestServer_RemoveIDPLink(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) - userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") require.NoError(t, err) ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) - userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email()) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") require.NoError(t, err) - userNoLinkResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userNoLinkResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context diff --git a/internal/api/grpc/user/v2/integration_test/phone_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go index 1c1f75854d..49050c5fe6 100644 --- a/internal/api/grpc/user/v2/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -123,7 +123,7 @@ func TestServer_SetPhone(t *testing.T) { func TestServer_ResendPhoneCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2/integration_test/query_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go index a00d1b1a48..2551a4a833 100644 --- a/internal/api/grpc/user/v2/integration_test/query_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -5,6 +5,7 @@ package user_test import ( "context" "fmt" + "slices" "testing" "time" @@ -24,7 +25,7 @@ func TestServer_GetUserByID(t *testing.T) { type args struct { ctx context.Context req *user.GetUserByIDRequest - dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) + dep func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr } tests := []struct { name string @@ -39,8 +40,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -52,8 +53,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "unknown", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -63,10 +64,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, false) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -90,7 +91,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -107,11 +107,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, true) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -135,7 +134,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -152,9 +150,7 @@ func TestServer_GetUserByID(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - username := gofakeit.Email() - userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req) - require.NoError(t, err) + userAttr := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -174,11 +170,12 @@ func TestServer_GetUserByID(t *testing.T) { tt.want.User.LoginNames = []string{userAttr.Username} if human := tt.want.User.GetHuman(); human != nil { human.Email.Email = userAttr.Username + human.Phone.Phone = userAttr.Phone if tt.want.User.GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = userAttr.Changed } } - assert.Equal(ttt, tt.want.User, got.User) + assert.EqualExportedValues(ttt, tt.want.User, got.User) integration.AssertDetails(ttt, tt.want, got) }, retryDuration, tick) }) @@ -325,21 +322,60 @@ func TestServer_GetUserByID_Permission(t *testing.T) { } } +type userAttrs []userAttr + +func (u userAttrs) userIDs() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].UserID + } + return ids +} + +func (u userAttrs) emails() []string { + emails := make([]string, len(u)) + for i := range u { + emails[i] = u[i].Username + } + return emails +} + type userAttr struct { UserID string Username string + Phone string Changed *timestamppb.Timestamp Details *object.Details } +func createUsers(ctx context.Context, orgID string, count int, passwordChangeRequired bool) userAttrs { + infos := make([]userAttr, count) + for i := 0; i < count; i++ { + infos[i] = createUser(ctx, orgID, passwordChangeRequired) + } + slices.Reverse(infos) + return infos +} + +func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr { + username := gofakeit.Email() + // used as default country prefix + phone := "+41" + gofakeit.Phone() + resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) + info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()} + if passwordChangeRequired { + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + info.Changed = details.GetChangeDate() + } + return info +} + func TestServer_ListUsers(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) - userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { - ctx context.Context - count int - req *user.ListUsersRequest - dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + ctx context.Context + req *user.ListUsersRequest + dep func(ctx context.Context, request *user.ListUsersRequest) userAttrs } tests := []struct { name string @@ -351,11 +387,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, no permission", args: args{ UserCTX, - 0, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -371,22 +407,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -412,7 +441,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -425,23 +453,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -467,7 +487,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -482,22 +501,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) + return infos }, }, want: &user.ListUsersResponse{ @@ -523,7 +535,27 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ IsVerified: true, }, }, @@ -544,28 +576,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", - IsVerified: true, - }, - }, - }, - }, { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -578,22 +588,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - request.Queries = append(request.Queries, UsernameQuery(username)) - } - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, UsernameQuery(info.Username)) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -619,7 +622,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -632,20 +634,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -671,7 +668,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -684,20 +680,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -723,7 +714,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -744,7 +734,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -765,7 +754,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -778,14 +766,81 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails no found, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), InUserEmailsQuery([]string{"notfound"}), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user phone, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, PhoneQuery(info.Phone)) + return []userAttr{info} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -801,19 +856,14 @@ func TestServer_ListUsers(t *testing.T) { name: "list user resourceowner multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -839,7 +889,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -860,7 +909,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -881,7 +929,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -893,12 +940,7 @@ func TestServer_ListUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - usernames := make([]string, tt.args.count) - for i := 0; i < tt.args.count; i++ { - usernames[i] = gofakeit.Email() - } - infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req) - require.NoError(t, err) + infos := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -924,6 +966,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].LoginNames = []string{infos[i].Username} if human := tt.want.Result[i].GetHuman(); human != nil { human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = infos[i].Changed } @@ -931,7 +974,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].Details = infos[i].Details } for i := range tt.want.Result { - assert.Contains(ttt, got.Result, tt.want.Result[i]) + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } } integration.AssertListDetails(ttt, tt.want, got) @@ -958,6 +1001,15 @@ func InUserEmailsQuery(emails []string) *user.SearchQuery { } } +func PhoneQuery(number string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, + }, + } +} + func UsernameQuery(username string) *user.SearchQuery { return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ UserNameQuery: &user.UserNameQuery{ 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..8d4c254c6b 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/zitadel/logging" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" @@ -43,7 +45,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) @@ -2629,6 +2631,247 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { } } +func TestServer_ListAuthenticationFactors(t *testing.T) { + tests := []struct { + name string + args *user.ListAuthenticationFactorsRequest + want *user.ListAuthenticationFactorsResponse + dep func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) + wantErr bool + ctx context.Context + }{ + { + name: "no auth", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: nil, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() + args.UserId = userIDWithoutAuth + }, + ctx: CTX, + }, + { + name: "with u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithU2F := Instance.CreateHumanUser(CTX).GetUserId() + U2FId := Instance.RegisterUserU2F(CTX, userWithU2F) + + args.UserId = userWithU2F + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FId, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with totp, u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_Otp{ + Otp: &user.AuthFactorOTP{}, + }, + }, + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId() + U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP) + + args.UserId = userWithTOTP + want.Result[1].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FIdWithTOTP, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with totp, u2f filtered", + args: &user.ListAuthenticationFactorsRequest{ + AuthFactors: []user.AuthFactors{user.AuthFactors_U2F}, + }, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId() + U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP) + + args.UserId = userWithTOTP + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FIdWithTOTP, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with sms", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_OtpSms{ + OtpSms: &user.AuthFactorOTPSMS{}, + }, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithSMS := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId() + Instance.RegisterUserOTPSMS(CTX, userWithSMS) + + args.UserId = userWithSMS + }, + ctx: CTX, + }, + { + name: "with email", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_OtpEmail{ + OtpEmail: &user.AuthFactorOTPEmail{}, + }, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithEmail := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId() + Instance.RegisterUserOTPEmail(CTX, userWithEmail) + + args.UserId = userWithEmail + }, + ctx: CTX, + }, + { + name: "with not ready u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{}, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId() + _, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{ + UserId: userWithNotReadyU2F, + Domain: Instance.Domain, + }) + logging.OnError(err).Panic("Could not register u2f") + + args.UserId = userWithNotReadyU2F + }, + ctx: CTX, + }, + { + name: "with not ready u2f state filtered", + args: &user.ListAuthenticationFactorsRequest{ + States: []user.AuthFactorState{user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY}, + }, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId() + U2FNotReady, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{ + UserId: userWithNotReadyU2F, + Domain: Instance.Domain, + }) + logging.OnError(err).Panic("Could not register u2f") + + args.UserId = userWithNotReadyU2F + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FNotReady.GetU2FId(), + Name: "", + }, + } + }, + ctx: CTX, + }, + { + name: "with no userId", + args: &user.ListAuthenticationFactorsRequest{ + UserId: "", + }, + ctx: CTX, + wantErr: true, + }, + { + name: "with no permission", + args: &user.ListAuthenticationFactorsRequest{}, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "totp").GetUserId() + + args.UserId = userWithTOTP + }, + ctx: UserCTX, + wantErr: true, + }, + { + name: "with unknown user", + args: &user.ListAuthenticationFactorsRequest{ + UserId: "unknown", + }, + want: &user.ListAuthenticationFactorsResponse{}, + ctx: CTX, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + tt.dep(tt.args, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListAuthenticationFactors(tt.ctx, tt.args) + if tt.wantErr { + require.Error(ttt, err) + return + } + require.NoError(ttt, err) + + assert.ElementsMatch(t, tt.want.GetResult(), got.GetResult()) + }, retryDuration, tick, "timeout waiting for expected auth methods result") + + }) + } +} + func TestServer_CreateInviteCode(t *testing.T) { type args struct { ctx context.Context diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 564d4c1c0a..4cfbb46a51 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -238,6 +238,8 @@ func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, return displayNameQueryToQuery(q.DisplayNameQuery) case *user.SearchQuery_EmailQuery: return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_PhoneQuery: + return phoneQueryToQuery(q.PhoneQuery) case *user.SearchQuery_StateQuery: return stateQueryToQuery(q.StateQuery) case *user.SearchQuery_TypeQuery: @@ -285,6 +287,10 @@ func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) } +func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { + return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) +} + func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { return query.NewUserStateSearchQuery(int32(q.State)) } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index c0416f84aa..9d99f210e5 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -597,6 +597,39 @@ func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.Li }, nil } +func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) { + query := new(query.UserAuthMethodSearchQueries) + + if err := query.AppendUserIDQuery(req.UserId); err != nil { + return nil, err + } + + authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail} + if len(req.GetAuthFactors()) > 0 { + authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors()) + } + if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil { + return nil, err + } + + states := []domain.MFAState{domain.MFAStateReady} + if len(req.GetStates()) > 0 { + states = object.AuthFactorStatesToPb(req.GetStates()) + } + if err := query.AppendStatesQuery(states...); err != nil { + return nil, err + } + + authMethods, err := s.query.SearchUserAuthMethods(ctx, query, s.checkPermission) + if err != nil { + return nil, err + } + + return &user.ListAuthenticationFactorsResponse{ + Result: object.AuthMethodsToPb(authMethods), + }, nil +} + func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { methods := make([]user.AuthenticationMethodType, len(methodTypes)) for i, method := range methodTypes { diff --git a/internal/api/grpc/user/v2beta/integration_test/email_test.go b/internal/api/grpc/user/v2beta/integration_test/email_test.go index d22355978a..48957e99d9 100644 --- a/internal/api/grpc/user/v2beta/integration_test/email_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/email_test.go @@ -147,7 +147,7 @@ func TestServer_SetEmail(t *testing.T) { func TestServer_ResendEmailCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2beta/integration_test/phone_test.go b/internal/api/grpc/user/v2beta/integration_test/phone_test.go index cd7199dcea..73d065231c 100644 --- a/internal/api/grpc/user/v2beta/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/phone_test.go @@ -125,7 +125,7 @@ func TestServer_SetPhone(t *testing.T) { func TestServer_ResendPhoneCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2beta/integration_test/query_test.go b/internal/api/grpc/user/v2beta/integration_test/query_test.go index fc1d71926e..67fc609212 100644 --- a/internal/api/grpc/user/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/query_test.go @@ -5,6 +5,7 @@ package user_test import ( "context" "fmt" + "slices" "testing" "time" @@ -33,7 +34,7 @@ func TestServer_GetUserByID(t *testing.T) { type args struct { ctx context.Context req *user.GetUserByIDRequest - dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) + dep func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr } tests := []struct { name string @@ -48,8 +49,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -61,8 +62,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "unknown", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -72,10 +73,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, false) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -99,7 +100,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -116,11 +116,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, true) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -144,7 +143,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -161,9 +159,7 @@ func TestServer_GetUserByID(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - username := gofakeit.Email() - userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req) - require.NoError(t, err) + userAttr := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -183,6 +179,7 @@ func TestServer_GetUserByID(t *testing.T) { tt.want.User.LoginNames = []string{userAttr.Username} if human := tt.want.User.GetHuman(); human != nil { human.Email.Email = userAttr.Username + human.Phone.Phone = userAttr.Phone if tt.want.User.GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = userAttr.Changed } @@ -335,21 +332,60 @@ func TestServer_GetUserByID_Permission(t *testing.T) { } } +type userAttrs []userAttr + +func (u userAttrs) userIDs() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].UserID + } + return ids +} + +func (u userAttrs) emails() []string { + emails := make([]string, len(u)) + for i := range u { + emails[i] = u[i].Username + } + return emails +} + type userAttr struct { UserID string Username string + Phone string Changed *timestamppb.Timestamp Details *object.Details } +func createUsers(ctx context.Context, orgID string, count int, passwordChangeRequired bool) userAttrs { + infos := make([]userAttr, count) + for i := 0; i < count; i++ { + infos[i] = createUser(ctx, orgID, passwordChangeRequired) + } + slices.Reverse(infos) + return infos +} + +func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr { + username := gofakeit.Email() + // used as default country prefix + phone := "+41" + gofakeit.Phone() + resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) + info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()} + if passwordChangeRequired { + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + info.Changed = details.GetChangeDate() + } + return info +} + func TestServer_ListUsers(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) - userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { - ctx context.Context - count int - req *user.ListUsersRequest - dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + ctx context.Context + req *user.ListUsersRequest + dep func(ctx context.Context, request *user.ListUsersRequest) userAttrs } tests := []struct { name string @@ -361,11 +397,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, no permission", args: args{ UserCTX, - 0, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -381,22 +417,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -422,7 +451,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -435,23 +463,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -477,7 +497,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -492,22 +511,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) + return infos }, }, want: &user.ListUsersResponse{ @@ -533,7 +545,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -554,7 +565,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -575,7 +585,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -588,22 +597,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - request.Queries = append(request.Queries, UsernameQuery(username)) - } - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, UsernameQuery(info.Username)) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -629,7 +631,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -642,20 +643,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -681,7 +677,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -694,20 +689,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -733,7 +723,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -754,7 +743,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -775,7 +763,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -788,14 +775,13 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails no found, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), InUserEmailsQuery([]string{"notfound"}), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -807,23 +793,64 @@ func TestServer_ListUsers(t *testing.T) { Result: []*user.User{}, }, }, + { + name: "list user phone, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, PhoneQuery(info.Phone)) + return []userAttr{info} + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, { name: "list user resourceowner multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -849,7 +876,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -870,7 +896,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -891,7 +916,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -903,12 +927,7 @@ func TestServer_ListUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - usernames := make([]string, tt.args.count) - for i := 0; i < tt.args.count; i++ { - usernames[i] = gofakeit.Email() - } - infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req) - require.NoError(t, err) + infos := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -934,6 +953,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].LoginNames = []string{infos[i].Username} if human := tt.want.Result[i].GetHuman(); human != nil { human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = infos[i].Changed } @@ -941,7 +961,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details) } for i := range tt.want.Result { - assert.Contains(ttt, got.Result, tt.want.Result[i]) + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } } integration.AssertListDetails(ttt, tt.want, got) @@ -968,6 +988,15 @@ func InUserEmailsQuery(emails []string) *user.SearchQuery { } } +func PhoneQuery(number string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, + }, + } +} + func UsernameQuery(username string) *user.SearchQuery { return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ UserNameQuery: &user.UserNameQuery{ 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/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 4567259d15..e3602abc33 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -238,6 +238,8 @@ func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, return displayNameQueryToQuery(q.DisplayNameQuery) case *user.SearchQuery_EmailQuery: return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_PhoneQuery: + return phoneQueryToQuery(q.PhoneQuery) case *user.SearchQuery_StateQuery: return stateQueryToQuery(q.StateQuery) case *user.SearchQuery_TypeQuery: @@ -285,6 +287,10 @@ func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) } +func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { + return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) +} + func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { return query.NewUserStateSearchQuery(int32(q.State)) } 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/api/saml/auth_request.go b/internal/api/saml/auth_request.go new file mode 100644 index 0000000000..a846cd090b --- /dev/null +++ b/internal/api/saml/auth_request.go @@ -0,0 +1,99 @@ +package saml + +import ( + "context" + "encoding/base64" + "net/url" + + "github.com/zitadel/saml/pkg/provider" + "github.com/zitadel/saml/pkg/provider/models" + "github.com/zitadel/saml/pkg/provider/xml" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" +) + +func (p *Provider) CreateErrorResponse(authReq models.AuthRequestInt, reason domain.SAMLErrorReason, description string) (string, string, error) { + resp := &provider.Response{ + ProtocolBinding: authReq.GetBindingType(), + RelayState: authReq.GetRelayState(), + AcsUrl: authReq.GetAccessConsumerServiceURL(), + RequestID: authReq.GetAuthRequestID(), + Issuer: authReq.GetDestination(), + Audience: authReq.GetIssuer(), + } + return createResponse(p.AuthCallbackErrorResponse(resp, domain.SAMLErrorReasonToString(reason), description), authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature) +} + +func (p *Provider) CreateResponse(ctx context.Context, authReq models.AuthRequestInt) (string, string, error) { + resp := &provider.Response{ + ProtocolBinding: authReq.GetBindingType(), + RelayState: authReq.GetRelayState(), + AcsUrl: authReq.GetAccessConsumerServiceURL(), + RequestID: authReq.GetAuthRequestID(), + Issuer: authReq.GetDestination(), + Audience: authReq.GetIssuer(), + } + samlResponse, err := p.AuthCallbackResponse(ctx, authReq, resp) + if err != nil { + return "", "", err + } + + if err := p.command.CreateSAMLSessionFromSAMLRequest( + setContextUserSystem(ctx), + authReq.GetID(), + samlComplianceChecker(), + samlResponse.Id, + p.Expiration(), + ); err != nil { + return "", "", err + } + + return createResponse(samlResponse, authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature) +} + +func createResponse(samlResponse interface{}, binding, acs, relayState, sigAlg, sig string) (string, string, error) { + respData, err := xml.Marshal(samlResponse) + if err != nil { + return "", "", err + } + + switch binding { + case provider.PostBinding: + return acs, base64.StdEncoding.EncodeToString(respData), nil + case provider.RedirectBinding: + respData, err := xml.DeflateAndBase64(respData) + if err != nil { + return "", "", err + } + parsed, err := url.Parse(acs) + if err != nil { + return "", "", err + } + values := parsed.Query() + values.Add("SAMLResponse", string(respData)) + values.Add("RelayState", relayState) + values.Add("SigAlg", sigAlg) + values.Add("Signature", sig) + parsed.RawQuery = values.Encode() + return parsed.String(), "", nil + } + return "", "", nil +} + +func setContextUserSystem(ctx context.Context) context.Context { + data := authz.CtxData{ + UserID: "SYSTEM", + } + return authz.SetCtxData(ctx, data) +} + +func samlComplianceChecker() command.SAMLRequestComplianceChecker { + return func(_ context.Context, samlReq *command.SAMLRequestWriteModel) error { + if err := samlReq.CheckAuthenticated(); err != nil { + return err + } + return nil + } +} diff --git a/internal/api/saml/auth_request_converter_v2.go b/internal/api/saml/auth_request_converter_v2.go new file mode 100644 index 0000000000..d392734c73 --- /dev/null +++ b/internal/api/saml/auth_request_converter_v2.go @@ -0,0 +1,45 @@ +package saml + +import ( + "github.com/zitadel/saml/pkg/provider/models" + + "github.com/zitadel/zitadel/internal/command" +) + +var _ models.AuthRequestInt = &AuthRequestV2{} + +type AuthRequestV2 struct { + *command.CurrentSAMLRequest +} + +func (a *AuthRequestV2) GetApplicationID() string { + return a.ApplicationID +} + +func (a *AuthRequestV2) GetID() string { + return a.ID +} +func (a *AuthRequestV2) GetRelayState() string { + return a.RelayState +} +func (a *AuthRequestV2) GetAccessConsumerServiceURL() string { + return a.ACSURL +} +func (a *AuthRequestV2) GetAuthRequestID() string { + return a.RequestID +} +func (a *AuthRequestV2) GetBindingType() string { + return a.Binding +} +func (a *AuthRequestV2) GetIssuer() string { + return a.Issuer +} +func (a *AuthRequestV2) GetDestination() string { + return a.Destination +} +func (a *AuthRequestV2) GetUserID() string { + return a.UserID +} +func (a *AuthRequestV2) Done() bool { + return a.UserID != "" && a.SessionID != "" +} diff --git a/internal/api/saml/provider.go b/internal/api/saml/provider.go index 4622ad5832..edf713456c 100644 --- a/internal/api/saml/provider.go +++ b/internal/api/saml/provider.go @@ -24,7 +24,13 @@ const ( ) type Config struct { - ProviderConfig *provider.Config + ProviderConfig *provider.Config + DefaultLoginURLV2 string +} + +type Provider struct { + *provider.Provider + command *command.Commands } func NewProvider( @@ -40,7 +46,7 @@ func NewProvider( instanceHandler, userAgentCookie func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, -) (*provider.Provider, error) { +) (*Provider, error) { metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} provStorage, err := newStorage( @@ -51,6 +57,8 @@ func NewProvider( certEncAlg, es, projections, + fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), + conf.DefaultLoginURLV2, ) if err != nil { return nil, err @@ -73,12 +81,19 @@ func NewProvider( options = append(options, provider.WithAllowInsecure()) } - return provider.NewProvider( + p, err := provider.NewProvider( provStorage, HandlerPrefix, conf.ProviderConfig, options..., ) + if err != nil { + return nil, err + } + return &Provider{ + p, + command, + }, nil } func newStorage( @@ -89,16 +104,19 @@ func newStorage( certEncAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, db *database.DB, + defaultLoginURL string, + defaultLoginURLV2 string, ) (*Storage, error) { return &Storage{ - encAlg: encAlg, - certEncAlg: certEncAlg, - locker: crdb.NewLocker(db.DB, locksTable, signingKey), - eventstore: es, - repo: repo, - command: command, - query: query, - defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), + encAlg: encAlg, + certEncAlg: certEncAlg, + locker: crdb.NewLocker(db.DB, locksTable, signingKey), + eventstore: es, + repo: repo, + command: command, + query: query, + defaultLoginURL: defaultLoginURL, + defaultLoginURLv2: defaultLoginURLV2, }, nil } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 76173c2592..76f1bfd903 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -3,6 +3,7 @@ package saml import ( "context" "encoding/json" + "strings" "time" "github.com/dop251/goja" @@ -16,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/activity" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/auth/repository" "github.com/zitadel/zitadel/internal/command" @@ -33,6 +35,10 @@ var _ provider.IdentityProviderStorage = &Storage{} var _ provider.AuthStorage = &Storage{} var _ provider.UserStorage = &Storage{} +const ( + LoginClientHeader = "x-zitadel-login-client" +) + type Storage struct { certChan <-chan interface{} defaultCertificateLifetime time.Duration @@ -51,7 +57,8 @@ type Storage struct { command *command.Commands query *query.Queries - defaultLoginURL string + defaultLoginURL string + defaultLoginURLv2 string } func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) { @@ -64,7 +71,12 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep &serviceprovider.Config{ Metadata: app.SAMLConfig.Metadata, }, - p.defaultLoginURL, + func(id string) string { + if strings.HasPrefix(id, command.IDPrefixV2) { + return p.defaultLoginURLv2 + id + } + return p.defaultLoginURL + id + }, ) } @@ -95,6 +107,38 @@ func (p *Storage) GetResponseSigningKey(ctx context.Context) (*key.CertificateAn func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + headers, _ := http_utils.HeadersFromCtx(ctx) + if loginClient := headers.Get(LoginClientHeader); loginClient != "" { + return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient) + } + return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID) +} + +func (p *Storage) createAuthRequestLoginClient(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID, loginClient string) (_ models.AuthRequestInt, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + samlRequest := &command.SAMLRequest{ + ApplicationID: applicationID, + ACSURL: acsUrl, + RelayState: relayState, + RequestID: req.Id, + Binding: protocolBinding, + Issuer: req.Issuer.Text, + Destination: req.Destination, + LoginClient: loginClient, + } + + aar, err := p.command.AddSAMLRequest(ctx, samlRequest) + if err != nil { + return nil, err + } + return &AuthRequestV2{aar}, nil +} + +func (p *Storage) createAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() userAgentID, ok := middleware.UserAgentIDFromCtx(ctx) if !ok { return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-sd436", "no user agent id") @@ -113,6 +157,15 @@ func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequest func (p *Storage) AuthRequestByID(ctx context.Context, id string) (_ models.AuthRequestInt, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + if strings.HasPrefix(id, command.IDPrefixV2) { + req, err := p.command.GetCurrentSAMLRequest(ctx, id) + if err != nil { + return nil, err + } + return &AuthRequestV2{req}, nil + } + userAgentID, ok := middleware.UserAgentIDFromCtx(ctx) if !ok { return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-D3g21", "no user agent id") diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index edbeb652ce..28f4d00a88 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -108,7 +108,7 @@ InitMFAPrompt: InitMFAOTP: Title: Zwei-Faktor-Authentifizierung Description: Erstelle deinen Zweitfaktor. Installiere eine Authentifizierungs-App, wenn du noch keine hast. - OTPDescription: Scanne den Code mit einer Authentifizierungs-App (z.B. Google/Mircorsoft Authenticator, Authy) oder kopiere das Secret und gib anschliessend den Code ein. + OTPDescription: Scanne den Code mit einer Authentifizierungs-App (z.B. Google/Microsoft Authenticator, Authy) oder kopiere das Secret und gib anschliessend den Code ein. SecretLabel: Secret CodeLabel: Code NextButtonText: Weiter 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/saml_request.go b/internal/command/saml_request.go new file mode 100644 index 0000000000..9d12ba6e44 --- /dev/null +++ b/internal/command/saml_request.go @@ -0,0 +1,161 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLRequest struct { + ID string + LoginClient string + + ApplicationID string + ACSURL string + RelayState string + RequestID string + Binding string + Issuer string + Destination string +} + +type CurrentSAMLRequest struct { + *SAMLRequest + SessionID string + UserID string + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time +} + +func (c *Commands) AddSAMLRequest(ctx context.Context, samlRequest *SAMLRequest) (_ *CurrentSAMLRequest, err error) { + id, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + samlRequest.ID = IDPrefixV2 + id + writeModel, err := c.getSAMLRequestWriteModel(ctx, samlRequest.ID) + if err != nil { + return nil, err + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateUnspecified { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting") + } + err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewAddedEvent( + ctx, + &samlrequest.NewAggregate(samlRequest.ID, authz.GetInstance(ctx).InstanceID()).Aggregate, + samlRequest.LoginClient, + samlRequest.ApplicationID, + samlRequest.ACSURL, + samlRequest.RelayState, + samlRequest.RequestID, + samlRequest.Binding, + samlRequest.Issuer, + samlRequest.Destination, + )) + if err != nil { + return nil, err + } + return samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { + writeModel, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, nil, err + } + if writeModel.SAMLRequestState == domain.SAMLRequestStateUnspecified { + return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting") + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled") + } + if checkLoginClient && authz.GetCtxData(ctx).UserID != writeModel.LoginClient { + return nil, nil, zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient") + } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, nil, err + } + if err = sessionWriteModel.CheckIsActive(); err != nil { + return nil, nil, err + } + if err := c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID); err != nil { + return nil, nil, err + } + + if err := c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewSessionLinkedEvent( + ctx, &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + sessionID, + sessionWriteModel.UserID, + sessionWriteModel.AuthenticationTime(), + sessionWriteModel.AuthMethodTypes(), + )); err != nil { + return nil, nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func (c *Commands) FailSAMLRequest(ctx context.Context, id string, reason domain.SAMLErrorReason) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { + writeModel, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, nil, err + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled") + } + err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewFailedEvent( + ctx, + &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + reason, + )) + if err != nil { + return nil, nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func samlRequestWriteModelToCurrentSAMLRequest(writeModel *SAMLRequestWriteModel) (_ *CurrentSAMLRequest) { + return &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: writeModel.AggregateID, + LoginClient: writeModel.LoginClient, + ApplicationID: writeModel.ApplicationID, + ACSURL: writeModel.ACSURL, + RelayState: writeModel.RelayState, + RequestID: writeModel.RequestID, + Binding: writeModel.Binding, + Issuer: writeModel.Issuer, + Destination: writeModel.Destination, + }, + SessionID: writeModel.SessionID, + UserID: writeModel.UserID, + AuthMethods: writeModel.AuthMethods, + AuthTime: writeModel.AuthTime, + } +} + +func (c *Commands) GetCurrentSAMLRequest(ctx context.Context, id string) (_ *CurrentSAMLRequest, err error) { + wm, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, err + } + return samlRequestWriteModelToCurrentSAMLRequest(wm), nil +} + +func (c *Commands) getSAMLRequestWriteModel(ctx context.Context, id string) (writeModel *SAMLRequestWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewSAMLRequestWriteModel(ctx, id) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/saml_request_model.go b/internal/command/saml_request_model.go new file mode 100644 index 0000000000..7ba640cbe8 --- /dev/null +++ b/internal/command/saml_request_model.go @@ -0,0 +1,88 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLRequestWriteModel struct { + eventstore.WriteModel + aggregate *eventstore.Aggregate + + LoginClient string + ApplicationID string + ACSURL string + RelayState string + RequestID string + Binding string + Issuer string + Destination string + + SessionID string + UserID string + AuthTime time.Time + AuthMethods []domain.UserAuthMethodType + SAMLRequestState domain.SAMLRequestState +} + +func NewSAMLRequestWriteModel(ctx context.Context, id string) *SAMLRequestWriteModel { + return &SAMLRequestWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + }, + aggregate: &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + } +} + +func (m *SAMLRequestWriteModel) Reduce() error { + for _, event := range m.Events { + switch e := event.(type) { + case *samlrequest.AddedEvent: + m.LoginClient = e.LoginClient + m.ApplicationID = e.ApplicationID + m.ACSURL = e.ACSURL + m.RelayState = e.RelayState + m.RequestID = e.RequestID + m.Binding = e.Binding + m.Issuer = e.Issuer + m.Destination = e.Destination + m.SAMLRequestState = domain.SAMLRequestStateAdded + case *samlrequest.SessionLinkedEvent: + m.SessionID = e.SessionID + m.UserID = e.UserID + m.AuthTime = e.AuthTime + m.AuthMethods = e.AuthMethods + case *samlrequest.FailedEvent: + m.SAMLRequestState = domain.SAMLRequestStateFailed + case *samlrequest.SucceededEvent: + m.SAMLRequestState = domain.SAMLRequestStateSucceeded + } + } + return m.WriteModel.Reduce() +} + +func (m *SAMLRequestWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(samlrequest.AggregateType). + AggregateIDs(m.AggregateID). + Builder() +} + +// CheckAuthenticated checks that the auth request exists, a session must have been linked +func (m *SAMLRequestWriteModel) CheckAuthenticated() error { + if m.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "AUTHR-3dNRNwSYeC", "Errors.SAMLRequest.NotAuthenticated") + } + // check that the requests exists, but has not succeeded yet + if m.SAMLRequestState == domain.SAMLRequestStateAdded { + return nil + } + return zerrors.ThrowPreconditionFailed(nil, "AUTHR-krQV50AlnJ", "Errors.SAMLRequest.NotAuthenticated") +} diff --git a/internal/command/saml_request_test.go b/internal/command/saml_request_test.go new file mode 100644 index 0000000000..18b1c2a392 --- /dev/null +++ b/internal/command/saml_request_test.go @@ -0,0 +1,676 @@ +package command + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_AddSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + request *SAMLRequest + } + tests := []struct { + name string + fields fields + args args + want *CurrentSAMLRequest + wantErr error + }{ + { + "already exists error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args{ + ctx: mockCtx, + request: &SAMLRequest{}, + }, + nil, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting"), + }, + { + "added", + fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args{ + ctx: mockCtx, + request: &SAMLRequest{ + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + got, err := c.AddSAMLRequest(tt.args.ctx, tt.args.request) + require.ErrorIs(t, tt.wantErr, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + id string + sessionID string + sessionToken string + checkLoginClient bool + } + type res struct { + details *domain.ObjectDetails + authReq *CurrentSAMLRequest + wantErr error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "samlRequest not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting"), + }, + }, + { + "samlRequest not existing", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("id", "instanceID").Aggregate, + domain.SAMLErrorReasonUnspecified, + ), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, + { + "wrong login client", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "wrongLoginClient"), + id: "id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient"), + }, + }, + { + "session not existing", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter(), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"), + }, + }, + { + "session expired", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + 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.Add(-5*time.Minute), &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow.Add(-5*time.Minute)), + ), + eventFromEventPusher( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Hkl3d", "Errors.Session.Expired"), + }, + }, + { + "invalid session token", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + 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"}}, + }, + )), + ), + ), + tokenVerifier: newMockTokenVerifierInvalid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "linked", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + 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( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with login client check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + 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( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "loginClient", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + 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(t), + sessionTokenVerifier: tt.fields.tokenVerifier, + } + details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + require.ErrorIs(t, err, tt.res.wantErr) + assertObjectDetails(t, tt.res.details, details) + if err == nil { + assert.WithinRange(t, got.AuthTime, testNow, testNow) + got.AuthTime = time.Time{} + } + assert.Equal(t, tt.res.authReq, got) + }) + } +} + +func TestCommands_FailSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + reason domain.SAMLErrorReason + description string + } + type res struct { + details *domain.ObjectDetails + samlReq *CurrentSAMLRequest + wantErr error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "authRequest not existing", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: mockCtx, + id: "foo", + reason: domain.SAMLErrorReasonAuthNFailed, + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, { + "already failed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + domain.SAMLErrorReasonAuthNFailed, + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + reason: domain.SAMLErrorReasonAuthNFailed, + description: "desc", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, + { + "failed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectPush( + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + domain.SAMLErrorReasonAuthNFailed, + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + reason: domain.SAMLErrorReasonAuthNFailed, + description: "desc", + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + samlReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + details, got, err := c.FailSAMLRequest(tt.args.ctx, tt.args.id, tt.args.reason) + require.ErrorIs(t, err, tt.res.wantErr) + assertObjectDetails(t, tt.res.details, details) + assert.Equal(t, tt.res.samlReq, got) + }) + } +} diff --git a/internal/command/saml_session.go b/internal/command/saml_session.go new file mode 100644 index 0000000000..6e0c37af9e --- /dev/null +++ b/internal/command/saml_session.go @@ -0,0 +1,186 @@ +package command + +import ( + "context" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/samlsession" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLSession struct { + SessionID string + SAMLResponseID string + EntityID string + UserID string + Audience []string + Expiration time.Time + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time + PreferredLanguage *language.Tag + UserAgent *domain.UserAgent +} + +type SAMLRequestComplianceChecker func(context.Context, *SAMLRequestWriteModel) error + +func (c *Commands) CreateSAMLSessionFromSAMLRequest(ctx context.Context, samlReqId string, complianceCheck SAMLRequestComplianceChecker, samlResponseID string, samlResponseLifetime time.Duration) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if samlReqId == "" { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode") + } + + samlReqModel, err := c.getSAMLRequestWriteModel(ctx, samlReqId) + if err != nil { + return err + } + + instanceID := authz.GetInstance(ctx).InstanceID() + sessionModel := NewSessionWriteModel(samlReqModel.SessionID, instanceID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionModel) + if err != nil { + return err + } + if err = sessionModel.CheckIsActive(); err != nil { + return err + } + + cmd, err := c.newSAMLSessionAddEvents(ctx, sessionModel.UserID, sessionModel.UserResourceOwner) + if err != nil { + return err + } + if err = complianceCheck(ctx, samlReqModel); err != nil { + return err + } + + cmd.AddSession(ctx, + sessionModel.UserID, + sessionModel.UserResourceOwner, + sessionModel.AggregateID, + samlReqModel.Issuer, + []string{samlReqModel.Issuer}, + samlReqModel.AuthMethods, + samlReqModel.AuthTime, + sessionModel.PreferredLanguage, + sessionModel.UserAgent, + ) + + if err = cmd.AddSAMLResponse(ctx, samlResponseID, samlResponseLifetime); err != nil { + return err + } + cmd.SetSAMLRequestSuccessful(ctx, samlReqModel.aggregate) + _, err = cmd.PushEvents(ctx) + return err +} + +func (c *Commands) newSAMLSessionAddEvents(ctx context.Context, userID, resourceOwner string, pending ...eventstore.Command) (*SAMLSessionEvents, error) { + userStateModel, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !userStateModel.UserState.IsEnabled() { + return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive") + } + sessionID, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + sessionID = IDPrefixV2 + sessionID + return &SAMLSessionEvents{ + commands: c, + idGenerator: c.idGenerator, + encryptionAlg: c.keyAlgorithm, + events: pending, + samlSessionWriteModel: NewSAMLSessionWriteModel(sessionID, resourceOwner), + userStateModel: userStateModel, + }, nil +} + +type SAMLSessionEvents struct { + commands *Commands + idGenerator id.Generator + encryptionAlg crypto.EncryptionAlgorithm + events []eventstore.Command + samlSessionWriteModel *SAMLSessionWriteModel + userStateModel *UserV2WriteModel + + // samlResponseID is set by the command + samlResponseID string +} + +func (c *SAMLSessionEvents) AddSession( + ctx context.Context, + userID, + userResourceOwner, + sessionID, + entityID string, + audience []string, + authMethods []domain.UserAuthMethodType, + authTime time.Time, + preferredLanguage *language.Tag, + userAgent *domain.UserAgent, +) { + c.events = append(c.events, samlsession.NewAddedEvent( + ctx, + c.samlSessionWriteModel.aggregate, + userID, + userResourceOwner, + sessionID, + entityID, + audience, + authMethods, + authTime, + preferredLanguage, + userAgent, + )) +} + +func (c *SAMLSessionEvents) SetSAMLRequestSuccessful(ctx context.Context, samlRequestAggregate *eventstore.Aggregate) { + c.events = append(c.events, samlrequest.NewSucceededEvent(ctx, samlRequestAggregate)) +} + +func (c *SAMLSessionEvents) SetSAMLRequestFailed(ctx context.Context, samlRequestAggregate *eventstore.Aggregate, err domain.SAMLErrorReason) { + c.events = append(c.events, samlrequest.NewFailedEvent(ctx, samlRequestAggregate, err)) +} + +func (c *SAMLSessionEvents) AddSAMLResponse(ctx context.Context, id string, lifetime time.Duration) error { + c.samlResponseID = id + c.events = append(c.events, samlsession.NewSAMLResponseAddedEvent(ctx, c.samlSessionWriteModel.aggregate, id, lifetime)) + return nil +} + +func (c *SAMLSessionEvents) PushEvents(ctx context.Context) (*SAMLSession, error) { + pushedEvents, err := c.commands.eventstore.Push(ctx, c.events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(c.samlSessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + session := &SAMLSession{ + SessionID: c.samlSessionWriteModel.SessionID, + EntityID: c.samlSessionWriteModel.EntityID, + UserID: c.samlSessionWriteModel.UserID, + Audience: c.samlSessionWriteModel.Audience, + Expiration: c.samlSessionWriteModel.SAMLResponseExpiration, + AuthMethods: c.samlSessionWriteModel.AuthMethods, + AuthTime: c.samlSessionWriteModel.AuthTime, + PreferredLanguage: c.samlSessionWriteModel.PreferredLanguage, + UserAgent: c.samlSessionWriteModel.UserAgent, + SAMLResponseID: c.samlSessionWriteModel.SAMLResponseID, + } + activity.Trigger(ctx, c.samlSessionWriteModel.UserResourceOwner, c.samlSessionWriteModel.UserID, activity.SAMLResponse, c.commands.eventstore.FilterToQueryReducer) + return session, nil +} diff --git a/internal/command/saml_session_model.go b/internal/command/saml_session_model.go new file mode 100644 index 0000000000..6c3b861492 --- /dev/null +++ b/internal/command/saml_session_model.go @@ -0,0 +1,102 @@ +package command + +import ( + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/samlsession" +) + +type SAMLSessionWriteModel struct { + eventstore.WriteModel + + UserID string + UserResourceOwner string + PreferredLanguage *language.Tag + SessionID string + EntityID string + Audience []string + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time + UserAgent *domain.UserAgent + State domain.SAMLSessionState + SAMLResponseID string + SAMLResponseCreation time.Time + SAMLResponseExpiration time.Time + + aggregate *eventstore.Aggregate +} + +func NewSAMLSessionWriteModel(id string, resourceOwner string) *SAMLSessionWriteModel { + return &SAMLSessionWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: resourceOwner, + }, + aggregate: &samlsession.NewAggregate(id, resourceOwner).Aggregate, + } +} + +func (wm *SAMLSessionWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *samlsession.AddedEvent: + wm.reduceAdded(e) + case *samlsession.SAMLResponseAddedEvent: + wm.reduceSAMLResponseAdded(e) + case *samlsession.SAMLResponseRevokedEvent: + wm.reduceSAMLResponseRevoked(e) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *SAMLSessionWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(samlsession.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + samlsession.AddedType, + samlsession.SAMLResponseAddedType, + samlsession.SAMLResponseRevokedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *SAMLSessionWriteModel) reduceAdded(e *samlsession.AddedEvent) { + wm.UserID = e.UserID + wm.UserResourceOwner = e.UserResourceOwner + wm.SessionID = e.SessionID + wm.EntityID = e.EntityID + wm.Audience = e.Audience + wm.AuthMethods = e.AuthMethods + wm.AuthTime = e.AuthTime + wm.PreferredLanguage = e.PreferredLanguage + wm.UserAgent = e.UserAgent + wm.State = domain.SAMLSessionStateActive + // the write model might be initialized without resource owner, + // so update the aggregate + if wm.ResourceOwner == "" { + wm.aggregate = &samlsession.NewAggregate(wm.AggregateID, e.Aggregate().ResourceOwner).Aggregate + } +} + +func (wm *SAMLSessionWriteModel) reduceSAMLResponseAdded(e *samlsession.SAMLResponseAddedEvent) { + wm.SAMLResponseID = e.ID + wm.SAMLResponseCreation = e.CreationDate() + wm.SAMLResponseExpiration = e.CreationDate().Add(e.Lifetime) +} + +func (wm *SAMLSessionWriteModel) reduceSAMLResponseRevoked(e *samlsession.SAMLResponseRevokedEvent) { + wm.SAMLResponseID = "" + wm.SAMLResponseExpiration = e.CreationDate() +} diff --git a/internal/command/saml_session_test.go b/internal/command/saml_session_test.go new file mode 100644 index 0000000000..12cc0683c5 --- /dev/null +++ b/internal/command/saml_session_test.go @@ -0,0 +1,337 @@ +package command + +import ( + "context" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/samlsession" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func mockSAMLRequestComplianceChecker(returnErr error) SAMLRequestComplianceChecker { + return func(context.Context, *SAMLRequestWriteModel) error { + return returnErr + } +} + +func TestCommands_CreateSAMLSessionFromSAMLRequest(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + samlRequestID string + samlResponseID string + complianceCheck SAMLRequestComplianceChecker + samlResponseLifetime time.Duration + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing code", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode"), + }, + }, + { + "filter error", + fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: io.ErrClosedPipe, + }, + }, + { + "session filter error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilterError(io.ErrClosedPipe), + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: io.ErrClosedPipe, + }, + }, + { + "inactive session error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter(), // inactive session + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"), + }, + }, + { + "user not active", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), + &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(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + testNow), + ), + ), + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + user.NewUserDeactivatedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + samlResponseID: "samlResponseID", + samlResponseLifetime: time.Minute * 5, + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive"), + }, + }, + { + "add successful", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), + &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(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + testNow), + ), + ), + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectPush( + samlsession.NewAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "issuer", []string{"issuer"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + samlsession.NewSAMLResponseAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, "samlResponseID", time.Minute*5), + samlrequest.NewSucceededEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "samlSessionID"), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + samlResponseID: "samlResponseID", + samlResponseLifetime: time.Minute * 5, + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + keyAlgorithm: tt.fields.keyAlgorithm, + } + err := c.CreateSAMLSessionFromSAMLRequest(tt.args.ctx, tt.args.samlRequestID, tt.args.complianceCheck, tt.args.samlResponseID, tt.args.samlResponseLifetime) + require.ErrorIs(t, err, tt.res.err) + }) + } +} 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/command/user_v2_email.go b/internal/command/user_v2_email.go index 1618e2cd48..4aa75d0935 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -57,6 +57,28 @@ func (c *Commands) ResendUserEmailReturnCode(ctx context.Context, userID string, return c.resendUserEmailCode(ctx, userID, alg, true, "") } +// SendUserEmailCode generates a new code +// and triggers a notification e-mail with the default confirmation URL format. +func (c *Commands) SendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) { + return c.sendUserEmailCode(ctx, userID, alg, false, "") +} + +// SendUserEmailCodeURLTemplate generates a new code +// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl. +// urlTmpl must be a valid [tmpl.Template]. +func (c *Commands) SendUserEmailCodeURLTemplate(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) { + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil { + return nil, err + } + return c.sendUserEmailCode(ctx, userID, alg, false, urlTmpl) +} + +// SendUserEmailReturnCode generates a new code and does not send a notification email. +// The generated plain text code will be set in the returned Email object. +func (c *Commands) SendUserEmailReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) { + return c.sendUserEmailCode(ctx, userID, alg, true, "") +} + // ChangeUserEmailVerified sets a user's email address and marks it is verified. // No code is generated and no confirmation e-mail is send. func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, email string) (*domain.Email, error) { @@ -89,7 +111,16 @@ func (c *Commands) resendUserEmailCode(ctx context.Context, userID string, alg c return nil, err } gen := crypto.NewEncryptionGenerator(*config, alg) - return c.resendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl) + return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, true) +} + +func (c *Commands) sendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) { + config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, false) } // changeUserEmailWithGenerator set a user's email address. @@ -104,8 +135,8 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, ema return cmd.Push(ctx) } -func (c *Commands) resendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) { - cmd, err := c.resendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl) +func (c *Commands) sendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*domain.Email, error) { + cmd, err := c.sendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl, existingCheck) if err != nil { return nil, err } @@ -129,7 +160,7 @@ func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userI return cmd, nil } -func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) { +func (c *Commands) sendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*UserEmailEvents, error) { cmd, err := c.NewUserEmailEvents(ctx, userID) if err != nil { return nil, err @@ -137,7 +168,7 @@ func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, u if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { return nil, err } - if cmd.model.Code == nil { + if existingCheck && cmd.model.Code == nil { return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty") } if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil { diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index 79a53705f8..73ab2e1c4c 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -512,6 +512,85 @@ func TestCommands_ResendUserEmailCode(t *testing.T) { } } +func TestCommands_SendUserEmailCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + func TestCommands_ResendUserEmailCodeURLTemplate(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -638,7 +717,99 @@ func TestCommands_ResendUserEmailCodeURLTemplate(t *testing.T) { } _, err := c.ResendUserEmailCodeURLTemplate(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), tt.args.urlTmpl) require.ErrorIs(t, err, tt.wantErr) - // successful cases are tested in TestCommands_resendUserEmailCodeWithGenerator + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + +func TestCommands_SendUserEmailCodeURLTemplate(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + urlTmpl string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "invalid template", + fields: fields{ + eventstore: eventstoreExpect(t), + }, + args: args{ + userID: "user1", + urlTmpl: "{{", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), + }, + { + name: "permission missing", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailCodeURLTemplate(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), tt.args.urlTmpl) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents }) } } @@ -760,6 +931,85 @@ func TestCommands_ResendUserEmailReturnCode(t *testing.T) { } } +func TestCommands_SendUserEmailReturnCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailReturnCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + func TestCommands_ChangeUserEmailVerified(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -1218,15 +1468,16 @@ func TestCommands_changeUserEmailWithGenerator(t *testing.T) { } } -func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { +func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { - userID string - returnCode bool - urlTmpl string + userID string + returnCode bool + urlTmpl string + checkExisting bool } tests := []struct { name string @@ -1247,37 +1498,6 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing"), }, - { - name: "resend code, missing code", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email@test.ch", - true, - ), - ), - ), - ), - checkPermission: newMockPermissionCheckAllowed(), - }, - args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "", - }, - wantErr: zerrors.ThrowPreconditionFailed(nil, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty"), - }, { name: "missing permission", fields: fields{ @@ -1322,6 +1542,58 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), }, + { + name: "send code", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectPush( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: false, + }, + want: &domain.Email{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + EmailAddress: "email@test.ch", + IsEmailVerified: false, + }, + }, { name: "resend code", fields: fields{ @@ -1373,9 +1645,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "", + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: true, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1387,7 +1660,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, }, { - name: "resend code, return code", + name: "resend code, missing code", fields: fields{ eventstore: eventstoreExpect( t, @@ -1406,17 +1679,36 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { true, ), ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: true, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty"), + }, + { + name: "send code, return code", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( eventFromEventPusher( - user.NewHumanEmailCodeAddedEventV2(context.Background(), + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - "", false, "", + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, ), ), ), @@ -1437,9 +1729,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: true, - urlTmpl: "", + userID: "user1", + returnCode: true, + urlTmpl: "", + checkExisting: false, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1452,7 +1745,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, }, { - name: "resend code, URL template", + name: "send code, URL template", fields: fields{ eventstore: eventstoreExpect( t, @@ -1471,19 +1764,6 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { true, ), ), - eventFromEventPusher( - user.NewHumanEmailCodeAddedEventV2(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - "", false, "", - ), - ), ), expectPush( user.NewHumanEmailCodeAddedEventV2(context.Background(), @@ -1502,9 +1782,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + userID: "user1", + returnCode: false, + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + checkExisting: false, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1522,7 +1803,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { eventstore: tt.fields.eventstore, checkPermission: tt.fields.checkPermission, } - got, err := c.resendUserEmailCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl) + got, err := c.sendUserEmailCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl, tt.args.checkExisting) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) 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/domain/saml_error_reason.go b/internal/domain/saml_error_reason.go new file mode 100644 index 0000000000..a62c3590d7 --- /dev/null +++ b/internal/domain/saml_error_reason.go @@ -0,0 +1,41 @@ +package domain + +import ( + "github.com/zitadel/saml/pkg/provider" +) + +type SAMLErrorReason int32 + +const ( + SAMLErrorReasonUnspecified SAMLErrorReason = iota + SAMLErrorReasonVersionMissmatch + SAMLErrorReasonAuthNFailed + SAMLErrorReasonInvalidAttrNameOrValue + SAMLErrorReasonInvalidNameIDPolicy + SAMLErrorReasonRequestDenied + SAMLErrorReasonRequestUnsupported + SAMLErrorReasonUnsupportedBinding +) + +func SAMLErrorReasonToString(reason SAMLErrorReason) string { + switch reason { + case SAMLErrorReasonUnspecified: + return "unspecified error" + case SAMLErrorReasonVersionMissmatch: + return provider.StatusCodeVersionMissmatch + case SAMLErrorReasonAuthNFailed: + return provider.StatusCodeAuthNFailed + case SAMLErrorReasonInvalidAttrNameOrValue: + return provider.StatusCodeInvalidAttrNameOrValue + case SAMLErrorReasonInvalidNameIDPolicy: + return provider.StatusCodeInvalidNameIDPolicy + case SAMLErrorReasonRequestDenied: + return provider.StatusCodeRequestDenied + case SAMLErrorReasonRequestUnsupported: + return provider.StatusCodeRequestUnsupported + case SAMLErrorReasonUnsupportedBinding: + return provider.StatusCodeUnsupportedBinding + default: + return "unspecified error" + } +} diff --git a/internal/domain/saml_request.go b/internal/domain/saml_request.go new file mode 100644 index 0000000000..8cf13be544 --- /dev/null +++ b/internal/domain/saml_request.go @@ -0,0 +1,10 @@ +package domain + +type SAMLRequestState int + +const ( + SAMLRequestStateUnspecified SAMLRequestState = iota + SAMLRequestStateAdded + SAMLRequestStateFailed + SAMLRequestStateSucceeded +) diff --git a/internal/domain/saml_session.go b/internal/domain/saml_session.go new file mode 100644 index 0000000000..ccc968df4c --- /dev/null +++ b/internal/domain/saml_session.go @@ -0,0 +1,9 @@ +package domain + +type SAMLSessionState int32 + +const ( + SAMLSessionStateUnspecified SAMLSessionState = iota + SAMLSessionStateActive + SAMLSessionStateTerminated +) 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/client.go b/internal/integration/client.go index dde8822acd..af30f0e642 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -34,6 +34,7 @@ import ( user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" userschema_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" webkey_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" @@ -65,6 +66,7 @@ type Client struct { WebKeyV3Alpha webkey_v3alpha.ZITADELWebKeysClient IDPv2 idp_pb.IdentityProviderServiceClient UserV3Alpha user_v3alpha.ZITADELUsersClient + SAMLv2 saml_pb.SAMLServiceClient } func newClient(ctx context.Context, target string) (*Client, error) { @@ -96,6 +98,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { WebKeyV3Alpha: webkey_v3alpha.NewZITADELWebKeysClient(cc), IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), + SAMLv2: saml_pb.NewSAMLServiceClient(cc), } return client, client.pollHealth(ctx) } @@ -268,7 +271,7 @@ func (i *Instance) CreateOrganizationWithUserID(ctx context.Context, name, userI return resp } -func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email string) *user_v2.AddHumanUserResponse { +func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email, phone string) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ @@ -289,7 +292,7 @@ func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email strin }, }, Phone: &user_v2.SetHumanPhone{ - Phone: "+41791234567", + Phone: phone, Verification: &user_v2.SetHumanPhone_IsVerified{ IsVerified: true, }, @@ -324,7 +327,7 @@ func (i *Instance) CreateUserIDPlink(ctx context.Context, userID, externalID, id ) } -func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) { +func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) string { reg, err := i.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user_v2.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user_v2.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -347,9 +350,10 @@ func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) { PasskeyName: "nice name", }) logging.OnError(err).Panic("create user passkey") + return pkr.GetPasskeyId() } -func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) { +func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) string { pkr, err := i.Client.UserV2.RegisterU2F(ctx, &user_v2.RegisterU2FRequest{ UserId: userID, Domain: i.Domain, @@ -365,6 +369,21 @@ func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) { TokenName: "nice name", }) logging.OnError(err).Panic("create user u2f") + return pkr.GetU2FId() +} + +func (i *Instance) RegisterUserOTPSMS(ctx context.Context, userID string) { + _, err := i.Client.UserV2.AddOTPSMS(ctx, &user_v2.AddOTPSMSRequest{ + UserId: userID, + }) + logging.OnError(err).Panic("create user sms") +} + +func (i *Instance) RegisterUserOTPEmail(ctx context.Context, userID string) { + _, err := i.Client.UserV2.AddOTPEmail(ctx, &user_v2.AddOTPEmailRequest{ + UserId: userID, + }) + logging.OnError(err).Panic("create user email") } func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, changeRequired bool) *object.Details { @@ -379,7 +398,18 @@ func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, return resp.GetDetails() } +func (i *Instance) AddProviderToDefaultLoginPolicy(ctx context.Context, id string) { + _, err := i.Client.Admin.AddIDPToLoginPolicy(ctx, &admin.AddIDPToLoginPolicyRequest{ + IdpId: id, + }) + logging.OnError(err).Panic("add provider to default login policy") +} + func (i *Instance) AddGenericOAuthProvider(ctx context.Context, name string) *admin.AddGenericOAuthProviderResponse { + return i.AddGenericOAuthProviderWithOptions(ctx, name, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) +} + +func (i *Instance) AddGenericOAuthProviderWithOptions(ctx context.Context, name string, isLinkingAllowed, isCreationAllowed, isAutoCreation bool, autoLinking idp.AutoLinkingOption) *admin.AddGenericOAuthProviderResponse { resp, err := i.Client.Admin.AddGenericOAuthProvider(ctx, &admin.AddGenericOAuthProviderRequest{ Name: name, ClientId: "clientID", @@ -390,11 +420,11 @@ func (i *Instance) AddGenericOAuthProvider(ctx context.Context, name string) *ad Scopes: []string{"openid", "profile", "email"}, IdAttribute: "id", ProviderOptions: &idp.Options{ - IsLinkingAllowed: true, - IsCreationAllowed: true, - IsAutoCreation: true, + IsLinkingAllowed: isLinkingAllowed, + IsCreationAllowed: isCreationAllowed, + IsAutoCreation: isAutoCreation, IsAutoUpdate: true, - AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, + AutoLinking: autoLinking, }, }) logging.OnError(err).Panic("create generic OAuth idp") 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/saml.go b/internal/integration/saml.go new file mode 100644 index 0000000000..bf04246956 --- /dev/null +++ b/internal/integration/saml.go @@ -0,0 +1,223 @@ +package integration + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/brianvoe/gofakeit/v6" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + "github.com/zitadel/logging" + + http_util "github.com/zitadel/zitadel/internal/api/http" + oidc_internal "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/pkg/grpc/management" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + session_pb "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +const spCertificate = `-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUUo5urYkuUHAe7LQ9sZSL+xXAqBwwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIwNDEz +MTE1MFoXDTI1MDEwMzEzMTE1MFowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoACwbGIh8udK +Um1r+yQoPtfswEX6Cb6Y1KwR6WZDYgzHdMyUC5Sy8Bg1H2puUZZukDLuyu6Pqvum +8kfnzhjUR6nNCoUlidwE+yz020w5oOBofRKgJK/FVUuWD3k6kjdP9CrBFLG0PQQ3 +N2e4wilP4czCxizKero2a0e7Eq8OjHAPf8gjM+GWFZgVAbV8uf2Mjt1O2Vfbx5PZ +sLuBZtl5jokx3NiC7my/yj81MbGEDPcQo0emeVBz3J3nVG6Yr4kdCKkvv2dhJ26C +5cL7NIIUY4IQomJNwYC2NaYgSpQOxJHL/HsOPusO4Ia2WtUTXEZUFkxn1u0YuoSx +CkGehF/1OwIDAQABo1MwUTAdBgNVHQ4EFgQUr6S0wA2l3MdfnvfveWDueQtaoJMw +HwYDVR0jBBgwFoAUr6S0wA2l3MdfnvfveWDueQtaoJMwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAH3Q9obyWJaMKFuGJDkIp1RFot79RWTVcAcwA +XTJNfCseLONRIs4MkRxOn6GQBwV2IEqs1+hFG80dcd/c6yYyJ8bziKEyNMtPWrl6 +fdVD+1WnWcD1ZYrS8hgdz0FxXxl/+GjA8Pu6icmnhKgUDTYWns6Rj/gtQtZS8ZoA +JY+T/1mGze2+Xx6pjuArZ7+hnH6EWwo+ckcmXAKyhnkhX7xIo1UFvNY2VWaGl2wU +K2yyJA4Lu/NNmqPnpAcRDsnGP6r4frMhjnPq/ifC3B+6FT3p8dubV9PA0y86bAy5 +0yIgNje4DyWLy/DM9EpdPfJmvUAL6hOtyb8Aa9hR+a8stu7h6g== +-----END CERTIFICATE-----` +const spKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCgALBsYiHy50pS +bWv7JCg+1+zARfoJvpjUrBHpZkNiDMd0zJQLlLLwGDUfam5Rlm6QMu7K7o+q+6by +R+fOGNRHqc0KhSWJ3AT7LPTbTDmg4Gh9EqAkr8VVS5YPeTqSN0/0KsEUsbQ9BDc3 +Z7jCKU/hzMLGLMp6ujZrR7sSrw6McA9/yCMz4ZYVmBUBtXy5/YyO3U7ZV9vHk9mw +u4Fm2XmOiTHc2ILubL/KPzUxsYQM9xCjR6Z5UHPcnedUbpiviR0IqS+/Z2EnboLl +wvs0ghRjghCiYk3BgLY1piBKlA7Ekcv8ew4+6w7ghrZa1RNcRlQWTGfW7Ri6hLEK +QZ6EX/U7AgMBAAECggEAD1aRkwpDO+BdORKhP9WDACc93F647fc1+mk2XFv/yKX1 +9uXnqUaLcsW3TfgrdCnKFouzZYPCBP+TzPUErTanHumRrNj/tLwBRDzWijE/8wKg +MaE39dxdu+P/kiMqcLrZsMvqb3vrjc/aJTcNuJsyO7Cf2VSQ4nv4XIdnUQ60A9VR +OmUp//VULZxImnPx/R304/p5VfOhyXfzBeoxUPogBurjtzkyXVG0EG2enJMMiTix +900fTDez0TQ8V6O59vM04fhtPXvH51OkMTW/HU1QQvlnAJuX06I7k4CaBpF3xPII +QpEbFILq5y6yAQJWELRGWzeoxK6kn6bNfI8S0+oKqQKBgQDg2UM7ruMASpY7B4zj +XNztGDOx9BCdYyHH1O05r+ILmltBC7jFImwIYrHbaX+dg52l0PPImZuBz852IqrC +VAEF30yBn2gWyVzIdo7W3mw9Jgqc4LrhStaJxOuXVoT2/PAuDBF8TJMNH9oLNqiD +aPAI0cVn9BRV7AziEsrMlDLLiQKBgQC2K4Z/caAvwx/AescsN6lp+/m7MeLUpZzQ +myZt44bnR5LouUo3vCYl+Bk8wu6PTd41LUYW/SW26HDDFTKgkBb1zVHfk5QRApaB +VPwZnhcUvNapPOnDp75Qoq238wpfayQlKF1xCawS3N5AWkDaEdfzuH7umFJxVss2 +1tfDsn01owKBgAYWG3nMHBzv5+0lIS0uYFSSqSOSBbkc69cq7lj3Z9kEjp/OH2xG +qEH52fKkgm3TGDta0p6Fee4jn+UWvySPfY+ZIcsIc5raTIaonuk2EBv/oZ3pf2WF +zxTfnbj1AJhm9GFqtjZ1JC3gxNg03I7iEk1K0FsmAj7pKtgbxh2PjWhxAoGBAKBx +BSwJbwOh3r0vZWvUOilV+0SbUyPmGI7Blr8BvTbFGuZNCsi7tP2L3O5e4Kzl7+b1 +0N0+Z5EIdwfaC5TOUup5wroeyDGTDesqZj5JthpVltnHBDuF6WArZsS0EVaojlUL +kACWfC7AyB31X1iwjnng7CpHjZS01JWf8rgw44XxAoGAQ5YYd4WmGYZoJJak7zhb +xnYG7hU7nS7pBPGob1FvjYMw1x/htuJCjxLh08dlzJGM6SFlDn7HVM9ou99w5n+d +xtqmbthw2E9VjSk3zSYb4uFc6mv0C/kRPTDUFH+9CpQTBBx/O016hmcatxlBS6JL +VAV6oE8sEJYHtR6YdZiMWWo= +-----END PRIVATE KEY-----` + +func CreateSAMLSP(root string, idpMetadata *saml.EntityDescriptor, binding string) (*samlsp.Middleware, error) { + rootURL, err := url.Parse(root) + if err != nil { + return nil, err + } + keyPair, err := tls.X509KeyPair([]byte(spCertificate), []byte(spKey)) + if err != nil { + return nil, err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, err + } + + sp, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + UseArtifactResponse: false, + }) + if err != nil { + return nil, err + } + sp.Binding = binding + sp.ResponseBinding = binding + return sp, nil +} + +func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) { + spMetadata, err := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ") + if err != nil { + return nil, err + } + + if m.ResponseBinding == saml.HTTPRedirectBinding { + metadata := strings.Replace(string(spMetadata), saml.HTTPPostBinding, saml.HTTPRedirectBinding, 2) + spMetadata = []byte(metadata) + } + + resp, err := i.Client.Mgmt.AddSAMLApp(ctx, &management.AddSAMLAppRequest{ + ProjectId: projectID, + Name: fmt.Sprintf("app-%s", gofakeit.AppName()), + Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata}, + }) + if err != nil { + return nil, err + } + return resp, await(func() error { + _, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{ + Id: projectID, + }) + if err != nil { + return err + } + _, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ + ProjectId: projectID, + AppId: resp.GetAppId(), + }) + return err + }) +} + +func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (authRequestID string, err error) { + authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding) + if err != nil { + return "", err + } + + redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider) + if err != nil { + return "", err + } + + req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient}) + if err != nil { + return "", fmt.Errorf("get request: %w", err) + } + + loc, err := CheckRedirect(req) + if err != nil { + return "", fmt.Errorf("check redirect: %w", err) + } + + prefixWithHost := i.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()) + } + return strings.TrimPrefix(loc.String(), prefixWithHost), nil +} + +func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse { + resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{ + SamlRequestId: id, + ResponseKind: &saml_pb.CreateResponseRequest_Error{Error: &saml_pb.AuthorizationError{Error: reason}}, + }) + logging.OnError(err).Panic("create human user") + return resp +} + +func (i *Instance) SuccessfulSAMLAuthRequest(ctx context.Context, userId, id string) *saml_pb.CreateResponseResponse { + respSession, err := i.Client.SessionV2.CreateSession(ctx, &session_pb.CreateSessionRequest{ + Checks: &session_pb.Checks{ + User: &session_pb.CheckUser{ + Search: &session_pb.CheckUser_UserId{ + UserId: userId, + }, + }, + }, + }) + logging.OnError(err).Panic("create session") + + resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{ + SamlRequestId: id, + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: respSession.GetSessionId(), + SessionToken: respSession.GetSessionToken(), + }, + }, + }) + logging.OnError(err).Panic("create human user") + return resp +} + +func (i *Instance) GetSAMLIDPMetadata() (*saml.EntityDescriptor, error) { + idpEntityID := http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) + "/saml/v2/metadata" + resp, err := http.Get(idpEntityID) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + entityDescriptor := new(saml.EntityDescriptor) + if err := xml.Unmarshal(data, entityDescriptor); err != nil { + return nil, err + } + + return entityDescriptor, nil +} + +func (i *Instance) Issuer() string { + return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) +} 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/idp_login_policy_link.go b/internal/query/idp_login_policy_link.go index f257e88ad4..bdc2ef15b1 100644 --- a/internal/query/idp_login_policy_link.go +++ b/internal/query/idp_login_policy_link.go @@ -15,10 +15,15 @@ import ( ) type IDPLoginPolicyLink struct { - IDPID string - IDPName string - IDPType domain.IDPType - OwnerType domain.IdentityProviderType + IDPID string + IDPName string + IDPType domain.IDPType + OwnerType domain.IdentityProviderType + IsCreationAllowed bool + IsLinkingAllowed bool + IsAutoCreation bool + IsAutoUpdate bool + AutoLinking domain.AutoLinkingOption } type IDPLoginPolicyLinks struct { @@ -127,6 +132,11 @@ func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase, re IDPTemplateNameCol.identifier(), IDPTemplateTypeCol.identifier(), IDPTemplateOwnerTypeCol.identifier(), + IDPTemplateIsCreationAllowedCol.identifier(), + IDPTemplateIsLinkingAllowedCol.identifier(), + IDPTemplateIsAutoCreationCol.identifier(), + IDPTemplateIsAutoUpdateCol.identifier(), + IDPTemplateAutoLinkingCol.identifier(), countColumn.identifier()). From(idpLoginPolicyLinkTable.identifier()). LeftJoin(join(IDPTemplateIDCol, IDPLoginPolicyLinkIDPIDCol)). @@ -141,29 +151,60 @@ func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase, re var count uint64 for rows.Next() { var ( - idpName = sql.NullString{} - idpType = sql.NullInt16{} - idpOwnerType = sql.NullInt16{} - link = new(IDPLoginPolicyLink) + idpName = sql.NullString{} + idpType = sql.NullInt16{} + idpOwnerType = sql.NullInt16{} + link = new(IDPLoginPolicyLink) + isCreationAllowed = sql.NullBool{} + isLinkingAllowed = sql.NullBool{} + isAutoCreation = sql.NullBool{} + isAutoUpdate = sql.NullBool{} + autoLinking = sql.NullInt16{} ) err := rows.Scan( &link.IDPID, &idpName, &idpType, &idpOwnerType, + &isCreationAllowed, + &isLinkingAllowed, + &isAutoCreation, + &isAutoUpdate, + &autoLinking, &count, ) if err != nil { return nil, err } - link.IDPName = idpName.String + if idpName.Valid { + link.IDPName = idpName.String + } //IDPType 0 is oidc so we have to set unspecified manually if idpType.Valid { link.IDPType = domain.IDPType(idpType.Int16) } else { link.IDPType = domain.IDPTypeUnspecified } - link.OwnerType = domain.IdentityProviderType(idpOwnerType.Int16) + if idpOwnerType.Valid { + link.OwnerType = domain.IdentityProviderType(idpOwnerType.Int16) + } + if isCreationAllowed.Valid { + link.IsCreationAllowed = isCreationAllowed.Bool + } + if isLinkingAllowed.Valid { + link.IsLinkingAllowed = isLinkingAllowed.Bool + } + if isAutoCreation.Valid { + link.IsAutoCreation = isAutoCreation.Bool + } + if isAutoUpdate.Valid { + link.IsAutoUpdate = isAutoUpdate.Bool + } + if autoLinking.Valid { + link.AutoLinking = domain.AutoLinkingOption(autoLinking.Int16) + } else { + link.AutoLinking = domain.AutoLinkingOptionUnspecified + } links = append(links, link) } diff --git a/internal/query/idp_login_policy_link_test.go b/internal/query/idp_login_policy_link_test.go index 45cb8e594d..245eb22ccc 100644 --- a/internal/query/idp_login_policy_link_test.go +++ b/internal/query/idp_login_policy_link_test.go @@ -19,6 +19,11 @@ var ( ` projections.idp_templates6.name,` + ` projections.idp_templates6.type,` + ` projections.idp_templates6.owner_type,` + + ` projections.idp_templates6.is_creation_allowed,` + + ` projections.idp_templates6.is_linking_allowed,` + + ` projections.idp_templates6.is_auto_creation,` + + ` projections.idp_templates6.is_auto_update,` + + ` projections.idp_templates6.auto_linking,` + ` COUNT(*) OVER ()` + ` FROM projections.idp_login_policy_links5` + ` LEFT JOIN projections.idp_templates6 ON projections.idp_login_policy_links5.idp_id = projections.idp_templates6.id AND projections.idp_login_policy_links5.instance_id = projections.idp_templates6.instance_id` + @@ -31,6 +36,11 @@ var ( "name", "type", "owner_type", + "is_creation_allowed", + "is_linking_allowed", + "is_auto_creation", + "is_auto_update", + "auto_linking", "count", } ) @@ -61,6 +71,11 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { "idp-name", domain.IDPTypeJWT, domain.IdentityProviderTypeSystem, + true, + true, + true, + true, + domain.AutoLinkingOptionUsername, }, }, ), @@ -71,10 +86,15 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { }, Links: []*IDPLoginPolicyLink{ { - IDPID: "idp-id", - IDPName: "idp-name", - IDPType: domain.IDPTypeJWT, - OwnerType: domain.IdentityProviderTypeSystem, + IDPID: "idp-id", + IDPName: "idp-name", + IDPType: domain.IDPTypeJWT, + OwnerType: domain.IdentityProviderTypeSystem, + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: domain.AutoLinkingOptionUsername, }, }, }, @@ -94,6 +114,11 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { nil, nil, nil, + false, + false, + false, + false, + 0, }, }, ), @@ -104,9 +129,14 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { }, Links: []*IDPLoginPolicyLink{ { - IDPID: "idp-id", - IDPName: "", - IDPType: domain.IDPTypeUnspecified, + IDPID: "idp-id", + IDPName: "", + IDPType: domain.IDPTypeUnspecified, + IsCreationAllowed: false, + IsLinkingAllowed: false, + IsAutoCreation: false, + IsAutoUpdate: false, + AutoLinking: domain.AutoLinkingOptionUnspecified, }, }, }, diff --git a/internal/query/idp_template.go b/internal/query/idp_template.go index e3250f1ae7..dd36cee196 100644 --- a/internal/query/idp_template.go +++ b/internal/query/idp_template.go @@ -825,6 +825,22 @@ func NewIDPTemplateResourceOwnerListSearchQuery(ids ...string) (SearchQuery, err return NewListQuery(IDPTemplateResourceOwnerCol, list, ListIn) } +func NewIDPTemplateIsCreationAllowedSearchQuery(value bool) (SearchQuery, error) { + return NewBoolQuery(IDPTemplateIsCreationAllowedCol, value) +} + +func NewIDPTemplateIsLinkingAllowedSearchQuery(value bool) (SearchQuery, error) { + return NewBoolQuery(IDPTemplateIsLinkingAllowedCol, value) +} + +func NewIDPTemplateIsAutoCreationSearchQuery(value bool) (SearchQuery, error) { + return NewBoolQuery(IDPTemplateIsAutoCreationCol, value) +} + +func NewIDPTemplateAutoLinkingSearchQuery(value int, method NumberComparison) (SearchQuery, error) { + return NewNumberQuery(IDPTemplateAutoLinkingCol, value, method) +} + func (q *IDPTemplateSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { query = q.SearchRequest.toQuery(query) for _, q := range q.Queries { 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/projection.go b/internal/query/projection/projection.go index 30dd46a3c6..ebe7454b58 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -69,6 +69,7 @@ var ( DeviceAuthProjection *handler.Handler SessionProjection *handler.Handler AuthRequestProjection *handler.Handler + SamlRequestProjection *handler.Handler MilestoneProjection *handler.Handler QuotaProjection *quotaProjection LimitsProjection *handler.Handler @@ -157,6 +158,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"])) SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"])) AuthRequestProjection = newAuthRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["auth_requests"])) + SamlRequestProjection = newSamlRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["saml_requests"])) MilestoneProjection = newMilestoneProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["milestones"])) QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"])) LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"])) @@ -286,6 +288,7 @@ func newProjectionsList() { DeviceAuthProjection, SessionProjection, AuthRequestProjection, + SamlRequestProjection, MilestoneProjection, QuotaProjection.handler, LimitsProjection, diff --git a/internal/query/projection/saml_request.go b/internal/query/projection/saml_request.go new file mode 100644 index 0000000000..610619d31c --- /dev/null +++ b/internal/query/projection/saml_request.go @@ -0,0 +1,132 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + SamlRequestsProjectionTable = "projections.saml_requests" + + SamlRequestColumnID = "id" + SamlRequestColumnCreationDate = "creation_date" + SamlRequestColumnChangeDate = "change_date" + SamlRequestColumnSequence = "sequence" + SamlRequestColumnResourceOwner = "resource_owner" + SamlRequestColumnInstanceID = "instance_id" + SamlRequestColumnLoginClient = "login_client" + SamlRequestColumnIssuer = "issuer" + SamlRequestColumnACS = "acs" + SamlRequestColumnRelayState = "relay_state" + SamlRequestColumnBinding = "binding" +) + +type samlRequestProjection struct{} + +// Name implements handler.Projection. +func (*samlRequestProjection) Name() string { + return SamlRequestsProjectionTable +} + +func newSamlRequestProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(samlRequestProjection)) +} + +func (*samlRequestProjection) Init() *old_handler.Check { + return handler.NewMultiTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(SamlRequestColumnID, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnCreationDate, handler.ColumnTypeTimestamp), + handler.NewColumn(SamlRequestColumnChangeDate, handler.ColumnTypeTimestamp), + handler.NewColumn(SamlRequestColumnSequence, handler.ColumnTypeInt64), + handler.NewColumn(SamlRequestColumnResourceOwner, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnLoginClient, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnIssuer, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnACS, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnRelayState, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnBinding, handler.ColumnTypeText), + }, + handler.NewPrimaryKey(SamlRequestColumnInstanceID, SamlRequestColumnID), + ), + ) +} + +func (p *samlRequestProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: samlrequest.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: samlrequest.AddedType, + Reduce: p.reduceSamlRequestAdded, + }, + { + Event: samlrequest.SucceededType, + Reduce: p.reduceSamlRequestEnded, + }, + { + Event: samlrequest.FailedType, + Reduce: p.reduceSamlRequestEnded, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(SamlRequestColumnInstanceID), + }, + }, + }, + } +} + +func (p *samlRequestProjection) reduceSamlRequestAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*samlrequest.AddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sfwfa", "reduce.wrong.event.type %s", samlrequest.AddedType) + } + + return handler.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(SamlRequestColumnID, e.Aggregate().ID), + handler.NewCol(SamlRequestColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SamlRequestColumnCreationDate, e.CreationDate()), + handler.NewCol(SamlRequestColumnChangeDate, e.CreationDate()), + handler.NewCol(SamlRequestColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SamlRequestColumnSequence, e.Sequence()), + handler.NewCol(SamlRequestColumnLoginClient, e.LoginClient), + handler.NewCol(SamlRequestColumnIssuer, e.Issuer), + handler.NewCol(SamlRequestColumnACS, e.ACSURL), + handler.NewCol(SamlRequestColumnRelayState, e.RelayState), + handler.NewCol(SamlRequestColumnBinding, e.Binding), + }, + ), nil +} + +func (p *samlRequestProjection) reduceSamlRequestEnded(event eventstore.Event) (*handler.Statement, error) { + switch event.(type) { + case *samlrequest.SucceededEvent, + *samlrequest.FailedEvent: + break + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3h", "reduce.wrong.event.type %s", []eventstore.EventType{samlrequest.SucceededType, samlrequest.FailedType}) + } + + return handler.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(SamlRequestColumnID, event.Aggregate().ID), + handler.NewCond(SamlRequestColumnInstanceID, event.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/saml_request_test.go b/internal/query/projection/saml_request_test.go new file mode 100644 index 0000000000..b0fe842d03 --- /dev/null +++ b/internal/query/projection/saml_request_test.go @@ -0,0 +1,123 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestSamlRequestProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceSamlRequestAdded", + args: args{ + event: getEvent(testEvent( + samlrequest.AddedType, + samlrequest.AggregateType, + []byte(`{"login_client": "loginClient", "issuer": "issuer", "acs_url": "acs", "relay_state": "relayState", "binding": "binding"}`), + ), eventstore.GenericEventMapper[samlrequest.AddedEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.saml_requests (id, instance_id, creation_date, change_date, resource_owner, sequence, login_client, issuer, acs, relay_state, binding) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + anyArg{}, + anyArg{}, + "ro-id", + uint64(15), + "loginClient", + "issuer", + "acs", + "relayState", + "binding", + }, + }, + }, + }, + }, + }, + { + name: "reduceSamlRequestFailed", + args: args{ + event: getEvent(testEvent( + samlrequest.FailedType, + samlrequest.AggregateType, + []byte(`{"reason": 0}`), + ), eventstore.GenericEventMapper[samlrequest.FailedEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestEnded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSamlRequestSucceeded", + args: args{ + event: getEvent(testEvent( + samlrequest.SucceededType, + samlrequest.AggregateType, + nil, + ), eventstore.GenericEventMapper[samlrequest.SucceededEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestEnded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !zerrors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, SamlRequestsProjectionTable, tt.want) + }) + } +} 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/saml_request.go b/internal/query/saml_request.go new file mode 100644 index 0000000000..a0f6fdc6cd --- /dev/null +++ b/internal/query/saml_request.go @@ -0,0 +1,81 @@ +package query + +import ( + "context" + "database/sql" + _ "embed" + "errors" + "fmt" + "time" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SamlRequest struct { + ID string + CreationDate time.Time + LoginClient string + Issuer string + ACS string + RelayState string + Binding string +} + +func (a *SamlRequest) checkLoginClient(ctx context.Context) error { + if uid := authz.GetCtxData(ctx).UserID; uid != a.LoginClient { + return zerrors.ThrowPermissionDenied(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient") + } + return nil +} + +//go:embed saml_request_by_id.sql +var samlRequestByIDQuery string + +func (q *Queries) samlRequestByIDQuery(ctx context.Context) string { + return fmt.Sprintf(samlRequestByIDQuery, q.client.Timetravel(call.Took(ctx))) +} + +func (q *Queries) SamlRequestByID(ctx context.Context, shouldTriggerBulk bool, id string, checkLoginClient bool) (_ *SamlRequest, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if shouldTriggerBulk { + _, traceSpan := tracing.NewNamedSpan(ctx, "TriggerSamlRequestProjection") + ctx, err = projection.SamlRequestProjection.Trigger(ctx, handler.WithAwaitRunning()) + logging.OnError(err).Debug("trigger failed") + traceSpan.EndWithError(err) + } + + dst := new(SamlRequest) + err = q.client.QueryRowContext( + ctx, + func(row *sql.Row) error { + return row.Scan( + &dst.ID, &dst.CreationDate, &dst.LoginClient, &dst.Issuer, &dst.ACS, &dst.RelayState, &dst.Binding, + ) + }, + q.samlRequestByIDQuery(ctx), + id, authz.GetInstance(ctx).InstanceID(), + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-Thee9", "Errors.SamlRequest.NotExisting") + } + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Ou8ue", "Errors.Internal") + } + + if checkLoginClient { + if err = dst.checkLoginClient(ctx); err != nil { + return nil, err + } + } + + return dst, nil +} diff --git a/internal/query/saml_request_by_id.sql b/internal/query/saml_request_by_id.sql new file mode 100644 index 0000000000..ac1c60058f --- /dev/null +++ b/internal/query/saml_request_by_id.sql @@ -0,0 +1,11 @@ +select + id, + creation_date, + login_client, + issuer, + acs, + relay_state, + binding +from projections.saml_requests %s +where id = $1 and instance_id = $2 +limit 1; diff --git a/internal/query/saml_request_test.go b/internal/query/saml_request_test.go new file mode 100644 index 0000000000..5cf58369cb --- /dev/null +++ b/internal/query/saml_request_test.go @@ -0,0 +1,127 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + _ "embed" + "fmt" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestQueries_SamlRequestByID(t *testing.T) { + expQuery := regexp.QuoteMeta(fmt.Sprintf( + samlRequestByIDQuery, + asOfSystemTime, + )) + + cols := []string{ + projection.SamlRequestColumnID, + projection.SamlRequestColumnCreationDate, + projection.SamlRequestColumnLoginClient, + projection.SamlRequestColumnIssuer, + projection.SamlRequestColumnACS, + projection.SamlRequestColumnRelayState, + projection.SamlRequestColumnBinding, + } + type args struct { + shouldTriggerBulk bool + id string + checkLoginClient bool + } + tests := []struct { + name string + args args + expect sqlExpectation + want *SamlRequest + wantErr error + }{ + { + name: "success, all values", + args: args{ + shouldTriggerBulk: false, + id: "123", + checkLoginClient: true, + }, + expect: mockQuery(expQuery, cols, []driver.Value{ + "id", + testNow, + "loginClient", + "issuer", + "acs", + "relayState", + "binding", + }, "123", "instanceID"), + want: &SamlRequest{ + ID: "id", + CreationDate: testNow, + LoginClient: "loginClient", + Issuer: "issuer", + ACS: "acs", + RelayState: "relayState", + Binding: "binding", + }, + }, + { + name: "no rows", + args: args{ + shouldTriggerBulk: false, + id: "123", + }, + expect: mockQueryScanErr(expQuery, cols, nil, "123", "instanceID"), + wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-Thee9", "Errors.SamlRequest.NotExisting"), + }, + { + name: "query error", + args: args{ + shouldTriggerBulk: false, + id: "123", + }, + expect: mockQueryErr(expQuery, sql.ErrConnDone, "123", "instanceID"), + wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ou8ue", "Errors.Internal"), + }, + { + name: "wrong login client", + args: args{ + shouldTriggerBulk: false, + id: "123", + checkLoginClient: true, + }, + expect: mockQuery(expQuery, cols, []driver.Value{ + "id", + testNow, + "wrongLoginClient", + "issuer", + "acs", + "relayState", + "binding", + }, "123", "instanceID"), + wantErr: zerrors.ThrowPermissionDeniedf(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execMock(t, tt.expect, func(db *sql.DB) { + q := &Queries{ + client: &database.DB{ + DB: db, + Database: &prepareDB{}, + }, + } + ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") + + got, err := q.SamlRequestByID(ctx, tt.args.shouldTriggerBulk, tt.args.id, tt.args.checkLoginClient) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + }) + } +} 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/query/user_auth_method.go b/internal/query/user_auth_method.go index 3ba794ee0f..0687545aef 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -270,6 +270,14 @@ func NewUserAuthMethodTypesSearchQuery(values ...domain.UserAuthMethodType) (Sea return NewListQuery(UserAuthMethodColumnMethodType, list, ListIn) } +func NewUserAuthMethodStatesSearchQuery(values ...domain.MFAState) (SearchQuery, error) { + list := make([]interface{}, len(values)) + for i, value := range values { + list[i] = value + } + return NewListQuery(UserAuthMethodColumnState, list, ListIn) +} + func (r *UserAuthMethodSearchQueries) AppendResourceOwnerQuery(orgID string) error { query, err := NewUserAuthMethodResourceOwnerSearchQuery(orgID) if err != nil { @@ -306,6 +314,15 @@ func (r *UserAuthMethodSearchQueries) AppendStateQuery(state domain.MFAState) er return nil } +func (r *UserAuthMethodSearchQueries) AppendStatesQuery(state ...domain.MFAState) error { + query, err := NewUserAuthMethodStatesSearchQuery(state...) + if err != nil { + return err + } + r.Queries = append(r.Queries, query) + return nil +} + func (r *UserAuthMethodSearchQueries) AppendAuthMethodQuery(authMethod domain.UserAuthMethodType) error { query, err := NewUserAuthMethodTypeSearchQuery(authMethod) if err != 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/internal/repository/samlrequest/aggregate.go b/internal/repository/samlrequest/aggregate.go new file mode 100644 index 0000000000..551d64c70b --- /dev/null +++ b/internal/repository/samlrequest/aggregate.go @@ -0,0 +1,26 @@ +package samlrequest + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "saml_request" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, instanceID string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: instanceID, + InstanceID: instanceID, + }, + } +} diff --git a/internal/repository/samlrequest/eventstore.go b/internal/repository/samlrequest/eventstore.go new file mode 100644 index 0000000000..85cbec4460 --- /dev/null +++ b/internal/repository/samlrequest/eventstore.go @@ -0,0 +1,10 @@ +package samlrequest + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SessionLinkedType, eventstore.GenericEventMapper[SessionLinkedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, FailedType, eventstore.GenericEventMapper[FailedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SucceededType, eventstore.GenericEventMapper[SucceededEvent]) +} diff --git a/internal/repository/samlrequest/saml_request.go b/internal/repository/samlrequest/saml_request.go new file mode 100644 index 0000000000..b3ecdd753e --- /dev/null +++ b/internal/repository/samlrequest/saml_request.go @@ -0,0 +1,172 @@ +package samlrequest + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + samlRequestEventPrefix = "saml_request." + AddedType = samlRequestEventPrefix + "added" + FailedType = samlRequestEventPrefix + "failed" + SessionLinkedType = samlRequestEventPrefix + "session.linked" + SucceededType = samlRequestEventPrefix + "succeeded" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + + LoginClient string `json:"login_client,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + RelayState string `json:"relay_state,omitempty"` + RequestID string `json:"request_id,omitempty"` + Binding string `json:"binding,omitempty"` + Issuer string `json:"issuer,omitempty"` + Destination string `json:"destination,omitempty"` +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + loginClient, + applicationID string, + acsURL string, + relayState string, + requestID string, + binding string, + issuer string, + destination string, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + LoginClient: loginClient, + ApplicationID: applicationID, + ACSURL: acsURL, + RelayState: relayState, + RequestID: requestID, + Binding: binding, + Issuer: issuer, + Destination: destination, + } +} + +type SessionLinkedEvent struct { + *eventstore.BaseEvent `json:"-"` + + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + AuthTime time.Time `json:"auth_time"` + AuthMethods []domain.UserAuthMethodType `json:"auth_methods"` +} + +func (e *SessionLinkedEvent) Payload() interface{} { + return e +} + +func (e *SessionLinkedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewSessionLinkedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + sessionID, + userID string, + authTime time.Time, + authMethods []domain.UserAuthMethodType, +) *SessionLinkedEvent { + return &SessionLinkedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SessionLinkedType, + ), + SessionID: sessionID, + UserID: userID, + AuthTime: authTime, + AuthMethods: authMethods, + } +} + +func (e *SessionLinkedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +type FailedEvent struct { + *eventstore.BaseEvent `json:"-"` + + Reason domain.SAMLErrorReason `json:"reason,omitempty"` +} + +func (e *FailedEvent) Payload() interface{} { + return e +} + +func (e *FailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + reason domain.SAMLErrorReason, +) *FailedEvent { + return &FailedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + FailedType, + ), + Reason: reason, + } +} + +func (e *FailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +type SucceededEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *SucceededEvent) Payload() interface{} { + return nil +} + +func (e *SucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewSucceededEvent(ctx context.Context, + aggregate *eventstore.Aggregate, +) *SucceededEvent { + return &SucceededEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SucceededType, + ), + } +} + +func (e *SucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} diff --git a/internal/repository/samlsession/aggregate.go b/internal/repository/samlsession/aggregate.go new file mode 100644 index 0000000000..be702bc88a --- /dev/null +++ b/internal/repository/samlsession/aggregate.go @@ -0,0 +1,25 @@ +package samlsession + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "saml_session" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/samlsession/eventstore.go b/internal/repository/samlsession/eventstore.go new file mode 100644 index 0000000000..36688e1814 --- /dev/null +++ b/internal/repository/samlsession/eventstore.go @@ -0,0 +1,12 @@ +package samlsession + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseAddedType, eventstore.GenericEventMapper[SAMLResponseAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseRevokedType, eventstore.GenericEventMapper[SAMLResponseRevokedEvent]) + +} diff --git a/internal/repository/samlsession/saml_session.go b/internal/repository/samlsession/saml_session.go new file mode 100644 index 0000000000..d79ebdfd00 --- /dev/null +++ b/internal/repository/samlsession/saml_session.go @@ -0,0 +1,139 @@ +package samlsession + +import ( + "context" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + samlSessionEventPrefix = "saml_session." + AddedType = samlSessionEventPrefix + "added" + SAMLResponseAddedType = samlSessionEventPrefix + "saml_response.added" + SAMLResponseRevokedType = samlSessionEventPrefix + "saml_response.revoked" +) + +type AddedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserID string `json:"userID"` + UserResourceOwner string `json:"userResourceOwner"` + SessionID string `json:"sessionID"` + EntityID string `json:"entityID"` + Audience []string `json:"audience"` + AuthMethods []domain.UserAuthMethodType `json:"authMethods"` + AuthTime time.Time `json:"authTime"` + PreferredLanguage *language.Tag `json:"preferredLanguage,omitempty"` + UserAgent *domain.UserAgent `json:"userAgent,omitempty"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + userID, + userResourceOwner, + sessionID, + entityID string, + audience []string, + authMethods []domain.UserAuthMethodType, + authTime time.Time, + preferredLanguage *language.Tag, + userAgent *domain.UserAgent, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + UserID: userID, + UserResourceOwner: userResourceOwner, + SessionID: sessionID, + EntityID: entityID, + Audience: audience, + AuthMethods: authMethods, + AuthTime: authTime, + PreferredLanguage: preferredLanguage, + UserAgent: userAgent, + } +} + +type SAMLResponseAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + Lifetime time.Duration `json:"lifetime,omitempty"` +} + +func (e *SAMLResponseAddedEvent) Payload() interface{} { + return e +} + +func (e *SAMLResponseAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SAMLResponseAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewSAMLResponseAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + lifetime time.Duration, +) *SAMLResponseAddedEvent { + return &SAMLResponseAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SAMLResponseAddedType, + ), + ID: id, + Lifetime: lifetime, + } +} + +type SAMLResponseRevokedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *SAMLResponseRevokedEvent) Payload() interface{} { + return e +} + +func (e *SAMLResponseRevokedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SAMLResponseRevokedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewSAMLResponseRevokedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *SAMLResponseRevokedEvent { + return &SAMLResponseRevokedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SAMLResponseRevokedType, + ), + } +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 345acf42b0..d4cf88f62e 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -567,6 +567,13 @@ Errors: Token: Invalid: Токенът е невалиден Expired: Токенът е изтекъл + InvalidClient: Токенът не е издаден за този клиент + SAMLRequest: + AlreadyExists: SAMLRequest вече съществува + NotExisting: SAMLRequest не съществува + WrongLoginClient: SAMLRequest, създаден от друг клиент за влизане + SAMLSession: + InvalidClient: SAMLResponse не е издаден за този клиент Feature: NotExisting: Функцията не съществува TypeNotSupported: Типът функция не се поддържа @@ -641,6 +648,8 @@ AggregateTypes: system: Система session: Сесия web_key: Уеб ключ + saml_request: SAML заявка + saml_session: SAML сесия EventTypes: execution: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 75134b0112..9a990e0828 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -548,6 +548,12 @@ Errors: Invalid: Token je neplatný Expired: Token vypršel InvalidClient: Token nebyl vydán pro tohoto klienta + SAMLRequest: + AlreadyExists: SAMLRequest již existuje + NotExisting: SAMLRequest neexistuje + WrongLoginClient: SAMLRequest vytvořený jiným přihlašovacím klientem + SAMLSession: + InvalidClient: Pro tohoto klienta nebyla vydána odpověď SAMLResponse Feature: NotExisting: Funkce neexistuje TypeNotSupported: Typ funkce není podporován @@ -622,6 +628,8 @@ AggregateTypes: system: Systém session: Sezení web_key: Webový klíč + saml_request: Žádost SAML + saml_session: Relace SAML EventTypes: execution: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 624adf84ce..601fc2ba68 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Token ist ungültig Expired: Token ist abgelaufen InvalidClient: Token wurde nicht für diesen Client ausgestellt + SAMLRequest: + AlreadyExists: SAMLRequest existiert bereits + NotExisting: SAMLRequest existiert nicht + WrongLoginClient: SAMLRequest wurde con einem andere Login-Client erstellt + SAMLSession: + InvalidClient: SAMLResponse wurde nicht für diesen Client ausgestellt Feature: NotExisting: Feature existiert nicht TypeNotSupported: Feature Typ wird nicht unterstützt @@ -624,6 +630,8 @@ AggregateTypes: system: System session: Session web_key: Webschlüssel + saml_request: SAML Request + saml_session: SAML Session EventTypes: execution: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 614e8f7c4a..164b5b6866 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -551,6 +551,12 @@ Errors: Invalid: Token is invalid Expired: Token is expired InvalidClient: Token was not issued for this client + SAMLRequest: + AlreadyExists: SAMLRequest already exists + NotExisting: SAMLRequest does not exist + WrongLoginClient: SAMLRequest created by other login client + SAMLSession: + InvalidClient: SAMLResponse was not issued for this client Feature: NotExisting: Feature does not exist TypeNotSupported: Feature type is not supported @@ -625,6 +631,8 @@ AggregateTypes: system: System session: Session web_key: Web Key + saml_request: SAML Request + saml_session: SAML Session EventTypes: execution: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 4bb1db995a..87da41c37d 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -550,6 +550,12 @@ Errors: Invalid: El token no es válido Expired: El token ha caducado InvalidClient: El token no ha sido emitido para este cliente + SAMLRequest: + AlreadyExists: SAMLRequest ya existe + NotExisting: SAMLRequest no existe + WrongLoginClient: SAMLRequest creado por otro cliente de inicio de sesión + SAMLSession: + InvalidClient: SAMLResponse no ha sido emitido para este cliente Feature: NotExisting: La característica no existe TypeNotSupported: El tipo de característica no es compatible @@ -624,6 +630,8 @@ AggregateTypes: system: Sistema session: Sesión web_key: Clave web + saml_request: Solicitud SAML + saml_session: Sesión SAML EventTypes: execution: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 683e0628e4..b60b7a2ffd 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Le jeton n'est pas valide Expired: Le jeton est expiré InvalidClient: Le token n'a pas été émis pour ce client + SAMLRequest: + AlreadyExists: SAMLRequest existe déjà + NotExisting: SAMLRequest n'existe pas + WrongLoginClient: SAMLRequest créé par un autre client de connexion + SAMLSession: + InvalidClient: SAMLResponse n'a pas été émise pour ce client Feature: NotExisting: La fonctionnalité n'existe pas TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge @@ -624,6 +630,8 @@ AggregateTypes: system: Système session: Session web_key: Clé Web + saml_request: Requête SAML + saml_session: Session SAML EventTypes: execution: diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index c8e2cabb4e..d33b5f47bc 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -549,6 +549,12 @@ Errors: Invalid: A Token érvénytelen Expired: A Token lejárt InvalidClient: A Token nem ehhez a klienshez lett kiadva + SAMLRequest: + AlreadyExists: A SAMLRequest már létezik + NotExisting: A SAMLRequest nem létezik + WrongLoginClient: A SAMLRequest egy másik bejelentkezési ügyfél által létrehozott + SAMLSession: + InvalidClient: SAMLResponse nem lett kiadva ehhez az ügyfélhez Feature: NotExisting: A funkció nem létezik TypeNotSupported: A funkció típusa nem támogatott @@ -599,6 +605,7 @@ Errors: FeatureDisabled: A webkulcs funkció le van tiltva NoActive: Aktív web kulcs nem található NotFound: Web kulcs nem található + AggregateTypes: action: Művelet instance: Példány @@ -622,6 +629,9 @@ AggregateTypes: system: Rendszer session: Munkamenet web_key: Webkulcs + saml_request: SAML-kérés + saml_session: SAML munkamenet + EventTypes: execution: set: Végrehajtási készlet diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 60e4c395ad..449f91ffdc 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token tidak valid Expired: Token sudah habis masa berlakunya InvalidClient: Token tidak dikeluarkan untuk klien ini + SAMLRequest: + AlreadyExists: SAMLRequest sudah ada + NotExisting: SAMLRequest tidak ada + WrongLoginClient: SAMLRequest dibuat oleh klien login lainnya + SAMLSession: + InvalidClient: SAMLResponse tidak dikeluarkan untuk klien ini Feature: NotExisting: Fitur tidak ada TypeNotSupported: Jenis fitur tidak didukung @@ -594,6 +600,7 @@ Errors: FeatureDisabled: Fitur kunci web dinonaktifkan NoActive: Tidak ditemukan kunci web aktif NotFound: Kunci web tidak ditemukan + AggregateTypes: action: Tindakan instance: Contoh @@ -617,6 +624,9 @@ AggregateTypes: system: Sistem session: Sidang web_key: Kunci Web + saml_request: Sesi SAML + saml_session: Permintaan SAML + EventTypes: execution: set: Kumpulan eksekusi diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 33c73b0e08..8310032f7f 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Token non è valido Expired: Token è scaduto InvalidClient: Il token non è stato emesso per questo cliente + SAMLRequest: + AlreadyExists: SAMLRequest esiste già + NotExisting: SAMLRequest non esiste + WrongLoginClient: SAMLRequest creato da un altro client di accesso + SAMLSession: + InvalidClient: SAMLResponse non è stato emesso per questo client Feature: NotExisting: La funzionalità non esiste TypeNotSupported: Il tipo di funzionalità non è supportato @@ -624,6 +630,8 @@ AggregateTypes: system: Sistema session: Sessione web_key: Chiave Web + saml_request: Richiesta SAML + saml_session: Sessione SAML EventTypes: execution: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 33f74a72f5..b61318b537 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -539,6 +539,12 @@ Errors: Invalid: トークンが無効です Expired: トークンの有効期限が切れている InvalidClient: トークンが発行されていません + SAMLRequest: + AlreadyExists: SAMLリクエストはすでに存在します + NotExisting: SAMLリクエストが存在しません + WrongLoginClient: 他のログイン クライアントによって作成された SAMLRequest + SAMLSession: + InvalidClient: このクライアントに対してSAMLResponseは発行されませんでした Feature: NotExisting: 機能が存在しません TypeNotSupported: 機能タイプはサポートされていません @@ -613,6 +619,8 @@ AggregateTypes: system: システム session: セッション web_key: Web キー + saml_request: SAML リクエスト + saml_session: SAMLセッション EventTypes: execution: diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index fda5171de2..741f075ca2 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -550,6 +550,12 @@ Errors: Invalid: 토큰이 유효하지 않습니다 Expired: 토큰이 만료되었습니다 InvalidClient: 토큰이 이 클라이언트에 대해 발행되지 않았습니다 + SAMLRequest: + AlreadyExists: SAMLRequest가 이미 존재합니다 + NotExisting: SAMLRequest가 존재하지 않습니다 + WrongLoginClient: 다른 로그인 클라이언트가 생성한 SAMLRequest + SAMLSession: + InvalidClient: 이 클라이언트에 대해 SAMLResponse가 발행되지 않았습니다. Feature: NotExisting: 기능이 존재하지 않습니다 TypeNotSupported: 기능 유형이 지원되지 않습니다 @@ -624,6 +630,8 @@ AggregateTypes: system: 시스템 session: 세션 web_key: 웹 키 + saml_request: SAML 요청 + saml_session: SAML 세션 EventTypes: execution: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 84145c4730..4f320ca5c9 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -549,6 +549,12 @@ Errors: Invalid: токенот е неважечки Expired: токенот е истечен InvalidClient: Токен не беше издаден на овој клиент + SAMLRequest: + AlreadyExists: SAMLRequest веќе постои + NotExisting: SAMLRequest не постои + WrongLoginClient: SAML Барање создадено од друг клиент за најавување + SAMLSession: + InvalidClient: SAMLResponse не беше издаден за овој клиент Feature: NotExisting: Функцијата не постои TypeNotSupported: Типот на функција не е поддржан @@ -623,6 +629,8 @@ AggregateTypes: system: Систем session: Сесија web_key: Веб клуч + saml_request: Барање SAML + saml_session: SAML сесија EventTypes: execution: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 2cf0854ce5..0b6c2eeb84 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Token is ongeldig Expired: Token is verlopen InvalidClient: Token is niet uitgegeven voor deze client + SAMLRequest: + AlreadyExists: SAMLRequest bestaat al + NotExisting: SAMLRequest bestaat niet + WrongLoginClient: SAMLRequest aangemaakt door andere login client + SAMLSession: + InvalidClient: SAMLResponse is niet uitgegeven voor deze client Feature: NotExisting: Functie bestaat niet TypeNotSupported: Functie type wordt niet ondersteund @@ -624,6 +630,8 @@ AggregateTypes: system: Systeem session: Sessie web_key: Websleutel + saml_request: SAML-aanvraag + saml_session: SAML-sessie EventTypes: execution: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 24cc746a49..83645e525d 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Token jest nieprawidłowy Expired: Token wygasł InvalidClient: Token nie został wydany dla tego klienta + SAMLRequest: + AlreadyExists: SAMLRequest już istnieje + NotExisting: SAMLRequest nie istnieje + WrongLoginClient: SAMLRequest utworzony przez innego klienta logowania + SAMLSession: + InvalidClient: SAMLResponse nie został wydany dla tego klienta Feature: NotExisting: Funkcja nie istnieje TypeNotSupported: Typ funkcji nie jest obsługiwany @@ -624,6 +630,8 @@ AggregateTypes: system: System session: Sesja web_key: Klucz internetowy + saml_request: Żądanie SAML + saml_session: Sesja SAML EventTypes: execution: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 3dbf5901f0..a30e1bedaf 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -545,6 +545,16 @@ Errors: WrongLoginClient: A solicitação de autenticação foi criada por outro cliente de login OIDCSession: RefreshTokenInvalid: O Refresh Token é inválido + Token: + Invalid: O token é inválido + Expired: O token expirou + InvalidClient: O token não foi emitido para este cliente + SAMLRequest: + AlreadyExists: O SAMLRequest já existe + NotExisting: O SAMLRequest não existe + WrongLoginClient: SAMLRequest criado por outro cliente de login + SAMLSession: + InvalidClient: O SAMLResponse não foi emitido para este cliente Feature: NotExisting: O recurso não existe TypeNotSupported: O tipo de recurso não é compatível @@ -619,6 +629,8 @@ AggregateTypes: system: Sistema session: Sessão web_key: Chave da Web + saml_request: Solicitação SAML + saml_session: Sessão SAML EventTypes: execution: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 6d334a62a4..f1eab4365e 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -539,6 +539,12 @@ Errors: Invalid: Токен недействителен Expired: Срок действия токена истек InvalidClient: Токен не был выпущен для этого клиента + SAMLRequest: + AlreadyExists: SAMLRequest уже существует + NotExisting: SAMLRequest не существует + WrongLoginClient: SAMLRequest создан другим клиентом входа + SAMLSession: + InvalidClient: SAMLResponse не был отправлен для этого клиента Feature: NotExisting: ункция не существует TypeNotSupported: Тип объекта не поддерживается @@ -613,6 +619,8 @@ AggregateTypes: system: Система session: Сеанс web_key: Веб-ключ + saml_request: SAML-запрос + saml_session: Сессия SAML EventTypes: execution: diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 91198335b8..e31095b78c 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token är ogiltig Expired: Token har gått ut InvalidClient: Token utfärdades inte för denna klient + SAMLRequest: + AlreadyExists: SAMLRequest finns redan + NotExisting: SAMLRequest finns inte + WrongLoginClient: SAMLRequest skapad av annan inloggningsklient + SAMLSession: + InvalidClient: SAMLResponse utfärdades inte för den här klienten Feature: NotExisting: Funktionen existerar inte TypeNotSupported: Funktionstypen stöds inte @@ -623,6 +629,8 @@ AggregateTypes: system: System session: Session web_key: Webbnyckel + saml_request: SAML-förfrågan + saml_session: SAML-session EventTypes: execution: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 6e053c9687..4c18ed458f 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -550,6 +550,12 @@ Errors: Invalid: 令牌无效 Expired: 令牌已过期 InvalidClient: 没有为该客户发放令牌 + SAMLRequest: + AlreadyExists: SAMLRequest 已存在 + NotExisting: SAMLRequest不存在 + WrongLoginClient: 其他登录客户端创建的 SAMLRequest + SAMLSession: + InvalidClient: 未向该客户端发出 SAMLResponse Feature: NotExisting: 功能不存在 TypeNotSupported: 不支持功能类型 @@ -624,6 +630,8 @@ AggregateTypes: system: 系统 session: 会话 web_key: Web 密钥 + saml_request: SAML 请求 + saml_session: SAML 会话 EventTypes: execution: diff --git a/pkg/grpc/saml/v2/saml.go b/pkg/grpc/saml/v2/saml.go new file mode 100644 index 0000000000..0f96597339 --- /dev/null +++ b/pkg/grpc/saml/v2/saml.go @@ -0,0 +1,3 @@ +package saml + +type Redirect = isCreateResponseRequest_ResponseKind 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 { diff --git a/proto/zitadel/oidc/v2/oidc_service.proto b/proto/zitadel/oidc/v2/oidc_service.proto index 85044e9570..3c36057afa 100644 --- a/proto/zitadel/oidc/v2/oidc_service.proto +++ b/proto/zitadel/oidc/v2/oidc_service.proto @@ -169,8 +169,8 @@ message CreateCallbackRequest { string auth_request_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "Set this field when the authorization flow failed. It creates a callback URL to the application, with the error details set."; - ref: "https://openid.net/specs/openid-connect-core-1_0.html#AuthError"; + description: "ID of the Auth Request."; + example: "\"163840776835432705\""; } ]; diff --git a/proto/zitadel/saml/v2/authorization.proto b/proto/zitadel/saml/v2/authorization.proto new file mode 100644 index 0000000000..c93f8d7b98 --- /dev/null +++ b/proto/zitadel/saml/v2/authorization.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package zitadel.saml.v2; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml"; + +message SAMLRequest{ + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + external_docs: { + url: "https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html"; + description: "Find out more about SAMLRequest parameters"; + } + }; + + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of the SAMLRequest"; + } + ]; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Time when the SAMLRequest was created"; + } + ]; + + string issuer = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "SAML entityID of the application that created the SAMLRequest"; + } + ]; + + string assertion_consumer_service = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "URL which points back to the assertion consumer service of the application"; + } + ]; + + string relay_state = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "RelayState provided by the application for the request"; + } + ]; + + string binding = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Binding used by the application for the request"; + } + ]; +} + +message AuthorizationError { + ErrorReason error = 1; + optional string error_description = 2; +} + +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + + ERROR_REASON_VERSION_MISSMATCH = 1; + ERROR_REASON_AUTH_N_FAILED = 2; + ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE = 3; + ERROR_REASON_INVALID_NAMEID_POLICY = 4; + ERROR_REASON_REQUEST_DENIED =5; + ERROR_REASON_REQUEST_UNSUPPORTED = 6; + ERROR_REASON_UNSUPPORTED_BINDING = 7; +} \ No newline at end of file diff --git a/proto/zitadel/saml/v2/saml_service.proto b/proto/zitadel/saml/v2/saml_service.proto new file mode 100644 index 0000000000..3198cf3086 --- /dev/null +++ b/proto/zitadel/saml/v2/saml_service.proto @@ -0,0 +1,227 @@ +syntax = "proto3"; + +package zitadel.saml.v2; + +import "zitadel/object/v2/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/saml/v2/authorization.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "SAML Service"; + version: "2.0"; + description: "Get SAML Auth Request details and create callback URLs."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +service SAMLService { + rpc GetSAMLRequest (GetSAMLRequestRequest) returns (GetSAMLRequestResponse) { + option (google.api.http) = { + get: "/v2/saml/saml_requests/{saml_request_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get SAML Request details"; + description: "Get SAML Request details by ID. Returns details that are parsed from the application's SAML Request." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc CreateResponse (CreateResponseRequest) returns (CreateResponseResponse) { + option (google.api.http) = { + post: "/v2/saml/saml_requests/{saml_request_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Finalize a SAML Request and get the response."; + description: "Finalize a SAML Request and get the response definition for success or failure. The response must be handled as per the SAML definition to inform the application about the success or failure. On success, the response contains details for the application to obtain the SAMLResponse. This method can only be called once for an SAML request." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} + +message GetSAMLRequestRequest { + // ID of the SAML Request, as obtained from the redirect URL. + string saml_request_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message GetSAMLRequestResponse { + SAMLRequest saml_request = 1; +} + +message CreateResponseRequest { + // ID of the SAML Request. + string saml_request_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432705\""; + } + ]; + + oneof response_kind { + option (validate.required) = true; + Session session = 2; + // Set this field when the authorization flow failed. It creates a response depending on the SP, with the error details set. + AuthorizationError error = 3; + } +} + +message Session { + // ID of the session, used to login the user. Connects the session to the SAML Request. + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; + + // Token to verify the session is valid. + string session_token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + } + ]; +} + +message CreateResponseResponse { + zitadel.object.v2.Details details = 1; + // URL including the Assertion Consumer Service where the user should be redirected or has to call per POST, depending on the binding. Contains details for the application to obtain the response on success, or error details on failure. Note that this field must be treated as credentials, as the contained SAMLResponse or code can be used on behalve of the user. + string url = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://client.example.org/cb\"" + } + ]; + // Binding is defined through the request, what the IDP is able to use and what bindings are available for the SP. + oneof binding { + // Set if the binding is Redirect-Binding, where the user can directly be redirected to the application, using a \"302 FOUND\" status to the URL. + RedirectResponse redirect = 3; + // Set if the binding is POST-Binding, where the application expects to be called per HTTP POST with the SAMLResponse and RelayState in the form body. + PostResponse post = 4; + } +} + +message RedirectResponse{} +message PostResponse{ + string relay_state = 1; + string saml_response = 2; +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/login_settings.proto b/proto/zitadel/settings/v2/login_settings.proto index 9fdbb45993..2b5194f0b5 100644 --- a/proto/zitadel/settings/v2/login_settings.proto +++ b/proto/zitadel/settings/v2/login_settings.proto @@ -6,6 +6,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; import "protoc-gen-openapiv2/options/annotations.proto"; import "zitadel/settings/v2/settings.proto"; +import "zitadel/idp/v2/idp.proto"; import "google/protobuf/duration.proto"; message LoginSettings { @@ -134,6 +135,7 @@ message IdentityProvider { string id = 1; string name = 2; IdentityProviderType type = 3; + zitadel.idp.v2.Options options = 4; } enum IdentityProviderType { diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index cc8e5d05cc..77c20eb1c6 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -324,7 +324,7 @@ service SettingsService { }; } -// Get the security settings + // Get the security settings rpc GetSecuritySettings(GetSecuritySettingsRequest) returns (GetSecuritySettingsResponse) { option (google.api.http) = { get: "/v2/settings/security"; @@ -343,7 +343,7 @@ service SettingsService { }; } -// Set the security settings + // Set the security settings rpc SetSecuritySettings(SetSecuritySettingsRequest) returns (SetSecuritySettingsResponse) { option (google.api.http) = { put: "/v2/policies/security"; @@ -429,6 +429,10 @@ message GetLockoutSettingsResponse { message GetActiveIdentityProvidersRequest { zitadel.object.v2.RequestContext ctx = 1; + optional bool creation_allowed = 2; + optional bool linking_allowed = 3; + optional bool auto_creation = 4; + optional bool auto_linking = 5; } message GetActiveIdentityProvidersResponse { diff --git a/proto/zitadel/user/v2/query.proto b/proto/zitadel/user/v2/query.proto index 53f3446bca..71bb6dc594 100644 --- a/proto/zitadel/user/v2/query.proto +++ b/proto/zitadel/user/v2/query.proto @@ -30,6 +30,7 @@ message SearchQuery { NotQuery not_query = 13; InUserEmailsQuery in_user_emails_query = 14; OrganizationIdQuery organization_id_query = 15; + PhoneQuery phone_query = 16; } } @@ -184,6 +185,26 @@ message EmailQuery { ]; } +// Query for users with a specific phone. +message PhoneQuery { + string number = 1 [ + (validate.rules).string = {max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Phone number of the user" + min_length: 1; + max_length: 20; + example: "\"+41791234567\""; + } + ]; + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + // Query for users with a specific state. message LoginNameQuery { string login_name = 1 [ diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index cfeebbf33d..b569b81bbd 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -276,6 +276,36 @@ message Passkey { ]; } +message AuthFactor { + AuthFactorState state = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the auth factor"; + } + ]; + oneof type { + AuthFactorOTP otp = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "TOTP second factor" + } + ]; + AuthFactorU2F u2f = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "U2F second factor" + } + ]; + AuthFactorOTPSMS otp_sms = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "SMS second factor" + } + ]; + AuthFactorOTPEmail otp_email = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Email second factor" + } + ]; + } +} + enum AuthFactorState { AUTH_FACTOR_STATE_UNSPECIFIED = 0; AUTH_FACTOR_STATE_NOT_READY = 1; @@ -283,6 +313,23 @@ enum AuthFactorState { AUTH_FACTOR_STATE_REMOVED = 3; } +message AuthFactorOTP {} +message AuthFactorOTPSMS {} +message AuthFactorOTPEmail {} + +message AuthFactorU2F { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + string name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"fido key\"" + } + ]; +} + message SendInviteCode { // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. // If no template is set, the default ZITADEL url will be used. diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 83b025bf0a..7e5b8a02e8 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -252,9 +252,34 @@ service UserService { }; } + // Send code to verify user email + // + // Send code to verify user email. + rpc SendEmailCode (SendEmailCodeRequest) returns (SendEmailCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/email/send" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + // Verify the email // - // Verify the email with the generated code.. + // Verify the email with the generated code. rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/verify" @@ -1085,6 +1110,28 @@ service UserService { }; } + rpc ListAuthenticationFactors(ListAuthenticationFactorsRequest) returns (ListAuthenticationFactorsResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/authentication_factors/_search" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + + } + // Create an invite code for a user // // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. @@ -1310,6 +1357,29 @@ message ResendEmailCodeResponse{ optional string verification_code = 2; } +message SendEmailCodeRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // if no verification is specified, an email is sent with the default url + oneof verification { + SendEmailVerificationCode send_code = 2; + ReturnEmailVerificationCode return_code = 3; + } +} + +message SendEmailCodeResponse{ + zitadel.object.v2.Details details = 1; + // in case the verification was set to return_code, the code will be returned + optional string verification_code = 2; +} + message VerifyEmailRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -2168,6 +2238,41 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; } +message ListAuthenticationFactorsRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + repeated AuthFactors auth_factors = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the Auth Factors you are interested in" + default: "All Auth Factors" + } + ]; + repeated AuthFactorState states = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the state of the Auth Factors" + default: "Auth Factors that are ready" + } + ]; +} + +enum AuthFactors { + OTP = 0; + OTP_SMS = 1; + OTP_EMAIL = 2; + U2F = 3; +} + +message ListAuthenticationFactorsResponse { + repeated zitadel.user.v2.AuthFactor result = 1; +} + message CreateInviteCodeRequest { string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, diff --git a/proto/zitadel/user/v2beta/query.proto b/proto/zitadel/user/v2beta/query.proto index e339cdde71..caf02df747 100644 --- a/proto/zitadel/user/v2beta/query.proto +++ b/proto/zitadel/user/v2beta/query.proto @@ -30,6 +30,7 @@ message SearchQuery { NotQuery not_query = 13; InUserEmailsQuery in_user_emails_query = 14; OrganizationIdQuery organization_id_query = 15; + PhoneQuery phone_query = 16; } } @@ -184,6 +185,26 @@ message EmailQuery { ]; } +// Query for users with a specific phone. +message PhoneQuery { + string number = 1 [ + (validate.rules).string = {max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Phone number of the user" + min_length: 1; + max_length: 20; + example: "\"+41791234567\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + // Query for users with a specific state. message LoginNameQuery { string login_name = 1 [