feat: specify login UI version on instance and apps (#9071)

# Which Problems Are Solved

To be able to migrate or test the new login UI, admins might want to
(temporarily) switch individual apps.
At a later point admin might want to make sure all applications use the
new login UI.

# How the Problems Are Solved

- Added a feature flag `` on instance level to require all apps to use
the new login and provide an optional base url.
- if the flag is enabled, all (OIDC) applications will automatically use
the v2 login.
  - if disabled, applications can decide based on their configuration
- Added an option on OIDC apps to use the new login UI and an optional
base url.
- Removed the requirement to use `x-zitadel-login-client` to be
redirected to the login V2 and retrieve created authrequest and link
them to SSO sessions.
- Added a new "IAM_LOGIN_CLIENT" role to allow management of users,
sessions, grants and more without `x-zitadel-login-client`.

# Additional Changes

None

# Additional Context

closes https://github.com/zitadel/zitadel/issues/8702
This commit is contained in:
Livio Spring 2024-12-19 10:37:46 +01:00 committed by GitHub
parent b5e92a6144
commit 50d2b26a28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 1670 additions and 321 deletions

View File

@ -1289,6 +1289,7 @@ InternalAuthZ:
- "project.grant.member.delete"
- "events.read"
- "milestones.read"
- "session.read"
- "session.delete"
- "action.target.read"
- "action.target.write"
@ -1481,6 +1482,43 @@ InternalAuthZ:
- "project.grant.member.write"
- "project.grant.member.delete"
- "session.delete"
- Role: "IAM_LOGIN_CLIENT"
Permissions:
- "iam.read"
- "iam.policy.read"
- "iam.member.read"
- "iam.member.write"
- "iam.idp.read"
- "iam.feature.read"
- "iam.restrictions.read"
- "org.read"
- "org.member.read"
- "org.member.write"
- "org.idp.read"
- "org.feature.read"
- "user.read"
- "user.write"
- "user.grant.read"
- "user.grant.write"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.member.write"
- "project.role.read"
- "project.app.read"
- "project.member.read"
- "project.member.write"
- "project.grant.read"
- "project.grant.member.read"
- "project.grant.member.write"
- "session.read"
- "session.link"
- "session.delete"
- "userschema.read"
- Role: "ORG_USER_MANAGER"
Permissions:
- "org.read"

27
cmd/setup/42.go Normal file
View File

@ -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"
}

2
cmd/setup/42.sql Normal file
View File

@ -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;

View File

@ -127,6 +127,7 @@ type Steps struct {
s37Apps7OIDConfigsBackChannelLogoutURI *Apps7OIDConfigsBackChannelLogoutURI
s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart
s40InitPushFunc *InitPushFunc
s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion
}
func MustNewSteps(v *viper.Viper) *Steps {

View File

@ -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")
}

View File

@ -147,6 +147,22 @@
>
{{ 'APP.OIDC.REFRESHTOKEN' | translate }}
</mat-checkbox>
<mat-checkbox
*ngIf="loginV2"
color="primary"
class="rt"
[formControl]="loginV2"
name="loginV2"
matTooltip="{{ 'APP.LOGINV2DESC' | translate }}"
>
{{ 'APP.LOGINV2.USEV2' | translate }}
</mat-checkbox>
<cnsl-form-field class="app-formfield">
<cnsl-label>{{ 'APP.LOGINV2.BASEURL' | translate }}</cnsl-label>
<input cnslInput formControlName="loginV2BaseURL" />
</cnsl-form-field>
</div>
</div>

View File

@ -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<boolean>(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<boolean> | null {
return this.oidcForm.get('loginV2') as FormControl<boolean>;
}
public get loginV2BaseURL(): AbstractControl | null {
return this.oidcForm.get('loginV2BaseURL');
}
public get apiAuthMethodType(): AbstractControl | null {
return this.apiForm.get('authMethodType') as UntypedFormControl;
}

View File

@ -2537,6 +2537,10 @@
"CLIENTSECRETREGENERATED": "генерирана клиентска тайна.",
"DELETED": "Приложението е изтрито.",
"CONFIGCHANGED": "Открити са промени!"
},
"LOGINV2": {
"USEV2": "Използвайте новия интерфейс за вход",
"BASEURL": "Персонализиран основен URL адрес за новия интерфейс за вход"
}
},
"GENDERS": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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" },

View File

@ -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": {

View File

@ -2532,6 +2532,10 @@
"CLIENTSECRETREGENERATED": "クライアントシークレットが生成されました。",
"DELETED": "アプリが削除されました。",
"CONFIGCHANGED": "変更を検出しました!"
},
"LOGINV2": {
"USEV2": "新しいログインUIを使用する",
"BASEURL": "新しいログインUIのカスタムベースURL"
}
},
"GENDERS": {

View File

@ -2538,6 +2538,10 @@
"CLIENTSECRETREGENERATED": "Клиентската тајна е генерирана.",
"DELETED": "Апликацијата е избришана.",
"CONFIGCHANGED": "Детектирани промени!"
},
"LOGINV2": {
"USEV2": "Користете нов интерфејс за најава",
"BASEURL": "Прилагоден основен URL за новиот интерфејс за најава"
}
},
"GENDERS": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -2649,6 +2649,10 @@
"CLIENTSECRETREGENERATED": "Клиентский ключ сгенерирован.",
"DELETED": "Приложение удалено.",
"CONFIGCHANGED": "Обнаружены изменения!"
},
"LOGINV2": {
"USEV2": "Использовать новый интерфейс входа",
"BASEURL": "Настраиваемый базовый URL для нового интерфейса входа"
}
},
"GENDERS": {

View File

@ -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": {

View File

@ -2541,6 +2541,10 @@
"CLIENTSECRETREGENERATED": "客户端秘钥已生成。",
"DELETED": "应用已删除。",
"CONFIGCHANGED": "检测到变化!"
},
"LOGINV2": {
"USEV2": "使用新的登录UI",
"BASEURL": "新的登录UI的自定义基本URL"
}
},
"GENDERS": {

View File

@ -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) {

View File

@ -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) {

View File

@ -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,

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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()
}())

View File

@ -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
}
}

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -237,7 +237,7 @@ func TestServer_GetActiveIdentityProviders(t *testing.T) {
{
name: "permission error",
args: args{
ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin),
ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
req: &settings.GetActiveIdentityProvidersRequest{},
},
wantErr: true,

View File

@ -43,7 +43,7 @@ func TestMain(m *testing.M) {
Instance = integration.NewInstance(ctx)
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin)
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission)
IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
SystemCTX = integration.WithSystemAuthorization(ctx)
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)

View File

@ -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)

View File

@ -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.

View File

@ -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 {

View File

@ -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) {

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -156,6 +156,8 @@ func TestCommandSide_AddInstanceDomain(t *testing.T) {
[]string{"https://sub.test.ch"},
false,
"",
domain.LoginVersionUnspecified,
"",
),
),
),

View File

@ -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) {

View File

@ -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
}

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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,
"",
),
),
),

View File

@ -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,
}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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
)

View File

@ -46,6 +46,8 @@ type OIDCApp struct {
AdditionalOrigins []string
SkipNativeAppSuccessPage bool
BackChannelLogoutURI string
LoginVersion LoginVersion
LoginBaseURI string
State AppState
}

View File

@ -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"

View File

@ -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"`
}

View File

@ -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.

View File

@ -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) {

View File

@ -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))
}

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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,
},
},
},

View File

@ -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
}
}

View File

@ -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")

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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",
},

View File

@ -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),

View File

@ -88,6 +88,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer {
Event: feature_v2.SystemEnableBackChannelLogout,
Reduce: reduceSystemSetFeature[bool],
},
{
Event: feature_v2.SystemLoginVersion,
Reduce: reduceSystemSetFeature[*feature.LoginV2],
},
},
}}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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]])
}

View File

@ -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 (

View File

@ -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),

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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.";
}
];
}

View File

@ -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.";
}
];
}

View File

@ -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 {