diff --git a/fluxer_api/src/api/auth/services/SsoService.ts b/fluxer_api/src/api/auth/services/SsoService.ts index 78ad9059f..de7988467 100644 --- a/fluxer_api/src/api/auth/services/SsoService.ts +++ b/fluxer_api/src/api/auth/services/SsoService.ts @@ -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<{ diff --git a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts index 489c5c16e..3a1259320 100644 --- a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts +++ b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts @@ -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(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(harness) .post('/auth/sso/complete') diff --git a/fluxer_app/src/features/auth/components/pages/LoginPage.module.css b/fluxer_app/src/features/auth/components/pages/LoginPage.module.css index 1fd7f61ca..5049ef899 100644 --- a/fluxer_app/src/features/auth/components/pages/LoginPage.module.css +++ b/fluxer_app/src/features/auth/components/pages/LoginPage.module.css @@ -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; } diff --git a/fluxer_app/src/features/auth/components/pages/SsoCallbackPage.tsx b/fluxer_app/src/features/auth/components/pages/SsoCallbackPage.tsx index 98f99ba0f..3f8870ab9 100644 --- a/fluxer_app/src/features/auth/components/pages/SsoCallbackPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/SsoCallbackPage.tsx @@ -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(null); const [isProcessing, setIsProcessing] = useState(true); const abortControllerRef = useRef(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 ( +
+

+ Completing sign-in… +

+

+ Jump straight to the app to continue. +

+ +
+ ); + } if (error) { return (
diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index eba82db6d..878a2092c 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -73,7 +73,7 @@ Single sign-on settings for the deployment's OpenID Connect provider. 2 Each entry is stored lowercased and IDNA encoded, duplicates are collapsed, and an empty entry is dropped -3 The configured web application endpoint followed by `/auth/sso/callback`. No operation can set it +3 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 diff --git a/fluxer_docs/src/content/docs/http-api/authentication.mdx b/fluxer_docs/src/content/docs/http-api/authentication.mdx index 60104d6d7..16582a008 100644 --- a/fluxer_docs/src/content/docs/http-api/authentication.mdx +++ b/fluxer_docs/src/content/docs/http-api/authentication.mdx @@ -423,11 +423,11 @@ Starts a single sign-on flow. Authentication is not required. Returns an [SSO st | Field | Type | Description | | --- | --- | --- | | redirect_to?1 | ?string | The post-authentication redirect to bind to the state | -| redirect_uri?2 | ?string | The provider callback URI to use instead of the configured default | +| redirect_uri?2 | ?string | The callback URI the client wants the result delivered to | 1 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 -2 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 +2 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 diff --git a/packages/constants/src/SsoConstants.ts b/packages/constants/src/SsoConstants.ts new file mode 100644 index 000000000..0e81d751d --- /dev/null +++ b/packages/constants/src/SsoConstants.ts @@ -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.';