feat: use client-provided SSO redirectUri (#1155)

Co-authored-by: Hampus <[email protected]>
This commit is contained in:
Sven Uhrenholdt FrenzelandHampus authored and GitHub committed 2026-06-24 02:15:51 +02:00
1 parent b80d41a7bf
commit a45693f581
7 files changed
+92 -19

No files matched your search

@@ -151,7 +151,10 @@ export class AuthRequestService {
} }
startSso(data: SsoStartRequest) { 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) { completeSso(data: SsoCompleteRequest, request: Request) {
+19 -4
View File
@@ -53,6 +53,7 @@ interface SsoStatePayload {
codeVerifier: string; codeVerifier: string;
nonce: string; nonce: string;
redirectTo?: string; redirectTo?: string;
redirectUri?: string;
createdAt: number; createdAt: number;
} }
@@ -96,6 +97,7 @@ interface JwksCacheEntry {
const CODE_VERIFIER_BYTE_LENGTH = 32; const CODE_VERIFIER_BYTE_LENGTH = 32;
const STATE_BYTE_LENGTH = 16; const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16; const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
function randomBase64UrlToken(byteLength: number): string { function randomBase64UrlToken(byteLength: number): string {
return randomBytes(byteLength).toString('base64url'); return randomBytes(byteLength).toString('base64url');
@@ -118,6 +120,14 @@ function buildDiscoveryCacheKey(issuer: string): string {
return `sso:oidc-discovery:${key}`; 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 { function coerceEmailVerified(value: unknown): boolean | undefined {
if (value === true || value === false) return value; if (value === true || value === false) return value;
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -254,7 +264,7 @@ export class SsoService {
return config.enabled && config.ready && config.enforced; return config.enabled && config.ready && config.enforced;
} }
async startLogin(redirectTo?: string): Promise<{ async startLogin({redirectTo, redirectUri}: {redirectTo?: string; redirectUri?: string} = {}): Promise<{
authorization_url: string; authorization_url: string;
state: string; state: string;
redirect_uri: string; redirect_uri: string;
@@ -264,10 +274,12 @@ export class SsoService {
const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH); const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH);
const codeChallenge = buildCodeChallenge(codeVerifier); const codeChallenge = buildCodeChallenge(codeVerifier);
const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH); const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH);
const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri);
const statePayload: SsoStatePayload = { const statePayload: SsoStatePayload = {
codeVerifier, codeVerifier,
nonce, nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo), redirectTo: sanitizeSsoRedirectTo(redirectTo),
redirectUri: ssoRedirectUri,
createdAt: Date.now(), createdAt: Date.now(),
}; };
const {cache} = this.apiContext.services; const {cache} = this.apiContext.services;
@@ -275,7 +287,7 @@ export class SsoService {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: config.clientId ?? '', client_id: config.clientId ?? '',
redirect_uri: config.redirectUri, redirect_uri: ssoRedirectUri,
scope: config.scope, scope: config.scope,
state, state,
code_challenge: codeChallenge, code_challenge: codeChallenge,
@@ -297,7 +309,7 @@ export class SsoService {
throw new FeatureTemporarilyDisabledError(); 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<{ async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
@@ -314,6 +326,7 @@ export class SsoService {
const tokenResponse = await this.exchangeCode({ const tokenResponse = await this.exchangeCode({
code, code,
codeVerifier: statePayload.codeVerifier, codeVerifier: statePayload.codeVerifier,
redirectUri: statePayload.redirectUri ?? config.redirectUri,
config, config,
}); });
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce); const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
@@ -764,10 +777,12 @@ export class SsoService {
private async exchangeCode({ private async exchangeCode({
code, code,
codeVerifier, codeVerifier,
redirectUri,
config, config,
}: { }: {
code: string; code: string;
codeVerifier: string; codeVerifier: string;
redirectUri: string;
config: ResolvedSsoConfig; config: ResolvedSsoConfig;
}): Promise<{ }): Promise<{
id_token?: string; id_token?: string;
@@ -779,7 +794,7 @@ export class SsoService {
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
redirect_uri: config.redirectUri, redirect_uri: redirectUri,
client_id: config.clientId ?? '', client_id: config.clientId ?? '',
code_verifier: codeVerifier, code_verifier: codeVerifier,
}); });
@@ -29,6 +29,13 @@ interface SsoStatusResponse {
enabled: boolean; enabled: boolean;
enforced: boolean; enforced: boolean;
display_name?: string; 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', () => { describe('Auth SSO flow', () => {
@@ -130,6 +137,7 @@ describe('Auth SSO flow', () => {
expect(authUrlString).toContain('code_challenge_method=S256'); expect(authUrlString).toContain('code_challenge_method=S256');
expect(authUrlString).toContain('code_challenge='); expect(authUrlString).toContain('code_challenge=');
expect(authUrlString).toContain('nonce='); expect(authUrlString).toContain('nonce=');
expect(getAuthorizationUrlParam(authUrlString, 'redirect_uri')).toBe(startData.redirect_uri);
if (authUrlString.startsWith('http://') || authUrlString.startsWith('https://')) { if (authUrlString.startsWith('http://') || authUrlString.startsWith('https://')) {
const authUrl = new URL(authUrlString); const authUrl = new URL(authUrlString);
const stateParam = authUrl.searchParams.get('state'); const stateParam = authUrl.searchParams.get('state');
@@ -151,6 +159,7 @@ describe('Auth SSO flow', () => {
.execute(); .execute();
expect(completeData.token).toBeTruthy(); expect(completeData.token).toBeTruthy();
expect(completeData.user_id).toBeTruthy(); expect(completeData.user_id).toBeTruthy();
expect(completeData.redirect_to).toBe('/me');
const meData = await createBuilder<{ const meData = await createBuilder<{
email: string | null; email: string | null;
}>(harness, completeData.token) }>(harness, completeData.token)
@@ -170,6 +179,34 @@ describe('Auth SSO flow', () => {
expect(startData.redirect_uri).not.toContain('evil.example'); expect(startData.redirect_uri).not.toContain('evil.example');
expect(startData.authorization_url).toContain(encodeURIComponent(startData.redirect_uri)); 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<SsoStartResponse>(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<SsoCompleteResponse>(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', () => { describe('redirect validation', () => {
let admin: TestAccount; let admin: TestAccount;
+5 -1
View File
@@ -26323,7 +26323,7 @@
"properties": { "properties": {
"authorization_url": {"type": "string", "description": "URL to redirect user to for SSO authentication"}, "authorization_url": {"type": "string", "description": "URL to redirect user to for SSO authentication"},
"state": {"type": "string", "description": "State parameter for CSRF protection"}, "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"] "required": ["authorization_url", "state", "redirect_uri"]
}, },
@@ -26333,6 +26333,10 @@
"redirect_to": { "redirect_to": {
"anyOf": [{"type": "string"}, {"type": "null"}], "anyOf": [{"type": "string"}, {"type": "null"}],
"description": "URL to redirect to after SSO completion" "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"
} }
} }
}, },
@@ -16,6 +16,9 @@ import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type { import type {
AuthRegistrationPendingApprovalResponse, AuthRegistrationPendingApprovalResponse,
RegisterRequest, RegisterRequest,
SsoCompleteResponse,
SsoStartRequest,
SsoStartResponse,
} from '@fluxer/schema/src/domains/auth/AuthSchemas'; } from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser'; import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
@@ -627,20 +630,26 @@ export async function completeLogin(
} }
} }
export async function startSso(redirectTo?: string): Promise<{ export async function startSso({
authorization_url: string; redirectTo,
}> { redirectUri,
const response = await http.post<{ }: {
authorization_url: string; redirectTo?: string;
}>(Endpoints.AUTH_SSO_START, { redirectUri?: string;
body: {redirect_to: redirectTo}, } = {}): Promise<SsoStartResponse> {
const body: SsoStartRequest = {
redirect_to: redirectTo,
redirect_uri: redirectUri,
};
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
body,
headers: withPlatformHeader(), headers: withPlatformHeader(),
}); });
return response.body; return response.body;
} }
export async function completeSso({code, state}: {code: string; state: string}): Promise<TokenResponse> { export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
const response = await http.post<TokenResponse>(Endpoints.AUTH_SSO_COMPLETE, { const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
body: {code, state}, body: {code, state},
headers: withPlatformHeader(), headers: withPlatformHeader(),
}); });
@@ -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; authorizationUrl: string;
redirectUri: string;
}> { }> {
const safeRedirectTo = safeRedirectTarget(redirectTo); const safeRedirectTo = safeRedirectTarget(redirectTo);
const result = await AuthenticationCommands.startSso(safeRedirectTo ?? undefined); const result = await AuthenticationCommands.startSso({
redirectTo: safeRedirectTo ?? undefined,
redirectUri,
});
storeSsoRedirectTo(safeRedirectTo ?? undefined); 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<LoginSuccessPayload> { export async function completeSsoLogin({code, state}: {code: string; state: string}): Promise<LoginSuccessPayload> {
@@ -135,7 +135,7 @@ export type SsoStatusResponse = z.infer<typeof SsoStatusResponse>;
export const SsoStartResponse = z.object({ export const SsoStartResponse = z.object({
authorization_url: z.string().describe('URL to redirect user to for SSO authentication'), authorization_url: z.string().describe('URL to redirect user to for SSO authentication'),
state: z.string().describe('State parameter for CSRF protection'), 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<typeof SsoStartResponse>; export type SsoStartResponse = z.infer<typeof SsoStartResponse>;
@@ -254,6 +254,7 @@ export type HandoffStatusResponse = z.infer<typeof HandoffStatusResponse>;
export const SsoStartRequest = z.object({ export const SsoStartRequest = z.object({
redirect_to: createStringType(0, 2048).nullish().describe('URL to redirect to after SSO completion'), 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<typeof SsoStartRequest>; export type SsoStartRequest = z.infer<typeof SsoStartRequest>;