From dd6b0bccb3ed113ddf9420b160e2a1e6a8a76b2e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 16:05:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20phase=203=20=E2=80=94=20au?= =?UTF-8?q?thorization-code=20callback=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oidc/OidcCallback.tsx: standalone page that exchanges code+state via completeAuthorizationCodeGrant (SDK validates state = CSRF), derives user_id/device_id from the new access token via whoami(), persists the OIDC session (refresh token + expiry + issuer/clientId/redirectUri/idTokenClaims), then full-page-reloads at the app root. Minimal UI (no Overlay/portal) so it needs no app providers. - App.tsx: short-circuit — render OidcCallback before the RouterProvider when the path is the OIDC callback (redirect_uris can't contain a fragment, so it must live outside the hash router). The nginx SPA catch-all already serves index.html for it. Co-Authored-By: Claude Opus 4.8 --- src/app/pages/App.tsx | 10 ++ src/app/pages/auth/oidc/OidcCallback.tsx | 113 +++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/app/pages/auth/oidc/OidcCallback.tsx 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… + + + ); +}