diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 7d74f4a1c..16651c0b3 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -28,6 +28,8 @@ import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; import { SeasonalEffect } from '../components/seasonal/SeasonalEffect'; import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; import { zIndices } from '../styles/zIndex'; +import { OIDC_CALLBACK_PATH } from './paths'; +import { OidcCallback } from './auth/oidc/OidcCallback'; const FONT_MAP: Record = { system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", @@ -111,6 +113,14 @@ function App() { const portalContainer = document.getElementById('portalContainer') ?? undefined; + // OIDC/next-gen-auth callback is a real (non-hash) path: OAuth redirect_uris + // can't contain a fragment, so it must be handled OUTSIDE the router. Render + // the standalone callback page before the RouterProvider mounts. It needs no + // app providers (it only touches the SDK + localStorage). + if (window.location.pathname.endsWith(OIDC_CALLBACK_PATH)) { + return ; + } + return ( ( diff --git a/src/app/pages/auth/oidc/OidcCallback.tsx b/src/app/pages/auth/oidc/OidcCallback.tsx new file mode 100644 index 000000000..a1432b7f7 --- /dev/null +++ b/src/app/pages/auth/oidc/OidcCallback.tsx @@ -0,0 +1,113 @@ +import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds'; +import React, { useCallback, useEffect } from 'react'; +import { createClient } from 'matrix-js-sdk'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { setFallbackSession } from '../../../state/sessions'; +import { completeAuthorizationCodeGrant } from './oidcLoginUtil'; +import { getOidcCallbackUrl } from './oidcConfig'; +import { parseOidcCallbackParams } from './oidcState'; + +/** + * Exchange the authorization code for a Matrix session and persist it. The SDK + * (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage + * before the redirect and validates `state` (CSRF). `redirectUri` is the + * deterministic callback URL, recomputed here. + */ +const completeOidcLogin = async (code: string, state: string): Promise => { + const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } = + await completeAuthorizationCodeGrant(code, state); + + // Derive Matrix user_id + device_id from the freshly-issued access token (the + // device is owned by the authentication service under MSC3861). + const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token }); + const { user_id: userId, device_id: deviceId } = await tmp.whoami(); + if (!userId || !deviceId) { + throw new Error('Homeserver did not return a user/device for the OIDC session.'); + } + + setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, { + refreshToken: tokenResponse.refresh_token, + expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined, + oidc: { + issuer: oidcClientSettings.issuer, + clientId: oidcClientSettings.clientId, + redirectUri: getOidcCallbackUrl(), + idTokenClaims: idTokenClaims as unknown as Record, + }, + }); +}; + +function CallbackError({ message }: { message: string }) { + return ( + + + Sign-in failed + + {message} + + {/* Rendered outside the router, so use a plain navigation to the app root. */} + + + ); +} + +/** + * Standalone page mounted by App.tsx for the OIDC redirect path (before the + * router, since the redirect_uri must be a real non-hash path). + */ +export function OidcCallback() { + const params = parseOidcCallbackParams(window.location.search); + + const [state, complete] = useAsyncCallback( + useCallback(completeOidcLogin, []), + ); + + useEffect(() => { + if (params.kind === 'success') complete(params.code, params.state); + }, [params, complete]); + + useEffect(() => { + // Session persisted — full-page reload at the app root so it boots the + // authenticated client (works for both hash and browser router configs). + if (state.status === AsyncStatus.Success) { + window.location.replace(import.meta.env.BASE_URL); + } + }, [state.status]); + + if (params.kind === 'error') { + return ; + } + if (params.kind === 'invalid') { + return ( + + ); + } + if (state.status === AsyncStatus.Error) { + return ; + } + + return ( + + + + Signing you in… + + + ); +}