mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(sso): route mobile sign-in through the web callback (#3060)
This commit is contained in:
@@ -35,6 +35,7 @@ import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
|
||||
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
|
||||
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
|
||||
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
|
||||
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
|
||||
@@ -106,7 +107,6 @@ 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';
|
||||
|
||||
let ssoLogger: ILogger | undefined;
|
||||
|
||||
@@ -136,11 +136,10 @@ 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;
|
||||
function isMobileSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): boolean {
|
||||
const trimmed = requestedRedirectUri?.trim();
|
||||
if (!trimmed || trimmed === defaultRedirectUri) return false;
|
||||
if (trimmed === SSO_MOBILE_CALLBACK_URI) return true;
|
||||
throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT);
|
||||
}
|
||||
|
||||
@@ -285,16 +284,16 @@ export class SsoService {
|
||||
redirect_uri: string;
|
||||
}> {
|
||||
const config = await this.requireReadyConfig();
|
||||
const state = randomHexToken(STATE_BYTE_LENGTH);
|
||||
const isMobile = isMobileSsoRedirectUri(redirectUri, config.redirectUri);
|
||||
const state = `${isMobile ? SSO_MOBILE_STATE_PREFIX : ''}${randomHexToken(STATE_BYTE_LENGTH)}`;
|
||||
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,
|
||||
redirectUri: config.redirectUri,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const {cache} = this.apiContext.services;
|
||||
@@ -302,7 +301,7 @@ export class SsoService {
|
||||
const searchParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId ?? '',
|
||||
redirect_uri: ssoRedirectUri,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: config.scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
@@ -324,7 +323,7 @@ export class SsoService {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
}
|
||||
return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri};
|
||||
return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri};
|
||||
}
|
||||
|
||||
async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
|
||||
|
||||
@@ -131,6 +131,7 @@ describe('Auth SSO flow', () => {
|
||||
.body({redirect_to: '/me'})
|
||||
.execute();
|
||||
expect(startData.state).toBeTruthy();
|
||||
expect(startData.state.startsWith('m.')).toBe(false);
|
||||
expect(startData.authorization_url).toBeTruthy();
|
||||
const authUrlString = startData.authorization_url;
|
||||
expect(authUrlString).toContain(`state=${startData.state}`);
|
||||
@@ -179,7 +180,8 @@ 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 () => {
|
||||
it('routes mobile SSO through the default redirect URI without changing the post-login redirect', async () => {
|
||||
const status = await createBuilderWithoutAuth<{redirect_uri: string}>(harness).get('/auth/sso/status').execute();
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({
|
||||
@@ -187,8 +189,10 @@ describe('Auth SSO flow', () => {
|
||||
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');
|
||||
expect(startData.redirect_uri).toBe(status.redirect_uri);
|
||||
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe(status.redirect_uri);
|
||||
expect(startData.state.startsWith('m.')).toBe(true);
|
||||
expect(getAuthorizationUrlParam(startData.authorization_url, 'state')).toBe(startData.state);
|
||||
const email = `sso-mobile-redirect-${Date.now()}@example.com`;
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
}
|
||||
|
||||
.ssoRetryButton {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.625rem;
|
||||
border: none;
|
||||
@@ -376,6 +377,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import styles from '@app/features/auth/components/pages/LoginPage.module.css';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {safeRedirectTarget} from '@app/features/auth/utils/SafeRedirect';
|
||||
import {BACK_TO_SIGN_IN_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -29,6 +31,10 @@ const FAILED_TO_COMPLETE_SSO_SIGN_IN_DESCRIPTOR = msg({
|
||||
message: 'Failed to complete SSO sign-in',
|
||||
comment: 'Short label in the authentication SSO callback page. Keep the tone plain and specific.',
|
||||
});
|
||||
const OPEN_PRODUCT_DESCRIPTOR = msg({
|
||||
message: 'Open {productName}',
|
||||
comment: 'Button that hands SSO sign-in back to the mobile app. productName is the app name.',
|
||||
});
|
||||
const SSO_TIMEOUT_MS = 30_000;
|
||||
const SsoCallbackPage = observer(function SsoCallbackPage() {
|
||||
const {i18n} = useLingui();
|
||||
@@ -37,6 +43,9 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
|
||||
const state = params['get']('state');
|
||||
const providerError = params['get']('error');
|
||||
const providerErrorDescription = params['get']('error_description');
|
||||
const mobileCallbackUrl = state?.startsWith(SSO_MOBILE_STATE_PREFIX)
|
||||
? `${SSO_MOBILE_CALLBACK_URI}${window.location.search}`
|
||||
: null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -54,6 +63,10 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (mobileCallbackUrl) {
|
||||
window.location.replace(mobileCallbackUrl);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -97,7 +110,28 @@ const SsoCallbackPage = observer(function SsoCallbackPage() {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
};
|
||||
}, [code, state, providerError, providerErrorDescription, i18n]);
|
||||
}, [code, state, providerError, providerErrorDescription, mobileCallbackUrl, i18n]);
|
||||
if (mobileCallbackUrl) {
|
||||
return (
|
||||
<div className={styles.loginContainer} data-flx="auth.sso-callback-page.login-container--mobile">
|
||||
<h1 className={styles.title} data-flx="auth.sso-callback-page.title--mobile">
|
||||
<Trans>Completing sign-in…</Trans>
|
||||
</h1>
|
||||
<p className={styles.ssoProcessingHint} data-flx="auth.sso-callback-page.sso-processing-hint--mobile">
|
||||
<Trans>Jump straight to the app to continue.</Trans>
|
||||
</p>
|
||||
<div className={styles.ssoCallbackActions} data-flx="auth.sso-callback-page.sso-callback-actions--mobile">
|
||||
<a
|
||||
href={mobileCallbackUrl}
|
||||
className={styles.ssoRetryButton}
|
||||
data-flx="auth.sso-callback-page.sso-open-app-button"
|
||||
>
|
||||
{i18n._(OPEN_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.loginContainer} data-flx="auth.sso-callback-page.login-container">
|
||||
|
||||
@@ -73,7 +73,7 @@ Single sign-on settings for the deployment's OpenID Connect provider.
|
||||
|
||||
<sup>2</sup> Each entry is stored lowercased and IDNA encoded, duplicates are collapsed, and an empty entry is dropped
|
||||
|
||||
<sup>3</sup> The configured web application endpoint followed by `/auth/sso/callback`. No operation can set it
|
||||
<sup>3</sup> The configured web application endpoint followed by `/auth/sso/callback`. It is the only redirect URI the provider needs, mobile sign-in included. No operation can set it
|
||||
|
||||
## Gateway rollout configuration object
|
||||
|
||||
|
||||
@@ -423,11 +423,11 @@ Starts a single sign-on flow. Authentication is not required. Returns an [SSO st
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| redirect_to?<sup>1</sup> | ?string | The post-authentication redirect to bind to the state |
|
||||
| redirect_uri?<sup>2</sup> | ?string | The provider callback URI to use instead of the configured default |
|
||||
| redirect_uri?<sup>2</sup> | ?string | The callback URI the client wants the result delivered to |
|
||||
|
||||
<sup>1</sup> Fluxer sanitises the value before binding it to the state and discards a value that does not survive, which the [SSO completion response](#sso-completion-response-object) reports as the empty string. Sanitisation keeps the trimmed value only when it begins with a single `/`, is at most 2,048 characters, and contains no carriage return or line feed
|
||||
|
||||
<sup>2</sup> The accepted values are the instance default reported as `redirect_uri` by [get SSO status](#get-sso-status) and the mobile callback `fluxer://auth/sso/callback`, and any other value returns the field code `INVALID_URL_FORMAT`. The accepted value is bound to the state and reused at the token exchange
|
||||
<sup>2</sup> The accepted values are the instance default reported as `redirect_uri` by [get SSO status](#get-sso-status) and the mobile callback `fluxer://auth/sso/callback`, and any other value returns the field code `INVALID_URL_FORMAT`. The provider always receives the instance default. For the mobile callback the state starts with `m.`, and the web callback page forwards the provider's query string to `fluxer://auth/sso/callback` unchanged
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const SSO_MOBILE_CALLBACK_URI = 'fluxer://auth/sso/callback';
|
||||
|
||||
export const SSO_MOBILE_STATE_PREFIX = 'm.';
|
||||
Reference in New Issue
Block a user