diff --git a/cmd/setup/50.go b/cmd/setup/50.go new file mode 100644 index 0000000000..fea69f79ce --- /dev/null +++ b/cmd/setup/50.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 50.sql + addUsePKCE string +) + +type IDPTemplate6UsePKCE struct { + dbClient *database.DB +} + +func (mig *IDPTemplate6UsePKCE) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addUsePKCE) + return err +} + +func (mig *IDPTemplate6UsePKCE) String() string { + return "50_idp_templates6_add_use_pkce" +} diff --git a/cmd/setup/50.sql b/cmd/setup/50.sql new file mode 100644 index 0000000000..4ff0fd7042 --- /dev/null +++ b/cmd/setup/50.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS projections.idp_templates6_oauth2 ADD COLUMN IF NOT EXISTS use_pkce BOOLEAN; +ALTER TABLE IF EXISTS projections.idp_templates6_oidc ADD COLUMN IF NOT EXISTS use_pkce BOOLEAN; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0153f7227f..6706d219e6 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -138,6 +138,7 @@ type Steps struct { s47FillMembershipFields *FillMembershipFields s48Apps7SAMLConfigsLoginVersion *Apps7SAMLConfigsLoginVersion s49InitPermittedOrgsFunction *InitPermittedOrgsFunction + s50IDPTemplate6UsePKCE *IDPTemplate6UsePKCE } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 74b16355f3..a4bfc42403 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -175,6 +175,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s47FillMembershipFields = &FillMembershipFields{eventstore: eventstoreClient} steps.s48Apps7SAMLConfigsLoginVersion = &Apps7SAMLConfigsLoginVersion{dbClient: dbClient} steps.s49InitPermittedOrgsFunction = &InitPermittedOrgsFunction{eventstoreClient: dbClient} + steps.s50IDPTemplate6UsePKCE = &IDPTemplate6UsePKCE{dbClient: dbClient} err = projection.Create(ctx, dbClient, 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.s46InitPermissionFunctions, steps.s47FillMembershipFields, steps.s49InitPermittedOrgsFunction, + steps.s50IDPTemplate6UsePKCE, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/cmd/start/start.go b/cmd/start/start.go index 4091213d2d..7e574d88a8 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -143,10 +143,6 @@ type Server struct { func startZitadel(ctx context.Context, config *Config, masterKey string, server chan<- *Server) error { showBasicInformation(config) - // sink Server is stubbed out in production builds, see function's godoc. - closeSink := sink.StartServer() - defer closeSink() - i18n.MustLoadSupportedLanguagesFromDir() dbClient, err := database.Connect(config.Database, false) @@ -254,6 +250,10 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server } defer commands.Close(ctx) // wait for background jobs + // sink Server is stubbed out in production builds, see function's godoc. + closeSink := sink.StartServer(commands) + defer closeSink() + clock := clockpkg.New() actionsExecutionStdoutEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, &logstore.EmitterConfig{Enabled: config.LogStore.Execution.Stdout.Enabled}, stdout.NewStdoutEmitter[*record.ExecutionLog]()) if err != nil { @@ -560,7 +560,7 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { + if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure, keys.OIDC)); err != nil { return nil, err } // After SAML provider so that the callback endpoint can be used diff --git a/console/src/app/modules/filter-org/filter-org.component.ts b/console/src/app/modules/filter-org/filter-org.component.ts index 8e100971d0..220b219358 100644 --- a/console/src/app/modules/filter-org/filter-org.component.ts +++ b/console/src/app/modules/filter-org/filter-org.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -27,9 +27,10 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { constructor( router: Router, + destroyRef: DestroyRef, protected override route: ActivatedRoute, ) { - super(router, route); + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-project/filter-project.component.ts b/console/src/app/modules/filter-project/filter-project.component.ts index b884024c2c..92556d311d 100644 --- a/console/src/app/modules/filter-project/filter-project.component.ts +++ b/console/src/app/modules/filter-project/filter-project.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -23,8 +23,8 @@ export class FilterProjectComponent extends FilterComponent implements OnInit { public searchQueries: ProjectQuery[] = []; public states: ProjectState[] = [ProjectState.PROJECT_STATE_ACTIVE, ProjectState.PROJECT_STATE_INACTIVE]; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts index 3c17e8c208..dccaed13e5 100644 --- a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts +++ b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -29,8 +29,8 @@ export class FilterUserGrantsComponent extends FilterComponent implements OnInit public SubQuery: any = SubQuery; public searchQueries: UserGrantQuery[] = []; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user/filter-user.component.html b/console/src/app/modules/filter-user/filter-user.component.html index c5d3d9a820..907ea6d18d 100644 --- a/console/src/app/modules/filter-user/filter-user.component.html +++ b/console/src/app/modules/filter-user/filter-user.component.html @@ -1,4 +1,4 @@ - +
{ - const { filter } = params; - if (filter) { - const stringifiedFilters = filter as string; + this.route.queryParamMap + .pipe( + take(1), + map((params) => params.get('filter')), + filter(Boolean), + ) + .subscribe((stringifiedFilters) => { const filters: UserSearchQuery.AsObject[] = JSON.parse(stringifiedFilters) as UserSearchQuery.AsObject[]; const userQueries = filters.map((filter) => { @@ -94,8 +97,7 @@ export class FilterUserComponent extends FilterComponent implements OnInit { this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); // this.showFilter = true; // this.filterOpen.emit(true); - } - }); + }); } public changeCheckbox(subquery: SubQuery, event: MatCheckboxChange) { diff --git a/console/src/app/modules/filter/filter.component.ts b/console/src/app/modules/filter/filter.component.ts index ce2cc15c08..dac94525d9 100644 --- a/console/src/app/modules/filter/filter.component.ts +++ b/console/src/app/modules/filter/filter.component.ts @@ -1,7 +1,6 @@ import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay'; -import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable, Subject, takeUntil } from 'rxjs'; import { SearchQuery as MemberSearchQuery } from 'src/app/proto/generated/zitadel/member_pb'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; import { OrgQuery } from 'src/app/proto/generated/zitadel/org_pb'; @@ -9,6 +8,7 @@ import { ProjectQuery } from 'src/app/proto/generated/zitadel/project_pb'; import { SearchQuery as UserSearchQuery, UserGrantQuery } from 'src/app/proto/generated/zitadel/user_pb'; import { ActionKeysType } from '../action-keys/action-keys.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; type FilterSearchQuery = UserSearchQuery | MemberSearchQuery | UserGrantQuery | ProjectQuery | OrgQuery; type FilterSearchQueryAsObject = @@ -23,7 +23,7 @@ type FilterSearchQueryAsObject = templateUrl: './filter.component.html', styleUrls: ['./filter.component.scss'], }) -export class FilterComponent implements OnDestroy { +export class FilterComponent { @Output() public filterChanged: EventEmitter = new EventEmitter(); @Output() public filterOpen: EventEmitter = new EventEmitter(false); @@ -32,9 +32,6 @@ export class FilterComponent implements OnDestroy { @Input() public queryCount: number = 0; - private destroy$: Subject = new Subject(); - public filterChanged$: Observable = this.filterChanged.asObservable(); - public showFilter: boolean = false; public methods: TextQueryMethod[] = [ TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, @@ -59,17 +56,13 @@ export class FilterComponent implements OnDestroy { this.trigger.emit(); } - public ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - constructor( private router: Router, protected route: ActivatedRoute, + destroyRef: DestroyRef, ) { const changes$ = this.filterChanged.asObservable(); - changes$.pipe(takeUntil(this.destroy$)).subscribe((queries) => { + changes$.pipe(takeUntilDestroyed(destroyRef)).subscribe((queries) => { const filters: Array | undefined = queries ?.map((q) => q.toObject()) .map((query) => @@ -81,15 +74,17 @@ export class FilterComponent implements OnDestroy { ); if (filters && Object.keys(filters)) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { - ['filter']: JSON.stringify(filters), - }, - replaceUrl: true, - queryParamsHandling: 'merge', - skipLocationChange: false, - }); + this.router + .navigate([], { + relativeTo: this.route, + queryParams: { + ['filter']: JSON.stringify(filters), + }, + replaceUrl: true, + queryParamsHandling: 'merge', + skipLocationChange: false, + }) + .then(); } }); } diff --git a/console/src/app/modules/providers/provider-oauth/provider-oauth.component.html b/console/src/app/modules/providers/provider-oauth/provider-oauth.component.html index b8f43a856a..8c6412cd91 100644 --- a/console/src/app/modules/providers/provider-oauth/provider-oauth.component.html +++ b/console/src/app/modules/providers/provider-oauth/provider-oauth.component.html @@ -110,6 +110,15 @@
+
+ +
+

{{ 'IDP.USEPKCE_DESC' | translate }}

+ {{ 'IDP.USEPKCE' | translate }} +
+
+
+
-
+

{{ 'IDP.ISIDTOKENMAPPING_DESC' | translate }}

{{ 'IDP.ISIDTOKENMAPPING' | translate }}
+ + +
+

{{ 'IDP.USEPKCE_DESC' | translate }}

+ {{ 'IDP.USEPKCE' | translate }} +
+
+ { @@ -165,6 +166,7 @@ export class ProviderOIDCComponent { req.setScopesList(this.scopesList?.value); req.setProviderOptions(this.options); req.setIsIdTokenMapping(this.isIdTokenMapping?.value); + req.setUsePkce(this.usePkce?.value); this.loading = true; this.service @@ -193,6 +195,7 @@ export class ProviderOIDCComponent { req.setScopesList(this.scopesList?.value); req.setProviderOptions(this.options); req.setIsIdTokenMapping(this.isIdTokenMapping?.value); + req.setUsePkce(this.usePkce?.value); this.loading = true; this.service @@ -261,4 +264,8 @@ export class ProviderOIDCComponent { public get isIdTokenMapping(): AbstractControl | null { return this.form.get('isIdTokenMapping'); } + + public get usePkce(): AbstractControl | null { + return this.form.get('usePkce'); + } } diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html index e244ee886e..80f985eb24 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html @@ -2,12 +2,11 @@ - {{ 'USER.PAGES.NOUSER' | translate }} - + @@ -124,15 +123,14 @@ & { type: { case: 'human'; value: Human } }; +type UserWithHumanType = Omit & { type: { case: 'human'; value: HumanUser } }; @Component({ selector: 'cnsl-auth-user-detail', @@ -67,17 +62,17 @@ type UserWithHumanType = Omit & { type: { case: 'human'; value: Hu styleUrls: ['./auth-user-detail.component.scss'], }) export class AuthUserDetailComponent implements OnInit { - public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE]; + protected readonly genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE]; - public ChangeType: any = ChangeType; + protected readonly ChangeType = ChangeType; public userLoginMustBeDomain: boolean = false; - public UserState: any = UserState; + protected readonly UserState = UserState; - public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; - public refreshChanges$: EventEmitter = new EventEmitter(); - public refreshMetadata$ = new Subject(); + protected USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; + protected readonly refreshChanges$: EventEmitter = new EventEmitter(); + protected readonly refreshMetadata$ = new Subject(); - public settingsList: SidenavSetting[] = [ + protected readonly settingsList: SidenavSetting[] = [ { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }, { id: 'security', i18nKey: 'USER.SETTINGS.SECURITY' }, { id: 'idp', i18nKey: 'USER.SETTINGS.IDP' }, @@ -92,9 +87,9 @@ export class AuthUserDetailComponent implements OnInit { protected readonly user$: Observable; protected readonly metadata$: Observable; private readonly savedLanguage$: Observable; - protected currentSetting$: Observable; - public loginPolicy$: Observable; - protected userName$: Observable; + protected readonly currentSetting$: Observable; + protected readonly loginPolicy$: Observable; + protected readonly userName$: Observable; constructor( public translate: TranslateService, @@ -209,13 +204,8 @@ export class AuthUserDetailComponent implements OnInit { } private getMyUser(): Observable { - return defer(() => this.newAuthService.getMyUser()).pipe( - map(({ user }) => { - if (user) { - return { state: 'success', value: user } as const; - } - return { state: 'notfound' } as const; - }), + return defer(() => this.userService.getMyUser()).pipe( + map((user) => ({ state: 'success' as const, value: user })), catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)), startWith({ state: 'loading' } as const), ); @@ -232,7 +222,7 @@ export class AuthUserDetailComponent implements OnInit { if (!user.value) { return EMPTY; } - return this.getMetadataById(user.value.id); + return this.getMetadataById(user.value.userId); }), pairwiseStartWith(undefined), map(([prev, curr]) => { @@ -259,7 +249,7 @@ export class AuthUserDetailComponent implements OnInit { labelKey: 'ACTIONS.NEWVALUE' as const, titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE' as const, descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC' as const, - value: user.userName, + value: user.username, }; const dialogRef = this.dialog.open(EditDialogComponent, { data, @@ -271,8 +261,8 @@ export class AuthUserDetailComponent implements OnInit { .pipe( map((value) => value?.value), filter(Boolean), - filter((value) => user.userName != value), - switchMap((username) => this.userService.updateUser({ userId: user.id, username })), + filter((value) => user.username != value), + switchMap((username) => this.userService.updateUser({ userId: user.userId, username })), ) .subscribe({ next: () => { @@ -288,7 +278,7 @@ export class AuthUserDetailComponent implements OnInit { public saveProfile(user: User, profile: HumanProfile): void { this.userService .updateUser({ - userId: user.id, + userId: user.userId, profile: { givenName: profile.givenName, familyName: profile.familyName, @@ -350,7 +340,7 @@ export class AuthUserDetailComponent implements OnInit { public resendEmailVerification(user: User): void { this.newMgmtService - .resendHumanEmailVerification(user.id) + .resendHumanEmailVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); this.refreshChanges$.emit(); @@ -362,7 +352,7 @@ export class AuthUserDetailComponent implements OnInit { public resendPhoneVerification(user: User): void { this.newMgmtService - .resendHumanPhoneVerification(user.id) + .resendHumanPhoneVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true); this.refreshChanges$.emit(); @@ -374,7 +364,7 @@ export class AuthUserDetailComponent implements OnInit { public deletePhone(user: User): void { this.userService - .removePhone(user.id) + .removePhone(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); this.refreshChanges$.emit(); @@ -417,7 +407,7 @@ export class AuthUserDetailComponent implements OnInit { filter((resp): resp is Required => !!resp?.value), switchMap(({ value, isVerified }) => this.userService.setEmail({ - userId: user.id, + userId: user.userId, email: value, verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined }, }), @@ -453,7 +443,7 @@ export class AuthUserDetailComponent implements OnInit { .pipe( map((resp) => formatPhone(resp?.value)), filter(Boolean), - switchMap(({ phone }) => this.userService.setPhone({ userId: user.id, phone })), + switchMap(({ phone }) => this.userService.setPhone({ userId: user.userId, phone })), ) .subscribe({ next: () => { @@ -482,7 +472,7 @@ export class AuthUserDetailComponent implements OnInit { .afterClosed() .pipe( filter(Boolean), - switchMap(() => this.userService.deleteUser(user.id)), + switchMap(() => this.userService.deleteUser(user.userId)), ) .subscribe({ next: () => { @@ -498,9 +488,9 @@ export class AuthUserDetailComponent implements OnInit { this.newMgmtService.setUserMetadata({ key, value: Buffer.from(value), - id: user.id, + id: user.userId, }); - const removeFcn = (key: string): Promise => this.newMgmtService.removeUserMetadata({ key, id: user.id }); + const removeFcn = (key: string): Promise => this.newMgmtService.removeUserMetadata({ key, id: user.userId }); const dialogRef = this.dialog.open(MetadataDialogComponent, { data: { diff --git a/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html b/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html index 8f52fc3b98..4c5fa510de 100644 --- a/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html +++ b/console/src/app/pages/users/user-detail/detail-form-machine/detail-form-machine.component.html @@ -2,7 +2,7 @@
{{ 'USER.MACHINE.USERNAME' | translate }} - + {{ 'USER.MACHINE.NAME' | translate }} diff --git a/console/src/app/pages/users/user-list/user-list.component.html b/console/src/app/pages/users/user-list/user-list.component.html index b7d2110245..132b5b7942 100644 --- a/console/src/app/pages/users/user-list/user-list.component.html +++ b/console/src/app/pages/users/user-list/user-list.component.html @@ -1,5 +1,5 @@
-
+

{{ 'DESCRIPTIONS.USERS.TITLE' | translate }}

@@ -7,21 +7,6 @@

{{ 'DESCRIPTIONS.USERS.DESCRIPTION' | translate }}

- - - - - - - - +
diff --git a/console/src/app/pages/users/user-list/user-list.component.ts b/console/src/app/pages/users/user-list/user-list.component.ts index 9773f89dd6..ffb0b96b64 100644 --- a/console/src/app/pages/users/user-list/user-list.component.ts +++ b/console/src/app/pages/users/user-list/user-list.component.ts @@ -1,8 +1,5 @@ import { Component } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { take } from 'rxjs/operators'; -import { Type } from 'src/app/proto/generated/zitadel/user_pb'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; @Component({ @@ -11,23 +8,10 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/ styleUrls: ['./user-list.component.scss'], }) export class UserListComponent { - public Type: any = Type; - public type: Type = Type.TYPE_HUMAN; - constructor( - public translate: TranslateService, - activatedRoute: ActivatedRoute, + protected readonly translate: TranslateService, breadcrumbService: BreadcrumbService, ) { - activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => { - const { type } = params; - if (type && type === 'human') { - this.type = Type.TYPE_HUMAN; - } else if (type && type === 'machine') { - this.type = Type.TYPE_MACHINE; - } - }); - const bread: Breadcrumb = { type: BreadcrumbType.ORG, routerLink: ['/org'], diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.html b/console/src/app/pages/users/user-list/user-table/user-table.component.html index c40127dcc2..07583b5626 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.html +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.html @@ -1,11 +1,11 @@
@@ -60,12 +60,12 @@
@@ -85,7 +85,7 @@
- +
@@ -133,12 +133,7 @@ -
+ {{ 'USER.PROFILE.DISPLAYNAME' | translate }} @@ -148,12 +143,7 @@ - + {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} @@ -162,12 +152,7 @@ - + {{ 'USER.PROFILE.USERNAME' | translate }} @@ -176,12 +161,7 @@ - + {{ 'USER.EMAIL' | translate }} @@ -250,17 +230,16 @@
-
+
{{ 'USER.TABLE.EMPTY' | translate }}
+ diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.ts b/console/src/app/pages/users/user-list/user-table/user-table.component.ts index e894472d74..79481de63d 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.ts +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.ts @@ -1,34 +1,45 @@ -import { LiveAnnouncer } from '@angular/cdk/a11y'; import { SelectionModel } from '@angular/cdk/collections'; -import { Component, EventEmitter, Input, OnInit, Output, Signal, ViewChild } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, signal, Signal, ViewChild } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { MatSort, Sort } from '@angular/material/sort'; +import { MatSort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { + combineLatestWith, + defer, + delay, + distinctUntilChanged, + EMPTY, + from, + Observable, + of, + ReplaySubject, + shareReplay, + switchMap, + toArray, +} from 'rxjs'; +import { catchError, filter, finalize, map, startWith, take } from 'rxjs/operators'; import { enterAnimations } from 'src/app/animations'; import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; -import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; +import { PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; -import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ToastService } from 'src/app/services/toast.service'; import { UserService } from 'src/app/services/user.service'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { User } from 'src/app/proto/generated/zitadel/user_pb'; -import { SearchQuery, SearchQuerySchema, Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; -import { UserState, User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb'; -import { create } from '@bufbuild/protobuf'; -import { Timestamp } from '@bufbuild/protobuf/wkt'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { SearchQuery as UserSearchQuery } from 'src/app/proto/generated/zitadel/user_pb'; +import { Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; +import { UserState, User } from '@zitadel/proto/zitadel/user/v2/user_pb'; +import { MessageInitShape } from '@bufbuild/protobuf'; +import { ListUsersRequestSchema, ListUsersResponse } from '@zitadel/proto/zitadel/user/v2/user_service_pb'; +import { AuthenticationService } from 'src/app/services/authentication.service'; +import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { UserState as UserStateV1 } from 'src/app/proto/generated/zitadel/user_pb'; -enum UserListSearchKey { - FIRST_NAME, - LAST_NAME, - DISPLAY_NAME, - USER_NAME, - EMAIL, -} +type Query = Exclude< + Exclude['queries'], undefined>[number]['query'], + undefined +>; @Component({ selector: 'cnsl-user-table', @@ -37,25 +48,33 @@ enum UserListSearchKey { animations: [enterAnimations], }) export class UserTableComponent implements OnInit { - public userSearchKey: UserListSearchKey | undefined = undefined; - public Type = Type; - @Input() public type: Type = Type.HUMAN; - @Input() refreshOnPreviousRoutes: string[] = []; + protected readonly Type = Type; + protected readonly refresh$ = new ReplaySubject(1); + @Input() public canWrite$: Observable = of(false); @Input() public canDelete$: Observable = of(false); - private user: Signal = toSignal(this.authService.user, { requireSync: true }); + protected readonly dataSize: Signal; + protected readonly loading = signal(false); - @ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; - @ViewChild(MatSort) public sort!: MatSort; - public INITIAL_PAGE_SIZE: number = 20; + private readonly paginator$ = new ReplaySubject(1); + @ViewChild(PaginatorComponent) public set paginator(paginator: PaginatorComponent) { + this.paginator$.next(paginator); + } + private readonly sort$ = new ReplaySubject(1); + @ViewChild(MatSort) public set sort(sort: MatSort) { + this.sort$.next(sort); + } + + protected readonly INITIAL_PAGE_SIZE = 20; + + protected readonly dataSource: MatTableDataSource = new MatTableDataSource(); + protected readonly selection: SelectionModel = new SelectionModel(true, []); + protected readonly users$: Observable; + protected readonly type$: Observable; + protected readonly searchQueries$ = new ReplaySubject(1); + protected readonly myUser: Signal; - public viewTimestamp!: Timestamp; - public totalResult: number = 0; - public dataSource: MatTableDataSource = new MatTableDataSource(); - public selection: SelectionModel = new SelectionModel(true, []); - private loadingSubject: BehaviorSubject = new BehaviorSubject(false); - public loading$: Observable = this.loadingSubject.asObservable(); @Input() public displayedColumnsHuman: string[] = [ 'select', 'displayName', @@ -76,46 +95,56 @@ export class UserTableComponent implements OnInit { 'actions', ]; - @Output() public changedSelection: EventEmitter> = new EventEmitter(); + @Output() public changedSelection: EventEmitter> = new EventEmitter(); - public UserState: any = UserState; - public UserListSearchKey: any = UserListSearchKey; + protected readonly UserState = UserState; - public ActionKeysType: any = ActionKeysType; - public filterOpen: boolean = false; + protected ActionKeysType = ActionKeysType; + protected filterOpen: boolean = false; - private searchQueries: SearchQuery[] = []; constructor( - private router: Router, - public translate: TranslateService, - private authService: GrpcAuthService, - private userService: UserService, - private toast: ToastService, - private dialog: MatDialog, - private route: ActivatedRoute, - private _liveAnnouncer: LiveAnnouncer, + protected readonly router: Router, + public readonly translate: TranslateService, + private readonly userService: UserService, + private readonly toast: ToastService, + private readonly dialog: MatDialog, + private readonly route: ActivatedRoute, + private readonly destroyRef: DestroyRef, + private readonly authenticationService: AuthenticationService, + private readonly authService: GrpcAuthService, ) { - this.selection.changed.subscribe(() => { - this.changedSelection.emit(this.selection.selected); - }); + this.type$ = this.getType$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + this.users$ = this.getUsers(this.type$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + this.myUser = toSignal(this.getMyUser()); + + this.dataSize = toSignal( + this.users$.pipe( + map((users) => users.result.length), + distinctUntilChanged(), + ), + { initialValue: 0 }, + ); } ngOnInit(): void { - this.route.queryParams.pipe(take(1)).subscribe((params) => { - if (!params['filter']) { - this.getData(this.INITIAL_PAGE_SIZE, 0, this.type, this.searchQueries).then(); - } - - if (params['deferredReload']) { - setTimeout(() => { - this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type).then(); - }, 2000); - } + this.selection.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.changedSelection.emit(this.selection.selected); }); + + this.users$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((users) => (this.dataSource.data = users.result)); + + this.route.queryParamMap + .pipe( + map((params) => params.get('deferredReload')), + filter(Boolean), + take(1), + delay(2000), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => this.refresh$.next(true)); } - public setType(type: Type): void { - this.type = type; + setType(type: Type) { this.router .navigate([], { relativeTo: this.route, @@ -127,12 +156,195 @@ export class UserTableComponent implements OnInit { skipLocationChange: false, }) .then(); - this.getData( - this.paginator.pageSize, - this.paginator.pageIndex * this.paginator.pageSize, - this.type, - this.searchQueries, - ).then(); + } + + private getMyUser() { + return defer(() => this.userService.getMyUser()).pipe( + catchError((error) => { + this.toast.showError(error); + return EMPTY; + }), + ); + } + + private getType$(): Observable { + return this.route.queryParamMap.pipe( + map((params) => params.get('type')), + filter(Boolean), + map((type) => (type === 'machine' ? Type.MACHINE : Type.HUMAN)), + startWith(Type.HUMAN), + distinctUntilChanged(), + ); + } + + private getDirection$() { + return this.sort$.pipe( + switchMap((sort) => + sort.sortChange.pipe( + map(({ direction }) => direction), + startWith(sort.direction), + ), + ), + distinctUntilChanged(), + ); + } + + private getSortingColumn$() { + return this.sort$.pipe( + switchMap((sort) => + sort.sortChange.pipe( + map(({ active }) => active), + startWith(sort.active), + ), + ), + map((active) => { + switch (active) { + case 'displayName': + return UserFieldName.DISPLAY_NAME; + case 'username': + return UserFieldName.USER_NAME; + case 'preferredLoginName': + // TODO: replace with preferred username sorting once implemented + return UserFieldName.USER_NAME; + case 'email': + return UserFieldName.EMAIL; + case 'state': + return UserFieldName.STATE; + case 'creationDate': + return UserFieldName.CREATION_DATE; + default: + return undefined; + } + }), + distinctUntilChanged(), + ); + } + + private getQueries(type$: Observable): Observable { + const activeOrgId$ = this.getActiveOrgId(); + + return this.searchQueries$.pipe( + startWith([]), + combineLatestWith(type$, activeOrgId$), + switchMap(([queries, type, organizationId]) => + from(queries).pipe( + map((query) => this.searchQueryToV2(query.toObject())), + startWith({ case: 'typeQuery' as const, value: { type } }), + startWith(organizationId ? { case: 'organizationIdQuery' as const, value: { organizationId } } : undefined), + filter(Boolean), + toArray(), + ), + ), + ); + } + + private searchQueryToV2(query: UserSearchQuery.AsObject): Query | undefined { + if (query.userNameQuery) { + return { + case: 'userNameQuery' as const, + value: { + userName: query.userNameQuery.userName, + method: query.userNameQuery.method as unknown as any, + }, + }; + } else if (query.displayNameQuery) { + return { + case: 'displayNameQuery' as const, + value: { + displayName: query.displayNameQuery.displayName, + method: query.displayNameQuery.method as unknown as any, + }, + }; + } else if (query.emailQuery) { + return { + case: 'emailQuery' as const, + value: { + emailAddress: query.emailQuery.emailAddress, + method: query.emailQuery.method as unknown as any, + }, + }; + } else if (query.stateQuery) { + return { + case: 'stateQuery' as const, + value: { + state: this.toV2State(query.stateQuery.state), + }, + }; + } else { + return undefined; + } + } + + private toV2State(state: UserStateV1) { + switch (state) { + case UserStateV1.USER_STATE_ACTIVE: + return UserState.ACTIVE; + case UserStateV1.USER_STATE_INACTIVE: + return UserState.INACTIVE; + case UserStateV1.USER_STATE_DELETED: + return UserState.DELETED; + case UserStateV1.USER_STATE_LOCKED: + return UserState.LOCKED; + case UserStateV1.USER_STATE_INITIAL: + return UserState.INITIAL; + default: + throw new Error(`Invalid UserState ${state}`); + } + } + + private getUsers(type$: Observable) { + const queries$ = this.getQueries(type$); + const direction$ = this.getDirection$(); + const sortingColumn$ = this.getSortingColumn$(); + + const page$ = this.paginator$.pipe(switchMap((paginator) => paginator.page)); + const pageSize$ = page$.pipe( + map(({ pageSize }) => pageSize), + startWith(this.INITIAL_PAGE_SIZE), + distinctUntilChanged(), + ); + const pageIndex$ = page$.pipe( + map(({ pageIndex }) => pageIndex), + startWith(0), + distinctUntilChanged(), + ); + + return this.refresh$.pipe( + startWith(true), + combineLatestWith(queries$, direction$, sortingColumn$, pageSize$, pageIndex$), + switchMap(([_, queries, direction, sortingColumn, pageSize, pageIndex]) => { + return this.fetchUsers(queries, direction, sortingColumn, pageSize, pageIndex); + }), + ); + } + + private fetchUsers( + queries: Query[], + direction: SortDirection, + sortingColumn: UserFieldName | undefined, + pageSize: number, + pageIndex: number, + ) { + return defer(() => { + const req = { + query: { + limit: pageSize, + offset: BigInt(pageIndex * pageSize), + asc: direction === 'asc', + }, + sortingColumn, + queries: queries.map((query) => ({ query })), + }; + + this.loading.set(true); + return this.userService.listUsers(req); + }).pipe( + catchError((error) => { + this.toast.showError(error); + return EMPTY; + }), + finalize(() => this.loading.set(false)), + ); } public isAllSelected(): boolean { @@ -145,147 +357,49 @@ export class UserTableComponent implements OnInit { this.isAllSelected() ? this.selection.clear() : this.dataSource.data.forEach((row) => this.selection.select(row)); } - public changePage(event: PageEvent): void { - this.selection.clear(); - this.getData(event.pageSize, event.pageIndex * event.pageSize, this.type, this.searchQueries).then(); - } - - public deactivateSelectedUsers(): void { + public async deactivateSelectedUsers(): Promise { const usersToDeactivate = this.selection.selected .filter((u) => u.state === UserState.ACTIVE) .map((value) => { return this.userService.deactivateUser(value.userId); }); - Promise.all(usersToDeactivate) - .then(() => { - this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); - this.selection.clear(); - setTimeout(() => { - this.refreshPage(); - }, 1000); - }) - .catch((error) => { - this.toast.showError(error); - }); + try { + await Promise.all(usersToDeactivate); + } catch (error) { + this.toast.showError(error); + return; + } + + this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); + this.selection.clear(); + setTimeout(() => { + this.refresh$.next(true); + }, 1000); } - public reactivateSelectedUsers(): void { + public async reactivateSelectedUsers(): Promise { const usersToReactivate = this.selection.selected .filter((u) => u.state === UserState.INACTIVE) .map((value) => { return this.userService.reactivateUser(value.userId); }); - Promise.all(usersToReactivate) - .then(() => { - this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); - this.selection.clear(); - setTimeout(() => { - this.refreshPage(); - }, 1000); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - - public gotoRouterLink(rL: any): Promise { - return this.router.navigate(rL); - } - - private async getData(limit: number, offset: number, type: Type, searchQueries?: SearchQuery[]): Promise { - this.loadingSubject.next(true); - - let queryT = create(SearchQuerySchema, { - query: { - case: 'typeQuery', - value: { - type, - }, - }, - }); - - let sortingField: UserFieldName | undefined = undefined; - if (this.sort?.active && this.sort?.direction) - switch (this.sort.active) { - case 'displayName': - sortingField = UserFieldName.DISPLAY_NAME; - break; - case 'username': - sortingField = UserFieldName.USER_NAME; - break; - case 'preferredLoginName': - // TODO: replace with preferred username sorting once implemented - sortingField = UserFieldName.USER_NAME; - break; - case 'email': - sortingField = UserFieldName.EMAIL; - break; - case 'state': - sortingField = UserFieldName.STATE; - break; - case 'creationDate': - sortingField = UserFieldName.CREATION_DATE; - break; - } - - this.userService - .listUsers( - limit, - offset, - searchQueries?.length ? [queryT, ...searchQueries] : [queryT], - sortingField, - this.sort?.direction, - ) - .then((resp) => { - if (resp.details?.totalResult) { - this.totalResult = Number(resp.details.totalResult); - } else { - this.totalResult = 0; - } - if (resp.details?.timestamp) { - this.viewTimestamp = resp.details?.timestamp; - } - this.dataSource.data = resp.result; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } - - public refreshPage(): void { - this.getData( - this.paginator.pageSize, - this.paginator.pageIndex * this.paginator.pageSize, - this.type, - this.searchQueries, - ).then(); - } - - public sortChange(sortState: Sort) { - if (sortState.direction && sortState.active) { - this._liveAnnouncer.announce(`Sorted ${sortState.direction} ending`).then(); - this.refreshPage(); - } else { - this._liveAnnouncer.announce('Sorting cleared').then(); + try { + await Promise.all(usersToReactivate); + } catch (error) { + this.toast.showError(error); + return; } - } - public applySearchQuery(searchQueries: SearchQuery[]): void { + this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); this.selection.clear(); - this.searchQueries = searchQueries; - this.getData( - this.paginator ? this.paginator.pageSize : this.INITIAL_PAGE_SIZE, - this.paginator ? this.paginator.pageIndex * this.paginator.pageSize : 0, - this.type, - searchQueries, - ).then(); + setTimeout(() => { + this.refresh$.next(true); + }, 1000); } - public deleteUser(user: UserV2): void { + public deleteUser(user: User): void { const authUserData = { confirmKey: 'ACTIONS.DELETE', cancelKey: 'ACTIONS.CANCEL', @@ -309,8 +423,9 @@ export class UserTableComponent implements OnInit { }; if (user?.userId) { - const authUser = this.user(); - const isMe = authUser?.id === user.userId; + const authUser = this.myUser(); + console.log('my user', authUser); + const isMe = authUser?.userId === user.userId; let dialogRef; @@ -326,22 +441,22 @@ export class UserTableComponent implements OnInit { }); } - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.userService - .deleteUser(user.userId) - .then(() => { - setTimeout(() => { - this.refreshPage(); - }, 1000); - this.selection.clear(); - this.toast.showInfo('USER.TOAST.DELETED', true); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.userService.deleteUser(user.userId)), + ) + .subscribe({ + next: () => { + setTimeout(() => { + this.refresh$.next(true); + }, 1000); + this.selection.clear(); + this.toast.showInfo('USER.TOAST.DELETED', true); + }, + error: (err) => this.toast.showError(err), + }); } } @@ -354,4 +469,20 @@ export class UserTableComponent implements OnInit { const selected = this.selection.selected; return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false; } + + private getActiveOrgId() { + return this.authenticationService.authenticationChanged.pipe( + startWith(true), + filter(Boolean), + switchMap(() => + from(this.authService.getActiveOrg()).pipe( + catchError((err) => { + this.toast.showError(err); + return of(undefined); + }), + ), + ), + map((org) => org?.id), + ); + } } diff --git a/console/src/app/services/user.service.ts b/console/src/app/services/user.service.ts index 328d3c7e0f..d0388571d3 100644 --- a/console/src/app/services/user.service.ts +++ b/console/src/app/services/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable } from '@angular/core'; import { GrpcService } from './grpc.service'; import { AddHumanUserRequestSchema, @@ -11,7 +11,6 @@ import { DeactivateUserResponse, DeleteUserRequestSchema, DeleteUserResponse, - GetUserByIDRequestSchema, GetUserByIDResponse, ListAuthenticationFactorsRequestSchema, ListAuthenticationFactorsResponse, @@ -65,60 +64,87 @@ import { } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { create } from '@bufbuild/protobuf'; import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt'; -import { Details, DetailsSchema, ListQuerySchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; -import { SearchQuery, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; -import { SortDirection } from '@angular/material/sort'; +import { Details, DetailsSchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; import { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb'; import { ObjectDetails } from '../proto/generated/zitadel/object_pb'; import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb'; import { HumanPhone, HumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_pb'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { firstValueFrom, Observable, shareReplay } from 'rxjs'; +import { filter, map, startWith, tap, timeout } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Injectable({ providedIn: 'root', }) export class UserService { - constructor(private readonly grpcService: GrpcService) {} + private readonly userId$: Observable; + private user: UserV2 | undefined; + + constructor( + private readonly grpcService: GrpcService, + private readonly oauthService: OAuthService, + destroyRef: DestroyRef, + ) { + this.userId$ = this.getUserId().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + // this preloads the userId and deletes the cache everytime the userId changes + this.userId$.pipe(takeUntilDestroyed(destroyRef)).subscribe(async () => { + this.user = undefined; + try { + await this.getMyUser(); + } catch (error) { + console.warn(error); + } + }); + } + + private getUserId() { + return this.oauthService.events.pipe( + filter((event) => event.type === 'token_received'), + startWith(this.oauthService.getIdToken), + map(() => this.oauthService.getIdToken()), + filter(Boolean), + // split jwt and get base64 encoded payload + map((token) => token.split('.')[1]), + // decode payload + map(atob), + // parse payload + map((payload) => JSON.parse(payload)), + map((payload: unknown) => { + // check if sub is in payload and is a string + if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') { + return payload.sub; + } + throw new Error('Invalid payload'); + }), + ); + } public addHumanUser(req: MessageInitShape): Promise { return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req)); } - public listUsers( - limit: number, - offset: number, - queriesList?: SearchQuery[], - sortingColumn?: UserFieldName, - sortingDirection?: SortDirection, - ): Promise { - const query = create(ListQuerySchema); - - if (limit) { - query.limit = limit; - } - if (offset) { - query.offset = BigInt(offset); - } - if (sortingDirection) { - query.asc = sortingDirection === 'asc'; - } - - const req = create(ListUsersRequestSchema, { - query, - }); - - if (sortingColumn) { - req.sortingColumn = sortingColumn; - } - - if (queriesList) { - req.queries = queriesList; - } - + public listUsers(req: MessageInitShape): Promise { return this.grpcService.userNew.listUsers(req); } + public async getMyUser(): Promise { + const userId = await firstValueFrom(this.userId$.pipe(timeout(2000))); + if (this.user) { + return this.user; + } + const resp = await this.getUserById(userId); + if (!resp.user) { + throw new Error("Couldn't find user"); + } + + this.user = resp.user; + return resp.user; + } + public getUserById(userId: string): Promise { - return this.grpcService.userNew.getUserByID(create(GetUserByIDRequestSchema, { userId })); + return this.grpcService.userNew.getUserByID({ userId }); } public deactivateUser(userId: string): Promise { diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 9571f75db3..90f6a821bd 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -2200,7 +2200,9 @@ "REMOVED": "Премахнато успешно." }, "ISIDTOKENMAPPING": "Съответствие от ID токен", - "ISIDTOKENMAPPING_DESC": "Ако е избрано, информацията на доставчика се съответства от ID токена, а не от userinfo крайната точка." + "ISIDTOKENMAPPING_DESC": "Ако е избрано, информацията на доставчика се съответства от ID токена, а не от userinfo крайната точка.", + "USEPKCE": "Използвайте PKCE", + "USEPKCE_DESC": "Определя дали параметрите code_challenge и code_challenge_method са включени в заявката за удостоверяване" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 68558efab3..b77fcacf87 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -2213,7 +2213,9 @@ "REMOVED": "Úspěšně odebráno." }, "ISIDTOKENMAPPING": "Mapování z ID tokenu", - "ISIDTOKENMAPPING_DESC": "Pokud je vybráno, informace o poskytovateli jsou mapovány z ID tokenu, nikoli z koncového bodu userinfo." + "ISIDTOKENMAPPING_DESC": "Pokud je vybráno, informace o poskytovateli jsou mapovány z ID tokenu, nikoli z koncového bodu userinfo.", + "USEPKCE": "Použijte PKCE", + "USEPKCE_DESC": "Určuje, zda jsou v požadavku na ověření zahrnuty parametry code_challenge a code_challenge_method" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index af65e76365..309aa7fd2b 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -2204,7 +2204,9 @@ "REMOVED": "Erfolgreich entfernt." }, "ISIDTOKENMAPPING": "Zuordnung vom ID-Token", - "ISIDTOKENMAPPING_DESC": "Legt fest, ob für das Mapping der Provider Informationen das ID-Token verwendet werden soll, anstatt des Userinfo-Endpoints." + "ISIDTOKENMAPPING_DESC": "Legt fest, ob für das Mapping der Provider Informationen das ID-Token verwendet werden soll, anstatt des Userinfo-Endpoints.", + "USEPKCE": "Verwenden Sie PKCE", + "USEPKCE_DESC": "Bestimmt, ob die Parameter code_challenge und code_challenge_method in der Authentifizierungsanforderung enthalten sind" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 40c215e1bf..53d4f0c898 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -2225,7 +2225,9 @@ "REMOVED": "Removed successfully." }, "ISIDTOKENMAPPING": "Map from the ID token", - "ISIDTOKENMAPPING_DESC": "If selected, provider information gets mapped from the ID token, not from the userinfo endpoint." + "ISIDTOKENMAPPING_DESC": "If selected, provider information gets mapped from the ID token, not from the userinfo endpoint.", + "USEPKCE": "Use PKCE", + "USEPKCE_DESC": "Determines whether the code_challenge and code_challenge_method params are included in the auth request" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index b21b001c4d..9142d8930b 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -2201,7 +2201,9 @@ "REMOVED": "Eliminado con éxito." }, "ISIDTOKENMAPPING": "Asignación del ID token", - "ISIDTOKENMAPPING_DESC": "Si se selecciona, la información del proveedor se asigna desde el ID token, no desde el punto final de userinfo." + "ISIDTOKENMAPPING_DESC": "Si se selecciona, la información del proveedor se asigna desde el ID token, no desde el punto final de userinfo.", + "USEPKCE": "Usa PKCE", + "USEPKCE_DESC": "Determina si los parámetros code_challenge y code_challenge_method son incluidos en la solicitud de autenticación." }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 6b3cd356d1..1de197356e 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -2205,7 +2205,9 @@ "REMOVED": "Suppression réussie." }, "ISIDTOKENMAPPING": "Mappage depuis le jeton ID", - "ISIDTOKENMAPPING_DESC": "Si sélectionné, les informations du fournisseur sont mappées à partir du jeton ID, et non à partir du point d'extrémité userinfo." + "ISIDTOKENMAPPING_DESC": "Si sélectionné, les informations du fournisseur sont mappées à partir du jeton ID, et non à partir du point d'extrémité userinfo.", + "USEPKCE": "Utiliser PKCE", + "USEPKCE_DESC": "Détermine si les paramètres code_challenge et code_challenge_method sont inclus dans la demande d'authentification" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index dc9db9ed2f..71d7421ffb 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -2223,7 +2223,9 @@ "REMOVED": "Sikeresen eltávolítva." }, "ISIDTOKENMAPPING": "Hozzárendelés az ID token alapján", - "ISIDTOKENMAPPING_DESC": "Ha ezt választod, a szolgáltatói információkat az ID token alapján rendeljük hozzá, nem a userinfo végpontból." + "ISIDTOKENMAPPING_DESC": "Ha ezt választod, a szolgáltatói információkat az ID token alapján rendeljük hozzá, nem a userinfo végpontból.", + "USEPKCE": "PKCE használata", + "USEPKCE_DESC": "Meghatározza, hogy a code_challenge és a code_challenge_method paraméterek szerepeljenek-e a hitelesítési kérelemben" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 50c897a752..d4b4150a13 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -2006,7 +2006,9 @@ "REMOVED": "Berhasil dihapus." }, "ISIDTOKENMAPPING": "Peta dari token ID", - "ISIDTOKENMAPPING_DESC": "Jika dipilih, informasi penyedia akan dipetakan dari token ID, bukan dari titik akhir info pengguna." + "ISIDTOKENMAPPING_DESC": "Jika dipilih, informasi penyedia akan dipetakan dari token ID, bukan dari titik akhir info pengguna.", + "USEPKCE": "Gunakan PKCE", + "USEPKCE_DESC": "Menentukan apakah parameter code_challenge dan code_challenge_method disertakan dalam permintaan autentikasi" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 0f8c324d3b..1b944cc517 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -2205,7 +2205,9 @@ "REMOVED": "Rimosso con successo." }, "ISIDTOKENMAPPING": "Mappatura dal token ID", - "ISIDTOKENMAPPING_DESC": "Se selezionato, le informazioni del provider vengono mappate dal token ID, non dal punto finale userinfo." + "ISIDTOKENMAPPING_DESC": "Se selezionato, le informazioni del provider vengono mappate dal token ID, non dal punto finale userinfo.", + "USEPKCE": "Usa PKCE", + "USEPKCE_DESC": "Determina se i parametri code_challenge e code_challenge_method sono inclusi nella richiesta di autenticazione" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 18047c847e..8f4b054fb9 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1547,6 +1547,10 @@ "MAXSIZEEXCEEDED": "最大サイズの524KBを超えました。", "NOSVGSUPPORTED": "SVGはサポートされていません!", "FONTINLOGINONLY": "フォントは現在、ログインインターフェイスにのみ表示されます。", + "BACKGROUNDCOLOR": "背景色", + "PRIMARYCOLOR": "プライマリカラー", + "WARNCOLOR": "警告色", + "FONTCOLOR": "フォントカラー", "VIEWS": { "PREVIEW": "プレビュー", "CURRENT": "現在の構成" @@ -1682,7 +1686,8 @@ "PR": "パスワードのリセット", "DC": "ドメインクレーム", "PL": "パスワードレス", - "PC": "パスワードの変更" + "PC": "パスワードの変更", + "IU": "ユーザーの招待" }, "CHIPS": { "firstname": "名", @@ -2064,6 +2069,14 @@ "LDAP": { "TITLE": "LDAPプロバイダー", "DESCRIPTION": "LDAPプロバイダーのクレデンシャルを入力してください。" + }, + "APPLE": { + "TITLE": "Appleプロバイダー", + "DESCRIPTION": "Appleプロバイダーのクレデンシャルを入力してください。" + }, + "SAML": { + "TITLE": "SAMLプロバイダー", + "DESCRIPTION": "SAMLプロバイダーのクレデンシャルを入力してください。" } }, "DETAIL": { @@ -2183,6 +2196,23 @@ "JWTENDPOINT": "JWTエンドポイント", "JWTKEYSENDPOINT": "JWTキーエンドポイント" }, + "APPLE": { + "TEAMID": "チームID", + "KEYID": "キーID", + "PRIVATEKEY": "秘密鍵", + "UPDATEPRIVATEKEY": "秘密鍵の更新", + "UPLOADPRIVATEKEY": "秘密鍵のアップロード", + "KEYMAXSIZEEXCEEDED": "最大サイズの5kBを超えています" + }, + "SAML": { + "METADATAXML": "Metadata XML", + "METADATAURL": "Metadata URL", + "BINDING": "Binding", + "SIGNEDREQUEST": "署名付きリクエスト", + "NAMEIDFORMAT": "NameIDフォーマット", + "TRANSIENTMAPPINGATTRIBUTENAME": "カスタム属性名", + "TRANSIENTMAPPINGATTRIBUTENAME_DESC": "NameIDフォーマットが`transient`の場合にユーザーをマッピングするためのカスタム属性名 (例: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`)" + }, "TOAST": { "SAVED": "正常に保存されました。", "REACTIVATED": "IDPがアクティブになりました。", @@ -2195,7 +2225,9 @@ "REMOVED": "正常に削除されました。" }, "ISIDTOKENMAPPING": "IDトークンからのマッピング", - "ISIDTOKENMAPPING_DESC": "選択された場合、プロバイダ情報はIDトークンからマッピングされ、userinfoエンドポイントからではありません。" + "ISIDTOKENMAPPING_DESC": "選択された場合、プロバイダ情報はIDトークンからマッピングされ、userinfoエンドポイントからではありません。", + "USEPKCE": "PKCEを使用する", + "USEPKCE_DESC": "code_challenge パラメータと code_challenge_method パラメータが認証リクエストに含まれるかどうかを決定します。" }, "MFA": { "LIST": { @@ -2267,7 +2299,11 @@ "DELETE_TITLE": "SMTP設定を削除する", "DELETE_DESCRIPTION": "構成を削除しようとしています。送信者名を入力してこのアクションを確認します", "DELETED": "SMTP設定が削除されました", - "SENDER": "この SMTP 構成を削除するには、「{{ value }}」と入力します。" + "SENDER": "この SMTP 構成を削除するには、「{{ value }}」と入力します。", + "TEST_TITLE": "SMTP設定をテストする", + "TEST_DESCRIPTION": "テスト用のメールアドレスを指定してください", + "TEST_EMAIL": "メールアドレス", + "TEST_RESULT": "テスト結果" } }, "CREATE": { diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 14aec2246e..9c077d74d7 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -2225,7 +2225,9 @@ "REMOVED": "성공적으로 제거되었습니다." }, "ISIDTOKENMAPPING": "ID 토큰에서 매핑", - "ISIDTOKENMAPPING_DESC": "선택 시, 사용자 정보 엔드포인트가 아닌 ID 토큰에서 제공자 정보를 매핑합니다." + "ISIDTOKENMAPPING_DESC": "선택 시, 사용자 정보 엔드포인트가 아닌 ID 토큰에서 제공자 정보를 매핑합니다.", + "USEPKCE": "PKCE 사용", + "USEPKCE_DESC": "code_challenge 및 code_challenge_method 매개변수가 인증 요청에 포함되는지 여부를 결정합니다" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 51c645e233..9d8e4bd95d 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -2201,7 +2201,9 @@ "REMOVED": "Успешно отстрането." }, "ISIDTOKENMAPPING": "Совпаѓање од ID токен", - "ISIDTOKENMAPPING_DESC": "Ако е избрано, информациите од провајдерот се мапираат од ID токенот, а не од userinfo крајната точка." + "ISIDTOKENMAPPING_DESC": "Ако е избрано, информациите од провајдерот се мапираат од ID токенот, а не од userinfo крајната точка.", + "USEPKCE": "Користете PKCE", + "USEPKCE_DESC": "Определува дали параметрите code_challenge и code_challenge_method се вклучени во барањето за авторизација" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 0914858c85..306a5f44a2 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -2220,7 +2220,9 @@ "REMOVED": "Succesvol verwijderd." }, "ISIDTOKENMAPPING": "Kaart van de ID token", - "ISIDTOKENMAPPING_DESC": "Als geselecteerd, wordt provider informatie in kaart gebracht van de ID token, niet van de userinfo eindpunt." + "ISIDTOKENMAPPING_DESC": "Als geselecteerd, wordt provider informatie in kaart gebracht van de ID token, niet van de userinfo eindpunt.", + "USEPKCE": "Gebruik PKCE", + "USEPKCE_DESC": "Bepaalt of de parameters code_challenge en code_challenge_method zijn opgenomen in het verificatieverzoek" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index a8987142d7..59bc20ff35 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -2204,7 +2204,9 @@ "REMOVED": "Usunięto pomyślnie." }, "ISIDTOKENMAPPING": "Mapowanie z tokena ID", - "ISIDTOKENMAPPING_DESC": "Jeśli wybrane, informacje dostawcy są mapowane z tokena ID, a nie z punktu końcowego userinfo." + "ISIDTOKENMAPPING_DESC": "Jeśli wybrane, informacje dostawcy są mapowane z tokena ID, a nie z punktu końcowego userinfo.", + "USEPKCE": "Skorzystaj z PKCE", + "USEPKCE_DESC": "Określa, czy parametry code_challenge i code_challenge_method są uwzględnione w żądaniu uwierzytelnienia" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index cb1624ba3a..d901089823 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -2200,7 +2200,9 @@ "REMOVED": "Removido com sucesso." }, "ISIDTOKENMAPPING": "Mapeamento do token ID", - "ISIDTOKENMAPPING_DESC": "Se selecionado, as informações do provedor são mapeadas a partir do token ID, e não do ponto final userinfo." + "ISIDTOKENMAPPING_DESC": "Se selecionado, as informações do provedor são mapeadas a partir do token ID, e não do ponto final userinfo.", + "USEPKCE": "Usar PKCE", + "USEPKCE_DESC": "Determina se os parâmetros code_challenge e code_challenge_method estão incluídos na solicitação de autenticação" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 123b00122e..38983410b6 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2316,7 +2316,9 @@ "REMOVED": "Удалено успешно." }, "ISIDTOKENMAPPING": "Карта из ID-токена", - "ISIDTOKENMAPPING_DESC": "Если этот флажок установлен, информация о поставщике сопоставляется с маркером идентификатора, а не с конечной точкой информации о пользователе." + "ISIDTOKENMAPPING_DESC": "Если этот флажок установлен, информация о поставщике сопоставляется с маркером идентификатора, а не с конечной точкой информации о пользователе.", + "USEPKCE": "Используйте ПКСЕ", + "USEPKCE_DESC": "Определяет, включены ли параметры code_challenge и code_challenge_method в запрос аутентификации." }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index aeb3e31074..84ac42ac0b 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -2229,7 +2229,9 @@ "REMOVED": "Borttagen framgångsrikt." }, "ISIDTOKENMAPPING": "Mappa från ID-token", - "ISIDTOKENMAPPING_DESC": "Om valt, mappas leverantörsinformation från ID-token, inte från användarinfo-slutpunkten." + "ISIDTOKENMAPPING_DESC": "Om valt, mappas leverantörsinformation från ID-token, inte från användarinfo-slutpunkten.", + "USEPKCE": "Använd PKCE", + "USEPKCE_DESC": "Avgör om parametrarna code_challenge och code_challenge_method ingår i autentiseringsbegäran" }, "MFA": { "LIST": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index fb2eb1a080..7dbc03b2fd 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -2204,7 +2204,9 @@ "REMOVED": "成功删除。" }, "ISIDTOKENMAPPING": "从ID令牌映射", - "ISIDTOKENMAPPING_DESC": "如果选中,提供商信息将从ID令牌映射,而不是从userinfo端点。" + "ISIDTOKENMAPPING_DESC": "如果选中,提供商信息将从ID令牌映射,而不是从userinfo端点。", + "USEPKCE": "使用PKCE", + "USEPKCE_DESC": "确定 auth 请求中是否包含 code_challenge 和 code_challenge_method 参数" }, "MFA": { "LIST": { diff --git a/docs/docs/guides/integrate/identity-providers/keycloak.mdx b/docs/docs/guides/integrate/identity-providers/keycloak.mdx index 8eb619d8b2..1864884118 100644 --- a/docs/docs/guides/integrate/identity-providers/keycloak.mdx +++ b/docs/docs/guides/integrate/identity-providers/keycloak.mdx @@ -50,6 +50,9 @@ A useful default will be filled if you don't change anything. This information will be taken to create/update the user within ZITADEL. ZITADEL ensures that at least the `openid`-scope is always sent. +**Use PKCE**: If enabled, the provider will use Proof Key for Code Exchange (PKCE) to secure the authorization code flow +in addition to the client secret. + ![Keycloak Provider](/img/guides/zitadel_keycloak_create_provider.png) diff --git a/docs/docs/guides/integrate/identity-providers/okta-oidc.mdx b/docs/docs/guides/integrate/identity-providers/okta-oidc.mdx index f96043d941..0c2a46000a 100644 --- a/docs/docs/guides/integrate/identity-providers/okta-oidc.mdx +++ b/docs/docs/guides/integrate/identity-providers/okta-oidc.mdx @@ -49,6 +49,9 @@ A useful default will be filled if you don't change anything. This information will be taken to create/update the user within ZITADEL. ZITADEL ensures that at least the `openid`-scope is always sent. +**Use PKCE**: If enabled, the provider will use Proof Key for Code Exchange (PKCE) to secure the authorization code flow +in addition to the client secret. + ### Activate IdP diff --git a/docs/docs/guides/integrate/login-ui/device-auth.mdx b/docs/docs/guides/integrate/login-ui/device-auth.mdx new file mode 100644 index 0000000000..f60fad1310 --- /dev/null +++ b/docs/docs/guides/integrate/login-ui/device-auth.mdx @@ -0,0 +1,165 @@ +--- +title: Support for the Device Authorization Grant in a Custom Login UI +sidebar_label: Device Authorization +--- + +In case one of your applications requires the [OAuth2 Device Authorization Grant](/docs/guides/integrate/login/oidc/device-authorization) this guide will show you how to implement +this in your application as well as the custom login UI. + +The following flow shows you the different components you need to enable OAuth2 Device Authorization Grant for your login. +![Device Auth Flow](/img/guides/login-ui/device-auth-flow.png) + +1. Your application makes a device authorization request to your login UI +2. The login UI proxies the request to ZITADEL. +3. ZITADEL parses the request and does what it needs to interpret certain parameters (e.g., organization scope, etc.) +4. ZITADEL returns the device authorization response +5. Your application presents the `user_code` and `verification_uri` or maybe even renders a QR code with the `verification_uri_complete` for the user to scan +6. Your application starts a polling mechanism to check if the user has approved the device authorization request on the token endpoint +7. When the user opens the browser at the verification_uri, he can enter the user_code, or it's automatically filled in, if they scan the QR code +8. Request the device authorization request from the ZITADEL API using the user_code +9. Your login UI allows to approve or deny the device request +10. In case they approved, authenticate the user in your login UI by creating and updating a session with all the checks you need. +11. Inform ZITADEL about the decision: + 1. Authorize the device authorization request by sending the session and the previously retrieved id of the device authorization request to the ZITADEL API + 2. In case they denied, deny the device authorization from the ZITADEL API using the previously retrieved id of the device authorization request +12. Notify the user that they can close the window now and return to the application. +13. Your applications request to the token endpoint now receives the tokens or an error if the user denied the request. + +## Example + +Let's assume you host your login UI on the following URL: +``` +https://login.example.com +``` + +## Device Authorization Request + +A user opens your application and is unauthenticated, the application will create the following request: +```HTTP +POST /oauth/v2/device_authorization HTTP/1.1 +Host: login.example.com +Content-type: application/x-www-form-urlencoded + +client_id=170086824411201793& +scope=openid%20email%20profile +``` + +The request includes all the relevant information for the OAuth2 Device Authorization Grant and in this example we also have some scopes for the user. + +You now have to proxy the auth request from your own UI to the device authorization Endpoint of ZITADEL. +For more information, see [OIDC Proxy](./typescript-repo#oidc-proxy) for the necessary headers. + +:::note +The version and the optional custom URI for the available login UI is configurable under the application settings. +::: + +The endpoint will return the device authorization response: +```json +{ + "device_code": "0jbAZbU3ClK-Mkt0li4U1A", + "user_code": "FWRK-JGWK", + "verification_uri": "https://login.example.com/device", + "verification_uri_complete": "https://login.example.com/device?user_code=FWRK-JGWK", + "expires_in": 300, + "interval": 5 +} +``` + +The device presents the `user_code` and `verification_uri` or maybe even render a QR code with the `verification_uri_complete` for the user to scan. + +Your login will have to provide a page on the `verification_uri` where the user can enter the `user_code`, or it's automatically filled in, if they scan the QR code. + +### Get the Device Authorization Request by User Code + +With the user_code entered by the user you will now be able to get the information of the device authorization request. +[Get Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-get-device-authorization-request) + +```bash +curl --request GET \ + --url https://$ZITADEL_DOMAIN/v2/oidc/device_authorization/FWRK-JGWK \ + --header 'Authorization: Bearer '"$TOKEN"'' +``` + +Response Example: + +```json +{ + "deviceAuthorizationRequest": { + "id": "XzNejv6NxqVU8Qur5uxEh7f_Wi1p0qUu4PJTJ6JUIx0xtJ2uqmU", + "clientId": "170086824411201793", + "scope": [ + "openid", + "profile" + ], + "appName": "TV App", + "projectName": "My Project" + } +} +``` + +Present the user with the information of the device authorization request and allow them to approve or deny the request. + +### Perform Login + +After you have initialized the OIDC flow you can implement the login. +Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session. + +Read the following resources for more information about the different checks: +- [Username and Password](./username-password) +- [External Identity Provider](./external-login) +- [Passkeys](./passkey) +- [Multi-Factor](./mfa) + +### Authorize the Device Authorization Request + +To finalize the auth request and connect an existing user session with it, you have to update the auth request with the session token. +On the create and update user session request you will always get a session token in the response. + +The latest session token has to be sent to the following request: + +Read more about the [Authorize or Deny Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-authorize-device-authorization) + +Make sure that the authorization header is from an account which is permitted to finalize the Auth Request through the `IAM_LOGIN_CLIENT` role. +```bash +curl --request POST \ + --url $ZITADEL_DOMAIN/v2/oidc/device_authorization/XzNejv6NxqVU8Qur5uxEh7f_Wi1p0qUu4PJTJ6JUIx0xtJ2uqmU \ + --header 'Accept: application/json' \ + --header 'Authorization: Bearer '"$TOKEN"''\ + --header 'Content-Type: application/json' \ + --data '{ + "session": { + "sessionId": "225307381909694508", + "sessionToken": "7N5kQCvC4jIf2OuBjwfyWSX2FUKbQqg4iG3uWT-TBngMhlS9miGUwpyUaN0HJ8OcbSzk4QHZy_Bvvv" + } +}' +``` + +If you don't get any error back, the request succeeded, and you can notify the user that they can close the window now and return to the application. + +### Deny the Device Authorization Request + +If the user denies the device authorization request, you can deny the request by sending the following request: + +```bash +curl --request POST \ + --url $ZITADEL_DOMAIN/v2/oidc/device_authorization/ \ + --header 'Accept: application/json' \ + --header 'Authorization: Bearer '"$TOKEN"''\ + --header 'Content-Type: application/json' \ + --data '{ + "deny": {} +}' +``` + +If you don't get any error back, the request succeeded, and you can notify the user that they can close the window now and return to the application. + +### Device Authorization Endpoints + +All OAuth2 Device Authorization Grant endpoints are provided by ZITADEL. In your login UI you just have to proxy them through and send them directly to the backend. + +These endpoints are: +- Well-known +- Device Authorization Endpoint +- Token + +Additionally, we recommend you to proxy all the other [OIDC relevant endpoints](./oidc-standard#endpoints). \ No newline at end of file diff --git a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx index f05d0d99b1..c96338fbf0 100644 --- a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx @@ -56,7 +56,7 @@ With the ID from the redirect before you will now be able to get the information ```bash curl --request GET \ --url https://$ZITADEL_DOMAIN/v2/oidc/auth_requests/V2_224908753244265546 \ - --header 'Authorization: Bearer '"$TOKEN"''\ + --header 'Authorization: Bearer '"$TOKEN"'' ``` Response Example: @@ -90,7 +90,7 @@ Read the following resources for more information about the different checks: ### Finalize Auth Request -To finalize the auth request and connect an existing user session with it you have to update the auth request with the session token. +To finalize the auth request and connect an existing user session with it, you have to update the auth request with the session token. On the create and update user session request you will always get a session token in the response. The latest session token has to be sent to the following request: @@ -128,7 +128,7 @@ Example Response: ### OIDC Endpoints -All OIDC relevant endpoints are provided by ZITADEL. In you login UI you just have to proxy them through and send them directly to the backend. +All OIDC relevant endpoints are provided by ZITADEL. In your login UI you just have to proxy them through and send them directly to the backend. These are endpoints like: - Userinfo diff --git a/docs/docs/guides/integrate/login-ui/saml-standard.mdx b/docs/docs/guides/integrate/login-ui/saml-standard.mdx index a2cb907874..8114350d5d 100644 --- a/docs/docs/guides/integrate/login-ui/saml-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/saml-standard.mdx @@ -56,7 +56,7 @@ With the ID from the redirect before you will now be able to get the information ```bash curl --request GET \ --url https://$ZITADEL_DOMAIN/v2/saml/saml_requests/V2_224908753244265546 \ - --header 'Authorization: Bearer '"$TOKEN"''\ + --header 'Authorization: Bearer '"$TOKEN"'' ``` Response Example: @@ -87,7 +87,7 @@ Read the following resources for more information about the different checks: ### Finalize SAML Request -To finalize the SAML request and connect an existing user session with it you have to update the SAML Request with the session token. +To finalize the SAML request and connect an existing user session with it, you have to update the SAML Request with the session token. On the create and update user session request you will always get a session token in the response. The latest session token has to be sent to the following request: diff --git a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx index d4a0726621..44ff9694d3 100644 --- a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx +++ b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx @@ -11,7 +11,6 @@ The typescript repository contains all TypeScript and JavaScript packages and ap - **[login](./typescript-repo#new-login-ui)**: The future login UI used by ZITADEL Cloud, powered by Next.js - `@zitadel/proto`: Typescript implementation of Protocol Buffers, suitable for web browsers and Node.js. - `@zitadel/client`: Core components for establishing a client connection -- `@zitadel/node`: Core components for establishing a server connection - `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo - `eslint-config-zitadel`: ESLint preset @@ -137,11 +136,10 @@ You can review an example implementation of a middlware [here](https://github.co #### Deploy to Vercel -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_ID,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_OWNER%20membership%20on%20your%20instance%20and%20provide%20its%20id%20and%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) To deploy your own version on Vercel, navigate to your instance and create a service user. -Copy its id from the overview and set it as `ZITADEL_SERVICE_USER_ID`. -Then create a personal access token (PAT), copy and set it as `ZITADEL_SERVICE_USER_TOKEN`, then navigate to Default settings and make sure it gets `IAM_OWNER` permissions. +Create a personal access token (PAT) for the user and copy and set it as `ZITADEL_SERVICE_USER_TOKEN`, then navigate to Default settings and make sure it gets `IAM_LOGIN_CLIENT` permissions. Finally set your instance url as `ZITADEL_API_URL`. Make sure to set it without trailing slash. Also ensure your login domain is registered on your instance by adding it as a [trusted domain](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain). diff --git a/docs/docs/guides/integrate/login/hosted-login.mdx b/docs/docs/guides/integrate/login/hosted-login.mdx index fcb7729314..28509fd98c 100644 --- a/docs/docs/guides/integrate/login/hosted-login.mdx +++ b/docs/docs/guides/integrate/login/hosted-login.mdx @@ -179,12 +179,11 @@ Your contributions will play a crucial role in shaping the future of our login s The simplest way to deploy the new login for yourself is by using the [“Deploy” button in our repository](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) to deploy the login directly to your Vercel. -1. [Create a service user](https://zitadel.com/docs/guides/integrate/service-users/personal-access-token#create-a-service-user-with-a-pat) (ZITADEL_SERVICE_USER_ID) with a PAT in your instance +1. [Create a service user](https://zitadel.com/docs/guides/integrate/service-users/personal-access-token#create-a-service-user-with-a-pat) with a PAT in your instance 2. Give the user IAM_LOGIN_CLIENT Permissions in the default settings (YOUR_DOMAIN/ui/console/instance?id=organizations) Note: [Zitadel Manager Guide](https://zitadel.com/docs/guides/manage/console/managers) 3. Deploy login to Vercel: You can do so, be directly clicking the [“Deploy” button](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) at the bottom of the readme in our [repository](https://github.com/zitadel/typescript) 4. If you have used the deploy button in the steps before, you will automatically be asked for this step. Enter the environment variables in Vercel - - ZITADEL_SERVICE_USER_ID - PAT - ZITADEL_API_URL (Example: https://my-domain.zitadel.cloud, no trailing slash) 5. Add the domain where your login UI is hosted to the [trusted domains](https://zitadel.com/docs/apis/resources/admin/admin-service-add-instance-trusted-domain) in Zitadel. (Example: my-new-zitadel-login.vercel.app) diff --git a/docs/sidebars.js b/docs/sidebars.js index 52eef6fffe..6bcd0cf05c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -328,6 +328,7 @@ module.exports = { "guides/integrate/login-ui/logout", "guides/integrate/login-ui/oidc-standard", "guides/integrate/login-ui/saml-standard", + "guides/integrate/login-ui/device-auth", "guides/integrate/login-ui/typescript-repo", ], }, diff --git a/docs/static/img/guides/login-ui/device-auth-flow.png b/docs/static/img/guides/login-ui/device-auth-flow.png new file mode 100644 index 0000000000..f240df32f4 Binary files /dev/null and b/docs/static/img/guides/login-ui/device-auth-flow.png differ diff --git a/internal/api/grpc/admin/iam_member.go b/internal/api/grpc/admin/iam_member.go index edd4dd0ce6..8f9b11ce2a 100644 --- a/internal/api/grpc/admin/iam_member.go +++ b/internal/api/grpc/admin/iam_member.go @@ -28,8 +28,7 @@ func (s *Server) ListIAMMembers(ctx context.Context, req *admin_pb.ListIAMMember } return &admin_pb.ListIAMMembersResponse{ Details: object.ToListDetails(res.Count, res.Sequence, res.LastRun), - //TODO: resource owner of user of the member instead of the membership resource owner - Result: member.MembersToPb("", res.Members), + Result: member.MembersToPb("", res.Members), }, nil } diff --git a/internal/api/grpc/admin/iam_member_converter.go b/internal/api/grpc/admin/iam_member_converter.go index 07e91d21b3..2fe75214fd 100644 --- a/internal/api/grpc/admin/iam_member_converter.go +++ b/internal/api/grpc/admin/iam_member_converter.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" + member_pb "github.com/zitadel/zitadel/pkg/grpc/member" ) func AddIAMMemberToDomain(req *admin_pb.AddIAMMemberRequest) *domain.Member { @@ -31,12 +32,29 @@ func ListIAMMembersRequestToQuery(req *admin_pb.ListIAMMembersRequest) (*query.I return &query.IAMMembersQuery{ MembersQuery: query.MembersQuery{ SearchRequest: query.SearchRequest{ - Offset: offset, - Limit: limit, - Asc: asc, - // SortingColumn: model.IAMMemberSearchKey, //TOOD: not implemented in proto + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToMemberColumn(req.SortingColumn), }, Queries: queries, }, }, nil } + +func fieldNameToMemberColumn(fieldName member_pb.MemberFieldColumnName) query.Column { + switch fieldName { + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_UNSPECIFIED: + return query.InstanceMemberInstanceID + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_USER_ID: + return query.InstanceMemberUserID + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_CREATION_DATE: + return query.InstanceMemberCreationDate + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_CHANGE_DATE: + return query.InstanceMemberChangeDate + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_USER_RESOURCE_OWNER: + return query.InstanceMemberResourceOwner + default: + return query.Column{} + } +} diff --git a/internal/api/grpc/admin/idp_converter.go b/internal/api/grpc/admin/idp_converter.go index e181542384..3084031a73 100644 --- a/internal/api/grpc/admin/idp_converter.go +++ b/internal/api/grpc/admin/idp_converter.go @@ -215,6 +215,7 @@ func addGenericOAuthProviderToCommand(req *admin_pb.AddGenericOAuthProviderReque UserEndpoint: req.UserEndpoint, Scopes: req.Scopes, IDAttribute: req.IdAttribute, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } @@ -229,6 +230,7 @@ func updateGenericOAuthProviderToCommand(req *admin_pb.UpdateGenericOAuthProvide UserEndpoint: req.UserEndpoint, Scopes: req.Scopes, IDAttribute: req.IdAttribute, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } @@ -241,6 +243,7 @@ func addGenericOIDCProviderToCommand(req *admin_pb.AddGenericOIDCProviderRequest ClientSecret: req.ClientSecret, Scopes: req.Scopes, IsIDTokenMapping: req.IsIdTokenMapping, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } @@ -253,6 +256,7 @@ func updateGenericOIDCProviderToCommand(req *admin_pb.UpdateGenericOIDCProviderR ClientSecret: req.ClientSecret, Scopes: req.Scopes, IsIDTokenMapping: req.IsIdTokenMapping, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index f788bb5f5a..93e6936d42 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -112,9 +112,6 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain str if err != nil { return nil, err } - if err != nil { - return nil, err - } userIDs := make([]string, len(users.Users)) for i, user := range users.Users { userIDs[i] = user.ID diff --git a/internal/api/grpc/idp/converter.go b/internal/api/grpc/idp/converter.go index 8edc585fe8..6269f122c0 100644 --- a/internal/api/grpc/idp/converter.go +++ b/internal/api/grpc/idp/converter.go @@ -504,6 +504,7 @@ func oauthConfigToPb(providerConfig *idp_pb.ProviderConfig, template *query.OAut UserEndpoint: template.UserEndpoint, Scopes: template.Scopes, IdAttribute: template.IDAttribute, + UsePkce: template.UsePKCE, }, } } @@ -515,6 +516,7 @@ func oidcConfigToPb(providerConfig *idp_pb.ProviderConfig, template *query.OIDCI Issuer: template.Issuer, Scopes: template.Scopes, IsIdTokenMapping: template.IsIDTokenMapping, + UsePkce: template.UsePKCE, }, } } diff --git a/internal/api/grpc/idp/v2/integration_test/query_test.go b/internal/api/grpc/idp/v2/integration_test/query_test.go index 7bfa286b5e..2c4be8a73b 100644 --- a/internal/api/grpc/idp/v2/integration_test/query_test.go +++ b/internal/api/grpc/idp/v2/integration_test/query_test.go @@ -76,6 +76,7 @@ func TestServer_GetIDPByID(t *testing.T) { name, &object.Details{ Sequence: resp.Details.Sequence, + CreationDate: resp.Details.CreationDate, ChangeDate: resp.Details.ChangeDate, ResourceOwner: resp.Details.ResourceOwner, }} @@ -124,6 +125,7 @@ func TestServer_GetIDPByID(t *testing.T) { name, &object.Details{ Sequence: resp.Details.Sequence, + CreationDate: resp.Details.CreationDate, ChangeDate: resp.Details.ChangeDate, ResourceOwner: resp.Details.ResourceOwner, }} @@ -145,6 +147,7 @@ func TestServer_GetIDPByID(t *testing.T) { name, &object.Details{ Sequence: resp.Details.Sequence, + CreationDate: resp.Details.CreationDate, ChangeDate: resp.Details.ChangeDate, ResourceOwner: resp.Details.ResourceOwner, }} @@ -193,6 +196,7 @@ func TestServer_GetIDPByID(t *testing.T) { name, &object.Details{ Sequence: resp.Details.Sequence, + CreationDate: resp.Details.CreationDate, ChangeDate: resp.Details.ChangeDate, ResourceOwner: resp.Details.ResourceOwner, }} diff --git a/internal/api/grpc/idp/v2/query.go b/internal/api/grpc/idp/v2/query.go index e53d54eee7..de78775804 100644 --- a/internal/api/grpc/idp/v2/query.go +++ b/internal/api/grpc/idp/v2/query.go @@ -31,6 +31,7 @@ func idpToPb(idp *query.IDPTemplate) *idp_pb.IDP { Sequence: idp.Sequence, EventDate: idp.ChangeDate, ResourceOwner: idp.ResourceOwner, + CreationDate: idp.CreationDate, }), State: idpStateToPb(idp.State), Name: idp.Name, diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go index b2fcb7652a..4f68c5f919 100644 --- a/internal/api/grpc/management/idp_converter.go +++ b/internal/api/grpc/management/idp_converter.go @@ -209,6 +209,7 @@ func addGenericOAuthProviderToCommand(req *mgmt_pb.AddGenericOAuthProviderReques Scopes: req.Scopes, IDAttribute: req.IdAttribute, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + UsePKCE: req.UsePkce, } } @@ -223,6 +224,7 @@ func updateGenericOAuthProviderToCommand(req *mgmt_pb.UpdateGenericOAuthProvider Scopes: req.Scopes, IDAttribute: req.IdAttribute, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + UsePKCE: req.UsePkce, } } @@ -234,6 +236,7 @@ func addGenericOIDCProviderToCommand(req *mgmt_pb.AddGenericOIDCProviderRequest) ClientSecret: req.ClientSecret, Scopes: req.Scopes, IsIDTokenMapping: req.IsIdTokenMapping, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } @@ -246,6 +249,7 @@ func updateGenericOIDCProviderToCommand(req *mgmt_pb.UpdateGenericOIDCProviderRe ClientSecret: req.ClientSecret, Scopes: req.Scopes, IsIDTokenMapping: req.IsIdTokenMapping, + UsePKCE: req.UsePkce, IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index 8cf0d8b1fa..753b9db095 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -20,6 +20,9 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { if !objectDetail.EventDate.IsZero() { details.ChangeDate = timestamppb.New(objectDetail.EventDate) } + if !objectDetail.CreationDate.IsZero() { + details.CreationDate = timestamppb.New(objectDetail.CreationDate) + } return details } diff --git a/internal/api/grpc/object/v2beta/converter.go b/internal/api/grpc/object/v2beta/converter.go index cc7e02c7fe..9b14bb677a 100644 --- a/internal/api/grpc/object/v2beta/converter.go +++ b/internal/api/grpc/object/v2beta/converter.go @@ -19,6 +19,9 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { if !objectDetail.EventDate.IsZero() { details.ChangeDate = timestamppb.New(objectDetail.EventDate) } + if !objectDetail.CreationDate.IsZero() { + details.CreationDate = timestamppb.New(objectDetail.CreationDate) + } return details } diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index d6b5c7b8cf..1eb031bd6d 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -13,6 +13,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" @@ -636,6 +637,230 @@ func TestServer_CreateCallback_Permission(t *testing.T) { } } +func TestServer_GetDeviceAuthorizationRequest(t *testing.T) { + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) + require.NoError(t, err) + + tests := []struct { + name string + dep func() (*oidc.DeviceAuthorizationResponse, error) + ctx context.Context + want *oidc.DeviceAuthorizationResponse + wantErr bool + }{ + { + name: "Not found", + dep: func() (*oidc.DeviceAuthorizationResponse, error) { + return &oidc.DeviceAuthorizationResponse{ + UserCode: "notFound", + }, nil + }, + ctx: CTX, + wantErr: true, + }, + { + name: "success", + dep: func() (*oidc.DeviceAuthorizationResponse, error) { + return Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + }, + ctx: CTX, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deviceAuth, err := tt.dep() + require.NoError(t, err) + + got, err := Client.GetDeviceAuthorizationRequest(tt.ctx, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: deviceAuth.UserCode, + }) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + authRequest := got.GetDeviceAuthorizationRequest() + assert.NotNil(t, authRequest) + assert.NotEmpty(t, authRequest.GetId()) + assert.Equal(t, client.GetClientId(), authRequest.GetClientId()) + assert.Contains(t, authRequest.GetScope(), "openid") + assert.NotEmpty(t, authRequest.GetAppName()) + assert.NotEmpty(t, authRequest.GetProjectName()) + }) + } +} + +func TestServer_AuthorizeOrDenyDeviceAuthorization(t *testing.T) { + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) + require.NoError(t, err) + sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) + + tests := []struct { + name string + ctx context.Context + req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest + AuthError string + want *oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "Not found", + ctx: CTX, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: "123", + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "session not found", + ctx: CTX, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: func() string { + req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + require.NoError(t, err) + var id string + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: req.UserCode, + }) + assert.NoError(t, err) + id = resp.GetDeviceAuthorizationRequest().GetId() + }, 5*time.Second, 100*time.Millisecond) + return id + }(), + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: "foo", + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "session token invalid", + ctx: CTX, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: func() string { + req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + require.NoError(t, err) + var id string + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: req.UserCode, + }) + assert.NoError(collectT, err) + id = resp.GetDeviceAuthorizationRequest().GetId() + }, 5*time.Second, 100*time.Millisecond) + return id + }(), + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "deny device authorization", + ctx: CTX, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: func() string { + req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + require.NoError(t, err) + var id string + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: req.UserCode, + }) + assert.NoError(collectT, err) + id = resp.GetDeviceAuthorizationRequest().GetId() + }, 5*time.Second, 100*time.Millisecond) + return id + }(), + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny{}, + }, + want: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, + wantErr: false, + }, + { + name: "authorize, no permission, error", + ctx: CTX, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: func() string { + req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + require.NoError(t, err) + var id string + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: req.UserCode, + }) + assert.NoError(collectT, err) + id = resp.GetDeviceAuthorizationRequest().GetId() + }, 5*time.Second, 100*time.Millisecond) + return id + }(), + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "authorize, with permission", + ctx: CTXLoginClient, + req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: func() string { + req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid") + require.NoError(t, err) + var id string + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: req.UserCode, + }) + assert.NoError(collectT, err) + id = resp.GetDeviceAuthorizationRequest().GetId() + }, 5*time.Second, 100*time.Millisecond) + return id + }(), + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Client.AuthorizeOrDenyDeviceAuthorization(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse { sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ Checks: &session.Checks{ diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index d1ddc35cc0..73fc995be2 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "encoding/base64" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" @@ -28,6 +29,54 @@ func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequest }, nil } +func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { + switch v := req.GetCallbackKind().(type) { + case *oidc_pb.CreateCallbackRequest_Error: + return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + case *oidc_pb.CreateCallbackRequest_Session: + return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) + } +} + +func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb.GetDeviceAuthorizationRequestRequest) (*oidc_pb.GetDeviceAuthorizationRequestResponse, error) { + deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.GetUserCode()) + if err != nil { + return nil, err + } + encrypted, err := s.encryption.Encrypt([]byte(deviceRequest.DeviceCode)) + if err != nil { + return nil, err + } + return &oidc_pb.GetDeviceAuthorizationRequestResponse{ + DeviceAuthorizationRequest: &oidc_pb.DeviceAuthorizationRequest{ + Id: base64.RawURLEncoding.EncodeToString(encrypted), + ClientId: deviceRequest.ClientID, + Scope: deviceRequest.Scopes, + AppName: deviceRequest.AppName, + ProjectName: deviceRequest.ProjectName, + }, + }, nil +} + +func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest) (*oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse, error) { + deviceCode, err := s.deviceCodeFromID(req.GetDeviceAuthorizationId()) + if err != nil { + return nil, err + } + switch req.GetDecision().(type) { + case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session: + _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.GetSession().GetSessionId(), req.GetSession().GetSessionToken()) + case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny: + _, err = s.command.CancelDeviceAuth(ctx, deviceCode, domain.DeviceAuthCanceledDenied) + } + if err != nil { + return nil, err + } + return &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, nil +} + func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { pba := &oidc_pb.AuthRequest{ Id: a.ID, @@ -87,17 +136,6 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { - case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) - case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) - default: - return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) - } -} - func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { @@ -215,3 +253,11 @@ func errorReasonToOIDC(reason oidc_pb.ErrorReason) string { return "server_error" } } + +func (s *Server) deviceCodeFromID(deviceAuthID string) (string, error) { + decoded, err := base64.RawURLEncoding.DecodeString(deviceAuthID) + if err != nil { + return "", err + } + return s.encryption.DecryptString(decoded, s.encryption.EncryptionKeyID()) +} diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 28c7134904..99234ee3d7 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) @@ -20,6 +21,7 @@ type Server struct { op *oidc.Server externalSecure bool + encryption crypto.EncryptionAlgorithm } type Config struct{} @@ -29,12 +31,14 @@ func CreateServer( query *query.Queries, op *oidc.Server, externalSecure bool, + encryption crypto.EncryptionAlgorithm, ) *Server { return &Server{ command: command, query: query, op: op, externalSecure: externalSecure, + encryption: encryption, } } diff --git a/internal/api/grpc/org/v2/integration_test/query_test.go b/internal/api/grpc/org/v2/integration_test/query_test.go index 188aeddf9f..86a2bd312b 100644 --- a/internal/api/grpc/org/v2/integration_test/query_test.go +++ b/internal/api/grpc/org/v2/integration_test/query_test.go @@ -26,6 +26,16 @@ type orgAttr struct { Details *object.Details } +func createOrganization(ctx context.Context, name string) orgAttr { + orgResp := Instance.CreateOrganization(ctx, name, gofakeit.Email()) + orgResp.Details.CreationDate = orgResp.Details.ChangeDate + return orgAttr{ + ID: orgResp.GetOrganizationId(), + Name: name, + Details: orgResp.GetDetails(), + } +} + func TestServer_ListOrganizations(t *testing.T) { type args struct { ctx context.Context @@ -63,6 +73,7 @@ func TestServer_ListOrganizations(t *testing.T) { State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.CreationDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, @@ -85,12 +96,7 @@ func TestServer_ListOrganizations(t *testing.T) { prefix := fmt.Sprintf("ListOrgs-%s", gofakeit.AppName()) for i := 0; i < count; i++ { name := prefix + strconv.Itoa(i) - orgResp := Instance.CreateOrganization(ctx, name, gofakeit.Email()) - orgs[i] = orgAttr{ - ID: orgResp.GetOrganizationId(), - Name: name, - Details: orgResp.GetDetails(), - } + orgs[i] = createOrganization(ctx, name) } request.Queries = []*org.SearchQuery{ OrganizationNamePrefixQuery(prefix), @@ -140,6 +146,7 @@ func TestServer_ListOrganizations(t *testing.T) { Name: Instance.DefaultOrg.Name, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.CreationDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, @@ -172,6 +179,7 @@ func TestServer_ListOrganizations(t *testing.T) { Name: Instance.DefaultOrg.Name, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.CreationDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, @@ -204,6 +212,7 @@ func TestServer_ListOrganizations(t *testing.T) { Name: Instance.DefaultOrg.Name, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.CreationDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, @@ -221,14 +230,9 @@ func TestServer_ListOrganizations(t *testing.T) { func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { orgs := make([]orgAttr, 1) name := fmt.Sprintf("ListOrgs-%s", gofakeit.AppName()) - orgResp := Instance.CreateOrganization(ctx, name, gofakeit.Email()) - orgs[0] = orgAttr{ - ID: orgResp.GetOrganizationId(), - Name: name, - Details: orgResp.GetDetails(), - } + orgs[0] = createOrganization(ctx, name) domain := gofakeit.DomainName() - _, err := Instance.Client.Mgmt.AddOrgDomain(integration.SetOrgID(ctx, orgResp.GetOrganizationId()), &management.AddOrgDomainRequest{ + _, err := Instance.Client.Mgmt.AddOrgDomain(integration.SetOrgID(ctx, orgs[0].ID), &management.AddOrgDomainRequest{ Domain: domain, }) if err != nil { @@ -262,18 +266,19 @@ func TestServer_ListOrganizations(t *testing.T) { }, func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { name := gofakeit.Name() - orgResp := Instance.CreateOrganization(ctx, name, gofakeit.Email()) - deactivateOrgResp := Instance.DeactivateOrganization(ctx, orgResp.GetOrganizationId()) + orgResp := createOrganization(ctx, name) + deactivateOrgResp := Instance.DeactivateOrganization(ctx, orgResp.ID) request.Queries = []*org.SearchQuery{ - OrganizationIdQuery(orgResp.GetOrganizationId()), + OrganizationIdQuery(orgResp.ID), OrganizationStateQuery(org.OrganizationState_ORGANIZATION_STATE_INACTIVE), } return []orgAttr{{ - ID: orgResp.GetOrganizationId(), + ID: orgResp.ID, Name: name, Details: &object.Details{ ResourceOwner: deactivateOrgResp.GetDetails().GetResourceOwner(), Sequence: deactivateOrgResp.GetDetails().GetSequence(), + CreationDate: orgResp.Details.GetCreationDate(), ChangeDate: deactivateOrgResp.GetDetails().GetChangeDate(), }, }}, nil @@ -317,6 +322,7 @@ func TestServer_ListOrganizations(t *testing.T) { Name: Instance.DefaultOrg.Name, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.ChangeDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, @@ -414,6 +420,7 @@ func TestServer_ListOrganizations(t *testing.T) { Name: Instance.DefaultOrg.Name, Details: &object.Details{ Sequence: Instance.DefaultOrg.Details.Sequence, + CreationDate: Instance.DefaultOrg.Details.ChangeDate, ChangeDate: Instance.DefaultOrg.Details.ChangeDate, ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, }, diff --git a/internal/api/grpc/org/v2/query.go b/internal/api/grpc/org/v2/query.go index f07fb71d20..27f279d40e 100644 --- a/internal/api/grpc/org/v2/query.go +++ b/internal/api/grpc/org/v2/query.go @@ -129,6 +129,7 @@ func organizationToPb(organization *query.Org) *org.Organization { Sequence: organization.Sequence, EventDate: organization.ChangeDate, ResourceOwner: organization.ResourceOwner, + CreationDate: organization.CreationDate, }), State: orgStateToPb(organization.State), } diff --git a/internal/api/grpc/session/v2/integration_test/server_test.go b/internal/api/grpc/session/v2/integration_test/server_test.go index 70e2146069..6ea2b4dbda 100644 --- a/internal/api/grpc/session/v2/integration_test/server_test.go +++ b/internal/api/grpc/session/v2/integration_test/server_test.go @@ -19,6 +19,7 @@ var ( CTX context.Context IAMOwnerCTX context.Context UserCTX context.Context + LoginCTX context.Context Instance *integration.Instance Client session.SessionServiceClient User *user.AddHumanUserResponse @@ -37,6 +38,7 @@ func TestMain(m *testing.M) { CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + LoginCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) User = createFullUser(CTX) DeactivatedUser = createDeactivatedUser(CTX) LockedUser = createLockedUser(CTX) diff --git a/internal/api/grpc/session/v2/integration_test/session_test.go b/internal/api/grpc/session/v2/integration_test/session_test.go index 7622550b15..b9a060c749 100644 --- a/internal/api/grpc/session/v2/integration_test/session_test.go +++ b/internal/api/grpc/session/v2/integration_test/session_test.go @@ -21,6 +21,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" @@ -339,10 +340,9 @@ func TestServer_CreateSession_webauthn(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified) } -/* func TestServer_CreateSession_successfulIntent(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) - createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() + createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ Search: &session.CheckUser_UserId{ @@ -354,8 +354,9 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") - updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + require.NoError(t, err) + updateResp, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Checks: &session.Checks{ IdpIntent: &session.CheckIDPIntent{ @@ -369,9 +370,10 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { } func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + require.NoError(t, err) createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ @@ -390,11 +392,11 @@ func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { } func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() // successful intent without known / linked user idpUserID := "id" - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", idpUserID) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, idpUserID, "") // link the user (with info from intent) Instance.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId()) @@ -418,7 +420,7 @@ func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { } func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ @@ -432,19 +434,18 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID := Instance.CreateIntent(t, CTX, idpID) + intent := Instance.CreateIntent(CTX, idpID) _, err = Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Checks: &session.Checks{ IdpIntent: &session.CheckIDPIntent{ - IdpIntentId: intentID, + IdpIntentId: intent.GetIdpIntent().GetIdpIntentId(), IdpIntentToken: "false", }, }, }) require.Error(t, err) } -*/ func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { resp, err := Instance.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ diff --git a/internal/api/grpc/session/v2beta/integration_test/session_test.go b/internal/api/grpc/session/v2beta/integration_test/session_test.go index 26d2291629..d0fc1179ef 100644 --- a/internal/api/grpc/session/v2beta/integration_test/session_test.go +++ b/internal/api/grpc/session/v2beta/integration_test/session_test.go @@ -21,6 +21,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" @@ -339,9 +340,8 @@ func TestServer_CreateSession_webauthn(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified) } -/* func TestServer_CreateSession_successfulIntent(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ @@ -354,7 +354,8 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + require.NoError(t, err) updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Checks: &session.Checks{ @@ -369,9 +370,10 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { } func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + require.NoError(t, err) createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ @@ -390,11 +392,12 @@ func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { } func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() // successful intent without known / linked user idpUserID := "id" - intentID, token, _, _ := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", idpUserID) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + require.NoError(t, err) // link the user (with info from intent) Instance.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId()) @@ -418,7 +421,7 @@ func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { } func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ @@ -432,19 +435,18 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID := Instance.CreateIntent(t, CTX, idpID) + intent := Instance.CreateIntent(CTX, idpID) _, err = Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Checks: &session.Checks{ IdpIntent: &session.CheckIDPIntent{ - IdpIntentId: intentID, + IdpIntentId: intent.GetIdpIntent().GetIdpIntentId(), IdpIntentToken: "false", }, }, }) require.Error(t, err) } -*/ func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { resp, err := Instance.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 8b9ab9b845..77874bf970 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -23,6 +23,7 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, @@ -38,6 +39,7 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -53,6 +55,7 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -68,6 +71,7 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -83,6 +87,7 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -98,6 +103,7 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -113,6 +119,7 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go index 677d8f1c15..6193f129ba 100644 --- a/internal/api/grpc/settings/v2beta/settings.go +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -23,6 +23,7 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, @@ -38,6 +39,7 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -53,6 +55,7 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -68,6 +71,7 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -83,6 +87,7 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -98,6 +103,7 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, @@ -113,6 +119,7 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, diff --git a/internal/api/grpc/system/instance_converter.go b/internal/api/grpc/system/instance_converter.go index 551079aec5..6826cb5694 100644 --- a/internal/api/grpc/system/instance_converter.go +++ b/internal/api/grpc/system/instance_converter.go @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" instance_pb "github.com/zitadel/zitadel/pkg/grpc/instance" + member_pb "github.com/zitadel/zitadel/pkg/grpc/member" system_pb "github.com/zitadel/zitadel/pkg/grpc/system" ) @@ -271,12 +272,29 @@ func ListIAMMembersRequestToQuery(req *system_pb.ListIAMMembersRequest) (*query. return &query.IAMMembersQuery{ MembersQuery: query.MembersQuery{ SearchRequest: query.SearchRequest{ - Offset: offset, - Limit: limit, - Asc: asc, - // SortingColumn: model.IAMMemberSearchKey, //TOOD: not implemented in proto + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToMemberColumn(req.SortingColumn), }, Queries: queries, }, }, nil } + +func fieldNameToMemberColumn(fieldName member_pb.MemberFieldColumnName) query.Column { + switch fieldName { + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_UNSPECIFIED: + return query.InstanceMemberInstanceID + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_USER_ID: + return query.InstanceMemberUserID + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_CREATION_DATE: + return query.InstanceMemberCreationDate + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_CHANGE_DATE: + return query.InstanceMemberChangeDate + case member_pb.MemberFieldColumnName_MEMBER_FIELD_NAME_USER_RESOURCE_OWNER: + return query.InstanceMemberResourceOwner + default: + return query.Column{} + } +} diff --git a/internal/api/grpc/user/v2/integration_test/query_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go index 2551a4a833..554de2b69a 100644 --- a/internal/api/grpc/user/v2/integration_test/query_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -4,6 +4,7 @@ package user_test import ( "context" + "errors" "fmt" "slices" "testing" @@ -16,10 +17,61 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) +var ( + permissionCheckV2SetFlagInital bool + permissionCheckV2SetFlag bool +) + +type permissionCheckV2SettingsStruct struct { + TestNamePrependString string + SetFlag bool +} + +var permissionCheckV2Settings []permissionCheckV2SettingsStruct = []permissionCheckV2SettingsStruct{ + { + SetFlag: false, + TestNamePrependString: "permission_check_v2 IS NOT SET" + " ", + }, + { + SetFlag: true, + TestNamePrependString: "permission_check_v2 IS SET" + " ", + }, +} + +func setPermissionCheckV2Flag(t *testing.T, setFlag bool) { + if permissionCheckV2SetFlagInital && permissionCheckV2SetFlag == setFlag { + return + } + + _, err := Instance.Client.FeatureV2.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: &setFlag, + }) + require.NoError(t, err) + + var flagSet bool + for i := 0; !flagSet || i < 6; i++ { + res, err := Instance.Client.FeatureV2.GetInstanceFeatures(IamCTX, &feature.GetInstanceFeaturesRequest{}) + require.NoError(t, err) + if res.PermissionCheckV2.Enabled == setFlag { + flagSet = true + continue + } + time.Sleep(10 * time.Second) + } + + if !flagSet { + require.NoError(t, errors.New("unable to set permission_check_v2 flag")) + } + permissionCheckV2SetFlagInital = true + permissionCheckV2SetFlag = setFlag +} + func TestServer_GetUserByID(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { @@ -98,6 +150,7 @@ func TestServer_GetUserByID(t *testing.T) { }, Details: &object.Details{ ChangeDate: timestamppb.Now(), + CreationDate: timestamppb.Now(), ResourceOwner: orgResp.OrganizationId, }, }, @@ -143,6 +196,7 @@ func TestServer_GetUserByID(t *testing.T) { }, Details: &object.Details{ ChangeDate: timestamppb.Now(), + CreationDate: timestamppb.Now(), ResourceOwner: orgResp.OrganizationId, }, }, @@ -230,6 +284,7 @@ func TestServer_GetUserByID_Permission(t *testing.T) { }, Details: &object.Details{ ChangeDate: timestamppb.Now(), + CreationDate: timestamppb.Now(), ResourceOwner: newOrg.GetOrganizationId(), }, }, @@ -268,6 +323,7 @@ func TestServer_GetUserByID_Permission(t *testing.T) { }, Details: &object.Details{ ChangeDate: timestamppb.Now(), + CreationDate: timestamppb.Now(), ResourceOwner: newOrg.GetOrganizationId(), }, }, @@ -363,6 +419,8 @@ func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) phone := "+41" + gofakeit.Phone() resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()} + // as the change date of the creation is the creation date + resp.Details.CreationDate = resp.GetDetails().GetChangeDate() if passwordChangeRequired { details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) info.Changed = details.GetChangeDate() @@ -371,6 +429,11 @@ func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) } func TestServer_ListUsers(t *testing.T) { + defer func() { + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }() + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { ctx context.Context @@ -384,7 +447,7 @@ func TestServer_ListUsers(t *testing.T) { wantErr bool }{ { - name: "list user by id, no permission", + name: "list user by id, no permission machine user", args: args{ UserCTX, &user.ListUsersRequest{}, @@ -403,17 +466,77 @@ func TestServer_ListUsers(t *testing.T) { Result: []*user.User{}, }, }, + { + name: "list user by id, no permission human user", + args: func() args { + info := createUser(IamCTX, orgResp.OrganizationId, true) + // create session to get token + userID := info.UserID + createResp, err := Instance.Client.SessionV2.CreateSession(IamCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{UserId: userID}, + }, + Password: &session.CheckPassword{ + Password: integration.UserPassword, + }, + }, + }) + if err != nil { + require.NoError(t, err) + } + // use token to get ctx + HumanCTX := integration.WithAuthorizationToken(IamCTX, createResp.GetSessionToken()) + return args{ + HumanCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{info} + }, + } + }(), + want: &user.ListUsersResponse{ // human user should return itself when calling ListUsers() even if it has no permissions + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + PasswordChangeRequired: true, + PasswordChanged: timestamppb.Now(), + }, + }, + }, + }, + }, + }, { name: "list user by id, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) return []userAttr{info} }, @@ -453,13 +576,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) return []userAttr{info} }, @@ -501,13 +622,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) return infos }, @@ -560,7 +679,8 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, - }, { + }, + { State: user.UserState_USER_STATE_ACTIVE, Type: &user.User_Human{ Human: &user.HumanUser{ @@ -588,13 +708,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, UsernameQuery(info.Username)) return []userAttr{info} }, @@ -634,13 +752,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) return []userAttr{info} }, @@ -678,189 +794,12 @@ func TestServer_ListUsers(t *testing.T) { }, { name: "list user in emails multiple, ok", - args: args{ - IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, - func(ctx context.Context, request *user.ListUsersRequest) userAttrs { - infos := createUsers(ctx, orgResp.OrganizationId, 3, false) - request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) - return infos - }, - }, - want: &user.ListUsersResponse{ - Details: &object.ListDetails{ - TotalResult: 3, - Timestamp: timestamppb.Now(), - }, - SortingColumn: 0, - Result: []*user.User{ - { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - IsVerified: true, - }, - }, - }, - }, { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - IsVerified: true, - }, - }, - }, - }, { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - IsVerified: true, - }, - }, - }, - }, - }, - }, - }, - { - name: "list user in emails no found, ok", - args: args{ - IamCTX, - &user.ListUsersRequest{Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - InUserEmailsQuery([]string{"notfound"}), - }, - }, - func(ctx context.Context, request *user.ListUsersRequest) userAttrs { - return []userAttr{} - }, - }, - want: &user.ListUsersResponse{ - Details: &object.ListDetails{ - TotalResult: 0, - Timestamp: timestamppb.Now(), - }, - SortingColumn: 0, - Result: []*user.User{}, - }, - }, - { - name: "list user phone, ok", - args: args{ - IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, - func(ctx context.Context, request *user.ListUsersRequest) userAttrs { - info := createUser(ctx, orgResp.OrganizationId, false) - request.Queries = append(request.Queries, PhoneQuery(info.Phone)) - return []userAttr{info} - }, - }, - want: &user.ListUsersResponse{ - Details: &object.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - SortingColumn: 0, - Result: []*user.User{ - { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - IsVerified: true, - }, - }, - }, - }, - }, - }, - }, - { - name: "list user in emails no found, ok", - args: args{ - IamCTX, - &user.ListUsersRequest{Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - InUserEmailsQuery([]string{"notfound"}), - }, - }, - func(ctx context.Context, request *user.ListUsersRequest) userAttrs { - return []userAttr{} - }, - }, - want: &user.ListUsersResponse{ - Details: &object.ListDetails{ - TotalResult: 0, - Timestamp: timestamppb.Now(), - }, - SortingColumn: 0, - Result: []*user.User{}, - }, - }, - { - name: "list user resourceowner multiple, ok", args: args{ IamCTX, &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { - orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) - infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) return infos @@ -937,93 +876,355 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user phone, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) + request.Queries = append(request.Queries, PhoneQuery(info.Phone)) + return []userAttr{info} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user resourceowner multiple, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) + + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user with org query", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + orgRespForOrgTests := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + info := createUser(ctx, orgRespForOrgTests.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests.OrganizationId)) + return []userAttr{info, {}} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 2, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + // this is the admin of the org craated in Instance.CreateOrganization() + nil, + }, + }, + }, + { + name: "list user with wrong org query", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + orgRespForOrgTests := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + orgRespForOrgTests2 := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + // info := createUser(ctx, orgRespForOrgTests.OrganizationId, false) + createUser(ctx, orgRespForOrgTests.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests2.OrganizationId)) + return []userAttr{{}} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + // this is the admin of the org craated in Instance.CreateOrganization() + nil, + }, + }, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - infos := tt.args.dep(IamCTX, tt.args.req) + for _, f := range permissionCheckV2Settings { + f := f + for _, tt := range tests { + t.Run(f.TestNamePrependString+tt.name, func(t *testing.T) { + setPermissionCheckV2Flag(t, f.SetFlag) + infos := tt.args.dep(IamCTX, tt.args.req) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, err := Client.ListUsers(tt.args.ctx, tt.args.req) - if tt.wantErr { - require.Error(ttt, err) - return - } - require.NoError(ttt, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListUsers(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, err) + return + } + require.NoError(ttt, err) - // always only give back dependency infos which are required for the response - require.Len(ttt, tt.want.Result, len(infos)) - // always first check length, otherwise its failed anyway - if assert.Len(ttt, got.Result, len(tt.want.Result)) { - // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions - tt.want.Details.TotalResult = got.Details.TotalResult + // always only give back dependency infos which are required for the response + require.Len(ttt, tt.want.Result, len(infos)) + if assert.Len(ttt, got.Result, len(tt.want.Result)) { + tt.want.Details.TotalResult = got.Details.TotalResult - // fill in userid and username as it is generated - for i := range infos { - tt.want.Result[i].UserId = infos[i].UserID - tt.want.Result[i].Username = infos[i].Username - tt.want.Result[i].PreferredLoginName = infos[i].Username - tt.want.Result[i].LoginNames = []string{infos[i].Username} - if human := tt.want.Result[i].GetHuman(); human != nil { - human.Email.Email = infos[i].Username - human.Phone.Phone = infos[i].Phone - if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { - human.PasswordChanged = infos[i].Changed + // fill in userid and username as it is generated + for i := range infos { + if tt.want.Result[i] == nil { + continue } + tt.want.Result[i].UserId = infos[i].UserID + tt.want.Result[i].Username = infos[i].Username + tt.want.Result[i].PreferredLoginName = infos[i].Username + tt.want.Result[i].LoginNames = []string{infos[i].Username} + if human := tt.want.Result[i].GetHuman(); human != nil { + human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone + if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { + human.PasswordChanged = infos[i].Changed + } + } + tt.want.Result[i].Details = infos[i].Details + } + for i := range tt.want.Result { + if tt.want.Result[i] == nil { + continue + } + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } - tt.want.Result[i].Details = infos[i].Details } - for i := range tt.want.Result { - assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) - } - } - integration.AssertListDetails(ttt, tt.want, got) - }, retryDuration, tick, "timeout waiting for expected user result") - }) + integration.AssertListDetails(ttt, tt.want, got) + }, retryDuration, tick, "timeout waiting for expected user result") + }) + } } } func InUserIDsQuery(ids []string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_InUserIdsQuery{ - InUserIdsQuery: &user.InUserIDQuery{ - UserIds: ids, + return &user.SearchQuery{ + Query: &user.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &user.InUserIDQuery{ + UserIds: ids, + }, }, - }, } } func InUserEmailsQuery(emails []string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_InUserEmailsQuery{ - InUserEmailsQuery: &user.InUserEmailsQuery{ - UserEmails: emails, + return &user.SearchQuery{ + Query: &user.SearchQuery_InUserEmailsQuery{ + InUserEmailsQuery: &user.InUserEmailsQuery{ + UserEmails: emails, + }, }, - }, } } func PhoneQuery(number string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ - PhoneQuery: &user.PhoneQuery{ - Number: number, + return &user.SearchQuery{ + Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, }, - }, } } func UsernameQuery(username string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ - UserNameQuery: &user.UserNameQuery{ - UserName: username, + return &user.SearchQuery{ + Query: &user.SearchQuery_UserNameQuery{ + UserNameQuery: &user.UserNameQuery{ + UserName: username, + }, }, - }, } } func OrganizationIdQuery(resourceowner string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_OrganizationIdQuery{ - OrganizationIdQuery: &user.OrganizationIdQuery{ - OrganizationId: resourceowner, + return &user.SearchQuery{ + Query: &user.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &user.OrganizationIdQuery{ + OrganizationId: resourceowner, + }, }, - }, } } diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 0ed93fd92e..6d5d112e98 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -10,16 +10,20 @@ import ( "testing" "time" + "github.com/zitadel/logging" + "google.golang.org/protobuf/types/known/structpb" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" @@ -2110,15 +2114,20 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } -/* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) - intentID := Instance.CreateIntent(t, CTX, idpID) - successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") - ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id") - ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id") - samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Instance.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id") + idpID := Instance.AddGenericOAuthProvider(IamCTX, gofakeit.AppName()).GetId() + intentID := Instance.CreateIntent(CTX, idpID).GetIdpIntent().GetIdpIntentId() + + successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", "user") + require.NoError(t, err) + ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) + ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), idpID, "id", "user") + require.NoError(t, err) + samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) type args struct { ctx context.Context req *user.RetrieveIdentityProviderIntentRequest @@ -2369,7 +2378,6 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }) } } -*/ func ctxFromNewUserWithRegisteredPasswordlessLegacy(t *testing.T) (context.Context, string, *auth.AddMyPasswordlessResponse) { userID := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index 7facfc74e0..9263012b98 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -74,6 +74,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -90,6 +91,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -104,6 +106,10 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { Seconds: 3000, Nanos: 22, }, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ResourceOwner: "me", }, PasskeyId: "123", @@ -150,6 +156,7 @@ func Test_passkeyDetailsToPb(t *testing.T) { details: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, err: nil, @@ -161,6 +168,10 @@ func Test_passkeyDetailsToPb(t *testing.T) { Seconds: 3000, Nanos: 22, }, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ResourceOwner: "me", }, }, @@ -199,6 +210,7 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, CodeID: "123", @@ -213,6 +225,10 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { Seconds: 3000, Nanos: 22, }, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ResourceOwner: "me", }, Code: &user.PasskeyRegistrationCode{ diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index aec5367ded..23d4b4422c 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -21,6 +21,7 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) return &user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, + CreationDate: resp.CreationDate, EventDate: resp.ChangeDate, ResourceOwner: resp.ResourceOwner, }), @@ -58,6 +59,7 @@ func userToPb(userQ *query.User, assetPrefix string) *user.User { Sequence: userQ.Sequence, EventDate: userQ.ChangeDate, ResourceOwner: userQ.ResourceOwner, + CreationDate: userQ.CreationDate, }), State: userStateToPb(userQ.State), Username: userQ.Username, diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go index 73366ab29b..fae3ba1cdb 100644 --- a/internal/api/grpc/user/v2/u2f_test.go +++ b/internal/api/grpc/user/v2/u2f_test.go @@ -43,6 +43,7 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -59,6 +60,7 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -73,6 +75,10 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { Seconds: 3000, Nanos: 22, }, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ResourceOwner: "me", }, U2FId: "123", diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 9a092afacf..a743206cf0 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -368,31 +368,31 @@ func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.Star } func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { - intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID()) + state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err } - content, redirect, err := s.command.AuthFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) + _, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), session.PersistentParameters()) if err != nil { return nil, err } + content, redirect := session.GetAuth(ctx) if redirect { return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, }, nil - } else { - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil } + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ + PostForm: []byte(content), + }, + }, nil } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { - intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetInstance(ctx).InstanceID()) + intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2beta/integration_test/query_test.go b/internal/api/grpc/user/v2beta/integration_test/query_test.go index 67fc609212..73bff3fd0d 100644 --- a/internal/api/grpc/user/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/query_test.go @@ -4,6 +4,7 @@ package user_test import ( "context" + "errors" "fmt" "slices" "testing" @@ -16,8 +17,10 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/object/v2" object_v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) @@ -29,6 +32,55 @@ func detailsV2ToV2beta(obj *object.Details) *object_v2beta.Details { } } +var ( + permissionCheckV2SetFlagInital bool + permissionCheckV2SetFlag bool +) + +type permissionCheckV2SettingsStruct struct { + TestNamePrependString string + SetFlag bool +} + +var permissionCheckV2Settings []permissionCheckV2SettingsStruct = []permissionCheckV2SettingsStruct{ + { + SetFlag: false, + TestNamePrependString: "permission_check_v2 IS NOT SET" + " ", + }, + { + SetFlag: true, + TestNamePrependString: "permission_check_v2 IS SET" + " ", + }, +} + +func setPermissionCheckV2Flag(t *testing.T, setFlag bool) { + if permissionCheckV2SetFlagInital && permissionCheckV2SetFlag == setFlag { + return + } + + _, err := Instance.Client.FeatureV2.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: &setFlag, + }) + require.NoError(t, err) + + var flagSet bool + for i := 0; !flagSet || i < 6; i++ { + res, err := Instance.Client.FeatureV2.GetInstanceFeatures(IamCTX, &feature.GetInstanceFeaturesRequest{}) + require.NoError(t, err) + if res.PermissionCheckV2.Enabled == setFlag { + flagSet = true + continue + } + time.Sleep(10 * time.Second) + } + + if !flagSet { + require.NoError(t, errors.New("unable to set permission_check_v2 flag")) + } + permissionCheckV2SetFlagInital = true + permissionCheckV2SetFlag = setFlag +} + func TestServer_GetUserByID(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { @@ -381,6 +433,11 @@ func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) } func TestServer_ListUsers(t *testing.T) { + defer func() { + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }() + orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { ctx context.Context @@ -394,7 +451,7 @@ func TestServer_ListUsers(t *testing.T) { wantErr bool }{ { - name: "list user by id, no permission", + name: "list user by id, no permission machine user", args: args{ UserCTX, &user.ListUsersRequest{}, @@ -413,17 +470,77 @@ func TestServer_ListUsers(t *testing.T) { Result: []*user.User{}, }, }, + { + name: "list user by id, no permission human user", + args: func() args { + info := createUser(IamCTX, orgResp.OrganizationId, true) + // create session to get token + userID := info.UserID + createResp, err := Instance.Client.SessionV2.CreateSession(IamCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{UserId: userID}, + }, + Password: &session.CheckPassword{ + Password: integration.UserPassword, + }, + }, + }) + if err != nil { + require.NoError(t, err) + } + // use token to get ctx + HumanCTX := integration.WithAuthorizationToken(IamCTX, createResp.GetSessionToken()) + return args{ + HumanCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{info} + }, + } + }(), + want: &user.ListUsersResponse{ // human user should return itself when calling ListUsers() even if it has no permissions + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + PasswordChangeRequired: true, + PasswordChanged: timestamppb.Now(), + }, + }, + }, + }, + }, + }, { name: "list user by id, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) return []userAttr{info} }, @@ -463,13 +580,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) return []userAttr{info} }, @@ -511,13 +626,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) return infos }, @@ -549,7 +662,8 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, - }, { + }, + { State: user.UserState_USER_STATE_ACTIVE, Type: &user.User_Human{ Human: &user.HumanUser{ @@ -569,7 +683,8 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, - }, { + }, + { State: user.UserState_USER_STATE_ACTIVE, Type: &user.User_Human{ Human: &user.HumanUser{ @@ -597,13 +712,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, UsernameQuery(info.Username)) return []userAttr{info} }, @@ -650,6 +763,7 @@ func TestServer_ListUsers(t *testing.T) { }, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) return []userAttr{info} }, @@ -689,13 +803,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails multiple, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) return infos }, @@ -775,10 +887,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails no found, ok", args: args{ IamCTX, - &user.ListUsersRequest{Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - InUserEmailsQuery([]string{"notfound"}), - }, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, }, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { return []userAttr{} @@ -797,13 +910,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user phone, ok", args: args{ IamCTX, - &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - OrganizationIdQuery(orgResp.OrganizationId), - }, - }, + &user.ListUsersRequest{}, func(ctx context.Context, request *user.ListUsersRequest) userAttrs { info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, PhoneQuery(info.Phone)) return []userAttr{info} }, @@ -839,6 +950,29 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, { name: "list user resourceowner multiple, ok", args: args{ @@ -848,6 +982,7 @@ func TestServer_ListUsers(t *testing.T) { orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = []*user.SearchQuery{} request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) return infos @@ -924,93 +1059,182 @@ func TestServer_ListUsers(t *testing.T) { }, }, }, + { + name: "list user with org query", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + orgRespForOrgTests := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + info := createUser(ctx, orgRespForOrgTests.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests.OrganizationId)) + return []userAttr{info, {}} + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 2, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + // this is the admin of the org craated in Instance.CreateOrganization() + nil, + }, + }, + }, + { + name: "list user with wrong org query", + args: args{ + IamCTX, + &user.ListUsersRequest{}, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + orgRespForOrgTests := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + orgRespForOrgTests2 := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) + // info := createUser(ctx, orgRespForOrgTests.OrganizationId, false) + createUser(ctx, orgRespForOrgTests.OrganizationId, false) + request.Queries = []*user.SearchQuery{} + request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests2.OrganizationId)) + return []userAttr{{}} + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + // this is the admin of the org craated in Instance.CreateOrganization() + nil, + }, + }, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - infos := tt.args.dep(IamCTX, tt.args.req) + for _, f := range permissionCheckV2Settings { + f := f + for _, tt := range tests { + t.Run(f.TestNamePrependString+tt.name, func(t *testing.T) { + setPermissionCheckV2Flag(t, f.SetFlag) + infos := tt.args.dep(IamCTX, tt.args.req) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, err := Client.ListUsers(tt.args.ctx, tt.args.req) - if tt.wantErr { - require.Error(ttt, err) - return - } - require.NoError(ttt, err) + // retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, 10*time.Minute) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, 20*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListUsers(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, err) + return + } + require.NoError(ttt, err) - // always only give back dependency infos which are required for the response - require.Len(ttt, tt.want.Result, len(infos)) - // always first check length, otherwise its failed anyway - if assert.Len(ttt, got.Result, len(tt.want.Result)) { - // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions - tt.want.Details.TotalResult = got.Details.TotalResult + // always only give back dependency infos which are required for the response + require.Len(ttt, tt.want.Result, len(infos)) + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Result, len(tt.want.Result)) { + // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions + tt.want.Details.TotalResult = got.Details.TotalResult - // fill in userid and username as it is generated - for i := range infos { - tt.want.Result[i].UserId = infos[i].UserID - tt.want.Result[i].Username = infos[i].Username - tt.want.Result[i].PreferredLoginName = infos[i].Username - tt.want.Result[i].LoginNames = []string{infos[i].Username} - if human := tt.want.Result[i].GetHuman(); human != nil { - human.Email.Email = infos[i].Username - human.Phone.Phone = infos[i].Phone - if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { - human.PasswordChanged = infos[i].Changed + // fill in userid and username as it is generated + for i := range infos { + if tt.want.Result[i] == nil { + continue } + tt.want.Result[i].UserId = infos[i].UserID + tt.want.Result[i].Username = infos[i].Username + tt.want.Result[i].PreferredLoginName = infos[i].Username + tt.want.Result[i].LoginNames = []string{infos[i].Username} + if human := tt.want.Result[i].GetHuman(); human != nil { + human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone + if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { + human.PasswordChanged = infos[i].Changed + } + } + tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details) + } + for i := range tt.want.Result { + if tt.want.Result[i] == nil { + continue + } + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } - tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details) } - for i := range tt.want.Result { - assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) - } - } - integration.AssertListDetails(ttt, tt.want, got) - }, retryDuration, tick, "timeout waiting for expected user result") - }) + integration.AssertListDetails(ttt, tt.want, got) + }, retryDuration, tick, "timeout waiting for expected user result") + }) + } } } func InUserIDsQuery(ids []string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_InUserIdsQuery{ - InUserIdsQuery: &user.InUserIDQuery{ - UserIds: ids, + return &user.SearchQuery{ + Query: &user.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &user.InUserIDQuery{ + UserIds: ids, + }, }, - }, } } func InUserEmailsQuery(emails []string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_InUserEmailsQuery{ - InUserEmailsQuery: &user.InUserEmailsQuery{ - UserEmails: emails, + return &user.SearchQuery{ + Query: &user.SearchQuery_InUserEmailsQuery{ + InUserEmailsQuery: &user.InUserEmailsQuery{ + UserEmails: emails, + }, }, - }, } } func PhoneQuery(number string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ - PhoneQuery: &user.PhoneQuery{ - Number: number, + return &user.SearchQuery{ + Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, }, - }, } } func UsernameQuery(username string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ - UserNameQuery: &user.UserNameQuery{ - UserName: username, + return &user.SearchQuery{ + Query: &user.SearchQuery_UserNameQuery{ + UserNameQuery: &user.UserNameQuery{ + UserName: username, + }, }, - }, } } func OrganizationIdQuery(resourceowner string) *user.SearchQuery { - return &user.SearchQuery{Query: &user.SearchQuery_OrganizationIdQuery{ - OrganizationIdQuery: &user.OrganizationIdQuery{ - OrganizationId: resourceowner, + return &user.SearchQuery{ + Query: &user.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &user.OrganizationIdQuery{ + OrganizationId: resourceowner, + }, }, - }, } } diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 9cf59ae563..ab2e3215ee 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -16,9 +16,12 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" @@ -2142,15 +2145,19 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } -/* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - idpID := Instance.AddGenericOAuthProvider(t, CTX) - intentID := Instance.CreateIntent(t, CTX, idpID) - successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "", "id") - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "user", "id") - ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID.Id, "", "id") - ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Instance.CreateSuccessfulLDAPIntent(t, CTX, idpID.Id, "user", "id") - samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Instance.CreateSuccessfulSAMLIntent(t, CTX, idpID.Id, "", "id") + idpID := Instance.AddGenericOAuthProvider(IamCTX, gofakeit.AppName()).GetId() + intentID := Instance.CreateIntent(CTX, idpID).GetIdpIntent().GetIdpIntentId() + successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", "user") + require.NoError(t, err) + ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) + ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), idpID, "id", "user") + require.NoError(t, err) + samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), idpID, "id", "") + require.NoError(t, err) type args struct { ctx context.Context req *user.RetrieveIdentityProviderIntentRequest @@ -2205,7 +2212,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdToken: gu.Ptr("idToken"), }, }, - IdpId: idpID.Id, + IdpId: idpID, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2243,7 +2250,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdToken: gu.Ptr("idToken"), }, }, - IdpId: idpID.Id, + IdpId: idpID, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2287,7 +2294,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }(), }, }, - IdpId: idpID.Id, + IdpId: idpID, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2333,7 +2340,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }(), }, }, - IdpId: idpID.Id, + IdpId: idpID, UserId: "id", UserName: "username", RawInformation: func() *structpb.Struct { @@ -2370,7 +2377,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { Assertion: []byte(""), }, }, - IdpId: idpID.Id, + IdpId: idpID, UserId: "id", UserName: "", RawInformation: func() *structpb.Struct { @@ -2401,7 +2408,6 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }) } } -*/ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2beta/passkey_test.go b/internal/api/grpc/user/v2beta/passkey_test.go index 7d45c41756..f4a48ed941 100644 --- a/internal/api/grpc/user/v2beta/passkey_test.go +++ b/internal/api/grpc/user/v2beta/passkey_test.go @@ -74,6 +74,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -90,6 +91,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -100,6 +102,10 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { want: &user.RegisterPasskeyResponse{ Details: &object.Details{ Sequence: 22, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ChangeDate: ×tamppb.Timestamp{ Seconds: 3000, Nanos: 22, @@ -150,6 +156,7 @@ func Test_passkeyDetailsToPb(t *testing.T) { details: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, err: nil, @@ -157,6 +164,10 @@ func Test_passkeyDetailsToPb(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ Sequence: 22, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ChangeDate: ×tamppb.Timestamp{ Seconds: 3000, Nanos: 22, @@ -199,6 +210,7 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, CodeID: "123", @@ -209,6 +221,10 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { want: &user.CreatePasskeyRegistrationLinkResponse{ Details: &object.Details{ Sequence: 22, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ChangeDate: ×tamppb.Timestamp{ Seconds: 3000, Nanos: 22, diff --git a/internal/api/grpc/user/v2beta/u2f_test.go b/internal/api/grpc/user/v2beta/u2f_test.go index 087837ce3c..53f2a0bb8c 100644 --- a/internal/api/grpc/user/v2beta/u2f_test.go +++ b/internal/api/grpc/user/v2beta/u2f_test.go @@ -43,6 +43,7 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -59,6 +60,7 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ Sequence: 22, EventDate: time.Unix(3000, 22), + CreationDate: time.Unix(3000, 22), ResourceOwner: "me", }, ID: "123", @@ -69,6 +71,10 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { want: &user.RegisterU2FResponse{ Details: &object.Details{ Sequence: 22, + CreationDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, ChangeDate: ×tamppb.Timestamp{ Seconds: 3000, Nanos: 22, diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 52da057906..cf6dfa6304 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -371,31 +371,31 @@ func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.Star } func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { - intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID()) + state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err } - content, redirect, err := s.command.AuthFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) + _, details, err := s.command.CreateIntent(ctx, state, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID(), session.PersistentParameters()) if err != nil { return nil, err } + content, redirect := session.GetAuth(ctx) if redirect { return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, }, nil - } else { - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil } + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ + PostForm: []byte(content), + }, + }, nil } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { - intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetInstance(ctx).InstanceID()) + intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err } diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go index 01594c43ba..c3e9586a59 100644 --- a/internal/api/idp/idp.go +++ b/internal/api/idp/idp.go @@ -328,7 +328,7 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { return } - idpUser, idpSession, err := h.fetchIDPUserFromCode(ctx, provider, data.Code, data.User) + idpUser, idpSession, err := h.fetchIDPUserFromCode(ctx, provider, data.Code, data.User, intent.IDPArguments) if err != nil { cmdErr := h.commands.FailIDPIntent(ctx, intent, err.Error()) logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") @@ -410,23 +410,23 @@ func redirectToFailureURL(w http.ResponseWriter, r *http.Request, i *command.IDP http.Redirect(w, r, i.FailureURL.String(), http.StatusFound) } -func (h *Handler) fetchIDPUserFromCode(ctx context.Context, identityProvider idp.Provider, code string, appleUser string) (user idp.User, idpTokens idp.Session, err error) { +func (h *Handler) fetchIDPUserFromCode(ctx context.Context, identityProvider idp.Provider, code string, appleUser string, idpArguments map[string]any) (user idp.User, idpTokens idp.Session, err error) { var session idp.Session switch provider := identityProvider.(type) { case *oauth.Provider: - session = &oauth.Session{Provider: provider, Code: code} + session = oauth.NewSession(provider, code, idpArguments) case *openid.Provider: - session = &openid.Session{Provider: provider, Code: code} + session = openid.NewSession(provider, code, idpArguments) case *azuread.Provider: - session = &azuread.Session{Provider: provider, Code: code} + session = azuread.NewSession(provider, code) case *github.Provider: - session = &oauth.Session{Provider: provider.Provider, Code: code} + session = oauth.NewSession(provider.Provider, code, idpArguments) case *gitlab.Provider: - session = &openid.Session{Provider: provider.Provider, Code: code} + session = openid.NewSession(provider.Provider, code, idpArguments) case *google.Provider: - session = &openid.Session{Provider: provider.Provider, Code: code} + session = openid.NewSession(provider.Provider, code, idpArguments) case *apple.Provider: - session = &apple.Session{Session: &openid.Session{Provider: provider.Provider, Code: code}, UserFormValue: appleUser} + session = apple.NewSession(provider, code, appleUser) case *jwt.Provider, *ldap.Provider, *saml2.Provider: return nil, nil, zerrors.ThrowInvalidArgument(nil, "IDP-52jmn", "Errors.ExternalIDP.IDPTypeNotImplemented") default: diff --git a/internal/api/oidc/integration_test/token_device_test.go b/internal/api/oidc/integration_test/token_device_test.go new file mode 100644 index 0000000000..0c6a65e8a2 --- /dev/null +++ b/internal/api/oidc/integration_test/token_device_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" + + "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" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" +) + +func TestServer_DeviceAuth(t *testing.T) { + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) + require.NoError(t, err) + + tests := []struct { + name string + scope []string + decision func(t *testing.T, id string) + wantErr error + }{ + { + name: "authorized", + scope: []string{}, + decision: func(t *testing.T, id string) { + sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + _, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: id, + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + }, + }, + { + name: "authorized, with ZITADEL", + scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, domain.ProjectScopeZITADEL}, + decision: func(t *testing.T, id string) { + sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) + _, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: id, + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + }, + }, + { + name: "denied", + scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, domain.ProjectScopeZITADEL}, + decision: func(t *testing.T, id string) { + _, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{ + DeviceAuthorizationId: id, + Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny{ + Deny: &oidc_pb.Deny{}, + }, + }) + require.NoError(t, err) + }, + wantErr: oidc.ErrAccessDenied(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", "", tt.scope) + require.NoError(t, err) + deviceAuthorization, err := rp.DeviceAuthorization(CTX, tt.scope, provider, nil) + require.NoError(t, err) + + relyingPartyDone := make(chan struct{}) + go func() { + ctx, cancel := context.WithTimeout(CTX, 1*time.Minute) + defer func() { + cancel() + relyingPartyDone <- struct{}{} + }() + tokens, err := rp.DeviceAccessToken(ctx, deviceAuthorization.DeviceCode, time.Duration(deviceAuthorization.Interval)*time.Second, provider) + require.ErrorIs(t, err, tt.wantErr) + + if tokens == nil { + return + } + _, err = Instance.Client.Auth.GetMyUser(integration.WithAuthorizationToken(CTX, tokens.AccessToken), &auth.GetMyUserRequest{}) + if slices.Contains(tt.scope, domain.ProjectScopeZITADEL) { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }() + + var req *oidc_pb.GetDeviceAuthorizationRequestResponse + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + assert.EventuallyWithT(t, func(collectT *assert.CollectT) { + req, err = Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{ + UserCode: deviceAuthorization.UserCode, + }) + assert.NoError(collectT, err) + }, retryDuration, tick) + + tt.decision(t, req.GetDeviceAuthorizationRequest().GetId()) + + <-relyingPartyDone + }) + } +} diff --git a/internal/api/oidc/token_device.go b/internal/api/oidc/token_device.go index 464e9e46ae..8e0f8dc993 100644 --- a/internal/api/oidc/token_device.go +++ b/internal/api/oidc/token_device.go @@ -42,6 +42,9 @@ func (s *Server) DeviceToken(ctx context.Context, r *op.ClientRequest[oidc.Devic if state == domain.DeviceAuthStateExpired { return nil, oidc.ErrExpiredDeviceCode() } + if state == domain.DeviceAuthStateDenied { + return nil, oidc.ErrAccessDenied() + } } - return nil, oidc.ErrAccessDenied().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError) + return nil, oidc.ErrInvalidGrant().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError) } diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 5d0e0ede67..649c8a958e 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -149,14 +149,7 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai l.renderError(w, r, authReq, err) return } - userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - err = l.authRepo.SelectExternalIDP(r.Context(), authReq.ID, identityProvider.ID, userAgentID) - if err != nil { - l.externalAuthFailed(w, r, authReq, err) - return - } var provider idp.Provider - switch identityProvider.Type { case domain.IDPTypeOAuth: provider, err = l.oauthProvider(r.Context(), identityProvider) @@ -199,6 +192,13 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai return } + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + err = l.authRepo.SelectExternalIDP(r.Context(), authReq.ID, identityProvider.ID, userAgentID, session.PersistentParameters()) + if err != nil { + l.externalAuthFailed(w, r, authReq, err) + return + } + content, redirect := session.GetAuth(r.Context()) if redirect { http.Redirect(w, r, content, http.StatusFound) @@ -271,79 +271,78 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - var provider idp.Provider var session idp.Session switch identityProvider.Type { case domain.IDPTypeOAuth: - provider, err = l.oauthProvider(r.Context(), identityProvider) + provider, err := l.oauthProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &oauth.Session{Provider: provider.(*oauth.Provider), Code: data.Code} + session = oauth.NewSession(provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeOIDC: - provider, err = l.oidcProvider(r.Context(), identityProvider) + provider, err := l.oidcProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &openid.Session{Provider: provider.(*openid.Provider), Code: data.Code} + session = openid.NewSession(provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeAzureAD: - provider, err = l.azureProvider(r.Context(), identityProvider) + provider, err := l.azureProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &azuread.Session{Provider: provider.(*azuread.Provider), Code: data.Code} + session = azuread.NewSession(provider, data.Code) case domain.IDPTypeGitHub: - provider, err = l.githubProvider(r.Context(), identityProvider) + provider, err := l.githubProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &oauth.Session{Provider: provider.(*github.Provider).Provider, Code: data.Code} + session = oauth.NewSession(provider.Provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeGitHubEnterprise: - provider, err = l.githubEnterpriseProvider(r.Context(), identityProvider) + provider, err := l.githubEnterpriseProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &oauth.Session{Provider: provider.(*github.Provider).Provider, Code: data.Code} + session = oauth.NewSession(provider.Provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeGitLab: - provider, err = l.gitlabProvider(r.Context(), identityProvider) + provider, err := l.gitlabProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &openid.Session{Provider: provider.(*gitlab.Provider).Provider, Code: data.Code} + session = openid.NewSession(provider.Provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeGitLabSelfHosted: - provider, err = l.gitlabSelfHostedProvider(r.Context(), identityProvider) + provider, err := l.gitlabSelfHostedProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &openid.Session{Provider: provider.(*gitlab.Provider).Provider, Code: data.Code} + session = openid.NewSession(provider.Provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeGoogle: - provider, err = l.googleProvider(r.Context(), identityProvider) + provider, err := l.googleProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &openid.Session{Provider: provider.(*google.Provider).Provider, Code: data.Code} + session = openid.NewSession(provider.Provider, data.Code, authReq.SelectedIDPConfigArgs) case domain.IDPTypeApple: - provider, err = l.appleProvider(r.Context(), identityProvider) + provider, err := l.appleProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session = &apple.Session{Session: &openid.Session{Provider: provider.(*apple.Provider).Provider, Code: data.Code}, UserFormValue: data.User} + session = apple.NewSession(provider, data.Code, data.User) case domain.IDPTypeSAML: - provider, err = l.samlProvider(r.Context(), identityProvider) + provider, err := l.samlProvider(r.Context(), identityProvider) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return } - session, err = saml.NewSession(provider.(*saml.Provider), authReq.SAMLRequestID, r) + session, err = saml.NewSession(provider, authReq.SAMLRequestID, r) if err != nil { l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err) return @@ -440,7 +439,7 @@ func (l *Login) handleExternalUserAuthenticated( ) { externalUser := mapIDPUserToExternalUser(user, provider.ID) // ensure the linked IDP is added to the login policy - if err := l.authRepo.SelectExternalIDP(r.Context(), authReq.ID, provider.ID, authReq.AgentID); err != nil { + if err := l.authRepo.SelectExternalIDP(r.Context(), authReq.ID, provider.ID, authReq.AgentID, authReq.SelectedIDPConfigArgs); err != nil { l.renderError(w, r, authReq, err) return } @@ -1005,11 +1004,17 @@ func (l *Login) oidcProvider(ctx context.Context, identityProvider *query.IDPTem if err != nil { return nil, err } - opts := make([]openid.ProviderOpts, 1, 2) + opts := make([]openid.ProviderOpts, 1, 3) opts[0] = openid.WithSelectAccount() if identityProvider.OIDCIDPTemplate.IsIDTokenMapping { opts = append(opts, openid.WithIDTokenMapping()) } + + if identityProvider.OIDCIDPTemplate.UsePKCE { + // we do not pass any cookie handler, since we store the verifier internally, rather than in a cookie + opts = append(opts, openid.WithRelyingPartyOption(rp.WithPKCE(nil))) + } + return openid.New(identityProvider.Name, identityProvider.OIDCIDPTemplate.Issuer, identityProvider.OIDCIDPTemplate.ClientID, @@ -1047,6 +1052,12 @@ func (l *Login) oauthProvider(ctx context.Context, identityProvider *query.IDPTe RedirectURL: l.baseURL(ctx) + EndpointExternalLoginCallback, Scopes: identityProvider.OAuthIDPTemplate.Scopes, } + + opts := make([]oauth.ProviderOpts, 0, 1) + if identityProvider.OAuthIDPTemplate.UsePKCE { + // we do not pass any cookie handler, since we store the verifier internally, rather than in a cookie + opts = append(opts, oauth.WithRelyingPartyOption(rp.WithPKCE(nil))) + } return oauth.New( config, identityProvider.Name, @@ -1054,6 +1065,7 @@ func (l *Login) oauthProvider(ctx context.Context, identityProvider *query.IDPTe func() idp.User { return oauth.NewUserMapper(identityProvider.OAuthIDPTemplate.IDAttribute) }, + opts..., ) } diff --git a/internal/api/ui/login/jwt_handler.go b/internal/api/ui/login/jwt_handler.go index 7c643e9a43..bff316252f 100644 --- a/internal/api/ui/login/jwt_handler.go +++ b/internal/api/ui/login/jwt_handler.go @@ -81,7 +81,7 @@ func (l *Login) handleJWTExtraction(w http.ResponseWriter, r *http.Request, auth l.renderError(w, r, authReq, err) return } - session := &jwt.Session{Provider: provider, Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{IDToken: token, Token: &oauth2.Token{}}} + session := jwt.NewSession(provider, &oidc.Tokens[*oidc.IDTokenClaims]{IDToken: token, Token: &oauth2.Token{}}) user, err := session.FetchUser(r.Context()) if err != nil { if _, _, actionErr := l.runPostExternalAuthenticationActions(new(domain.ExternalUser), tokens(session), authReq, r, user, err); actionErr != nil { diff --git a/internal/api/ui/login/ldap_handler.go b/internal/api/ui/login/ldap_handler.go index 147a319523..4703a462bf 100644 --- a/internal/api/ui/login/ldap_handler.go +++ b/internal/api/ui/login/ldap_handler.go @@ -66,7 +66,7 @@ func (l *Login) handleLDAPCallback(w http.ResponseWriter, r *http.Request) { l.renderLDAPLogin(w, r, authReq, err) return } - session := &ldap.Session{Provider: provider, User: data.Username, Password: data.Password} + session := ldap.NewSession(provider, data.Username, data.Password) user, err := session.FetchUser(r.Context()) if err != nil { diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 8d725785c6..66df5de081 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -11,6 +11,13 @@ Login: RegisterButtonText: 登録 NextButtonText: 次へ +LDAP: + Title: ようこそ! + Description: LDAPのログイン情報を入力してください + LoginNameLabel: ログイン名 + PasswordLabel: パスワード + NextButtonText: 次へ + SelectAccount: Title: アカウントの選択 Description: ZITADELアカウントを使用します。 @@ -198,6 +205,7 @@ PasswordChange: NewPasswordConfirmLabel: 新パスワードの確認 CancelButtonText: キャンセル NextButtonText: 次へ + Footer: フッター PasswordChangeDone: Title: パスワードの変更完了 @@ -401,6 +409,7 @@ Footer: Tos: TOS PrivacyPolicy: プライバシーポリシー Help: ヘルプ + SupportEmail: サポートメール SignIn: '{{.Provider}} でサインイン' @@ -415,6 +424,7 @@ Errors: MissingParameters: 必要なパラメーターが不足しています User: NotFound: ユーザーが見つかりません + AlreadyExists: ユーザーは既に存在します Inactive: ユーザーは非アクティブです NotFoundOnOrg: ユーザーは、選択した組織で見つけることができませんでした NotAllowedOrg: ユーザーは必要な組織のメンバーではありません @@ -423,6 +433,33 @@ Errors: Invalid: 無効なユーザーデータです DomainNotAllowedAsUsername: このドメインはすでに予約されているため使用できません NotAllowedToLink: このユーザーは外部ログインプロバイダーにリンクすることを許可されていません + Profile: + NotFound: プロファイルが見つかりません + NotChanged: プロファイルが変更されていません + Empty: プロファイルが空です + FirstNameEmpty: 名前が空です + LastNameEmpty: 姓が空です + IDMissing: プロファイルIDが不足しています + Email: + NotFound: メールアドレスが見つかりません + Invalid: メールアドレスが無効です + AlreadyVerified: メールアドレスはすでに登録されています + NotChanged: メールアドレスが変更されていません + Empty: メールアドレスが空です + IDMissing: メールアドレスIDが不足しています + Phone: + NotFound: 電話番号が見つかりません + Invalid: 電話番号が無効です + AlreadyVerified: 電話番号はすでに登録されています + Empty: 電話番号が空です + NotChanged: 電話番号が変更されていません + Address: + NotFound: 住所が見つかりません + NotChanged: 住所が変更されていません + Username: + AlreadyExists: ユーザー名はすでに使用されています + Reserved: ユーザー名はすでに使用されています + Empty: ユーザー名が空です Password: ConfirmationWrong: 確認用パスワードが間違っています Empty: パスワードが空です @@ -480,6 +517,9 @@ Errors: IAM: LockoutPolicy: NotExisting: ロックアウトポリシーが存在しません + Org: + LoginPolicy: + RegistrationNotAllowed: 新規登録は許可されていません DeviceAuth: NotExisting: ユーザーコードが存在しません diff --git a/internal/auth/repository/auth_request.go b/internal/auth/repository/auth_request.go index c16a757a01..53272d2d2f 100644 --- a/internal/auth/repository/auth_request.go +++ b/internal/auth/repository/auth_request.go @@ -20,7 +20,7 @@ type AuthRequestRepository interface { SetExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser) error SetLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error SelectUser(ctx context.Context, authReqID, userID, userAgentID string) error - SelectExternalIDP(ctx context.Context, authReqID, idpConfigID, userAgentID string) error + SelectExternalIDP(ctx context.Context, authReqID, idpConfigID, userAgentID string, idpArguments map[string]any) error VerifyPassword(ctx context.Context, id, userID, resourceOwner, password, userAgentID string, info *domain.BrowserInfo) error VerifyMFAOTP(ctx context.Context, authRequestID, userID, resourceOwner, code, userAgentID string, info *domain.BrowserInfo) error diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 60486b66f9..c8ae92b418 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -255,14 +255,14 @@ func (repo *AuthRequestRepo) CheckLoginName(ctx context.Context, id, loginName, return repo.AuthRequests.UpdateAuthRequest(ctx, request) } -func (repo *AuthRequestRepo) SelectExternalIDP(ctx context.Context, authReqID, idpConfigID, userAgentID string) (err error) { +func (repo *AuthRequestRepo) SelectExternalIDP(ctx context.Context, authReqID, idpConfigID, userAgentID string, idpArguments map[string]any) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() request, err := repo.getAuthRequest(ctx, authReqID, userAgentID) if err != nil { return err } - err = repo.checkSelectedExternalIDP(request, idpConfigID) + err = repo.checkSelectedExternalIDP(request, idpConfigID, idpArguments) if err != nil { return err } @@ -984,10 +984,11 @@ func queryLoginPolicyToDomain(policy *query.LoginPolicy) *domain.LoginPolicy { } } -func (repo *AuthRequestRepo) checkSelectedExternalIDP(request *domain.AuthRequest, idpConfigID string) error { +func (repo *AuthRequestRepo) checkSelectedExternalIDP(request *domain.AuthRequest, idpConfigID string, idpArguments map[string]any) error { for _, externalIDP := range request.AllowedExternalIDPs { if externalIDP.IDPConfigID == idpConfigID { request.SelectedIDPConfigID = idpConfigID + request.SelectedIDPConfigArgs = idpArguments return nil } } diff --git a/internal/command/device_auth.go b/internal/command/device_auth.go index a2754650ea..d3588660be 100644 --- a/internal/command/device_auth.go +++ b/internal/command/device_auth.go @@ -59,6 +59,9 @@ func (c *Commands) ApproveDeviceAuth( if !model.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound") } + if model.State != domain.DeviceAuthStateInitiated { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-GEJL3", "Errors.DeviceAuth.AlreadyHandled") + } pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent, sessionID)) if err != nil { return nil, err @@ -71,6 +74,60 @@ func (c *Commands) ApproveDeviceAuth( return writeModelToObjectDetails(&model.WriteModel), nil } +func (c *Commands) ApproveDeviceAuthWithSession( + ctx context.Context, + deviceCode, + sessionID, + sessionToken string, +) (*domain.ObjectDetails, error) { + model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode) + if err != nil { + return nil, err + } + if !model.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-D2hf2", "Errors.DeviceAuth.NotFound") + } + if model.State != domain.DeviceAuthStateInitiated { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-D30Jf", "Errors.DeviceAuth.AlreadyHandled") + } + if err := c.checkPermission(ctx, domain.PermissionSessionLink, model.ResourceOwner, ""); err != nil { + return nil, err + } + + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, err + } + if err = sessionWriteModel.CheckIsActive(); err != nil { + return nil, err + } + if err := c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID); err != nil { + return nil, err + } + + pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent( + ctx, + model.aggregate, + sessionWriteModel.UserID, + sessionWriteModel.UserResourceOwner, + sessionWriteModel.AuthMethodTypes(), + sessionWriteModel.AuthenticationTime(), + sessionWriteModel.PreferredLanguage, + sessionWriteModel.UserAgent, + sessionID, + )) + if err != nil { + return nil, err + } + err = AppendAndReduce(model, pushedEvents...) + if err != nil { + return nil, err + } + + return writeModelToObjectDetails(&model.WriteModel), nil +} + func (c *Commands) CancelDeviceAuth(ctx context.Context, id string, reason domain.DeviceAuthCanceled) (*domain.ObjectDetails, error) { model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, id) if err != nil { diff --git a/internal/command/device_auth_model.go b/internal/command/device_auth_model.go index 21ab1b29ec..28833ae898 100644 --- a/internal/command/device_auth_model.go +++ b/internal/command/device_auth_model.go @@ -82,6 +82,7 @@ func (m *DeviceAuthWriteModel) Query() *eventstore.SearchQueryBuilder { deviceauth.AddedEventType, deviceauth.ApprovedEventType, deviceauth.CanceledEventType, + deviceauth.DoneEventType, ). Builder() } diff --git a/internal/command/device_auth_test.go b/internal/command/device_auth_test.go index f25be7053a..508ca10571 100644 --- a/internal/command/device_auth_test.go +++ b/internal/command/device_auth_test.go @@ -23,6 +23,7 @@ import ( "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/deviceauth" "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -265,6 +266,310 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { } } +func TestCommands_ApproveDeviceAuthFromSession(t *testing.T) { + ctx := authz.WithInstanceID(context.Background(), "instance1") + now := time.Now() + pushErr := errors.New("pushErr") + + type fields struct { + 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 + deviceCode string + sessionID string + sessionToken string + } + tests := []struct { + name string + fields fields + args args + wantDetails *domain.ObjectDetails + wantErr error + }{ + { + name: "not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx, + "notfound", + "sessionID", + "sessionToken", + }, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-D2hf2", "Errors.DeviceAuth.NotFound"), + }, + { + name: "not initialized, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + ), + eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewCanceledEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + domain.DeviceAuthCanceledDenied, + )), + ), + ), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "sessionToken", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D30Jf", "Errors.DeviceAuth.AlreadyHandled"), + }, + { + name: "missing permission, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + )), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "sessionToken", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "session not active, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + )), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "sessionToken", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"), + }, + { + name: "invalid session token, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + )), + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + session.NewAddedEvent(ctx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + )), + tokenVerifier: newMockTokenVerifierInvalid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "invalidToken", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + { + name: "push error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + )), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(ctx, + &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(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "orgID", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPushFailed(pushErr, + deviceauth.NewApprovedEvent( + ctx, deviceauth.NewAggregate("deviceCode", "instance1"), "userID", "orgID", + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + testNow, &language.Afrikaans, &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + "sessionID", + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "sessionToken", + }, + wantErr: pushErr, + }, + { + name: "authorized", + fields: fields{ + eventstore: expectEventstore( + expectFilter(eventFromEventPusherWithInstanceID( + "instance1", + deviceauth.NewAddedEvent( + ctx, + deviceauth.NewAggregate("deviceCode", "instance1"), + "client_id", "deviceCode", "456", now, + []string{"a", "b", "c"}, + []string{"projectID", "clientID"}, true, + ), + )), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(ctx, + &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(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "orgID", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + deviceauth.NewApprovedEvent( + ctx, deviceauth.NewAggregate("deviceCode", "instance1"), "userID", "orgID", + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + testNow, &language.Afrikaans, &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + "sessionID", + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx, + "deviceCode", + "sessionID", + "sessionToken", + }, + wantDetails: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + sessionTokenVerifier: tt.fields.tokenVerifier, + checkPermission: tt.fields.checkPermission, + } + gotDetails, err := c.ApproveDeviceAuthWithSession(tt.args.ctx, tt.args.deviceCode, tt.args.sessionID, tt.args.sessionToken) + require.ErrorIs(t, err, tt.wantErr) + assertObjectDetails(t, tt.wantDetails, gotDetails) + }) + } +} + func TestCommands_CancelDeviceAuth(t *testing.T) { ctx := authz.WithInstanceID(context.Background(), "instance1") now := time.Now() diff --git a/internal/command/idp.go b/internal/command/idp.go index b9185cbc57..821a577900 100644 --- a/internal/command/idp.go +++ b/internal/command/idp.go @@ -20,6 +20,7 @@ type GenericOAuthProvider struct { UserEndpoint string Scopes []string IDAttribute string + UsePKCE bool IDPOptions idp.Options } @@ -30,6 +31,7 @@ type GenericOIDCProvider struct { ClientSecret string Scopes []string IsIDTokenMapping bool + UsePKCE bool IDPOptions idp.Options } diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go index 483cdcd08e..3cd9991679 100644 --- a/internal/command/idp_intent.go +++ b/internal/command/idp_intent.go @@ -25,7 +25,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, successURL, failureURL string) preparation.Validation { +func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, successURL, failureURL string, idpArguments map[string]any) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { if idpID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x8j2bk", "Errors.Intent.IDPMissing") @@ -53,24 +53,25 @@ func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID, s successURL, failureURL, idpID, + idpArguments, ), }, nil }, nil } } -func (c *Commands) CreateIntent(ctx context.Context, idpID, successURL, failureURL, resourceOwner string) (*IDPIntentWriteModel, *domain.ObjectDetails, error) { - id, err := c.idGenerator.Next() - if err != nil { - return nil, nil, err - } - writeModel := NewIDPIntentWriteModel(id, resourceOwner) - if err != nil { - return nil, nil, err +func (c *Commands) CreateIntent(ctx context.Context, intentID, idpID, successURL, failureURL, resourceOwner string, idpArguments map[string]any) (*IDPIntentWriteModel, *domain.ObjectDetails, error) { + if intentID == "" { + var err error + intentID, err = c.idGenerator.Next() + if err != nil { + return nil, nil, err + } } + writeModel := NewIDPIntentWriteModel(intentID, resourceOwner) //nolint: staticcheck - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL)) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL, idpArguments)) if err != nil { return nil, nil, err } @@ -132,18 +133,17 @@ func (c *Commands) GetActiveIntent(ctx context.Context, intentID string) (*IDPIn return intent, nil } -func (c *Commands) AuthFromProvider(ctx context.Context, idpID, state string, idpCallback, samlRootURL string) (string, bool, error) { +func (c *Commands) AuthFromProvider(ctx context.Context, idpID, idpCallback, samlRootURL string) (state string, session idp.Session, err error) { + state, err = c.idGenerator.Next() + if err != nil { + return "", nil, err + } provider, err := c.GetProvider(ctx, idpID, idpCallback, samlRootURL) if err != nil { - return "", false, err + return "", nil, err } - session, err := provider.BeginAuth(ctx, state) - if err != nil { - return "", false, err - } - - content, redirect := session.GetAuth(ctx) - return content, redirect, nil + session, err = provider.BeginAuth(ctx, state) + return state, session, err } func getIDPIntentWriteModel(ctx context.Context, writeModel *IDPIntentWriteModel, filter preparation.FilterToQueryReducer) error { diff --git a/internal/command/idp_intent_model.go b/internal/command/idp_intent_model.go index 62794323e1..c6bc26ab06 100644 --- a/internal/command/idp_intent_model.go +++ b/internal/command/idp_intent_model.go @@ -12,13 +12,14 @@ import ( type IDPIntentWriteModel struct { eventstore.WriteModel - SuccessURL *url.URL - FailureURL *url.URL - IDPID string - IDPUser []byte - IDPUserID string - IDPUserName string - UserID string + SuccessURL *url.URL + FailureURL *url.URL + IDPID string + IDPArguments map[string]any + IDPUser []byte + IDPUserID string + IDPUserName string + UserID string IDPAccessToken *crypto.CryptoValue IDPIDToken string @@ -81,6 +82,7 @@ func (wm *IDPIntentWriteModel) reduceStartedEvent(e *idpintent.StartedEvent) { wm.SuccessURL = e.SuccessURL wm.FailureURL = e.FailureURL wm.IDPID = e.IDPID + wm.IDPArguments = e.IDPArguments wm.State = domain.IDPIntentStateStarted } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 832e2e9902..2400b9ee35 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -39,11 +39,13 @@ func TestCommands_CreateIntent(t *testing.T) { idGenerator id.Generator } type args struct { - ctx context.Context - idpID string - successURL string - failureURL string - instanceID string + ctx context.Context + intentID string + idpID string + successURL string + failureURL string + instanceID string + idpArguments map[string]any } type res struct { intentID string @@ -182,6 +184,7 @@ func TestCommands_CreateIntent(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), ), @@ -195,6 +198,7 @@ func TestCommands_CreateIntent(t *testing.T) { success, failure, "idp", + nil, ) }(), ), @@ -235,6 +239,7 @@ func TestCommands_CreateIntent(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), ), @@ -248,6 +253,9 @@ func TestCommands_CreateIntent(t *testing.T) { success, failure, "idp", + map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, ) }(), ), @@ -260,6 +268,9 @@ func TestCommands_CreateIntent(t *testing.T) { idpID: "idp", successURL: "https://success.url", failureURL: "https://failure.url", + idpArguments: map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, }, res{ intentID: "id", @@ -288,6 +299,7 @@ func TestCommands_CreateIntent(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), ), @@ -301,6 +313,9 @@ func TestCommands_CreateIntent(t *testing.T) { success, failure, "idp", + map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, ) }(), ), @@ -313,6 +328,69 @@ func TestCommands_CreateIntent(t *testing.T) { idpID: "idp", successURL: "https://success.url", failureURL: "https://failure.url", + idpArguments: map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, + }, + res{ + intentID: "id", + details: &domain.ObjectDetails{ResourceOwner: "instance"}, + }, + }, + { + "push, with id", + fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + "clientID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("clientSecret"), + }, + "auth", + "token", + "user", + "idAttribute", + nil, + true, + rep_idp.Options{}, + )), + ), + expectPush( + func() eventstore.Command { + success, _ := url.Parse("https://success.url") + failure, _ := url.Parse("https://failure.url") + return idpintent.NewStartedEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + success, + failure, + "idp", + map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, + ) + }(), + ), + ), + }, + args{ + ctx: context.Background(), + instanceID: "instance", + intentID: "id", + idpID: "idp", + successURL: "https://success.url", + failureURL: "https://failure.url", + idpArguments: map[string]interface{}{ + "verifier": "pkceOAuthVerifier", + }, }, res{ intentID: "id", @@ -326,7 +404,7 @@ func TestCommands_CreateIntent(t *testing.T) { eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } - intentWriteModel, details, err := c.CreateIntent(tt.args.ctx, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.instanceID) + intentWriteModel, details, err := c.CreateIntent(tt.args.ctx, tt.args.intentID, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.instanceID, tt.args.idpArguments) require.ErrorIs(t, err, tt.res.err) if intentWriteModel != nil { assert.Equal(t, tt.res.intentID, intentWriteModel.AggregateID) @@ -342,11 +420,11 @@ func TestCommands_AuthFromProvider(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore secretCrypto crypto.EncryptionAlgorithm + idGenerator id.Generator } type args struct { ctx context.Context idpID string - state string callbackURL string samlRootURL string } @@ -361,6 +439,22 @@ func TestCommands_AuthFromProvider(t *testing.T) { args args res res }{ + { + "error no id generator", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: expectEventstore(), + idGenerator: mock.NewIDGeneratorExpectError(t, zerrors.ThrowInternal(nil, "", "error id")), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + callbackURL: "url", + }, + res{ + err: zerrors.ThrowInternal(nil, "", "error id"), + }, + }, { "idp not existing", fields{ @@ -368,11 +462,11 @@ func TestCommands_AuthFromProvider(t *testing.T) { eventstore: expectEventstore( expectFilter(), ), + idGenerator: mock.ExpectID(t, "id"), }, args{ ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), idpID: "idp", - state: "state", callbackURL: "url", }, res{ @@ -402,6 +496,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), eventFromEventPusherWithInstanceID( @@ -412,11 +507,11 @@ func TestCommands_AuthFromProvider(t *testing.T) { ), ), ), + idGenerator: mock.ExpectID(t, "id"), }, args{ ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), idpID: "idp", - state: "state", callbackURL: "url", }, res{ @@ -446,6 +541,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), ), @@ -467,19 +563,20 @@ func TestCommands_AuthFromProvider(t *testing.T) { "user", "idAttribute", nil, + true, rep_idp.Options{}, )), ), ), + idGenerator: mock.ExpectID(t, "id"), }, args{ ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), idpID: "idp", - state: "state", callbackURL: "url", }, res{ - content: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=state", + content: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id", redirect: true, }, }, @@ -504,6 +601,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { }, []string{"openid", "profile", "User.Read"}, false, + true, rep_idp.Options{}, )), eventFromEventPusherWithInstanceID( @@ -540,6 +638,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { }, []string{"openid", "profile", "User.Read"}, false, + true, rep_idp.Options{}, )), eventFromEventPusherWithInstanceID( @@ -561,15 +660,15 @@ func TestCommands_AuthFromProvider(t *testing.T) { )), ), ), + idGenerator: mock.ExpectID(t, "id"), }, args{ ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), idpID: "idp", - state: "state", callbackURL: "url", }, res{ - content: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=state", + content: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id", redirect: true, }, }, @@ -579,9 +678,16 @@ func TestCommands_AuthFromProvider(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore(t), idpConfigEncryption: tt.fields.secretCrypto, + idGenerator: tt.fields.idGenerator, } - content, redirect, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.state, tt.args.callbackURL, tt.args.samlRootURL) + _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) + + var content string + var redirect bool + if err == nil { + content, redirect = session.GetAuth(tt.args.ctx) + } assert.Equal(t, tt.res.redirect, redirect) assert.Equal(t, tt.res.content, content) }) @@ -592,11 +698,11 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore secretCrypto crypto.EncryptionAlgorithm + idGenerator id.Generator } type args struct { ctx context.Context idpID string - state string callbackURL string samlRootURL string } @@ -669,6 +775,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { success, failure, "idp", + nil, ) }(), ), @@ -683,11 +790,11 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { }, ), ), + idGenerator: mock.ExpectID(t, "id"), }, args{ ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), idpID: "idp", - state: "id", callbackURL: "url", samlRootURL: "samlurl", }, @@ -705,10 +812,12 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore(t), idpConfigEncryption: tt.fields.secretCrypto, + idGenerator: tt.fields.idGenerator, } - content, _, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.state, tt.args.callbackURL, tt.args.samlRootURL) + _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) + content, _ := session.GetAuth(tt.args.ctx) authURL, err := url.Parse(content) require.NoError(t, err) diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 7a51ea1112..5257d38bf4 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -45,6 +45,7 @@ type OAuthIDPWriteModel struct { UserEndpoint string Scopes []string IDAttribute string + UsePKCE bool idp.Options State domain.IDPState @@ -73,6 +74,7 @@ func (wm *OAuthIDPWriteModel) reduceAddedEvent(e *idp.OAuthIDPAddedEvent) { wm.UserEndpoint = e.UserEndpoint wm.Scopes = e.Scopes wm.IDAttribute = e.IDAttribute + wm.UsePKCE = e.UsePKCE wm.Options = e.Options wm.State = domain.IDPStateActive } @@ -102,6 +104,9 @@ func (wm *OAuthIDPWriteModel) reduceChangedEvent(e *idp.OAuthIDPChangedEvent) { if e.IDAttribute != nil { wm.IDAttribute = *e.IDAttribute } + if e.UsePKCE != nil { + wm.UsePKCE = *e.UsePKCE + } wm.Options.ReduceChanges(e.OptionChanges) } @@ -115,6 +120,7 @@ func (wm *OAuthIDPWriteModel) NewChanges( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options idp.Options, ) ([]idp.OAuthIDPChanges, error) { changes := make([]idp.OAuthIDPChanges, 0) @@ -148,6 +154,9 @@ func (wm *OAuthIDPWriteModel) NewChanges( if wm.IDAttribute != idAttribute { changes = append(changes, idp.ChangeOAuthIDAttribute(idAttribute)) } + if wm.UsePKCE != usePKCE { + changes = append(changes, idp.ChangeOAuthUsePKCE(usePKCE)) + } opts := wm.Options.Changes(options) if !opts.IsZero() { changes = append(changes, idp.ChangeOAuthOptions(opts)) @@ -208,6 +217,7 @@ type OIDCIDPWriteModel struct { ClientSecret *crypto.CryptoValue Scopes []string IsIDTokenMapping bool + UsePKCE bool idp.Options State domain.IDPState @@ -248,6 +258,7 @@ func (wm *OIDCIDPWriteModel) reduceAddedEvent(e *idp.OIDCIDPAddedEvent) { wm.ClientSecret = e.ClientSecret wm.Scopes = e.Scopes wm.IsIDTokenMapping = e.IsIDTokenMapping + wm.UsePKCE = e.UsePKCE wm.Options = e.Options wm.State = domain.IDPStateActive } @@ -271,6 +282,9 @@ func (wm *OIDCIDPWriteModel) reduceChangedEvent(e *idp.OIDCIDPChangedEvent) { if e.IsIDTokenMapping != nil { wm.IsIDTokenMapping = *e.IsIDTokenMapping } + if e.UsePKCE != nil { + wm.UsePKCE = *e.UsePKCE + } wm.Options.ReduceChanges(e.OptionChanges) } @@ -281,7 +295,7 @@ func (wm *OIDCIDPWriteModel) NewChanges( clientSecretString string, secretCrypto crypto.EncryptionAlgorithm, scopes []string, - idTokenMapping bool, + idTokenMapping, usePKCE bool, options idp.Options, ) ([]idp.OIDCIDPChanges, error) { changes := make([]idp.OIDCIDPChanges, 0) @@ -309,6 +323,9 @@ func (wm *OIDCIDPWriteModel) NewChanges( if wm.IsIDTokenMapping != idTokenMapping { changes = append(changes, idp.ChangeOIDCIsIDTokenMapping(idTokenMapping)) } + if wm.UsePKCE != usePKCE { + changes = append(changes, idp.ChangeOIDCUsePKCE(usePKCE)) + } opts := wm.Options.Changes(options) if !opts.IsZero() { changes = append(changes, idp.ChangeOIDCOptions(opts)) diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index 46f54cad62..cea850dc0e 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -656,6 +656,7 @@ func (c *Commands) prepareAddInstanceOAuthProvider(a *instance.Aggregate, writeM provider.UserEndpoint, provider.IDAttribute, provider.Scopes, + provider.UsePKCE, provider.IDPOptions, ), }, nil @@ -711,6 +712,7 @@ func (c *Commands) prepareUpdateInstanceOAuthProvider(a *instance.Aggregate, wri provider.UserEndpoint, provider.IDAttribute, provider.Scopes, + provider.UsePKCE, provider.IDPOptions, ) if err != nil || event == nil { @@ -759,6 +761,7 @@ func (c *Commands) prepareAddInstanceOIDCProvider(a *instance.Aggregate, writeMo secret, provider.Scopes, provider.IsIDTokenMapping, + provider.UsePKCE, provider.IDPOptions, ), }, nil @@ -803,6 +806,7 @@ func (c *Commands) prepareUpdateInstanceOIDCProvider(a *instance.Aggregate, writ c.idpConfigEncryption, provider.Scopes, provider.IsIDTokenMapping, + provider.UsePKCE, provider.IDPOptions, ) if err != nil || event == nil { diff --git a/internal/command/instance_idp_model.go b/internal/command/instance_idp_model.go index 68f060dd26..d94c19d318 100644 --- a/internal/command/instance_idp_model.go +++ b/internal/command/instance_idp_model.go @@ -68,6 +68,7 @@ func (wm *InstanceOAuthIDPWriteModel) NewChangedEvent( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options idp.Options, ) (*instance.OAuthIDPChangedEvent, error) { @@ -81,6 +82,7 @@ func (wm *InstanceOAuthIDPWriteModel) NewChangedEvent( userEndpoint, idAttribute, scopes, + usePKCE, options, ) if err != nil || len(changes) == 0 { @@ -174,7 +176,7 @@ func (wm *InstanceOIDCIDPWriteModel) NewChangedEvent( clientSecretString string, secretCrypto crypto.EncryptionAlgorithm, scopes []string, - idTokenMapping bool, + idTokenMapping, usePKCE bool, options idp.Options, ) (*instance.OIDCIDPChangedEvent, error) { @@ -186,6 +188,7 @@ func (wm *InstanceOIDCIDPWriteModel) NewChangedEvent( secretCrypto, scopes, idTokenMapping, + usePKCE, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index 39eb74815b..8d872561c2 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -270,6 +270,7 @@ func TestCommandSide_AddInstanceGenericOAuthIDP(t *testing.T) { "user", "idAttribute", nil, + true, idp.Options{}, ), ), @@ -287,6 +288,7 @@ func TestCommandSide_AddInstanceGenericOAuthIDP(t *testing.T) { TokenEndpoint: "token", UserEndpoint: "user", IDAttribute: "idAttribute", + UsePKCE: true, }, }, res: res{ @@ -315,6 +317,7 @@ func TestCommandSide_AddInstanceGenericOAuthIDP(t *testing.T) { "user", "idAttribute", []string{"user"}, + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -338,6 +341,7 @@ func TestCommandSide_AddInstanceGenericOAuthIDP(t *testing.T) { UserEndpoint: "user", Scopes: []string{"user"}, IDAttribute: "idAttribute", + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -569,6 +573,7 @@ func TestCommandSide_UpdateInstanceGenericOAuthIDP(t *testing.T) { "user", "idAttribute", nil, + true, idp.Options{}, )), ), @@ -584,6 +589,7 @@ func TestCommandSide_UpdateInstanceGenericOAuthIDP(t *testing.T) { TokenEndpoint: "token", UserEndpoint: "user", IDAttribute: "idAttribute", + UsePKCE: true, }, }, res: res{ @@ -611,6 +617,7 @@ func TestCommandSide_UpdateInstanceGenericOAuthIDP(t *testing.T) { "user", "idAttribute", nil, + false, idp.Options{}, )), ), @@ -633,6 +640,7 @@ func TestCommandSide_UpdateInstanceGenericOAuthIDP(t *testing.T) { idp.ChangeOAuthUserEndpoint("new user"), idp.ChangeOAuthScopes([]string{"openid", "profile"}), idp.ChangeOAuthIDAttribute("newAttribute"), + idp.ChangeOAuthUsePKCE(true), idp.ChangeOAuthOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -659,6 +667,7 @@ func TestCommandSide_UpdateInstanceGenericOAuthIDP(t *testing.T) { UserEndpoint: "new user", Scopes: []string{"openid", "profile"}, IDAttribute: "newAttribute", + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -805,6 +814,7 @@ func TestCommandSide_AddInstanceGenericOIDCIDP(t *testing.T) { }, nil, false, + true, idp.Options{}, ), ), @@ -819,6 +829,7 @@ func TestCommandSide_AddInstanceGenericOIDCIDP(t *testing.T) { Issuer: "issuer", ClientID: "clientID", ClientSecret: "clientSecret", + UsePKCE: true, }, }, res: res{ @@ -845,6 +856,7 @@ func TestCommandSide_AddInstanceGenericOIDCIDP(t *testing.T) { }, []string{openid.ScopeOpenID}, true, + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -866,6 +878,7 @@ func TestCommandSide_AddInstanceGenericOIDCIDP(t *testing.T) { ClientSecret: "clientSecret", Scopes: []string{openid.ScopeOpenID}, IsIDTokenMapping: true, + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -1029,6 +1042,7 @@ func TestCommandSide_UpdateInstanceGenericOIDCIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1066,6 +1080,7 @@ func TestCommandSide_UpdateInstanceGenericOIDCIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1086,6 +1101,7 @@ func TestCommandSide_UpdateInstanceGenericOIDCIDP(t *testing.T) { }), idp.ChangeOIDCScopes([]string{"openid", "profile"}), idp.ChangeOIDCIsIDTokenMapping(true), + idp.ChangeOIDCUsePKCE(true), idp.ChangeOIDCOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -1110,6 +1126,7 @@ func TestCommandSide_UpdateInstanceGenericOIDCIDP(t *testing.T) { ClientSecret: "newSecret", Scopes: []string{"openid", "profile"}, IsIDTokenMapping: true, + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -1253,6 +1270,7 @@ func TestCommandSide_MigrateInstanceGenericOIDCToAzureADProvider(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1311,6 +1329,7 @@ func TestCommandSide_MigrateInstanceGenericOIDCToAzureADProvider(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1475,6 +1494,7 @@ func TestCommandSide_MigrateInstanceOIDCToGoogleIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1527,6 +1547,7 @@ func TestCommandSide_MigrateInstanceOIDCToGoogleIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index 21d871133a..d24b0f7840 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -628,6 +628,7 @@ func (c *Commands) prepareAddOrgOAuthProvider(a *org.Aggregate, writeModel *OrgO provider.UserEndpoint, provider.IDAttribute, provider.Scopes, + provider.UsePKCE, provider.IDPOptions, ), }, nil @@ -683,6 +684,7 @@ func (c *Commands) prepareUpdateOrgOAuthProvider(a *org.Aggregate, writeModel *O provider.UserEndpoint, provider.IDAttribute, provider.Scopes, + provider.UsePKCE, provider.IDPOptions, ) if err != nil || event == nil { @@ -731,6 +733,7 @@ func (c *Commands) prepareAddOrgOIDCProvider(a *org.Aggregate, writeModel *OrgOI secret, provider.Scopes, provider.IsIDTokenMapping, + provider.UsePKCE, provider.IDPOptions, ), }, nil @@ -775,6 +778,7 @@ func (c *Commands) prepareUpdateOrgOIDCProvider(a *org.Aggregate, writeModel *Or c.idpConfigEncryption, provider.Scopes, provider.IsIDTokenMapping, + provider.UsePKCE, provider.IDPOptions, ) if err != nil || event == nil { diff --git a/internal/command/org_idp_model.go b/internal/command/org_idp_model.go index 634eb42eaf..3baea11495 100644 --- a/internal/command/org_idp_model.go +++ b/internal/command/org_idp_model.go @@ -70,6 +70,7 @@ func (wm *OrgOAuthIDPWriteModel) NewChangedEvent( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options idp.Options, ) (*org.OAuthIDPChangedEvent, error) { @@ -83,6 +84,7 @@ func (wm *OrgOAuthIDPWriteModel) NewChangedEvent( userEndpoint, idAttribute, scopes, + usePKCE, options, ) if err != nil || len(changes) == 0 { @@ -176,7 +178,7 @@ func (wm *OrgOIDCIDPWriteModel) NewChangedEvent( clientSecretString string, secretCrypto crypto.EncryptionAlgorithm, scopes []string, - idTokenMapping bool, + idTokenMapping, usePKCE bool, options idp.Options, ) (*org.OIDCIDPChangedEvent, error) { @@ -188,6 +190,7 @@ func (wm *OrgOIDCIDPWriteModel) NewChangedEvent( secretCrypto, scopes, idTokenMapping, + usePKCE, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index e2fd68fa90..25115f71fe 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -210,6 +210,7 @@ func TestCommandSide_AddOrgGenericOAuthProvider(t *testing.T) { "user", "idAttribute", nil, + false, idp.Options{}, ), ), @@ -256,6 +257,7 @@ func TestCommandSide_AddOrgGenericOAuthProvider(t *testing.T) { "user", "idAttribute", []string{"user"}, + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -280,6 +282,7 @@ func TestCommandSide_AddOrgGenericOAuthProvider(t *testing.T) { UserEndpoint: "user", Scopes: []string{"user"}, IDAttribute: "idAttribute", + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -520,6 +523,7 @@ func TestCommandSide_UpdateOrgGenericOAuthIDP(t *testing.T) { "user", "idAttribute", nil, + false, idp.Options{}, )), ), @@ -536,6 +540,7 @@ func TestCommandSide_UpdateOrgGenericOAuthIDP(t *testing.T) { TokenEndpoint: "token", UserEndpoint: "user", IDAttribute: "idAttribute", + UsePKCE: false, }, }, res: res{ @@ -563,6 +568,7 @@ func TestCommandSide_UpdateOrgGenericOAuthIDP(t *testing.T) { "user", "idAttribute", nil, + false, idp.Options{}, )), ), @@ -585,6 +591,7 @@ func TestCommandSide_UpdateOrgGenericOAuthIDP(t *testing.T) { idp.ChangeOAuthUserEndpoint("new user"), idp.ChangeOAuthScopes([]string{"openid", "profile"}), idp.ChangeOAuthIDAttribute("newAttribute"), + idp.ChangeOAuthUsePKCE(true), idp.ChangeOAuthOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -612,6 +619,7 @@ func TestCommandSide_UpdateOrgGenericOAuthIDP(t *testing.T) { UserEndpoint: "new user", Scopes: []string{"openid", "profile"}, IDAttribute: "newAttribute", + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -763,6 +771,7 @@ func TestCommandSide_AddOrgGenericOIDCIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, ), ), @@ -804,6 +813,7 @@ func TestCommandSide_AddOrgGenericOIDCIDP(t *testing.T) { }, []string{openid.ScopeOpenID}, true, + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -826,6 +836,7 @@ func TestCommandSide_AddOrgGenericOIDCIDP(t *testing.T) { ClientSecret: "clientSecret", Scopes: []string{openid.ScopeOpenID}, IsIDTokenMapping: true, + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -995,6 +1006,7 @@ func TestCommandSide_UpdateOrgGenericOIDCIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1033,6 +1045,7 @@ func TestCommandSide_UpdateOrgGenericOIDCIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1053,6 +1066,7 @@ func TestCommandSide_UpdateOrgGenericOIDCIDP(t *testing.T) { }), idp.ChangeOIDCScopes([]string{"openid", "profile"}), idp.ChangeOIDCIsIDTokenMapping(true), + idp.ChangeOIDCUsePKCE(true), idp.ChangeOIDCOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -1078,6 +1092,7 @@ func TestCommandSide_UpdateOrgGenericOIDCIDP(t *testing.T) { ClientSecret: "newSecret", Scopes: []string{"openid", "profile"}, IsIDTokenMapping: true, + UsePKCE: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -1225,6 +1240,7 @@ func TestCommandSide_MigrateOrgGenericOIDCToAzureADProvider(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1284,6 +1300,7 @@ func TestCommandSide_MigrateOrgGenericOIDCToAzureADProvider(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1452,6 +1469,7 @@ func TestCommandSide_MigrateOrgOIDCToGoogleIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), @@ -1505,6 +1523,7 @@ func TestCommandSide_MigrateOrgOIDCToGoogleIDP(t *testing.T) { }, nil, false, + false, idp.Options{}, )), ), diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 5fba985148..60027d3a05 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -837,6 +837,7 @@ func TestCommands_updateSession(t *testing.T) { nil, nil, "idpID", + nil, ), ), eventFromEventPusher( diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index 85ec340f67..c880c5159e 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -43,6 +43,7 @@ type AuthRequest struct { ApplicationResourceOwner string PrivateLabelingSetting PrivateLabelingSetting SelectedIDPConfigID string + SelectedIDPConfigArgs map[string]any LinkingUsers []*ExternalUser PossibleSteps []NextStep `json:"-"` PasswordVerified bool diff --git a/internal/domain/request.go b/internal/domain/request.go index 1b54cfa41c..92e45c0d2f 100644 --- a/internal/domain/request.go +++ b/internal/domain/request.go @@ -62,11 +62,13 @@ func (a *AuthRequestSAML) IsValid() bool { } type AuthRequestDevice struct { - ClientID string - DeviceCode string - UserCode string - Scopes []string - Audience []string + ClientID string + DeviceCode string + UserCode string + Scopes []string + Audience []string + AppName string + ProjectName string } func (*AuthRequestDevice) Type() AuthRequestType { diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 9ae0b6b1ea..ced76953cb 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -144,7 +144,17 @@ func (m *MockRepository) ExpectPushFailed(err error, expectedCommands []eventsto assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Creator(), commands[i].Creator()) assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Type(), commands[i].Type()) assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Revision(), commands[i].Revision()) - assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Payload(), commands[i].Payload()) + var expectedPayload []byte + expectedPayload, ok := expectedCommand.Payload().([]byte) + if !ok { + expectedPayload, _ = json.Marshal(expectedCommand.Payload()) + } + if string(expectedPayload) == "" { + expectedPayload = []byte("null") + } + gotPayload, _ := json.Marshal(commands[i].Payload()) + + assert.Equal(m.MockPusher.ctrl.T, expectedPayload, gotPayload) assert.ElementsMatch(m.MockPusher.ctrl.T, expectedCommand.UniqueConstraints(), commands[i].UniqueConstraints()) } diff --git a/internal/idp/providers/apple/session.go b/internal/idp/providers/apple/session.go index 5e9143f050..eee68fa2a5 100644 --- a/internal/idp/providers/apple/session.go +++ b/internal/idp/providers/apple/session.go @@ -17,6 +17,10 @@ type Session struct { UserFormValue string } +func NewSession(provider *Provider, code, userFormValue string) *Session { + return &Session{Session: oidc.NewSession(provider.Provider, code, nil), UserFormValue: userFormValue} +} + type userFormValue struct { Name userNamesFormValue `json:"name,omitempty" schema:"name"` } diff --git a/internal/idp/providers/azuread/session.go b/internal/idp/providers/azuread/session.go index a9d8df2e8c..4b0a6fb844 100644 --- a/internal/idp/providers/azuread/session.go +++ b/internal/idp/providers/azuread/session.go @@ -20,6 +20,10 @@ type Session struct { OAuthSession *oauth.Session } +func NewSession(provider *Provider, code string) *Session { + return &Session{Provider: provider, Code: code} +} + // GetAuth implements the [idp.Provider] interface by calling the wrapped [oauth.Session]. func (s *Session) GetAuth(ctx context.Context) (content string, redirect bool) { return s.oauth().GetAuth(ctx) @@ -39,6 +43,11 @@ func (s *Session) RetrievePreviousID() (string, error) { return userinfo.Subject, nil } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + return nil +} + // FetchUser implements the [idp.Session] interface. // It will execute an OAuth 2.0 code exchange if needed to retrieve the access token, // call the specified userEndpoint and map the received information into an [idp.User]. diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 54fcc039eb..6df08a6998 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -30,11 +30,20 @@ type Session struct { Tokens *oidc.Tokens[*oidc.IDTokenClaims] } +func NewSession(provider *Provider, tokens *oidc.Tokens[*oidc.IDTokenClaims]) *Session { + return &Session{Provider: provider, Tokens: tokens} +} + // GetAuth implements the [idp.Session] interface. func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Redirect(s.AuthURL) } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + return nil +} + // FetchUser implements the [idp.Session] interface. // It will map the received idToken into an [idp.User]. func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index 4984ad3531..0a6a87ba3d 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -34,11 +34,21 @@ type Session struct { Entry *ldap.Entry } +func NewSession(provider *Provider, username, password string) *Session { + return &Session{Provider: provider, User: username, Password: password} +} + // GetAuth implements the [idp.Session] interface. func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Redirect(s.loginUrl) } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + return nil +} + +// FetchUser implements the [idp.Session] interface. func (s *Session) FetchUser(_ context.Context) (_ idp.User, err error) { var user *ldap.Entry for _, server := range s.Provider.servers { diff --git a/internal/idp/providers/oauth/oauth2.go b/internal/idp/providers/oauth/oauth2.go index 910ceb1b9c..e9c627509a 100644 --- a/internal/idp/providers/oauth/oauth2.go +++ b/internal/idp/providers/oauth/oauth2.go @@ -23,6 +23,7 @@ type Provider struct { isCreationAllowed bool isAutoCreation bool isAutoUpdate bool + generateVerifier func() string } type ProviderOpts func(provider *Provider) @@ -66,9 +67,10 @@ func WithRelyingPartyOption(option rp.Option) ProviderOpts { // New creates a generic OAuth 2.0 provider func New(config *oauth2.Config, name, userEndpoint string, userMapper func() idp.User, options ...ProviderOpts) (provider *Provider, err error) { provider = &Provider{ - name: name, - userEndpoint: userEndpoint, - userMapper: userMapper, + name: name, + userEndpoint: userEndpoint, + userMapper: userMapper, + generateVerifier: oauth2.GenerateVerifier, } for _, option := range options { option(provider) @@ -99,8 +101,15 @@ func (p *Provider) BeginAuth(ctx context.Context, state string, params ...idp.Pa if !loginHintSet { opts = append(opts, rp.WithPrompt(oidc.PromptSelectAccount)) } + + var codeVerifier string + if p.RelyingParty.IsPKCE() { + codeVerifier = p.generateVerifier() + opts = append(opts, rp.WithCodeChallenge(oidc.NewSHACodeChallenge(codeVerifier))) + } + url := rp.AuthURL(state, p.RelyingParty, opts...) - return &Session{AuthURL: url, Provider: p}, nil + return &Session{AuthURL: url, Provider: p, CodeVerifier: codeVerifier}, nil } func loginHint(hint string) rp.AuthURLOpt { diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go index 814a7ac9c2..984315ac1f 100644 --- a/internal/idp/providers/oauth/oauth2_test.go +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -18,6 +18,7 @@ func TestProvider_BeginAuth(t *testing.T) { name string userEndpoint string userMapper func() idp.User + options []ProviderOpts } tests := []struct { name string @@ -25,7 +26,7 @@ func TestProvider_BeginAuth(t *testing.T) { want idp.Session }{ { - name: "successful auth", + name: "successful auth without PKCE", fields: fields{ config: &oauth2.Config{ ClientID: "clientID", @@ -40,14 +41,40 @@ func TestProvider_BeginAuth(t *testing.T) { }, want: &Session{AuthURL: "https://oauth2.com/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=user&state=testState"}, }, + { + name: "successful auth with PKCE", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + options: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + WithRelyingPartyOption(rp.WithPKCE(nil)), + }, + }, + want: &Session{AuthURL: "https://oauth2.com/authorize?client_id=clientID&code_challenge=2ZoH_a01aprzLkwVbjlPsBo4m8mJ_zOKkaDqYM7Oh5w&code_challenge_method=S256&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=user&state=testState"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper) + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, tt.fields.options...) r.NoError(err) + provider.generateVerifier = func() string { + return "pkceOAuthVerifier" + } ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go index 065ad3b213..aca22234a2 100644 --- a/internal/idp/providers/oauth/session.go +++ b/internal/idp/providers/oauth/session.go @@ -14,22 +14,40 @@ import ( var ErrCodeMissing = errors.New("no auth code provided") +const ( + CodeVerifier = "codeVerifier" +) + var _ idp.Session = (*Session)(nil) // Session is the [idp.Session] implementation for the OAuth2.0 provider. type Session struct { - AuthURL string - Code string - Tokens *oidc.Tokens[*oidc.IDTokenClaims] + AuthURL string + CodeVerifier string + Code string + Tokens *oidc.Tokens[*oidc.IDTokenClaims] Provider *Provider } +func NewSession(provider *Provider, code string, idpArguments map[string]any) *Session { + verifier, _ := idpArguments[CodeVerifier].(string) + return &Session{Provider: provider, Code: code, CodeVerifier: verifier} +} + // GetAuth implements the [idp.Session] interface. func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Redirect(s.AuthURL) } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + if s.CodeVerifier == "" { + return nil + } + return map[string]any{CodeVerifier: s.CodeVerifier} +} + // FetchUser implements the [idp.Session] interface. // It will execute an OAuth 2.0 code exchange if needed to retrieve the access token, // call the specified userEndpoint and map the received information into an [idp.User]. @@ -55,7 +73,11 @@ func (s *Session) authorize(ctx context.Context) (err error) { if s.Code == "" { return ErrCodeMissing } - s.Tokens, err = rp.CodeExchange[*oidc.IDTokenClaims](ctx, s.Code, s.Provider.RelyingParty) + var opts []rp.CodeExchangeOpt + if s.CodeVerifier != "" { + opts = append(opts, rp.WithCodeVerifier(s.CodeVerifier)) + } + s.Tokens, err = rp.CodeExchange[*oidc.IDTokenClaims](ctx, s.Code, s.Provider.RelyingParty, opts...) return err } diff --git a/internal/idp/providers/oidc/oidc.go b/internal/idp/providers/oidc/oidc.go index f122230c3a..cd3ac764fd 100644 --- a/internal/idp/providers/oidc/oidc.go +++ b/internal/idp/providers/oidc/oidc.go @@ -24,6 +24,7 @@ type Provider struct { useIDToken bool userInfoMapper func(info *oidc.UserInfo) idp.User authOptions []func(bool) rp.AuthURLOpt + generateVerifier func() string } type ProviderOpts func(provider *Provider) @@ -102,8 +103,9 @@ var DefaultMapper UserInfoMapper = func(info *oidc.UserInfo) idp.User { // New creates a generic OIDC provider func New(name, issuer, clientID, clientSecret, redirectURI string, scopes []string, userInfoMapper UserInfoMapper, options ...ProviderOpts) (provider *Provider, err error) { provider = &Provider{ - name: name, - userInfoMapper: userInfoMapper, + name: name, + userInfoMapper: userInfoMapper, + generateVerifier: oauth2.GenerateVerifier, } for _, option := range options { option(provider) @@ -150,8 +152,15 @@ func (p *Provider) BeginAuth(ctx context.Context, state string, params ...idp.Pa opts = append(opts, opt) } } + + var codeVerifier string + if p.RelyingParty.IsPKCE() { + codeVerifier = p.generateVerifier() + opts = append(opts, rp.WithCodeChallenge(oidc.NewSHACodeChallenge(codeVerifier))) + } + url := rp.AuthURL(state, p.RelyingParty, opts...) - return &Session{AuthURL: url, Provider: p}, nil + return &Session{AuthURL: url, Provider: p, CodeVerifier: codeVerifier}, nil } func loginHint(hint string) rp.AuthURLOpt { diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go index d510bf15c2..a46f09f13f 100644 --- a/internal/idp/providers/oidc/oidc_test.go +++ b/internal/idp/providers/oidc/oidc_test.go @@ -31,7 +31,7 @@ func TestProvider_BeginAuth(t *testing.T) { want idp.Session }{ { - name: "successful auth", + name: "successful auth without PKCE", fields: fields{ name: "oidc", issuer: "https://issuer.com", @@ -55,6 +55,31 @@ func TestProvider_BeginAuth(t *testing.T) { }, want: &Session{AuthURL: "https://issuer.com/authorize?client_id=clientID&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState"}, }, + { + name: "successful auth with PKCE", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + scopes: []string{"openid"}, + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + }, + opts: []ProviderOpts{WithSelectAccount(), WithRelyingPartyOption(rp.WithPKCE(nil))}, + }, + want: &Session{AuthURL: "https://issuer.com/authorize?client_id=clientID&code_challenge=2ZoH_a01aprzLkwVbjlPsBo4m8mJ_zOKkaDqYM7Oh5w&code_challenge_method=S256&prompt=select_account&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -65,6 +90,9 @@ func TestProvider_BeginAuth(t *testing.T) { provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, tt.fields.opts...) r.NoError(err) + provider.generateVerifier = func() string { + return "pkceOAuthVerifier" + } ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index bd6303f2e5..b17a3b0a0b 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" ) var ErrCodeMissing = errors.New("no auth code provided") @@ -18,10 +19,16 @@ var _ idp.Session = (*Session)(nil) // Session is the [idp.Session] implementation for the OIDC provider. type Session struct { - Provider *Provider - AuthURL string - Code string - Tokens *oidc.Tokens[*oidc.IDTokenClaims] + Provider *Provider + AuthURL string + CodeVerifier string + Code string + Tokens *oidc.Tokens[*oidc.IDTokenClaims] +} + +func NewSession(provider *Provider, code string, idpArguments map[string]any) *Session { + verifier, _ := idpArguments[oauth.CodeVerifier].(string) + return &Session{Provider: provider, Code: code, CodeVerifier: verifier} } // GetAuth implements the [idp.Session] interface. @@ -29,6 +36,14 @@ func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Redirect(s.AuthURL) } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + if s.CodeVerifier == "" { + return nil + } + return map[string]any{oauth.CodeVerifier: s.CodeVerifier} +} + // FetchUser implements the [idp.Session] interface. // It will execute an OIDC code exchange if needed to retrieve the tokens, // call the userinfo endpoint and map the received information into an [idp.User]. @@ -61,7 +76,11 @@ func (s *Session) Authorize(ctx context.Context) (err error) { if s.Code == "" { return ErrCodeMissing } - s.Tokens, err = rp.CodeExchange[*oidc.IDTokenClaims](ctx, s.Code, s.Provider.RelyingParty) + var opts []rp.CodeExchangeOpt + if s.CodeVerifier != "" { + opts = append(opts, rp.WithCodeVerifier(s.CodeVerifier)) + } + s.Tokens, err = rp.CodeExchange[*oidc.IDTokenClaims](ctx, s.Code, s.Provider.RelyingParty, opts...) return err } diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index 49a04e49cb..b0748d33a3 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -60,6 +60,11 @@ func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Form(resp.content.String()) } +// PersistentParameters implements the [idp.Session] interface. +func (s *Session) PersistentParameters() map[string]any { + return nil +} + // FetchUser implements the [idp.Session] interface. func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { if s.RequestID == "" || s.Request == nil { diff --git a/internal/idp/session.go b/internal/idp/session.go index 6d6519a54c..ab54bcabaa 100644 --- a/internal/idp/session.go +++ b/internal/idp/session.go @@ -7,6 +7,7 @@ import ( // Session is the minimal implementation for a session of a 3rd party authentication [Provider] type Session interface { GetAuth(ctx context.Context) (content string, redirect bool) + PersistentParameters() map[string]any FetchUser(ctx context.Context) (User, error) } diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 77d7558b55..22432a3a84 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -19,6 +19,7 @@ import ( type Details interface { comparable GetSequence() uint64 + GetCreationDate() *timestamppb.Timestamp GetChangeDate() *timestamppb.Timestamp GetResourceOwner() string } @@ -62,6 +63,12 @@ func AssertDetails[D Details, M DetailsMsg[D]](t assert.TestingT, expected, actu assert.NotZero(t, gotDetails.GetSequence()) + if wantDetails.GetCreationDate() != nil { + wantCreationDate := time.Now() + gotCreationDate := gotDetails.GetCreationDate().AsTime() + assert.WithinRange(t, gotCreationDate, wantCreationDate.Add(-time.Minute), wantCreationDate.Add(time.Minute)) + } + if wantDetails.GetChangeDate() != nil { wantChangeDate := time.Now() gotChangeDate := gotDetails.GetChangeDate().AsTime() diff --git a/internal/integration/client.go b/internal/integration/client.go index cefaf0ef42..a480a86ce0 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -526,101 +526,20 @@ func (i *Instance) AddSAMLPostProvider(ctx context.Context) string { return resp.GetId() } -/* -func (s *Instance) CreateIntent(t *testing.T, ctx context.Context, idpID string) string { - resp, err := i.Client.UserV2.StartIdentityProviderIntent(ctx, &user.StartIdentityProviderIntentRequest{ +func (i *Instance) CreateIntent(ctx context.Context, idpID string) *user_v2.StartIdentityProviderIntentResponse { + resp, err := i.Client.UserV2.StartIdentityProviderIntent(ctx, &user_v2.StartIdentityProviderIntentRequest{ IdpId: idpID, - Content: &user.StartIdentityProviderIntentRequest_Urls{ - Urls: &user.RedirectURLs{ + Content: &user_v2.StartIdentityProviderIntentRequest_Urls{ + Urls: &user_v2.RedirectURLs{ SuccessUrl: "https://example.com/success", FailureUrl: "https://example.com/failure", }, - AutoLinking: idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME, }, }) logging.OnError(err).Fatal("create generic OAuth idp") return resp } -func (i *Instance) CreateIntent(t *testing.T, ctx context.Context, idpID string) string { - ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) - writeModel, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Instance.InstanceID()) - require.NoError(t, err) - return writeModel.AggregateID -} - -func (i *Instance) CreateSuccessfulOAuthIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { - ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) - intentID := s.CreateIntent(t, ctx, idpID) - writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) - require.NoError(t, err) - idpUser := openid.NewUser( - &oidc.UserInfo{ - Subject: idpUserID, - UserInfoProfile: oidc.UserInfoProfile{ - PreferredUsername: "username", - }, - }, - ) - idpSession := &openid.Session{ - Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ - Token: &oauth2.Token{ - AccessToken: "accessToken", - }, - IDToken: "idToken", - }, - } - token, err := s.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, userID) - require.NoError(t, err) - return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence -} - -func (s *Instance) CreateSuccessfulLDAPIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { - ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) - intentID := s.CreateIntent(t, ctx, idpID) - writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) - require.NoError(t, err) - username := "username" - lang := language.Make("en") - idpUser := ldap.NewUser( - idpUserID, - "", - "", - "", - "", - username, - "", - false, - "", - false, - lang, - "", - "", - ) - attributes := map[string][]string{"id": {idpUserID}, "username": {username}, "language": {lang.String()}} - token, err := s.Commands.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, userID, attributes) - require.NoError(t, err) - return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence -} - -func (s *Instance) CreateSuccessfulSAMLIntent(t *testing.T, ctx context.Context, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { - ctx = authz.WithInstance(context.WithoutCancel(ctx), s.Instance) - intentID := s.CreateIntent(t, ctx, idpID) - writeModel, err := s.Server.Commands.GetIntentWriteModel(ctx, intentID, s.Instance.InstanceID()) - require.NoError(t, err) - - idpUser := &saml.UserMapper{ - ID: idpUserID, - Attributes: map[string][]string{"attribute1": {"value1"}}, - } - assertion := &crewjam_saml.Assertion{ID: "id"} - - token, err := s.Server.Commands.SucceedSAMLIDPIntent(ctx, writeModel, idpUser, userID, assertion) - require.NoError(t, err) - return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence -} -*/ - func (i *Instance) CreateVerifiedWebAuthNSession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) { return i.CreateVerifiedWebAuthNSessionWithLifetime(t, ctx, userID, 0) } diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 4d7f5277c9..19742741ab 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -466,3 +466,11 @@ func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context) (machine *man return machine, name, keyResp.GetKeyDetails(), nil } + +func (i *Instance) CreateDeviceAuthorizationRequest(ctx context.Context, clientID string, scopes ...string) (*oidc.DeviceAuthorizationResponse, error) { + provider, err := i.CreateRelyingParty(ctx, clientID, "", scopes...) + if err != nil { + return nil, err + } + return rp.DeviceAuthorization(ctx, scopes, provider, nil) +} diff --git a/internal/integration/sink/server.go b/internal/integration/sink/server.go index 959353ae5f..aee40cad02 100644 --- a/internal/integration/sink/server.go +++ b/internal/integration/sink/server.go @@ -3,6 +3,9 @@ package sink import ( + "bytes" + "context" + "encoding/json" "errors" "io" "net/http" @@ -10,11 +13,23 @@ import ( "path" "sync" "sync/atomic" + "time" "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + crewjam_saml "github.com/crewjam/saml" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" + openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/idp/providers/saml" ) const ( @@ -33,6 +48,60 @@ func CallURL(ch Channel) string { return u.String() } +func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { + u := url.URL{ + Scheme: "http", + Host: host, + Path: successfulIntentOAuthPath(), + } + resp, err := callIntent(u.String(), &SuccessfulIntentRequest{ + InstanceID: instanceID, + IDPID: idpID, + IDPUserID: idpUserID, + UserID: userID, + }) + if err != nil { + return "", "", time.Time{}, uint64(0), err + } + return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil +} + +func SuccessfulSAMLIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { + u := url.URL{ + Scheme: "http", + Host: host, + Path: successfulIntentSAMLPath(), + } + resp, err := callIntent(u.String(), &SuccessfulIntentRequest{ + InstanceID: instanceID, + IDPID: idpID, + IDPUserID: idpUserID, + UserID: userID, + }) + if err != nil { + return "", "", time.Time{}, uint64(0), err + } + return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil +} + +func SuccessfulLDAPIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { + u := url.URL{ + Scheme: "http", + Host: host, + Path: successfulIntentLDAPPath(), + } + resp, err := callIntent(u.String(), &SuccessfulIntentRequest{ + InstanceID: instanceID, + IDPID: idpID, + IDPUserID: idpUserID, + UserID: userID, + }) + if err != nil { + return "", "", time.Time{}, uint64(0), err + } + return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil +} + // StartServer starts a simple HTTP server on localhost:8081 // ZITADEL can use the server to send HTTP requests which can be // used to validate tests through [Subscribe]rs. @@ -41,7 +110,7 @@ func CallURL(ch Channel) string { // [CallURL] can be used to obtain the full URL for a given Channel. // // This function is only active when the `integration` build tag is enabled -func StartServer() (close func()) { +func StartServer(commands *command.Commands) (close func()) { router := chi.NewRouter() for _, ch := range ChannelValues() { fwd := &forwarder{ @@ -50,6 +119,9 @@ func StartServer() (close func()) { } router.HandleFunc(rootPath(ch), fwd.receiveHandler) router.HandleFunc(subscribePath(ch), fwd.subscriptionHandler) + router.HandleFunc(successfulIntentOAuthPath(), successfulIntentHandler(commands, createSuccessfulOAuthIntent)) + router.HandleFunc(successfulIntentSAMLPath(), successfulIntentHandler(commands, createSuccessfulSAMLIntent)) + router.HandleFunc(successfulIntentLDAPPath(), successfulIntentHandler(commands, createSuccessfulLDAPIntent)) } s := &http.Server{ Addr: listenAddr, @@ -76,6 +148,26 @@ func subscribePath(c Channel) string { return path.Join("/", c.String(), "subscribe") } +func intentPath() string { + return path.Join("/", "intent") +} + +func successfulIntentPath() string { + return path.Join(intentPath(), "/", "successful") +} + +func successfulIntentOAuthPath() string { + return path.Join(successfulIntentPath(), "/", "oauth") +} + +func successfulIntentSAMLPath() string { + return path.Join(successfulIntentPath(), "/", "saml") +} + +func successfulIntentLDAPPath() string { + return path.Join(successfulIntentPath(), "/", "ldap") +} + // forwarder handles incoming HTTP requests from ZITADEL and // forwards them to all subscribed web sockets. type forwarder struct { @@ -165,3 +257,165 @@ func readLoop(ws *websocket.Conn) (done chan error) { return done } + +type SuccessfulIntentRequest struct { + InstanceID string `json:"instance_id"` + IDPID string `json:"idp_id"` + IDPUserID string `json:"idp_user_id"` + UserID string `json:"user_id"` +} +type SuccessfulIntentResponse struct { + IntentID string `json:"intent_id"` + Token string `json:"token"` + ChangeDate time.Time `json:"change_date"` + Sequence uint64 `json:"sequence"` +} + +func callIntent(url string, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := http.Post(url, "application/json", io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.New(string(body)) + } + result := new(SuccessfulIntentResponse) + if err := json.Unmarshal(body, result); err != nil { + return nil, err + } + return result, nil +} + +func successfulIntentHandler(cmd *command.Commands, createIntent func(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error)) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req := &SuccessfulIntentRequest{} + if err := json.Unmarshal(body, req); err != nil { + } + + ctx := authz.WithInstanceID(r.Context(), req.InstanceID) + resp, err := createIntent(ctx, cmd, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + data, err := json.Marshal(resp) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Write(data) + return + } +} + +func createIntent(ctx context.Context, cmd *command.Commands, instanceID, idpID string) (string, error) { + writeModel, _, err := cmd.CreateIntent(ctx, "", idpID, "https://example.com/success", "https://example.com/failure", instanceID, nil) + if err != nil { + return "", err + } + return writeModel.AggregateID, nil +} + +func createSuccessfulOAuthIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) { + intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID) + writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID) + idpUser := openid.NewUser( + &oidc.UserInfo{ + Subject: req.IDPUserID, + UserInfoProfile: oidc.UserInfoProfile{ + PreferredUsername: "username", + }, + }, + ) + idpSession := &openid.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + IDToken: "idToken", + }, + } + token, err := cmd.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, req.UserID) + if err != nil { + return nil, err + } + return &SuccessfulIntentResponse{ + intentID, + token, + writeModel.ChangeDate, + writeModel.ProcessedSequence, + }, nil +} + +func createSuccessfulSAMLIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) { + intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID) + writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID) + + idpUser := &saml.UserMapper{ + ID: req.IDPUserID, + Attributes: map[string][]string{"attribute1": {"value1"}}, + } + assertion := &crewjam_saml.Assertion{ID: "id"} + + token, err := cmd.SucceedSAMLIDPIntent(ctx, writeModel, idpUser, req.UserID, assertion) + if err != nil { + return nil, err + } + return &SuccessfulIntentResponse{ + intentID, + token, + writeModel.ChangeDate, + writeModel.ProcessedSequence, + }, nil +} + +func createSuccessfulLDAPIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) { + intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID) + writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID) + username := "username" + lang := language.Make("en") + idpUser := ldap.NewUser( + req.IDPUserID, + "", + "", + "", + "", + username, + "", + false, + "", + false, + lang, + "", + "", + ) + attributes := map[string][]string{"id": {req.IDPUserID}, "username": {username}, "language": {lang.String()}} + token, err := cmd.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, req.UserID, attributes) + if err != nil { + return nil, err + } + return &SuccessfulIntentResponse{ + intentID, + token, + writeModel.ChangeDate, + writeModel.ProcessedSequence, + }, nil +} diff --git a/internal/integration/sink/stub.go b/internal/integration/sink/stub.go index 01d1047f34..62e1d541e1 100644 --- a/internal/integration/sink/stub.go +++ b/internal/integration/sink/stub.go @@ -2,8 +2,10 @@ package sink +import "github.com/zitadel/zitadel/internal/command" + // StartServer and its returned close function are a no-op // when the `integration` build tag is disabled. -func StartServer() (close func()) { +func StartServer(cmd *command.Commands) (close func()) { return func() {} } diff --git a/internal/query/device_auth.go b/internal/query/device_auth.go index d63bfe0209..e42b5a114e 100644 --- a/internal/query/device_auth.go +++ b/internal/query/device_auth.go @@ -86,15 +86,24 @@ var deviceAuthSelectColumns = []string{ DeviceAuthRequestColumnUserCode.identifier(), DeviceAuthRequestColumnScopes.identifier(), DeviceAuthRequestColumnAudience.identifier(), + AppColumnName.identifier(), + ProjectColumnName.identifier(), } func prepareDeviceAuthQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*domain.AuthRequestDevice, error)) { - return sq.Select(deviceAuthSelectColumns...).From(deviceAuthRequestTable.identifier()).PlaceholderFormat(sq.Dollar), + return sq.Select(deviceAuthSelectColumns...). + From(deviceAuthRequestTable.identifier()). + LeftJoin(join(AppOIDCConfigColumnClientID, DeviceAuthRequestColumnClientID)). + LeftJoin(join(AppColumnID, AppOIDCConfigColumnAppID)). + LeftJoin(join(ProjectColumnID, AppColumnProjectID)). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*domain.AuthRequestDevice, error) { dst := new(domain.AuthRequestDevice) var ( - scopes database.TextArray[string] - audience database.TextArray[string] + scopes database.TextArray[string] + audience database.TextArray[string] + appName sql.NullString + projectName sql.NullString ) err := row.Scan( @@ -103,15 +112,20 @@ func prepareDeviceAuthQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &dst.UserCode, &scopes, &audience, + &appName, + &projectName, ) if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-Sah9a", "Errors.DeviceAuth.NotExisting") + return nil, zerrors.ThrowNotFound(err, "QUERY-Sah9a", "Errors.DeviceAuth.NotFound") } if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-Voo3o", "Errors.Internal") } dst.Scopes = scopes dst.Audience = audience + dst.AppName = appName.String + dst.ProjectName = projectName.String + return dst, nil } } diff --git a/internal/query/device_auth_test.go b/internal/query/device_auth_test.go index f81a11b411..6f0f82b3be 100644 --- a/internal/query/device_auth_test.go +++ b/internal/query/device_auth_test.go @@ -24,8 +24,17 @@ const ( ` projections.device_auth_requests2.device_code,` + ` projections.device_auth_requests2.user_code,` + ` projections.device_auth_requests2.scopes,` + - ` projections.device_auth_requests2.audience` + - ` FROM projections.device_auth_requests2` + ` projections.device_auth_requests2.audience,` + + ` projections.apps7.name,` + + ` projections.projects4.name` + + ` FROM projections.device_auth_requests2` + + ` LEFT JOIN projections.apps7_oidc_configs` + + ` ON projections.device_auth_requests2.client_id = projections.apps7_oidc_configs.client_id` + + ` AND projections.device_auth_requests2.instance_id = projections.apps7_oidc_configs.instance_id` + + ` LEFT JOIN projections.apps7 ON projections.apps7_oidc_configs.app_id = projections.apps7.id` + + ` AND projections.apps7_oidc_configs.instance_id = projections.apps7.instance_id` + + ` LEFT JOIN projections.projects4 ON projections.apps7.project_id = projections.projects4.id` + + ` AND projections.apps7.instance_id = projections.projects4.instance_id` expectedDeviceAuthWhereUserCodeQueryC = expectedDeviceAuthQueryC + ` WHERE projections.device_auth_requests2.instance_id = $1` + ` AND projections.device_auth_requests2.user_code = $2` @@ -40,13 +49,17 @@ var ( "user-code", database.TextArray[string]{"a", "b", "c"}, []string{"projectID", "clientID"}, + "appName", + "projectName", } expectedDeviceAuth = &domain.AuthRequestDevice{ - ClientID: "client-id", - DeviceCode: "device1", - UserCode: "user-code", - Scopes: []string{"a", "b", "c"}, - Audience: []string{"projectID", "clientID"}, + ClientID: "client-id", + DeviceCode: "device1", + UserCode: "user-code", + Scopes: []string{"a", "b", "c"}, + Audience: []string{"projectID", "clientID"}, + AppName: "appName", + ProjectName: "projectName", } ) diff --git a/internal/query/idp_template.go b/internal/query/idp_template.go index d75e040ada..a63cb6f485 100644 --- a/internal/query/idp_template.go +++ b/internal/query/idp_template.go @@ -64,6 +64,7 @@ type OAuthIDPTemplate struct { UserEndpoint string Scopes database.TextArray[string] IDAttribute string + UsePKCE bool } type OIDCIDPTemplate struct { @@ -73,6 +74,7 @@ type OIDCIDPTemplate struct { Issuer string Scopes database.TextArray[string] IsIDTokenMapping bool + UsePKCE bool } type JWTIDPTemplate struct { @@ -278,6 +280,10 @@ var ( name: projection.OAuthIDAttributeCol, table: oauthIdpTemplateTable, } + OAuthUsePKCECol = Column{ + name: projection.OAuthUsePKCECol, + table: oauthIdpTemplateTable, + } ) var ( @@ -313,6 +319,10 @@ var ( name: projection.OIDCIDTokenMappingCol, table: oidcIdpTemplateTable, } + OIDCUsePKCECol = Column{ + name: projection.OIDCUsePKCECol, + table: oidcIdpTemplateTable, + } ) var ( @@ -879,6 +889,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se OAuthUserEndpointCol.identifier(), OAuthScopesCol.identifier(), OAuthIDAttributeCol.identifier(), + OAuthUsePKCECol.identifier(), // oidc OIDCIDCol.identifier(), OIDCIssuerCol.identifier(), @@ -886,6 +897,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se OIDCClientSecretCol.identifier(), OIDCScopesCol.identifier(), OIDCIDTokenMappingCol.identifier(), + OIDCUsePKCECol.identifier(), // jwt JWTIDCol.identifier(), JWTIssuerCol.identifier(), @@ -996,6 +1008,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se oauthUserEndpoint := sql.NullString{} oauthScopes := database.TextArray[string]{} oauthIDAttribute := sql.NullString{} + oauthUserPKCE := sql.NullBool{} oidcID := sql.NullString{} oidcIssuer := sql.NullString{} @@ -1003,6 +1016,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se oidcClientSecret := new(crypto.CryptoValue) oidcScopes := database.TextArray[string]{} oidcIDTokenMapping := sql.NullBool{} + oidcUserPKCE := sql.NullBool{} jwtID := sql.NullString{} jwtIssuer := sql.NullString{} @@ -1111,6 +1125,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se &oauthUserEndpoint, &oauthScopes, &oauthIDAttribute, + &oauthUserPKCE, // oidc &oidcID, &oidcIssuer, @@ -1118,6 +1133,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se &oidcClientSecret, &oidcScopes, &oidcIDTokenMapping, + &oidcUserPKCE, // jwt &jwtID, &jwtIssuer, @@ -1221,6 +1237,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se UserEndpoint: oauthUserEndpoint.String, Scopes: oauthScopes, IDAttribute: oauthIDAttribute.String, + UsePKCE: oauthUserPKCE.Bool, } } if oidcID.Valid { @@ -1231,6 +1248,7 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se Issuer: oidcIssuer.String, Scopes: oidcScopes, IsIDTokenMapping: oidcIDTokenMapping.Bool, + UsePKCE: oidcUserPKCE.Bool, } } if jwtID.Valid { @@ -1378,6 +1396,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec OAuthUserEndpointCol.identifier(), OAuthScopesCol.identifier(), OAuthIDAttributeCol.identifier(), + OAuthUsePKCECol.identifier(), // oidc OIDCIDCol.identifier(), OIDCIssuerCol.identifier(), @@ -1385,6 +1404,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec OIDCClientSecretCol.identifier(), OIDCScopesCol.identifier(), OIDCIDTokenMappingCol.identifier(), + OIDCUsePKCECol.identifier(), // jwt JWTIDCol.identifier(), JWTIssuerCol.identifier(), @@ -1500,6 +1520,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec oauthUserEndpoint := sql.NullString{} oauthScopes := database.TextArray[string]{} oauthIDAttribute := sql.NullString{} + oauthUserPKCE := sql.NullBool{} oidcID := sql.NullString{} oidcIssuer := sql.NullString{} @@ -1507,6 +1528,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec oidcClientSecret := new(crypto.CryptoValue) oidcScopes := database.TextArray[string]{} oidcIDTokenMapping := sql.NullBool{} + oidcUserPKCE := sql.NullBool{} jwtID := sql.NullString{} jwtIssuer := sql.NullString{} @@ -1615,6 +1637,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec &oauthUserEndpoint, &oauthScopes, &oauthIDAttribute, + &oauthUserPKCE, // oidc &oidcID, &oidcIssuer, @@ -1622,6 +1645,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec &oidcClientSecret, &oidcScopes, &oidcIDTokenMapping, + &oidcUserPKCE, // jwt &jwtID, &jwtIssuer, @@ -1724,6 +1748,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec UserEndpoint: oauthUserEndpoint.String, Scopes: oauthScopes, IDAttribute: oauthIDAttribute.String, + UsePKCE: oauthUserPKCE.Bool, } } if oidcID.Valid { @@ -1734,6 +1759,7 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec Issuer: oidcIssuer.String, Scopes: oidcScopes, IsIDTokenMapping: oidcIDTokenMapping.Bool, + UsePKCE: oidcUserPKCE.Bool, } } if jwtID.Valid { diff --git a/internal/query/idp_template_test.go b/internal/query/idp_template_test.go index 1c2310fee6..bee19e492a 100644 --- a/internal/query/idp_template_test.go +++ b/internal/query/idp_template_test.go @@ -39,6 +39,7 @@ var ( ` projections.idp_templates6_oauth2.user_endpoint,` + ` projections.idp_templates6_oauth2.scopes,` + ` projections.idp_templates6_oauth2.id_attribute,` + + ` projections.idp_templates6_oauth2.use_pkce,` + // oidc ` projections.idp_templates6_oidc.idp_id,` + ` projections.idp_templates6_oidc.issuer,` + @@ -46,6 +47,7 @@ var ( ` projections.idp_templates6_oidc.client_secret,` + ` projections.idp_templates6_oidc.scopes,` + ` projections.idp_templates6_oidc.id_token_mapping,` + + ` projections.idp_templates6_oidc.use_pkce,` + // jwt ` projections.idp_templates6_jwt.idp_id,` + ` projections.idp_templates6_jwt.issuer,` + @@ -167,6 +169,7 @@ var ( "user_endpoint", "scopes", "id_attribute", + "use_pkce", // oidc config "id_id", "issuer", @@ -174,6 +177,7 @@ var ( "client_secret", "scopes", "id_token_mapping", + "use_pkce", // jwt "idp_id", "issuer", @@ -281,6 +285,7 @@ var ( ` projections.idp_templates6_oauth2.user_endpoint,` + ` projections.idp_templates6_oauth2.scopes,` + ` projections.idp_templates6_oauth2.id_attribute,` + + ` projections.idp_templates6_oauth2.use_pkce,` + // oidc ` projections.idp_templates6_oidc.idp_id,` + ` projections.idp_templates6_oidc.issuer,` + @@ -288,6 +293,7 @@ var ( ` projections.idp_templates6_oidc.client_secret,` + ` projections.idp_templates6_oidc.scopes,` + ` projections.idp_templates6_oidc.id_token_mapping,` + + ` projections.idp_templates6_oidc.use_pkce,` + // jwt ` projections.idp_templates6_jwt.idp_id,` + ` projections.idp_templates6_jwt.issuer,` + @@ -410,6 +416,7 @@ var ( "user_endpoint", "scopes", "id_attribute", + "use_pkce", // oidc config "id_id", "issuer", @@ -417,6 +424,7 @@ var ( "client_secret", "scopes", "id_token_mapping", + "use_pkce", // jwt "idp_id", "issuer", @@ -564,6 +572,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { "user", database.TextArray[string]{"profile"}, "id-attribute", + true, // oidc nil, nil, @@ -571,6 +580,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -681,6 +691,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { UserEndpoint: "user", Scopes: []string{"profile"}, IDAttribute: "id-attribute", + UsePKCE: true, }, }, }, @@ -715,6 +726,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc "idp-id", "issuer", @@ -722,6 +734,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, database.TextArray[string]{"profile"}, true, + true, // jwt nil, nil, @@ -830,6 +843,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { ClientSecret: nil, Scopes: []string{"profile"}, IsIDTokenMapping: true, + UsePKCE: true, }, }, }, @@ -864,6 +878,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -871,6 +886,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt "idp-id", "issuer", @@ -1012,6 +1028,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1019,6 +1036,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1159,6 +1177,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1166,6 +1185,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1306,6 +1326,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1313,6 +1334,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1454,6 +1476,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1461,6 +1484,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1601,6 +1625,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1608,6 +1633,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1752,6 +1778,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1759,6 +1786,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -1920,6 +1948,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -1927,6 +1956,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2069,6 +2099,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2076,6 +2107,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2246,6 +2278,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2253,6 +2286,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2423,6 +2457,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2430,6 +2465,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2573,6 +2609,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2580,6 +2617,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2688,6 +2726,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2695,6 +2734,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2803,6 +2843,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -2810,6 +2851,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -2918,6 +2960,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { "user", database.TextArray[string]{"profile"}, "id-attribute", + true, // oidc nil, nil, @@ -2925,6 +2968,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt nil, nil, @@ -3033,6 +3077,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc "idp-id-oidc", "issuer", @@ -3040,6 +3085,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, database.TextArray[string]{"profile"}, true, + true, // jwt nil, nil, @@ -3148,6 +3194,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // oidc nil, nil, @@ -3155,6 +3202,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // jwt "idp-id-jwt", "issuer", @@ -3363,6 +3411,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { UserEndpoint: "user", Scopes: []string{"profile"}, IDAttribute: "id-attribute", + UsePKCE: true, }, }, { @@ -3387,6 +3436,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { ClientSecret: nil, Scopes: []string{"profile"}, IsIDTokenMapping: true, + UsePKCE: true, }, }, { diff --git a/internal/query/permission.go b/internal/query/permission.go index 591493375e..aeda33e541 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -12,7 +12,8 @@ import ( const ( // eventstore.permitted_orgs(instanceid text, userid text, perm text, filter_orgs text) - wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?))" + wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?))" + wherePermittedOrgsOrCurrentUserClause = "(" + wherePermittedOrgsClause + " OR %s = ?" + ")" ) // wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs @@ -35,3 +36,17 @@ func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, filterOrgId filterOrgIds, ) } + +func wherePermittedOrgsOrCurrentUser(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, userIdColum, permission string) sq.SelectBuilder { + userID := authz.GetCtxData(ctx).UserID + logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "user_id_colum", userIdColum, "permission", permission, "user_id", userID).Debug("permitted orgs check used") + + return query.Where( + fmt.Sprintf(wherePermittedOrgsOrCurrentUserClause, orgIDColumn, userIdColum), + authz.GetInstance(ctx).InstanceID(), + userID, + permission, + filterOrgIds, + userID, + ) +} diff --git a/internal/query/projection/idp_template.go b/internal/query/projection/idp_template.go index a5edcba169..47033e6bbb 100644 --- a/internal/query/projection/idp_template.go +++ b/internal/query/projection/idp_template.go @@ -70,6 +70,7 @@ const ( OAuthUserEndpointCol = "user_endpoint" OAuthScopesCol = "scopes" OAuthIDAttributeCol = "id_attribute" + OAuthUsePKCECol = "use_pkce" OIDCIDCol = "idp_id" OIDCInstanceIDCol = "instance_id" @@ -78,6 +79,7 @@ const ( OIDCClientSecretCol = "client_secret" OIDCScopesCol = "scopes" OIDCIDTokenMappingCol = "id_token_mapping" + OIDCUsePKCECol = "use_pkce" JWTIDCol = "idp_id" JWTInstanceIDCol = "instance_id" @@ -217,6 +219,7 @@ func (*idpTemplateProjection) Init() *old_handler.Check { handler.NewColumn(OAuthUserEndpointCol, handler.ColumnTypeText), handler.NewColumn(OAuthScopesCol, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(OAuthIDAttributeCol, handler.ColumnTypeText), + handler.NewColumn(OAuthUsePKCECol, handler.ColumnTypeBool, handler.Default(false)), }, handler.NewPrimaryKey(OAuthInstanceIDCol, OAuthIDCol), IDPTemplateOAuthSuffix, @@ -230,6 +233,7 @@ func (*idpTemplateProjection) Init() *old_handler.Check { handler.NewColumn(OIDCClientSecretCol, handler.ColumnTypeJSONB), handler.NewColumn(OIDCScopesCol, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(OIDCIDTokenMappingCol, handler.ColumnTypeBool, handler.Default(false)), + handler.NewColumn(OIDCUsePKCECol, handler.ColumnTypeBool, handler.Default(false)), }, handler.NewPrimaryKey(OIDCInstanceIDCol, OIDCIDCol), IDPTemplateOIDCSuffix, @@ -722,6 +726,7 @@ func (p *idpTemplateProjection) reduceOAuthIDPAdded(event eventstore.Event) (*ha handler.NewCol(OAuthUserEndpointCol, idpEvent.UserEndpoint), handler.NewCol(OAuthScopesCol, database.TextArray[string](idpEvent.Scopes)), handler.NewCol(OAuthIDAttributeCol, idpEvent.IDAttribute), + handler.NewCol(OAuthUsePKCECol, idpEvent.UsePKCE), }, handler.WithTableSuffix(IDPTemplateOAuthSuffix), ), @@ -813,6 +818,7 @@ func (p *idpTemplateProjection) reduceOIDCIDPAdded(event eventstore.Event) (*han handler.NewCol(OIDCClientSecretCol, idpEvent.ClientSecret), handler.NewCol(OIDCScopesCol, database.TextArray[string](idpEvent.Scopes)), handler.NewCol(OIDCIDTokenMappingCol, idpEvent.IsIDTokenMapping), + handler.NewCol(OIDCUsePKCECol, idpEvent.UsePKCE), }, handler.WithTableSuffix(IDPTemplateOIDCSuffix), ), @@ -1154,6 +1160,7 @@ func (p *idpTemplateProjection) reduceOldOIDCConfigAdded(event eventstore.Event) handler.NewCol(OIDCClientSecretCol, idpEvent.ClientSecret), handler.NewCol(OIDCScopesCol, database.TextArray[string](idpEvent.Scopes)), handler.NewCol(OIDCIDTokenMappingCol, true), + handler.NewCol(OIDCUsePKCECol, false), }, handler.WithTableSuffix(IDPTemplateOIDCSuffix), ), @@ -2253,6 +2260,9 @@ func reduceOAuthIDPChangedColumns(idpEvent idp.OAuthIDPChangedEvent) []handler.C if idpEvent.IDAttribute != nil { oauthCols = append(oauthCols, handler.NewCol(OAuthIDAttributeCol, *idpEvent.IDAttribute)) } + if idpEvent.UsePKCE != nil { + oauthCols = append(oauthCols, handler.NewCol(OAuthUsePKCECol, *idpEvent.UsePKCE)) + } return oauthCols } @@ -2273,6 +2283,9 @@ func reduceOIDCIDPChangedColumns(idpEvent idp.OIDCIDPChangedEvent) []handler.Col if idpEvent.IsIDTokenMapping != nil { oidcCols = append(oidcCols, handler.NewCol(OIDCIDTokenMappingCol, *idpEvent.IsIDTokenMapping)) } + if idpEvent.UsePKCE != nil { + oidcCols = append(oidcCols, handler.NewCol(OIDCUsePKCECol, *idpEvent.UsePKCE)) + } return oidcCols } diff --git a/internal/query/projection/idp_template_test.go b/internal/query/projection/idp_template_test.go index e8efa82f87..74adfe22c0 100644 --- a/internal/query/projection/idp_template_test.go +++ b/internal/query/projection/idp_template_test.go @@ -192,6 +192,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "userEndpoint": "user", "scopes": ["profile"], "idAttribute": "id-attribute", + "usePKCE": false, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -227,7 +228,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oauth2 (idp_id, instance_id, client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_oauth2 (idp_id, instance_id, client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -238,6 +239,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "user", database.TextArray[string]{"profile"}, "id-attribute", + false, }, }, }, @@ -265,6 +267,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "userEndpoint": "user", "scopes": ["profile"], "idAttribute": "id-attribute", + "usePKCE": true, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -300,7 +303,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oauth2 (idp_id, instance_id, client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_oauth2 (idp_id, instance_id, client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -311,6 +314,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "user", database.TextArray[string]{"profile"}, "id-attribute", + true, }, }, }, @@ -380,6 +384,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "userEndpoint": "user", "scopes": ["profile"], "idAttribute": "id-attribute", + "usePKCE": true, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -410,7 +415,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.idp_templates6_oauth2 SET (client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute) = ($1, $2, $3, $4, $5, $6, $7) WHERE (idp_id = $8) AND (instance_id = $9)", + expectedStmt: "UPDATE projections.idp_templates6_oauth2 SET (client_id, client_secret, authorization_endpoint, token_endpoint, user_endpoint, scopes, id_attribute, use_pkce) = ($1, $2, $3, $4, $5, $6, $7, $8) WHERE (idp_id = $9) AND (instance_id = $10)", expectedArgs: []interface{}{ "client_id", anyArg{}, @@ -419,6 +424,7 @@ func TestIDPTemplateProjection_reducesOAuth(t *testing.T) { "user", database.TextArray[string]{"profile"}, "id-attribute", + true, "idp-id", "instance-id", }, @@ -3081,6 +3087,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, "scopes": ["profile"], "idTokenMapping": true, + "usePKCE": true, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -3116,7 +3123,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -3125,6 +3132,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { anyArg{}, database.TextArray[string]{"profile"}, true, + true, }, }, }, @@ -3149,6 +3157,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, "scopes": ["profile"], "idTokenMapping": true, + "usePKCE": true, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -3184,7 +3193,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -3193,6 +3202,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { anyArg{}, database.TextArray[string]{"profile"}, true, + true, }, }, }, @@ -3260,6 +3270,7 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, "scopes": ["profile"], "idTokenMapping": true, + "usePKCE": true, "isCreationAllowed": true, "isLinkingAllowed": true, "isAutoCreation": true, @@ -3290,13 +3301,14 @@ func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.idp_templates6_oidc SET (client_id, client_secret, issuer, scopes, id_token_mapping) = ($1, $2, $3, $4, $5) WHERE (idp_id = $6) AND (instance_id = $7)", + expectedStmt: "UPDATE projections.idp_templates6_oidc SET (client_id, client_secret, issuer, scopes, id_token_mapping, use_pkce) = ($1, $2, $3, $4, $5, $6) WHERE (idp_id = $7) AND (instance_id = $8)", expectedArgs: []interface{}{ "client_id", anyArg{}, "issuer", database.TextArray[string]{"profile"}, true, + true, "idp-id", "instance-id", }, @@ -3810,7 +3822,7 @@ func TestIDPTemplateProjection_reducesOldConfig(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "idp-config-id", "instance-id", @@ -3819,6 +3831,7 @@ func TestIDPTemplateProjection_reducesOldConfig(t *testing.T) { anyArg{}, database.TextArray[string]{"profile"}, true, + false, }, }, }, @@ -3864,7 +3877,7 @@ func TestIDPTemplateProjection_reducesOldConfig(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedStmt: "INSERT INTO projections.idp_templates6_oidc (idp_id, instance_id, issuer, client_id, client_secret, scopes, id_token_mapping, use_pkce) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "idp-config-id", "instance-id", @@ -3873,6 +3886,7 @@ func TestIDPTemplateProjection_reducesOldConfig(t *testing.T) { anyArg{}, database.TextArray[string]{"profile"}, true, + false, }, }, }, diff --git a/internal/query/user.go b/internal/query/user.go index 0b00b45e03..3ee9a48463 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -655,7 +655,7 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, f UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }) if permissionCheckV2 { - query = wherePermittedOrgs(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), domain.PermissionUserRead) + query = wherePermittedOrgsOrCurrentUser(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), UserIDCol.identifier(), domain.PermissionUserRead) } stmt, args, err := query.ToSql() diff --git a/internal/repository/idp/oauth.go b/internal/repository/idp/oauth.go index 9b9b776082..e168459eea 100644 --- a/internal/repository/idp/oauth.go +++ b/internal/repository/idp/oauth.go @@ -18,6 +18,7 @@ type OAuthIDPAddedEvent struct { UserEndpoint string `json:"userEndpoint,omitempty"` Scopes []string `json:"scopes,omitempty"` IDAttribute string `json:"idAttribute,omitempty"` + UsePKCE bool `json:"usePKCE,omitempty"` Options } @@ -32,6 +33,7 @@ func NewOAuthIDPAddedEvent( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options Options, ) *OAuthIDPAddedEvent { return &OAuthIDPAddedEvent{ @@ -45,6 +47,7 @@ func NewOAuthIDPAddedEvent( UserEndpoint: userEndpoint, Scopes: scopes, IDAttribute: idAttribute, + UsePKCE: usePKCE, Options: options, } } @@ -82,6 +85,7 @@ type OAuthIDPChangedEvent struct { UserEndpoint *string `json:"userEndpoint,omitempty"` Scopes []string `json:"scopes,omitempty"` IDAttribute *string `json:"idAttribute,omitempty"` + UsePKCE *bool `json:"usePKCE,omitempty"` OptionChanges } @@ -158,6 +162,12 @@ func ChangeOAuthIDAttribute(idAttribute string) func(*OAuthIDPChangedEvent) { } } +func ChangeOAuthUsePKCE(usePKCE bool) func(*OAuthIDPChangedEvent) { + return func(e *OAuthIDPChangedEvent) { + e.UsePKCE = &usePKCE + } +} + func (e *OAuthIDPChangedEvent) Payload() interface{} { return e } diff --git a/internal/repository/idp/oidc.go b/internal/repository/idp/oidc.go index 0970129ceb..8c51baa6cf 100644 --- a/internal/repository/idp/oidc.go +++ b/internal/repository/idp/oidc.go @@ -16,6 +16,7 @@ type OIDCIDPAddedEvent struct { ClientSecret *crypto.CryptoValue `json:"clientSecret"` Scopes []string `json:"scopes,omitempty"` IsIDTokenMapping bool `json:"idTokenMapping,omitempty"` + UsePKCE bool `json:"usePKCE,omitempty"` Options } @@ -27,7 +28,7 @@ func NewOIDCIDPAddedEvent( clientID string, clientSecret *crypto.CryptoValue, scopes []string, - isIDTokenMapping bool, + isIDTokenMapping, usePKCE bool, options Options, ) *OIDCIDPAddedEvent { return &OIDCIDPAddedEvent{ @@ -39,6 +40,7 @@ func NewOIDCIDPAddedEvent( ClientSecret: clientSecret, Scopes: scopes, IsIDTokenMapping: isIDTokenMapping, + UsePKCE: usePKCE, Options: options, } } @@ -74,6 +76,7 @@ type OIDCIDPChangedEvent struct { ClientSecret *crypto.CryptoValue `json:"clientSecret,omitempty"` Scopes []string `json:"scopes,omitempty"` IsIDTokenMapping *bool `json:"idTokenMapping,omitempty"` + UsePKCE *bool `json:"usePKCE,omitempty"` OptionChanges } @@ -139,6 +142,12 @@ func ChangeOIDCIsIDTokenMapping(idTokenMapping bool) func(*OIDCIDPChangedEvent) } } +func ChangeOIDCUsePKCE(usePKCE bool) func(*OIDCIDPChangedEvent) { + return func(e *OIDCIDPChangedEvent) { + e.UsePKCE = &usePKCE + } +} + func (e *OIDCIDPChangedEvent) Payload() interface{} { return e } diff --git a/internal/repository/idpintent/intent.go b/internal/repository/idpintent/intent.go index 9ac1a875cc..27e6391f95 100644 --- a/internal/repository/idpintent/intent.go +++ b/internal/repository/idpintent/intent.go @@ -21,9 +21,10 @@ const ( type StartedEvent struct { eventstore.BaseEvent `json:"-"` - SuccessURL *url.URL `json:"successURL"` - FailureURL *url.URL `json:"failureURL"` - IDPID string `json:"idpId"` + SuccessURL *url.URL `json:"successURL"` + FailureURL *url.URL `json:"failureURL"` + IDPID string `json:"idpId"` + IDPArguments map[string]any `json:"idpArguments,omitempty"` } func NewStartedEvent( @@ -32,6 +33,7 @@ func NewStartedEvent( successURL, failureURL *url.URL, idpID string, + idpArguments map[string]any, ) *StartedEvent { return &StartedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -39,9 +41,10 @@ func NewStartedEvent( aggregate, StartedEventType, ), - SuccessURL: successURL, - FailureURL: failureURL, - IDPID: idpID, + SuccessURL: successURL, + FailureURL: failureURL, + IDPID: idpID, + IDPArguments: idpArguments, } } diff --git a/internal/repository/instance/idp.go b/internal/repository/instance/idp.go index 130a8d3d61..6ab60c0dd5 100644 --- a/internal/repository/instance/idp.go +++ b/internal/repository/instance/idp.go @@ -56,6 +56,7 @@ func NewOAuthIDPAddedEvent( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options idp.Options, ) *OAuthIDPAddedEvent { @@ -75,6 +76,7 @@ func NewOAuthIDPAddedEvent( userEndpoint, idAttribute, scopes, + usePKCE, options, ), } @@ -137,7 +139,7 @@ func NewOIDCIDPAddedEvent( clientID string, clientSecret *crypto.CryptoValue, scopes []string, - isIDTokenMapping bool, + isIDTokenMapping, usePKCE bool, options idp.Options, ) *OIDCIDPAddedEvent { @@ -155,6 +157,7 @@ func NewOIDCIDPAddedEvent( clientSecret, scopes, isIDTokenMapping, + usePKCE, options, ), } diff --git a/internal/repository/org/idp.go b/internal/repository/org/idp.go index 9510814e20..0070f71a95 100644 --- a/internal/repository/org/idp.go +++ b/internal/repository/org/idp.go @@ -56,6 +56,7 @@ func NewOAuthIDPAddedEvent( userEndpoint, idAttribute string, scopes []string, + usePKCE bool, options idp.Options, ) *OAuthIDPAddedEvent { @@ -75,6 +76,7 @@ func NewOAuthIDPAddedEvent( userEndpoint, idAttribute, scopes, + usePKCE, options, ), } @@ -137,7 +139,7 @@ func NewOIDCIDPAddedEvent( clientID string, clientSecret *crypto.CryptoValue, scopes []string, - isIDTokenMapping bool, + isIDTokenMapping, usePKCE bool, options idp.Options, ) *OIDCIDPAddedEvent { @@ -155,6 +157,7 @@ func NewOIDCIDPAddedEvent( clientSecret, scopes, isIDTokenMapping, + usePKCE, options, ), } diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 5539fabb12..d7dc18898b 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -561,6 +561,7 @@ Errors: AlreadyExists: Auth Request вече съществува NotExisting: Auth Request не съществува WrongLoginClient: Auth Request, създаден от друг клиент за влизане + AlreadyHandled: Заявката за удостоверяване вече е обработена OIDCSession: RefreshTokenInvalid: Токенът за опресняване е невалиден Token: @@ -571,8 +572,12 @@ Errors: AlreadyExists: SAMLRequest вече съществува NotExisting: SAMLRequest не съществува WrongLoginClient: SAMLRequest, създаден от друг клиент за влизане + AlreadyHandled: SAML заявката вече е обработена SAMLSession: InvalidClient: SAMLResponse не е издаден за този клиент + DeviceAuth: + NotFound: Заявката за авторизация на устройство не съществува + AlreadyHandled: Заявката за авторизация на устройство вече е обработена Feature: NotExisting: Функцията не съществува TypeNotSupported: Типът функция не се поддържа diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 6a21286a2c..80db4952f9 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -541,6 +541,7 @@ Errors: AlreadyExists: Požadavek na autentizaci již existuje NotExisting: Požadavek na autentizaci neexistuje WrongLoginClient: Požadavek na autentizaci vytvořen jiným klientem přihlášení + AlreadyHandled: Žádost o ověření již byla zpracována OIDCSession: RefreshTokenInvalid: Obnovovací token je neplatný Token: @@ -551,8 +552,12 @@ Errors: AlreadyExists: SAMLRequest již existuje NotExisting: SAMLRequest neexistuje WrongLoginClient: SAMLRequest vytvořený jiným přihlašovacím klientem + AlreadyHandled: SAML požadavek již byl zpracován SAMLSession: InvalidClient: Pro tohoto klienta nebyla vydána odpověď SAMLResponse + DeviceAuth: + NotFound: Žádost o autorizaci zařízení neexistuje + AlreadyHandled: Žádost o autorizaci zařízení již byla zpracována Feature: NotExisting: Funkce neexistuje TypeNotSupported: Typ funkce není podporován diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 117bbcb897..dcb3ac5c71 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Request existiert bereits NotExisting: Auth Request existiert nicht WrongLoginClient: Auth Request wurde von einem anderen Login-Client erstellt + AlreadyHandled: Auth Request wurde bereits bearbeitet OIDCSession: RefreshTokenInvalid: Refresh Token ist ungültig Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest existiert bereits NotExisting: SAMLRequest existiert nicht WrongLoginClient: SAMLRequest wurde con einem andere Login-Client erstellt + AlreadyHandled: SAMLRequest wurde bereits bearbeitet SAMLSession: InvalidClient: SAMLResponse wurde nicht für diesen Client ausgestellt + DeviceAuth: + NotFound: Die Geräteautorisierungsanforderung existiert nicht + AlreadyHandled: Die Geräteautorisierungsanforderung wurde bereits bearbeitet Feature: NotExisting: Feature existiert nicht TypeNotSupported: Feature Typ wird nicht unterstützt diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 618f4a500a..bd8d26d727 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -544,6 +544,7 @@ Errors: AlreadyExists: Auth Request already exists NotExisting: Auth Request does not exist WrongLoginClient: Auth Request created by other login client + AlreadyHandled: Auth Request has already been handled OIDCSession: RefreshTokenInvalid: Refresh Token is invalid Token: @@ -554,8 +555,12 @@ Errors: AlreadyExists: SAMLRequest already exists NotExisting: SAMLRequest does not exist WrongLoginClient: SAMLRequest created by other login client + AlreadyHandled: SAMLRequest has already been handled SAMLSession: InvalidClient: SAMLResponse was not issued for this client + DeviceAuth: + NotFound: Device Authorization Request does not exist + AlreadyHandled: Device Authorization Request has already been handled Feature: NotExisting: Feature does not exist TypeNotSupported: Feature type is not supported diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index b2c0c0a685..9f11b63964 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Request ya existe NotExisting: Auth Request no existe WrongLoginClient: Auth Request creado por otro cliente de inicio de sesión + AlreadyHandled: Auth Request ya ha sido procesada OIDCSession: RefreshTokenInvalid: El token de refresco no es válido Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest ya existe NotExisting: SAMLRequest no existe WrongLoginClient: SAMLRequest creado por otro cliente de inicio de sesión + AlreadyHandled: SAMLRequest ya ha sido procesada SAMLSession: InvalidClient: SAMLResponse no ha sido emitido para este cliente + DeviceAuth: + NotFound: La solicitud de autorización del dispositivo no existe + AlreadyHandled: La solicitud de autorización del dispositivo ya ha sido procesada Feature: NotExisting: La característica no existe TypeNotSupported: El tipo de característica no es compatible diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index eb143e592c..ff8393befc 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Request existe déjà NotExisting: Auth Request n'existe pas WrongLoginClient: Auth Request créé par un autre client de connexion + AlreadyHandled: Auth Request a déjà été traitée OIDCSession: RefreshTokenInvalid: Le jeton de rafraîchissement n'est pas valide Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest existe déjà NotExisting: SAMLRequest n'existe pas WrongLoginClient: SAMLRequest créé par un autre client de connexion + AlreadyHandled: SAMLRequest a déjà été traitée SAMLSession: InvalidClient: SAMLResponse n'a pas été émise pour ce client + DeviceAuth: + NotFound: La demande d'autorisation de l'appareil n'existe pas + AlreadyHandled: La demande d'autorisation de l'appareil a déjà été traitée Feature: NotExisting: La fonctionnalité n'existe pas TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index d33b5f47bc..b17c6a1225 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Az Auth Request már létezik NotExisting: Az Auth Request nem létezik WrongLoginClient: Az Auth Requestet egy másik bejelentkezési kliens hozta létre + AlreadyHandled: A hitelesítési kérelem már feldolgozva OIDCSession: RefreshTokenInvalid: Az Refresh Token érvénytelen Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: A SAMLRequest már létezik NotExisting: A SAMLRequest nem létezik WrongLoginClient: A SAMLRequest egy másik bejelentkezési ügyfél által létrehozott + AlreadyHandled: A SAMLRequest már feldolgozva SAMLSession: InvalidClient: SAMLResponse nem lett kiadva ehhez az ügyfélhez + DeviceAuth: + NotFound: Az eszközengedélyezési kérelem nem létezik + AlreadyHandled: Az eszközengedélyezési kérelem már feldolgozva Feature: NotExisting: A funkció nem létezik TypeNotSupported: A funkció típusa nem támogatott diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 449f91ffdc..56a454e71d 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Permintaan Otentikasi sudah ada NotExisting: Permintaan Otentikasi tidak ada WrongLoginClient: Permintaan Otentikasi dibuat oleh klien login lain + AlreadyHandled: Permintaan Otentikasi sudah ditangani OIDCSession: RefreshTokenInvalid: Token Penyegaran tidak valid Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest sudah ada NotExisting: SAMLRequest tidak ada WrongLoginClient: SAMLRequest dibuat oleh klien login lainnya + AlreadyHandled: SAMLRequest sudah ditangani SAMLSession: InvalidClient: SAMLResponse tidak dikeluarkan untuk klien ini + DeviceAuth: + NotFound: Permintaan Otorisasi Perangkat tidak ada + AlreadyHandled: Permintaan Otorisasi Perangkat sudah ditangani Feature: NotExisting: Fitur tidak ada TypeNotSupported: Jenis fitur tidak didukung diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index a94925a906..6713abf2e1 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Request esiste già NotExisting: Auth Request non esiste WrongLoginClient: Auth Request creato da un altro client di accesso + AlreadyHandled: Auth Request è già stata gestita OIDCSession: RefreshTokenInvalid: Refresh Token non è valido Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest esiste già NotExisting: SAMLRequest non esiste WrongLoginClient: SAMLRequest creato da un altro client di accesso + AlreadyHandled: SAMLRequest è già stata gestita SAMLSession: InvalidClient: SAMLResponse non è stato emesso per questo client + DeviceAuth: + NotFound: La richiesta di autorizzazione del dispositivo non esiste + AlreadyHandled: La richiesta di autorizzazione del dispositivo è già stata gestita Feature: NotExisting: La funzionalità non esiste TypeNotSupported: Il tipo di funzionalità non è supportato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 582e5037bb..f57d0f6661 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -53,6 +53,7 @@ Errors: NotFound: SMS構成が見つかりません AlreadyActive: このSMS構成はすでにアクティブです AlreadyDeactivated: このSMS構成はすでに非アクティブです + NotExternalVerification: SMS構成は外部のコード検証をサポートしていません SMTP: NotEmailMessage: メッセージは EmailMessage ではありません RequiredAttributes: 件名、受信者、コンテンツを設定する必要がありますが、一部またはすべてが空です @@ -98,16 +99,23 @@ Errors: Profile: NotFound: プロファイルが見つかりません NotChanged: プロファイルが変更されていません + Empty: プロファイルが空です + FirstNameEmpty: 名前が空です + LastNameEmpty: 姓が空です Invalid: プロファイルデータが無効です Email: NotFound: メールアドレスが見つかりません Invalid: 無効なメールアドレスです AlreadyVerified: メールアドレスはすでに検証済みです NotChanged: メールアドレスが変更されていません + Empty: メールアドレスが空です + IDMissing: メールアドレスIDが不足しています Phone: NotFound: 電話番号が見つかりません Invalid: 無効な電話番号です AlreadyVerified: 電話番号はすでに認証済みです + Empty: 電話番号が空です + NotChanged: 電話番号が変更されていません Address: NotFound: 住所が見つかりません NotChanged: 住所は変更されていません @@ -129,6 +137,7 @@ Errors: Username: AlreadyExists: ユーザー名はすでに使用されています Reserved: ユーザー名はすでに使用されています + Empty: ユーザー名が空です Code: Empty: コードは空です NotFound: コードが見つかりません @@ -276,6 +285,9 @@ Errors: NotFound: 通知ポリシーが見つかりません NotChanged: 通知ポリシーは変更されていません AlreadyExists: 通知ポリシーはすでに存在しています + LabelPolicy: + NotFound: プライベートラベルポリシーが見つかりません + NotChanged: プライベートラベルポリシーが変更されていません Project: ProjectIDMissing: プロジェクトIDがありません AlreadyExists: プロジェクトはすでに組織に存在しています @@ -532,6 +544,7 @@ Errors: AlreadyExists: AuthRequestはすでに存在する NotExisting: AuthRequest が存在しません WrongLoginClient: 他のログインクライアントによって作成された AuthRequest + AlreadyHandled: 認証リクエストは既に処理済みです OIDCSession: RefreshTokenInvalid: 無効なリフレッシュトークンです Token: @@ -542,8 +555,12 @@ Errors: AlreadyExists: SAMLリクエストはすでに存在します NotExisting: SAMLリクエストが存在しません WrongLoginClient: 他のログイン クライアントによって作成された SAMLRequest + AlreadyHandled: SAMLリクエストは既に処理済みです SAMLSession: InvalidClient: このクライアントに対してSAMLResponseは発行されませんでした + DeviceAuth: + NotFound: デバイス認証リクエストが存在しません + AlreadyHandled: デバイス認証リクエストは既に処理済みです Feature: NotExisting: 機能が存在しません TypeNotSupported: 機能タイプはサポートされていません @@ -710,6 +727,10 @@ EventTypes: check: succeeded: パスワードチェックの成功 failed: パスワードチェックの失敗 + change: + sent: パスワード変更メールを送信しました + hash: + updated: パスワードハッシュが更新されました externallogin: check: succeeded: 外部ログインの成功 @@ -813,10 +834,6 @@ EventTypes: check: succeeded: パスワードチェックの成功 failed: パスワードチェックの失敗 - change: - sent: パスワード変更を送信しました - hash: - updated: パスワードハッシュが更新されました phone: changed: 電話番号の変更 verified: 電話番号の検証 @@ -825,7 +842,7 @@ EventTypes: code: added: 電話番号コードの生成 sent: 電話番号コードの送信 - removed: 電話番号の削除 + profile: changed: ユーザープロファイルの変更 address: @@ -1048,6 +1065,9 @@ EventTypes: check: succeeded: OIDCクライアントシークレットチェックの成功 failed: OIDCクライアントシークレットチェックの失敗 + key: + added: OIDCアプリキーの追加 + removed: OIDCアプリキーの削除 api: secret: check: @@ -1345,6 +1365,7 @@ EventTypes: code: added: 電話番号の確認コードが生成されました sent: 電話番号の確認コードが送信されました + web_key: added: Web キーが追加されました activated: Web キーが有効化されました diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 741f075ca2..d238142e01 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -544,6 +544,7 @@ Errors: AlreadyExists: 인증 요청이 이미 존재합니다 NotExisting: 인증 요청이 존재하지 않습니다 WrongLoginClient: 다른 로그인 클라이언트에 의해 생성된 인증 요청 + AlreadyHandled: 인증 요청이 이미 처리되었습니다 OIDCSession: RefreshTokenInvalid: 새로 고침 토큰이 유효하지 않습니다 Token: @@ -554,8 +555,12 @@ Errors: AlreadyExists: SAMLRequest가 이미 존재합니다 NotExisting: SAMLRequest가 존재하지 않습니다 WrongLoginClient: 다른 로그인 클라이언트가 생성한 SAMLRequest + AlreadyHandled: SAML 요청이 이미 처리되었습니다 SAMLSession: InvalidClient: 이 클라이언트에 대해 SAMLResponse가 발행되지 않았습니다. + DeviceAuth: + NotFound: 장치 인증 요청이 존재하지 않습니다 + AlreadyHandled: 장치 인증 요청이 이미 처리되었습니다 Feature: NotExisting: 기능이 존재하지 않습니다 TypeNotSupported: 기능 유형이 지원되지 않습니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index be205f5380..898ed67360 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -542,6 +542,7 @@ Errors: AlreadyExists: Барањето за автентикација веќе постои NotExisting: Барањето за автентикација не постои WrongLoginClient: Барањето за автификација беше креирано од друг клиент за најавување + AlreadyHandled: Барањето за автентикација е веќе обработено OIDCSession: RefreshTokenInvalid: Токенот за освежување е неважечки Token: @@ -552,8 +553,12 @@ Errors: AlreadyExists: SAMLRequest веќе постои NotExisting: SAMLRequest не постои WrongLoginClient: SAML Барање создадено од друг клиент за најавување + AlreadyHandled: SAML барањето е веќе обработено SAMLSession: InvalidClient: SAMLResponse не беше издаден за овој клиент + DeviceAuth: + NotFound: Барањето за авторизација на уредот не постои + AlreadyHandled: Барањето за авторизација на уредот е веќе обработено Feature: NotExisting: Функцијата не постои TypeNotSupported: Типот на функција не е поддржан diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 262b24f2fb..882c58a4f2 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Verzoek bestaat al NotExisting: Auth Verzoek bestaat niet WrongLoginClient: Auth Verzoek aangemaakt door andere login client + AlreadyHandled: Authenticatieverzoek is al verwerkt OIDCSession: RefreshTokenInvalid: Refresh Token is ongeldig Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest bestaat al NotExisting: SAMLRequest bestaat niet WrongLoginClient: SAMLRequest aangemaakt door andere login client + AlreadyHandled: SAML-verzoek is al verwerkt SAMLSession: InvalidClient: SAMLResponse is niet uitgegeven voor deze client + DeviceAuth: + NotFound: Apparaatautorisatieverzoek bestaat niet + AlreadyHandled: Apparaatautorisatieverzoek is al verwerkt Feature: NotExisting: Functie bestaat niet TypeNotSupported: Functie type wordt niet ondersteund diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index cc511ec0c0..13125bc2a9 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Auth Request już istnieje NotExisting: Auth Request nie istnieje WrongLoginClient: Auth Request utworzony przez innego klienta logowania + AlreadyHandled: Żądanie uwierzytelnienia zostało już obsłużone OIDCSession: RefreshTokenInvalid: Refresh Token jest nieprawidłowy Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest już istnieje NotExisting: SAMLRequest nie istnieje WrongLoginClient: SAMLRequest utworzony przez innego klienta logowania + AlreadyHandled: Żądanie SAML zostało już obsłużone SAMLSession: InvalidClient: SAMLResponse nie został wydany dla tego klienta + DeviceAuth: + NotFound: Żądanie autoryzacji urządzenia nie istnieje + AlreadyHandled: Żądanie autoryzacji urządzenia zostało już obsłużone Feature: NotExisting: Funkcja nie istnieje TypeNotSupported: Typ funkcji nie jest obsługiwany diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index bd106ab259..4ab3573c2b 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -542,6 +542,7 @@ Errors: AlreadyExists: A solicitação de autenticação já existe NotExisting: A solicitação de autenticação não existe WrongLoginClient: A solicitação de autenticação foi criada por outro cliente de login + AlreadyHandled: O pedido de autenticação já foi processado OIDCSession: RefreshTokenInvalid: O Refresh Token é inválido Token: @@ -552,8 +553,12 @@ Errors: AlreadyExists: O SAMLRequest já existe NotExisting: O SAMLRequest não existe WrongLoginClient: SAMLRequest criado por outro cliente de login + AlreadyHandled: O pedido SAML já foi processado SAMLSession: InvalidClient: O SAMLResponse não foi emitido para este cliente + DeviceAuth: + NotFound: O pedido de autorização do dispositivo não existe + AlreadyHandled: O pedido de autorização do dispositivo já foi processado Feature: NotExisting: O recurso não existe TypeNotSupported: O tipo de recurso não é compatível diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 8c4b079f2e..64a8ef8013 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -532,6 +532,7 @@ Errors: AlreadyExists: Запрос на аутентификацию уже существует NotExisting: Запрос на аутентификацию не существует WrongLoginClient: Запрос на аутентификацию, созданный другим клиентом входа + AlreadyHandled: Запрос аутентификации уже обработан OIDCSession: RefreshTokenInvalid: Маркер обновления недействителен Token: @@ -542,8 +543,12 @@ Errors: AlreadyExists: SAMLRequest уже существует NotExisting: SAMLRequest не существует WrongLoginClient: SAMLRequest создан другим клиентом входа + AlreadyHandled: Запрос SAML уже обработан SAMLSession: InvalidClient: SAMLResponse не был отправлен для этого клиента + DeviceAuth: + NotFound: Запрос авторизации устройства не существует + AlreadyHandled: Запрос авторизации устройства уже обработан Feature: NotExisting: ункция не существует TypeNotSupported: Тип объекта не поддерживается diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index e31095b78c..2c292976d3 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: Autentiseringsbegäran finns redan NotExisting: Autentiseringsbegäran existerar inte WrongLoginClient: Autentiseringsbegäran skapad av annan inloggningsklient + AlreadyHandled: Autentiseringsbegäran har redan hanterats OIDCSession: RefreshTokenInvalid: Uppdateringstoken är ogiltig Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest finns redan NotExisting: SAMLRequest finns inte WrongLoginClient: SAMLRequest skapad av annan inloggningsklient + AlreadyHandled: SAML-begäran har redan hanterats SAMLSession: InvalidClient: SAMLResponse utfärdades inte för den här klienten + DeviceAuth: + NotFound: Begäran om enhetsauktorisering finns inte + AlreadyHandled: Begäran om enhetsauktorisering har redan hanterats Feature: NotExisting: Funktionen existerar inte TypeNotSupported: Funktionstypen stöds inte diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index b30609da90..d4b36df7ff 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -543,6 +543,7 @@ Errors: AlreadyExists: AuthRequest已经存在 NotExisting: AuthRequest不存在 WrongLoginClient: 其他登录客户端创建的AuthRequest + AlreadyHandled: 身份验证请求已被处理 OIDCSession: RefreshTokenInvalid: Refresh Token 无效 Token: @@ -553,8 +554,12 @@ Errors: AlreadyExists: SAMLRequest 已存在 NotExisting: SAMLRequest不存在 WrongLoginClient: 其他登录客户端创建的 SAMLRequest + AlreadyHandled: SAML请求已被处理 SAMLSession: InvalidClient: 未向该客户端发出 SAMLResponse + DeviceAuth: + NotFound: 设备授权请求不存在 + AlreadyHandled: 设备授权请求已被处理 Feature: NotExisting: 功能不存在 TypeNotSupported: 不支持功能类型 diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 020fa60e97..4050e9cee0 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -6124,6 +6124,8 @@ message AddGenericOAuthProviderRequest { } ]; zitadel.idp.v1.Options provider_options = 9; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OAuth2 flow. + bool use_pkce = 10; } message AddGenericOAuthProviderResponse { @@ -6191,6 +6193,8 @@ message UpdateGenericOAuthProviderRequest { } ]; zitadel.idp.v1.Options provider_options = 10; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OAuth2 flow. + bool use_pkce = 11; } message UpdateGenericOAuthProviderResponse { @@ -6234,6 +6238,8 @@ message AddGenericOIDCProviderRequest { ]; zitadel.idp.v1.Options provider_options = 6; bool is_id_token_mapping = 7; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OIDC flow. + bool use_pkce = 8; } message AddGenericOIDCProviderResponse { @@ -6285,6 +6291,8 @@ message UpdateGenericOIDCProviderRequest { ]; zitadel.idp.v1.Options provider_options = 7; bool is_id_token_mapping = 8; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OIDC flow. + bool use_pkce = 9; } message UpdateGenericOIDCProviderResponse { @@ -8782,6 +8790,7 @@ message ListIAMMembersRequest { zitadel.v1.ListQuery query = 1; //criteria the client is looking for repeated zitadel.member.v1.SearchQuery queries = 2; + zitadel.member.v1.MemberFieldColumnName sorting_column = 3; } message ListIAMMembersResponse { diff --git a/proto/zitadel/idp.proto b/proto/zitadel/idp.proto index eecb9eae98..82e32aa873 100644 --- a/proto/zitadel/idp.proto +++ b/proto/zitadel/idp.proto @@ -338,6 +338,8 @@ message OAuthConfig { description: "defines how the attribute is called where ZITADEL can get the id of the user"; } ]; + // Defines if the Proof Key for Code Exchange (PKCE) is used for the authorization code flow. + bool use_pkce = 7; } message GenericOIDCConfig { @@ -365,6 +367,12 @@ message GenericOIDCConfig { description: "if true, provider information get mapped from the id token, not from the userinfo endpoint"; } ]; + // Defines if the Proof Key for Code Exchange (PKCE) is used for the authorization code flow. + bool use_pkce = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + } + ]; } message GitHubConfig { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 818600efee..e69e331f87 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -12545,6 +12545,8 @@ message AddGenericOAuthProviderRequest { } ]; zitadel.idp.v1.Options provider_options = 9; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OAuth2 flow. + bool use_pkce = 10; } message AddGenericOAuthProviderResponse { @@ -12612,6 +12614,8 @@ message UpdateGenericOAuthProviderRequest { } ]; zitadel.idp.v1.Options provider_options = 10; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OAuth2 flow. + bool use_pkce = 11; } message UpdateGenericOAuthProviderResponse { @@ -12655,6 +12659,8 @@ message AddGenericOIDCProviderRequest { ]; zitadel.idp.v1.Options provider_options = 6; bool is_id_token_mapping = 7; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OIDC flow. + bool use_pkce = 8; } message AddGenericOIDCProviderResponse { @@ -12706,6 +12712,8 @@ message UpdateGenericOIDCProviderRequest { ]; zitadel.idp.v1.Options provider_options = 7; bool is_id_token_mapping = 8; + // Enable the use of Proof Key for Code Exchange (PKCE) for the OIDC flow. + bool use_pkce = 9; } message UpdateGenericOIDCProviderResponse { diff --git a/proto/zitadel/member.proto b/proto/zitadel/member.proto index c3351a99d3..f4ec080433 100644 --- a/proto/zitadel/member.proto +++ b/proto/zitadel/member.proto @@ -143,3 +143,11 @@ message UserIDQuery { } ]; } + +enum MemberFieldColumnName { + MEMBER_FIELD_NAME_UNSPECIFIED = 0; + MEMBER_FIELD_NAME_USER_ID=1; + MEMBER_FIELD_NAME_CREATION_DATE = 2; + MEMBER_FIELD_NAME_CHANGE_DATE=3; + MEMBER_FIELD_NAME_USER_RESOURCE_OWNER=4; +} \ No newline at end of file diff --git a/proto/zitadel/object/v2/object.proto b/proto/zitadel/object/v2/object.proto index 5a63ece19b..339966d3b2 100644 --- a/proto/zitadel/object/v2/object.proto +++ b/proto/zitadel/object/v2/object.proto @@ -78,6 +78,8 @@ message Details { example: "\"69629023906488334\""; } ]; + //creation_date is the timestamp where the first operation on the object was made + google.protobuf.Timestamp creation_date = 4; } message ListDetails { diff --git a/proto/zitadel/object/v2beta/object.proto b/proto/zitadel/object/v2beta/object.proto index df8e77319a..fd3190ccd6 100644 --- a/proto/zitadel/object/v2beta/object.proto +++ b/proto/zitadel/object/v2beta/object.proto @@ -78,6 +78,8 @@ message Details { example: "\"69629023906488334\""; } ]; + //creation_date is the timestamp where the first operation on the object was made + google.protobuf.Timestamp creation_date = 4; } message ListDetails { diff --git a/proto/zitadel/oidc/v2/authorization.proto b/proto/zitadel/oidc/v2/authorization.proto index c0ad751624..6cdf55de64 100644 --- a/proto/zitadel/oidc/v2/authorization.proto +++ b/proto/zitadel/oidc/v2/authorization.proto @@ -114,4 +114,17 @@ enum ErrorReason { ERROR_REASON_REQUEST_NOT_SUPPORTED = 14; ERROR_REASON_REQUEST_URI_NOT_SUPPORTED = 15; ERROR_REASON_REGISTRATION_NOT_SUPPORTED = 16; +} + +message DeviceAuthorizationRequest { + // The unique identifier of the device authorization request to be used for authorizing or denying the request. + string id = 1; + // The client_id of the application that initiated the device authorization request. + string client_id = 2; + // The scopes requested by the application. + repeated string scope = 3; + // Name of the client application. + string app_name = 4; + // Name of the project the client application is part of. + string project_name = 5; } \ No newline at end of file diff --git a/proto/zitadel/oidc/v2/oidc_service.proto b/proto/zitadel/oidc/v2/oidc_service.proto index 3c36057afa..e305cbfe9a 100644 --- a/proto/zitadel/oidc/v2/oidc_service.proto +++ b/proto/zitadel/oidc/v2/oidc_service.proto @@ -147,6 +147,58 @@ service OIDCService { }; }; } + + // Get device authorization request + // + // Get the device authorization based on the provided "user code". + // This will return the device authorization request, which contains the device authorization id + // that is required to authorize the request once the user signed in or to deny it. + rpc GetDeviceAuthorizationRequest(GetDeviceAuthorizationRequestRequest) returns (GetDeviceAuthorizationRequestResponse) { + option (google.api.http) = { + get: "/v2/oidc/device_authorization/{user_code}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Authorize or deny device authorization + // + // Authorize or deny the device authorization request based on the provided device authorization id. + rpc AuthorizeOrDenyDeviceAuthorization(AuthorizeOrDenyDeviceAuthorizationRequest) returns (AuthorizeOrDenyDeviceAuthorizationResponse) { + option (google.api.http) = { + post: "/v2/oidc/device_authorization/{device_authorization_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + } message GetAuthRequestRequest { @@ -217,3 +269,42 @@ message CreateCallbackResponse { ]; } +message GetDeviceAuthorizationRequestRequest { + // The user_code returned by the device authorization request and provided to the user by the device. + string user_code = 1 [ + (validate.rules).string = {len: 9}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 9; + max_length: 9; + example: "\"K9LV-3DMQ\""; + } + ]; +} + +message GetDeviceAuthorizationRequestResponse { + DeviceAuthorizationRequest device_authorization_request = 1; +} + +message AuthorizeOrDenyDeviceAuthorizationRequest { + // The device authorization id returned when submitting the user code. + string device_authorization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + } + ]; + + // The decision of the user to authorize or deny the device authorization request. + oneof decision { + option (validate.required) = true; + // To authorize the device authorization request, the user's session must be provided. + Session session = 2; + // Deny the device authorization request. + Deny deny = 3; + } +} + +message Deny{} + +message AuthorizeOrDenyDeviceAuthorizationResponse {} \ No newline at end of file diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 8e199aa505..b5852b1fec 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -689,6 +689,7 @@ message ListIAMMembersRequest { zitadel.v1.ListQuery query = 1; string instance_id = 2; repeated zitadel.member.v1.SearchQuery queries = 3; + zitadel.member.v1.MemberFieldColumnName sorting_column = 4; } message ListIAMMembersResponse {