From 59fe481af9284406e0c45385be729a293e90ffaa Mon Sep 17 00:00:00 2001 From: Robert Jaakke <11254908+rjaakke@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:03:08 +0200 Subject: [PATCH] feat: add OpenID Connect `prompt` Parameter Handling (#1299) Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Elias Schneider --- backend/internal/common/errors.go | 24 +++ .../internal/controller/oidc_controller.go | 21 +++ .../controller/well_known_controller.go | 1 + backend/internal/dto/oidc_dto.go | 1 + backend/internal/service/oidc_service.go | 73 ++++++-- backend/internal/service/oidc_service_test.go | 70 +++++++ frontend/src/lib/services/oidc-service.ts | 6 +- frontend/src/lib/types/oidc.type.ts | 8 +- frontend/src/routes/authorize/+page.svelte | 67 ++++++- frontend/src/routes/authorize/+page.ts | 3 +- tests/specs/oidc.spec.ts | 172 +++++++++++++++++- tests/utils/oidc.util.ts | 29 +++ 12 files changed, 447 insertions(+), 28 deletions(-) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index eb5e57ed..7062d2c0 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -448,3 +448,27 @@ func (e *InvalidEmailVerificationTokenError) Error() string { func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest } + +// OIDC prompt parameter errors - used for redirect error responses + +type OidcLoginRequiredError struct{} + +func (e *OidcLoginRequiredError) Error() string { return "login_required" } +func (e *OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest } + +type OidcConsentRequiredError struct{} + +func (e *OidcConsentRequiredError) Error() string { return "consent_required" } +func (e *OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest } + +type OidcInteractionRequiredError struct{} + +func (e *OidcInteractionRequiredError) Error() string { return "interaction_required" } +func (e *OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest } + +type OidcAccountSelectionRequiredError struct{} + +func (e *OidcAccountSelectionRequiredError) Error() string { + return "account_selection_required" +} +func (e *OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 8dd81a5a..1b5d3298 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -104,6 +104,14 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) { c.Request.UserAgent(), ) if err != nil { + // Check if this is a prompt-related error that should be returned as a redirect error + if isOidcPromptError(err) { + c.JSON(http.StatusOK, gin.H{ + "error": err.Error(), + "requiresRedirect": true, + }) + return + } _ = c.Error(err) return } @@ -117,6 +125,19 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) { c.JSON(http.StatusOK, response) } +// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect +func isOidcPromptError(err error) bool { + var loginReq *common.OidcLoginRequiredError + var consentReq *common.OidcConsentRequiredError + var interactionReq *common.OidcInteractionRequiredError + var accountSelectionReq *common.OidcAccountSelectionRequiredError + + return errors.As(err, &loginReq) || + errors.As(err, &consentReq) || + errors.As(err, &interactionReq) || + errors.As(err, &accountSelectionReq) +} + // authorizationConfirmationRequiredHandler godoc // @Summary Check if authorization confirmation is required // @Description Check if the user needs to confirm authorization for the client diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index a4e401ee..03ac2f56 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -91,6 +91,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { "id_token_signing_alg_values_supported": []string{alg.String()}, "authorization_response_iss_parameter_supported": true, "code_challenge_methods_supported": []string{"plain", "S256"}, + "prompt_values_supported": []string{"none", "login", "consent"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"}, } return json.Marshal(config) diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 350734a8..4d8187e0 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -71,6 +71,7 @@ type AuthorizeOidcClientRequestDto struct { CodeChallenge string `json:"codeChallenge"` CodeChallengeMethod string `json:"codeChallengeMethod"` ReauthenticationToken string `json:"reauthenticationToken"` + Prompt string `json:"prompt"` } type AuthorizeOidcClientResponseDto struct { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index e91a616a..ef765b27 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -137,27 +137,49 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie return "", "", err } - if client.RequiresReauthentication { - if input.ReauthenticationToken == "" { - return "", "", &common.ReauthenticationRequiredError{} - } - err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID) - if err != nil { - return "", "", err - } - } - // If the client is not public, the code challenge must be provided if client.IsPublic && input.CodeChallenge == "" { return "", "", &common.OidcMissingCodeChallengeError{} } - // Get the callback URL of the client. Return an error if the provided callback URL is not allowed + // Validate the callback URL before any prompt checks, so that prompt-related + // error responses never contain an unvalidated redirect target callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx) if err != nil { return "", "", err } + // Parse prompt parameter (space-delimited list per OIDC spec) + promptValues := parsePromptParameter(input.Prompt) + hasPromptNone := contains(promptValues, "none") + hasPromptLogin := contains(promptValues, "login") + hasPromptConsent := contains(promptValues, "consent") + hasPromptSelectAccount := contains(promptValues, "select_account") + + // Validate prompt parameter conflicts early. + // Per OIDC Core ยง3.1.2.6, prompt=none must not be combined with any + // value that requires user interaction. + if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) { + return "", "", &common.OidcInteractionRequiredError{} + } + + // Handle prompt=select_account early (not supported) + if hasPromptSelectAccount { + return "", "", &common.OidcInteractionRequiredError{} + } + + // If prompt=login is specified or the client requires reauthentication, check the reauthentication token + if hasPromptLogin || client.RequiresReauthentication { + if input.ReauthenticationToken == "" { + return "", "", &common.ReauthenticationRequiredError{} + } + + err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID) + if err != nil { + return "", "", err + } + } + // Check if the user group is allowed to authorize the client var user model.User err = tx. @@ -173,6 +195,17 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie return "", "", &common.OidcAccessDeniedError{} } + // Handle prompt=none - if consent would be required, we can't show UI + if hasPromptNone { + hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx) + if err != nil { + return "", "", err + } + if !hasAlreadyAuthorized { + return "", "", &common.OidcConsentRequiredError{} + } + } + hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx) if err != nil { return "", "", err @@ -2107,3 +2140,21 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID return provider, nil } + +// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values +func parsePromptParameter(prompt string) []string { + if prompt == "" { + return []string{} + } + return strings.Fields(prompt) +} + +// contains checks if a string slice contains a specific value +func contains(slice []string, value string) bool { + for _, item := range slice { + if item == value { + return true + } + } + return false +} diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index 78e6fac0..c7518e4c 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -1058,3 +1058,73 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { require.ErrorContains(t, err, "failed to look up client") }) } + +// Tests for prompt parameter parsing and handling +func TestParsePromptParameter(t *testing.T) { + t.Run("empty prompt returns empty slice", func(t *testing.T) { + result := parsePromptParameter("") + assert.Equal(t, []string{}, result) + }) + + t.Run("single prompt value", func(t *testing.T) { + result := parsePromptParameter("none") + assert.Equal(t, []string{"none"}, result) + }) + + t.Run("multiple prompt values space-delimited", func(t *testing.T) { + result := parsePromptParameter("login consent") + assert.Equal(t, []string{"login", "consent"}, result) + }) + + t.Run("multiple prompt values with extra spaces", func(t *testing.T) { + result := parsePromptParameter(" none login ") + assert.Equal(t, []string{"none", "login"}, result) + }) +} + +func TestPromptParameterConflicts(t *testing.T) { + tests := []struct { + name string + prompt string + expectConflict bool + }{ + {"none alone is valid", "none", false}, + {"login alone is valid", "login", false}, + {"consent alone is valid", "consent", false}, + {"login consent is valid", "login consent", false}, + {"none consent conflicts", "none consent", true}, + {"none login conflicts", "none login", true}, + {"none select_account conflicts", "none select_account", true}, + {"none consent login conflicts", "none consent login", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := parsePromptParameter(tt.prompt) + hasNone := contains(values, "none") + hasConsent := contains(values, "consent") + hasLogin := contains(values, "login") + hasSelectAccount := contains(values, "select_account") + + conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount) + assert.Equal(t, tt.expectConflict, conflict) + }) + } +} + +func TestContains(t *testing.T) { + t.Run("finds value in slice", func(t *testing.T) { + slice := []string{"none", "login", "consent"} + assert.True(t, contains(slice, "login")) + }) + + t.Run("returns false for missing value", func(t *testing.T) { + slice := []string{"none", "login"} + assert.False(t, contains(slice, "consent")) + }) + + t.Run("returns false for empty slice", func(t *testing.T) { + slice := []string{} + assert.False(t, contains(slice, "none")) + }) +} diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index fa4a7d09..abed878a 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -22,7 +22,8 @@ class OidcService extends APIService { nonce?: string, codeChallenge?: string, codeChallengeMethod?: string, - reauthenticationToken?: string + reauthenticationToken?: string, + prompt?: string ) => { const res = await this.api.post('/oidc/authorize', { scope, @@ -31,7 +32,8 @@ class OidcService extends APIService { clientId, codeChallenge, codeChallengeMethod, - reauthenticationToken + reauthenticationToken, + prompt }); return res.data as AuthorizeResponse; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index b0b37ed8..6b071cb2 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -62,9 +62,11 @@ export type OidcDeviceCodeInfo = { }; export type AuthorizeResponse = { - code: string; - callbackURL: string; - issuer: string; + code?: string; + callbackURL?: string; + issuer?: string; + error?: string; + requiresRedirect?: boolean; }; export type AccessibleOidcClient = OidcClientMetaData & { diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index e629d6e8..4ed5137c 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -20,7 +20,7 @@ const oidService = new OidcService(); let { data }: PageProps = $props(); - let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState } = + let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState, prompt } = data; let isLoading = $state(false); @@ -30,7 +30,26 @@ let authorizationConfirmed = $state(false); let userSignedInAt: Date | undefined; + // Parse prompt parameter once (space-delimited per OIDC spec) + const promptValues = prompt ? prompt.split(' ') : []; + const hasPromptNone = promptValues.includes('none'); + const hasPromptConsent = promptValues.includes('consent'); + const hasPromptLogin = promptValues.includes('login'); + const hasPromptSelectAccount = promptValues.includes('select_account'); + onMount(() => { + // Conflicting prompt values - none can't be combined with any interactive prompt + if (hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount)) { + redirectWithError('interaction_required'); + return; + } + + // If prompt=none and user is not signed in, redirect immediately with login_required + if (hasPromptNone && !$userStore) { + redirectWithError('login_required'); + return; + } + if ($userStore) { authorize(); } @@ -52,6 +71,18 @@ if (!authorizationConfirmed) { authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope); + + // If prompt=consent, always show consent UI + if (hasPromptConsent) { + authorizationRequired = true; + } + + // If prompt=none and consent required, redirect with error + if (hasPromptNone && authorizationRequired) { + redirectWithError('consent_required'); + return; + } + if (authorizationRequired) { isLoading = false; authorizationConfirmed = true; @@ -60,7 +91,7 @@ } let reauthToken: string | undefined; - if (client?.requiresReauthentication) { + if (client?.requiresReauthentication || hasPromptLogin) { let authResponse; const signedInRecently = userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000; @@ -71,22 +102,48 @@ reauthToken = await webauthnService.reauthenticate(authResponse); } - const authResult = await oidService.authorize( + const result = await oidService.authorize( client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, - reauthToken + reauthToken, + prompt ); - onSuccess(authResult.code, authResult.callbackURL, authResult.issuer); + + // Check if backend returned a redirect error + if (result.requiresRedirect && result.error) { + if (hasPromptNone) { + redirectWithError(result.error); + } else { + errorMessage = result.error; + isLoading = false; + } + return; + } + + onSuccess(result.code!, result.callbackURL!, result.issuer!); } catch (e) { errorMessage = getWebauthnErrorMessage(e); isLoading = false; } } + function redirectWithError(error: string) { + const redirectURL = new URL(callbackURL); + if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') { + throw new Error('Invalid redirect URL protocol'); + } + + redirectURL.searchParams.append('error', error); + if (authorizeState) { + redirectURL.searchParams.append('state', authorizeState); + } + window.location.href = redirectURL.toString(); + } + function onSuccess(code: string, callbackURL: string, issuer: string) { const redirectURL = new URL(callbackURL); if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') { diff --git a/frontend/src/routes/authorize/+page.ts b/frontend/src/routes/authorize/+page.ts index b9d5a2a7..80e807cd 100644 --- a/frontend/src/routes/authorize/+page.ts +++ b/frontend/src/routes/authorize/+page.ts @@ -14,6 +14,7 @@ export const load: PageLoad = async ({ url }) => { callbackURL: url.searchParams.get('redirect_uri')!, client, codeChallenge: url.searchParams.get('code_challenge')!, - codeChallengeMethod: url.searchParams.get('code_challenge_method')! + codeChallengeMethod: url.searchParams.get('code_challenge_method')!, + prompt: url.searchParams.get('prompt') || undefined }; }; diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 2b96709f..05c70f34 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -14,7 +14,7 @@ test('Authorize existing client', async ({ page }) => { // Ignore DNS resolution error as the callback URL is not reachable await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { throw e; } }); @@ -31,7 +31,7 @@ test('Authorize existing client while not signed in', async ({ page }) => { // Ignore DNS resolution error as the callback URL is not reachable await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { throw e; } }); @@ -49,7 +49,7 @@ test('Authorize new client', async ({ page }) => { // Ignore DNS resolution error as the callback URL is not reachable await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { throw e; } }); @@ -71,7 +71,7 @@ test('Authorize new client while not signed in', async ({ page }) => { // Ignore DNS resolution error as the callback URL is not reachable await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { throw e; } }); @@ -125,7 +125,7 @@ test('End session with id token hint redirects to callback URL', async ({ page } `/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}` ) .catch((e) => { - if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (e.message.includes('net::ERR_NAME_NOT_RESOLVED') || e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { redirectedCorrectly = true; } else { throw e; @@ -617,10 +617,170 @@ test('Forces reauthentication when client requires it', async ({ page, request } await expect(page.getByTestId('scopes')).not.toBeVisible(); await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => { - if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { throw e; } }); expect(webauthnStartCalled).toBe(true); }); + +test.describe('OIDC prompt parameter', () => { + test('prompt=none redirects with login_required when user not authenticated', async ({ + page + }) => { + await page.context().clearCookies(); + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none'); + + // Should redirect to callback URL with error + const redirectUrl = await oidcUtil.interceptCallbackRedirect( + page, + '/auth/callback', + () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) + ); + + expect(redirectUrl.searchParams.get('error')).toBe('login_required'); + expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); + + test('prompt=none redirects with consent_required when authorization needed', async ({ + page + }) => { + const oidcClient = oidcClients.immich; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none'); + + // Should redirect to callback URL with error + const redirectUrl = await oidcUtil.interceptCallbackRedirect( + page, + '/auth/callback', + () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) + ); + + expect(redirectUrl.searchParams.get('error')).toBe('consent_required'); + expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); + + test('prompt=none succeeds when user is authenticated and authorized', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none'); + + await page.goto(`/authorize?${urlParams.toString()}`); + + // Should redirect successfully to callback URL with code + await page.waitForURL(oidcClient.callbackUrl).catch((e) => { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { + throw e; + } + }); + }); + + test('prompt=consent forces consent display even for authorized client', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'consent'); + + await page.goto(`/authorize?${urlParams.toString()}`); + + // Should show consent UI even though client was already authorized + await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible(); + await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible(); + + await page.getByRole('button', { name: 'Sign in' }).click(); + + // Should redirect successfully after consent + await page.waitForURL(oidcClient.callbackUrl).catch((e) => { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { + throw e; + } + }); + }); + + test('prompt=login forces reauthentication', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'login'); + + let reauthCalled = false; + await page.route('/api/webauthn/login/start', async (route) => { + reauthCalled = true; + await route.continue(); + }); + + await (await passkeyUtil.init(page)).addPasskey(); + await page.goto(`/authorize?${urlParams.toString()}`); + + // Should require reauthentication even though user is signed in + await page.waitForURL(oidcClient.callbackUrl).catch((e) => { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) { + throw e; + } + }); + + expect(reauthCalled).toBe(true); + }); + + test('prompt=select_account returns interaction_required error', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'select_account'); + + await page.goto(`/authorize?${urlParams.toString()}`); + + // Should show error since account selection is not supported + await expect( + page.getByRole('paragraph').filter({ hasText: 'interaction_required' }) + ).toBeVisible(); + }); + + test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none consent'); + + // Should redirect with error since both can't be satisfied + const redirectUrl = await oidcUtil.interceptCallbackRedirect( + page, + '/auth/callback', + () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) + ); + + expect(redirectUrl.searchParams.get('error')).toBe('interaction_required'); + expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); + + test('prompt=none with prompt=login returns interaction_required', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none login'); + + // Should redirect with error since both can't be satisfied + const redirectUrl = await oidcUtil.interceptCallbackRedirect( + page, + '/auth/callback', + () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) + ); + + expect(redirectUrl.searchParams.get('error')).toBe('interaction_required'); + expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); + + test('prompt=none with prompt=select_account returns interaction_required', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none select_account'); + + // Should redirect with error since both can't be satisfied + const redirectUrl = await oidcUtil.interceptCallbackRedirect( + page, + '/auth/callback', + () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) + ); + + expect(redirectUrl.searchParams.get('error')).toBe('interaction_required'); + expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); +}); diff --git a/tests/utils/oidc.util.ts b/tests/utils/oidc.util.ts index 27527c4f..c16786b2 100644 --- a/tests/utils/oidc.util.ts +++ b/tests/utils/oidc.util.ts @@ -1,5 +1,34 @@ import type { Page } from '@playwright/test'; +export async function interceptCallbackRedirect( + page: Page, + callbackPath: string, + action: () => Promise, + timeoutMs: number = 10000 +): Promise { + const routeMatcher = (url: URL) => url.pathname === callbackPath; + + const callbackPromise = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for redirect to ${callbackPath}`)), + timeoutMs + ); + + page.route(routeMatcher, async (route) => { + clearTimeout(timer); + resolve(new URL(route.request().url())); + await route.abort(); + }); + }); + + try { + await action(); + return await callbackPromise; + } finally { + await page.unroute(routeMatcher); + } +} + export async function getUserCode( page: Page, clientId: string,