From d4678558708542719c5d3cc48c48d3053f37800c Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 26 Jun 2026 23:35:26 +0200 Subject: [PATCH] feat: add ability to skip consent for client --- backend/internal/dto/oidc_dto.go | 2 + backend/internal/model/oidc.go | 1 + .../internal/oidc/authorization_service.go | 13 ++- .../oidc/authorization_service_test.go | 86 +++++++++++++++++++ backend/internal/oidc/device_service.go | 3 +- backend/internal/service/e2etest_service.go | 13 ++- backend/internal/service/oidc_service.go | 1 + ...26120000_oidc_client_skip_consent.down.sql | 1 + ...0626120000_oidc_client_skip_consent.up.sql | 1 + ...26120000_oidc_client_skip_consent.down.sql | 5 ++ ...0626120000_oidc_client_skip_consent.up.sql | 5 ++ frontend/messages/en.json | 2 + frontend/src/lib/types/oidc.type.ts | 1 + .../oidc-clients/oidc-client-form.svelte | 8 ++ tests/data.ts | 6 ++ tests/resources/export/database.json | 39 +++++++-- tests/specs/apps-dashboard.spec.ts | 6 +- tests/specs/oidc.spec.ts | 46 ++++++++++ 18 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql create mode 100644 backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql create mode 100644 backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql create mode 100644 backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 6982fef3..adc2f6c6 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -18,6 +18,7 @@ type OidcClientDto struct { IsPublic bool `json:"isPublic"` PkceEnabled bool `json:"pkceEnabled"` RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"` + SkipConsent bool `json:"skipConsent"` Credentials OidcClientCredentialsDto `json:"credentials"` IsGroupRestricted bool `json:"isGroupRestricted"` } @@ -40,6 +41,7 @@ type OidcClientUpdateDto struct { PkceEnabled bool `json:"pkceEnabled"` RequiresReauthentication bool `json:"requiresReauthentication"` RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"` + SkipConsent bool `json:"skipConsent"` Credentials OidcClientCredentialsDto `json:"credentials"` LaunchURL *string `json:"launchURL" binding:"omitempty,url"` HasLogo bool `json:"hasLogo"` diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index bd9ca046..68882dc9 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -32,6 +32,7 @@ type OidcClient struct { PkceEnabled bool `sortable:"true" filterable:"true"` RequiresReauthentication bool `sortable:"true" filterable:"true"` RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"` + SkipConsent bool `sortable:"true" filterable:"true"` Credentials OidcClientCredentials LaunchURL *string IsGroupRestricted bool `sortable:"true" filterable:"true"` diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go index 93590d6d..7ac07b90 100644 --- a/backend/internal/oidc/authorization_service.go +++ b/backend/internal/oidc/authorization_service.go @@ -57,6 +57,15 @@ func (p promptValues) has(value string) bool { return slices.Contains(p, value) } +// consentRequired reports whether the user has to be shown the consent screen +// A client can be configured to skip consent so trusted first-party apps are not prompted on every new authorization, but an explicit prompt=consent always forces the screen regardless of that setting +func consentRequired(hasAlreadyAuthorizedClient, clientSkipsConsent bool, prompt promptValues) bool { + if prompt.has("consent") { + return true + } + return !hasAlreadyAuthorizedClient && !clientSkipsConsent +} + // authorizeInput is the authorization request as provided by the handler. type authorizeInput struct { userID string @@ -236,7 +245,7 @@ func (s *authorizationService) resolveRequirements(ctx context.Context, req auth } requirements := interactionRequirements{ - ConsentRequired: !hasAlreadyAuthorizedClient || req.prompt.has("consent"), + ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, req.client.SkipConsent, req.prompt), ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired, AccountSelectionRequired: req.prompt.has("select_account"), AuthenticationRequired: false, @@ -568,7 +577,7 @@ func (s *authorizationService) interactionRequirementsForUser(ctx context.Contex } return interactionRequirements{ - ConsentRequired: !hasAlreadyAuthorizedClient || prompt.has("consent"), + ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, interactionSession.Client.SkipConsent, prompt), ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired, AccountSelectionRequired: prompt.has("select_account"), AuthenticationRequired: false, diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go index 6add3972..03b5a8a0 100644 --- a/backend/internal/oidc/authorization_service_test.go +++ b/backend/internal/oidc/authorization_service_test.go @@ -913,3 +913,89 @@ func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Valu func stringPointer(value string) *string { return &value } + +func TestConsentRequired(t *testing.T) { + tests := []struct { + name string + hasAlreadyAuthorized bool + skipConsent bool + prompt string + want bool + }{ + {"new client without skip requires consent", false, false, "", true}, + {"returning client without skip skips consent", true, false, "", false}, + {"new client with skip skips consent", false, true, "", false}, + {"returning client with skip skips consent", true, true, "", false}, + {"prompt=consent forces consent despite skip", false, true, "consent", true}, + {"prompt=consent forces consent for returning client", true, false, "consent", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := consentRequired(tt.hasAlreadyAuthorized, tt.skipConsent, newPromptValues(tt.prompt)) + require.Equal(t, tt.want, got) + }) + } +} + +// A client with SkipConsent must be granted without a consent interaction even on the first authorization, while consent is still recorded so the user can later see and revoke the client +func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + auditLogger := &fakeAuditLogger{} + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger) + + const ( + userID = "test-user" + clientID = "test-client" + ) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error) + + // No prior UserAuthorizedOidcClient record exists, so a client without skip-consent would require the consent screen here + requester := newTestAuthorizeRequester("skip-consent-request", clientID, "") + requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}} + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: requester, + meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"}, + }) + require.NoError(t, err) + require.False(t, authorization.RequiresInteraction) + require.NotNil(t, authorization.Session) + + var count int64 + require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).Where("user_id = ? AND client_id = ?", userID, clientID).Count(&count).Error) + require.Equal(t, int64(1), count) + + require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events) +} + +// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent +func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil) + + const ( + userID = "test-user" + clientID = "test-client" + ) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error) + require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error) + + requester := newTestAuthorizeRequester("skip-consent-prompt-request", clientID, "consent") + requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}} + + authorization, err := service.authorize(t.Context(), authorizeInput{ + userID: userID, + authenticationTime: time.Now().UTC(), + requester: requester, + meta: requestMeta{}, + }) + require.NoError(t, err) + require.True(t, authorization.RequiresInteraction) + + interactionSession, err := newInteractionSessionService(db).get(t.Context(), authorization.InteractionID) + require.NoError(t, err) + require.True(t, interactionSession.ConsentRequired) +} diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go index 29661203..882395a3 100644 --- a/backend/internal/oidc/device_service.go +++ b/backend/internal/oidc/device_service.go @@ -157,7 +157,8 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID if err != nil { return nil, err } - authorizationRequired = !hasAuthorizedClient + // The device flow has no per-request prompt parameter, so consent depends only on prior authorization and the client's skip-consent setting + authorizationRequired = consentRequired(hasAuthorizedClient, client.SkipConsent, nil) } return &dto.DeviceCodeInfoDto{ diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 3af37e8a..ffcee99c 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -257,6 +257,17 @@ func (s *TestService) SeedDatabase(baseURL string) error { CallbackURLs: model.UrlList{"http://par-client.localhost/auth/callback"}, CreatedByID: new(users[0].ID), }, + { + Base: model.Base{ + ID: "e1f2a3b4-c5d6-7890-abcd-ef0000000002", + }, + Name: "Skip Consent Client", + Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY + CallbackURLs: model.UrlList{"http://skip-consent.localhost/auth/callback"}, + CreatedByID: new(users[0].ID), + // Trusted client that bypasses the consent screen by default + SkipConsent: true, + }, } for _, client := range oidcClients { if err := tx.Create(&client).Error; err != nil { @@ -770,7 +781,7 @@ func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite. RequestedAt: requestedAt, AuthTime: requestedAt, Subject: session.UserID, - Issuer: common.EnvConfig.AppURL, + Issuer: common.EnvConfig.AppURL, }, } oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt) diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index e8fd2d61..c4d549e6 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -220,6 +220,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.PkceEnabled = input.IsPublic || input.PkceEnabled client.RequiresReauthentication = input.RequiresReauthentication client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests + client.SkipConsent = input.SkipConsent client.LaunchURL = input.LaunchURL client.IsGroupRestricted = input.IsGroupRestricted diff --git a/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql new file mode 100644 index 00000000..f538edd8 --- /dev/null +++ b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients DROP COLUMN skip_consent; diff --git a/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql new file mode 100644 index 00000000..21bf54bc --- /dev/null +++ b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients ADD COLUMN skip_consent BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql new file mode 100644 index 00000000..d93f9ae6 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE oidc_clients DROP COLUMN skip_consent; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql new file mode 100644 index 00000000..7aa76373 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE oidc_clients ADD COLUMN skip_consent BOOLEAN NOT NULL DEFAULT FALSE; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4d84fa0a..eae64b8d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -182,6 +182,8 @@ "smtp_tls_option": "SMTP TLS Option", "email_tls_option": "Email TLS Option", "skip_certificate_verification": "Skip Certificate Verification", + "skip_consent": "Skip Consent Screen", + "skip_consent_description": "Don't ask users to approve this client and the scopes it requests. Only enable this for trusted first-party clients.", "this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.", "enabled_emails": "Enabled Emails", "email_login_notification": "Email Login Notification", diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index aa9b04d5..6cec6e4e 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & { pkceEnabled: boolean; requiresReauthentication: boolean; requiresPushedAuthorizationRequests: boolean; + skipConsent: boolean; credentials?: OidcClientCredentials; launchURL?: string; isGroupRestricted: boolean; diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index 42556a82..05edd566 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -51,6 +51,7 @@ requiresReauthentication: existingClient?.requiresReauthentication || false, requiresPushedAuthorizationRequests: existingClient?.requiresPushedAuthorizationRequests || false, + skipConsent: existingClient?.skipConsent || false, launchURL: existingClient?.launchURL || '', credentials: { federatedIdentities: existingClient?.credentials?.federatedIdentities || [] @@ -77,6 +78,7 @@ pkceEnabled: z.boolean(), requiresReauthentication: z.boolean(), requiresPushedAuthorizationRequests: z.boolean(), + skipConsent: z.boolean(), launchURL: optionalUrl, logoUrl: optionalUrl, darkLogoUrl: optionalUrl, @@ -226,6 +228,12 @@ description={m.requires_users_to_authenticate_again_on_each_authorization()} bind:checked={$inputs.requiresReauthentication.value} /> +
diff --git a/tests/data.ts b/tests/data.ts index 9717e6a9..e7357925 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -73,6 +73,12 @@ export const oidcClients = { name: 'PAR Test Client', callbackUrl: 'http://par-client.localhost/auth/callback', secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY' + }, + skipConsent: { + id: 'e1f2a3b4-c5d6-7890-abcd-ef0000000002', + name: 'Skip Consent Client', + callbackUrl: 'http://skip-consent.localhost/auth/callback', + secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY' } }; diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index bc7379cf..4a385303 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20260607120000, + "version": 20260626120000, "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"], "tables": { "api_keys": [ @@ -56,7 +56,8 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC" + "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", + "skip_consent": false }, { "callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", @@ -74,7 +75,8 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe" + "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", + "skip_consent": false }, { "callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", @@ -92,7 +94,8 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a" + "secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", + "skip_consent": false }, { "callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", @@ -110,7 +113,8 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe" + "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", + "skip_consent": false }, { "callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd", @@ -127,7 +131,8 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK" + "secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", + "skip_consent": false }, { "callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd", @@ -145,7 +150,27 @@ "pkce_enabled": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, - "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC" + "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", + "skip_consent": false + }, + { + "callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", + "credentials": "e30=", + "dark_image_type": null, + "id": "e1f2a3b4-c5d6-7890-abcd-ef0000000002", + "image_type": null, + "is_group_restricted": false, + "is_public": false, + "launch_url": null, + "logout_callback_urls": "bnVsbA==", + "name": "Skip Consent Client", + "pkce_enabled": false, + "requires_pushed_authorization_requests": false, + "requires_reauthentication": false, + "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", + "skip_consent": true } ], "oidc_clients_allowed_user_groups": [ diff --git a/tests/specs/apps-dashboard.spec.ts b/tests/specs/apps-dashboard.spec.ts index fa58daae..3473c8e4 100644 --- a/tests/specs/apps-dashboard.spec.ts +++ b/tests/specs/apps-dashboard.spec.ts @@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => { await page.goto('/settings/apps'); - await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(6); + await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(7); // Should be first const card1 = page.getByTestId('authorized-oidc-client-card').first(); @@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => { const cards = page.getByTestId('authorized-oidc-client-card'); - await expect(cards).toHaveCount(5); + await expect(cards).toHaveCount(6); const cardTexts = await cards.allTextContents(); expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false); @@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => { test('User can see all clients', async ({ page }) => { await page.goto('/settings/apps'); const cards = page.getByTestId('authorized-oidc-client-card'); - await expect(cards).toHaveCount(6); + await expect(cards).toHaveCount(7); }); }); diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index ca8165c4..3d2afa0f 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -1440,3 +1440,49 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await expect(savedToggle).toBeChecked(); }); }); + +test.describe('OIDC skip consent', () => { + // This seeded client has skip-consent enabled and is not pre-authorized for the signed-in + // user, so the consent screen would normally be shown on first authorization. + const client = oidcClients.skipConsent; + + test('skips the consent screen on first authorization', async ({ page }) => { + // The flow must go straight through to the callback instead of stopping at the consent screen + const urlParams = createUrlParams(client); + const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); + + expect(callbackUrl.searchParams.get('code')).toBeTruthy(); + expect(callbackUrl.searchParams.get('error')).toBeNull(); + }); + + test('still shows the consent screen when prompt=consent is requested', async ({ page }) => { + const urlParams = createUrlParams(client); + urlParams.set('prompt', 'consent'); + await page.goto(`/authorize?${urlParams.toString()}`); + + // prompt=consent overrides the client's skip-consent setting, so the scopes must still be shown + await expectScopes(page, ['Email', 'Profile']); + + const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => + page.getByRole('button', { name: 'Sign in' }).click() + ); + expect(callbackUrl.searchParams.get('code')).toBeTruthy(); + }); + + test('Admin UI: skip consent toggle reflects the seeded value and persists', async ({ page }) => { + await page.goto(`/settings/admin/oidc-clients/${client.id}`); + + // The seeded client has skip-consent enabled, so the toggle loads checked + const toggle = page.getByRole('switch', { name: 'Skip Consent Screen' }); + await expect(toggle).toBeChecked(); + + // Disabling it and saving must persist across a reload + await toggle.click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.reload(); + + await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked(); + }); +});