mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat: use client-provided SSO redirectUri (#1155)
Co-authored-by: Hampus <[email protected]>
This commit is contained in:
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in new issue
Block a user