From a45693f581e8cac4aac0099aad6386b40d2446a2 Mon Sep 17 00:00:00 2001 From: Sven Uhrenholdt Frenzel <1295777+frenzeldk@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:15:51 +0200 Subject: [PATCH] feat: use client-provided SSO redirectUri (#1155) Co-authored-by: Hampus --- fluxer_api/src/api/auth/AuthRequestService.ts | 5 ++- .../src/api/auth/services/SsoService.ts | 23 ++++++++++-- fluxer_api/src/api/auth/tests/SsoFlow.test.ts | 37 +++++++++++++++++++ fluxer_api/src/api/openapi/openapi.json | 6 ++- .../auth/commands/AuthenticationCommands.ts | 27 +++++++++----- .../src/features/auth/state/AuthFlow.ts | 10 +++-- .../schema/src/domains/auth/AuthSchemas.ts | 3 +- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/fluxer_api/src/api/auth/AuthRequestService.ts b/fluxer_api/src/api/auth/AuthRequestService.ts index 01356df21..53fd64d08 100644 --- a/fluxer_api/src/api/auth/AuthRequestService.ts +++ b/fluxer_api/src/api/auth/AuthRequestService.ts @@ -151,7 +151,10 @@ export class AuthRequestService { } startSso(data: SsoStartRequest) { - return this.ssoService.startLogin(data.redirect_to ?? undefined); + return this.ssoService.startLogin({ + redirectTo: data.redirect_to ?? undefined, + redirectUri: data.redirect_uri ?? undefined, + }); } completeSso(data: SsoCompleteRequest, request: Request) { diff --git a/fluxer_api/src/api/auth/services/SsoService.ts b/fluxer_api/src/api/auth/services/SsoService.ts index 7921ef927..63d5fbb1f 100644 --- a/fluxer_api/src/api/auth/services/SsoService.ts +++ b/fluxer_api/src/api/auth/services/SsoService.ts @@ -53,6 +53,7 @@ interface SsoStatePayload { codeVerifier: string; nonce: string; redirectTo?: string; + redirectUri?: string; createdAt: number; } @@ -96,6 +97,7 @@ interface JwksCacheEntry { const CODE_VERIFIER_BYTE_LENGTH = 32; const STATE_BYTE_LENGTH = 16; const NONCE_BYTE_LENGTH = 16; +const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback'; function randomBase64UrlToken(byteLength: number): string { return randomBytes(byteLength).toString('base64url'); @@ -118,6 +120,14 @@ function buildDiscoveryCacheKey(issuer: string): string { return `sso:oidc-discovery:${key}`; } +function resolveSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): string { + if (!requestedRedirectUri) return defaultRedirectUri; + const trimmed = requestedRedirectUri.trim(); + if (!trimmed) return defaultRedirectUri; + if (trimmed === defaultRedirectUri || trimmed === MOBILE_SSO_REDIRECT_URI) return trimmed; + throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT); +} + function coerceEmailVerified(value: unknown): boolean | undefined { if (value === true || value === false) return value; if (typeof value === 'string') { @@ -254,7 +264,7 @@ export class SsoService { return config.enabled && config.ready && config.enforced; } - async startLogin(redirectTo?: string): Promise<{ + async startLogin({redirectTo, redirectUri}: {redirectTo?: string; redirectUri?: string} = {}): Promise<{ authorization_url: string; state: string; redirect_uri: string; @@ -264,10 +274,12 @@ export class SsoService { const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH); const codeChallenge = buildCodeChallenge(codeVerifier); const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH); + const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri); const statePayload: SsoStatePayload = { codeVerifier, nonce, redirectTo: sanitizeSsoRedirectTo(redirectTo), + redirectUri: ssoRedirectUri, createdAt: Date.now(), }; const {cache} = this.apiContext.services; @@ -275,7 +287,7 @@ export class SsoService { const searchParams = new URLSearchParams({ response_type: 'code', client_id: config.clientId ?? '', - redirect_uri: config.redirectUri, + redirect_uri: ssoRedirectUri, scope: config.scope, state, code_challenge: codeChallenge, @@ -297,7 +309,7 @@ export class SsoService { throw new FeatureTemporarilyDisabledError(); } } - return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri}; + return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri}; } async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{ @@ -314,6 +326,7 @@ export class SsoService { const tokenResponse = await this.exchangeCode({ code, codeVerifier: statePayload.codeVerifier, + redirectUri: statePayload.redirectUri ?? config.redirectUri, config, }); const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce); @@ -764,10 +777,12 @@ export class SsoService { private async exchangeCode({ code, codeVerifier, + redirectUri, config, }: { code: string; codeVerifier: string; + redirectUri: string; config: ResolvedSsoConfig; }): Promise<{ id_token?: string; @@ -779,7 +794,7 @@ export class SsoService { const body = new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: config.redirectUri, + redirect_uri: redirectUri, client_id: config.clientId ?? '', code_verifier: codeVerifier, }); diff --git a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts index 74cb3b1a2..590e05a23 100644 --- a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts +++ b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts @@ -29,6 +29,13 @@ interface SsoStatusResponse { enabled: boolean; enforced: boolean; display_name?: string; + redirect_uri: string; +} + +function getAuthorizationUrlParam(authorizationUrlString: string, param: string): string | null { + const queryStart = authorizationUrlString.indexOf('?'); + if (queryStart === -1) return null; + return new URLSearchParams(authorizationUrlString.slice(queryStart + 1)).get(param); } describe('Auth SSO flow', () => { @@ -130,6 +137,7 @@ describe('Auth SSO flow', () => { expect(authUrlString).toContain('code_challenge_method=S256'); expect(authUrlString).toContain('code_challenge='); expect(authUrlString).toContain('nonce='); + expect(getAuthorizationUrlParam(authUrlString, 'redirect_uri')).toBe(startData.redirect_uri); if (authUrlString.startsWith('http://') || authUrlString.startsWith('https://')) { const authUrl = new URL(authUrlString); const stateParam = authUrl.searchParams.get('state'); @@ -151,6 +159,7 @@ describe('Auth SSO flow', () => { .execute(); expect(completeData.token).toBeTruthy(); expect(completeData.user_id).toBeTruthy(); + expect(completeData.redirect_to).toBe('/me'); const meData = await createBuilder<{ email: string | null; }>(harness, completeData.token) @@ -170,6 +179,34 @@ describe('Auth SSO flow', () => { expect(startData.redirect_uri).not.toContain('evil.example'); expect(startData.authorization_url).toContain(encodeURIComponent(startData.redirect_uri)); }); + it('uses the requested mobile SSO redirect URI without changing the post-login redirect', async () => { + const startData = await createBuilderWithoutAuth(harness) + .post('/auth/sso/start') + .body({ + redirect_to: '/me', + redirect_uri: 'fluxer://auth/sso/callback', + }) + .execute(); + expect(startData.redirect_uri).toBe('fluxer://auth/sso/callback'); + expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe('fluxer://auth/sso/callback'); + const email = `sso-mobile-redirect-${Date.now()}@example.com`; + const completeData = await createBuilderWithoutAuth(harness) + .post('/auth/sso/complete') + .body({ + code: email, + state: startData.state, + }) + .execute(); + expect(completeData.token).toBeTruthy(); + expect(completeData.redirect_to).toBe('/me'); + }); + it('rejects unapproved SSO redirect URIs', async () => { + await createBuilderWithoutAuth(harness) + .post('/auth/sso/start') + .body({redirect_uri: 'https://evil.example/auth/sso/callback'}) + .expect(400, 'INVALID_FORM_BODY') + .execute(); + }); }); describe('redirect validation', () => { let admin: TestAccount; diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 1862c07ec..b5be26778 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -26323,7 +26323,7 @@ "properties": { "authorization_url": {"type": "string", "description": "URL to redirect user to for SSO authentication"}, "state": {"type": "string", "description": "State parameter for CSRF protection"}, - "redirect_uri": {"type": "string", "description": "Redirect URI after SSO completion"} + "redirect_uri": {"type": "string", "description": "OAuth redirect URI used for the SSO provider callback"} }, "required": ["authorization_url", "state", "redirect_uri"] }, @@ -26333,6 +26333,10 @@ "redirect_to": { "anyOf": [{"type": "string"}, {"type": "null"}], "description": "URL to redirect to after SSO completion" + }, + "redirect_uri": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "OAuth redirect URI to use for the SSO provider callback" } } }, diff --git a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts index cd01bc23c..18b3aa6b9 100644 --- a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts +++ b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts @@ -16,6 +16,9 @@ import type {ValueOf} from '@fluxer/constants/src/ValueOf'; import type { AuthRegistrationPendingApprovalResponse, RegisterRequest, + SsoCompleteResponse, + SsoStartRequest, + SsoStartResponse, } from '@fluxer/schema/src/domains/auth/AuthSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser'; @@ -627,20 +630,26 @@ export async function completeLogin( } } -export async function startSso(redirectTo?: string): Promise<{ - authorization_url: string; -}> { - const response = await http.post<{ - authorization_url: string; - }>(Endpoints.AUTH_SSO_START, { - body: {redirect_to: redirectTo}, +export async function startSso({ + redirectTo, + redirectUri, +}: { + redirectTo?: string; + redirectUri?: string; +} = {}): Promise { + const body: SsoStartRequest = { + redirect_to: redirectTo, + redirect_uri: redirectUri, + }; + const response = await http.post(Endpoints.AUTH_SSO_START, { + body, headers: withPlatformHeader(), }); return response.body; } -export async function completeSso({code, state}: {code: string; state: string}): Promise { - const response = await http.post(Endpoints.AUTH_SSO_COMPLETE, { +export async function completeSso({code, state}: {code: string; state: string}): Promise { + const response = await http.post(Endpoints.AUTH_SSO_COMPLETE, { body: {code, state}, headers: withPlatformHeader(), }); diff --git a/fluxer_app/src/features/auth/state/AuthFlow.ts b/fluxer_app/src/features/auth/state/AuthFlow.ts index d4d5eb674..56ca4d09e 100644 --- a/fluxer_app/src/features/auth/state/AuthFlow.ts +++ b/fluxer_app/src/features/auth/state/AuthFlow.ts @@ -168,13 +168,17 @@ export function getPendingSsoRedirectTo(): string | undefined { } } -export async function startSsoLogin({redirectTo}: {redirectTo?: string}): Promise<{ +export async function startSsoLogin({redirectTo, redirectUri}: {redirectTo?: string; redirectUri?: string}): Promise<{ authorizationUrl: string; + redirectUri: string; }> { const safeRedirectTo = safeRedirectTarget(redirectTo); - const result = await AuthenticationCommands.startSso(safeRedirectTo ?? undefined); + const result = await AuthenticationCommands.startSso({ + redirectTo: safeRedirectTo ?? undefined, + redirectUri, + }); storeSsoRedirectTo(safeRedirectTo ?? undefined); - return {authorizationUrl: result.authorization_url}; + return {authorizationUrl: result.authorization_url, redirectUri: result.redirect_uri}; } export async function completeSsoLogin({code, state}: {code: string; state: string}): Promise { diff --git a/packages/schema/src/domains/auth/AuthSchemas.ts b/packages/schema/src/domains/auth/AuthSchemas.ts index eae9bbd2f..9c0244042 100644 --- a/packages/schema/src/domains/auth/AuthSchemas.ts +++ b/packages/schema/src/domains/auth/AuthSchemas.ts @@ -135,7 +135,7 @@ export type SsoStatusResponse = z.infer; export const SsoStartResponse = z.object({ authorization_url: z.string().describe('URL to redirect user to for SSO authentication'), state: z.string().describe('State parameter for CSRF protection'), - redirect_uri: z.string().describe('Redirect URI after SSO completion'), + redirect_uri: z.string().describe('OAuth redirect URI used for the SSO provider callback'), }); export type SsoStartResponse = z.infer; @@ -254,6 +254,7 @@ export type HandoffStatusResponse = z.infer; export const SsoStartRequest = z.object({ redirect_to: createStringType(0, 2048).nullish().describe('URL to redirect to after SSO completion'), + redirect_uri: createStringType(0, 2048).nullish().describe('OAuth redirect URI to use for the SSO provider callback'), }); export type SsoStartRequest = z.infer;