From 0c95b7c3cc171ed77b51f236009b5ff659da0bec Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 29 May 2026 09:14:44 +0200 Subject: [PATCH] feat: add support for `response_mode=fragment` --- backend/internal/dto/validations.go | 4 +- backend/internal/dto/validations_test.go | 3 +- frontend/src/routes/authorize/+page.svelte | 45 ++++++++++++------- tests/specs/oidc.spec.ts | 50 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 2380775c..72035972 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -92,11 +92,11 @@ func ValidateCallbackURLPattern(raw string) bool { } // ValidateResponseMode validates response_mode parameter -// If responseMode is present, it must be "form_post" or "query" +// If responseMode is present, it must be "form_post", "query", or "fragment" // Empty responseMode is allowed (field not provided, use default) func ValidateResponseMode(responseMode string) bool { switch responseMode { - case "form_post", "query": + case "form_post", "query", "fragment": return true case "": return true diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index 5f9d595d..d8b84621 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -66,8 +66,9 @@ func TestValidateResponseMode(t *testing.T) { }{ {"valid form_post", "form_post", true}, {"valid query", "query", true}, + {"valid fragment", "fragment", true}, {"valid empty", "", true}, - {"invalid fragment", "fragment", false}, + {"invalid unknown", "unknown", false}, } for _, tt := range tests { diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index 4909541e..83139af0 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -180,11 +180,10 @@ throw new Error('Invalid redirect URL protocol'); } - redirectURL.searchParams.append('error', error); - if (authorizeState) { - redirectURL.searchParams.append('state', authorizeState); - } - window.location.href = redirectURL.toString(); + window.location.href = createRedirectURL(callbackURL, { + error, + state: authorizeState + }); } function onSuccess(code: string, callbackURL: string, issuer: string) { @@ -193,10 +192,6 @@ throw new Error('Invalid redirect URL protocol'); } - redirectURL.searchParams.append('code', code); - redirectURL.searchParams.append('state', authorizeState); - redirectURL.searchParams.append('iss', issuer); - success = true; setTimeout(() => { if (responseMode === 'form_post') { @@ -231,16 +226,34 @@ document.body.appendChild(form); form.submit(); } else { - // Default query parameter redirect (response_mode=query or not specified) - const redirectURL = new URL(callbackURL); - redirectURL.searchParams.append('code', code); - redirectURL.searchParams.append('state', authorizeState); - redirectURL.searchParams.append('iss', issuer); - - window.location.href = redirectURL.toString(); + window.location.href = createRedirectURL(callbackURL, { + code, + state: authorizeState, + iss: issuer + }); } }, 1000); } + + function createRedirectURL(url: string, params: Record) { + const redirectURL = new URL(url); + const responseParams = + responseMode === 'fragment' + ? new URLSearchParams(redirectURL.hash.slice(1)) + : redirectURL.searchParams; + + for (const [key, value] of Object.entries(params)) { + if (value) { + responseParams.set(key, value); + } + } + + if (responseMode === 'fragment') { + redirectURL.hash = responseParams.toString(); + } + + return redirectURL.toString(); + } diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 5c6ed515..16900d6f 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -671,12 +671,45 @@ test('Authorize existing client with response_mode=form_post', async ({ page }) await expectFormPostRequest(formPostRequestPromise); }); +test('Authorize existing client with response_mode=fragment', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('response_mode', 'fragment'); + + await page.goto(`/authorize?${urlParams.toString()}`); + + const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl); + expect(redirectUrl.search).toBe(''); + + const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); + expect(fragmentParams.get('code')).toBeTruthy(); + expect(fragmentParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + expect(fragmentParams.get('iss')).toBeTruthy(); +}); + function waitForFormPostRequest(page: Page, callbackUrl: string): Promise { return page.waitForRequest( (request) => request.method() === 'POST' && request.url() === callbackUrl ); } +async function waitForCallbackURL(page: Page, callbackUrl: string): Promise { + const expectedUrl = new URL(callbackUrl); + + await page + .waitForURL((url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname) + .catch((e) => { + if ( + !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && + !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') + ) { + throw e; + } + }); + + return new URL(page.url()); +} + async function expectFormPostRequest(formPostRequestPromise: Promise) { const request = await formPostRequestPromise; const formData = new URLSearchParams(request.postData() ?? ''); @@ -704,6 +737,23 @@ test.describe('OIDC prompt parameter', () => { expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); + test('prompt=none redirects errors with response_mode=fragment', async ({ page }) => { + await page.context().clearCookies(); + const oidcClient = oidcClients.nextcloud; + const urlParams = createUrlParams(oidcClient); + urlParams.set('prompt', 'none'); + urlParams.set('response_mode', 'fragment'); + + await page.goto(`/authorize?${urlParams.toString()}`).then(() => {}); + + const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl); + expect(redirectUrl.search).toBe(''); + + const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); + expect(fragmentParams.get('error')).toBe('login_required'); + expect(fragmentParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); + }); + test('prompt=none redirects with consent_required when authorization needed', async ({ page }) => {