feat(auth): OIDC phase 3 — authorization-code callback route
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
|||||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
import { OIDC_CALLBACK_PATH } from './paths';
|
||||||
|
import { OidcCallback } from './auth/oidc/OidcCallback';
|
||||||
|
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
@@ -111,6 +113,14 @@ function App() {
|
|||||||
|
|
||||||
const portalContainer = document.getElementById('portalContainer') ?? undefined;
|
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 <OidcCallback />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
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<string, unknown>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function CallbackError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ height: '100dvh', padding: config.space.S700, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<Icon size="600" filled src={Icons.Warning} style={{ color: color.Critical.Main }} />
|
||||||
|
<Text size="H3">Sign-in failed</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{/* Rendered outside the router, so use a plain navigation to the app root. */}
|
||||||
|
<Button variant="Primary" onClick={() => window.location.assign(import.meta.env.BASE_URL)}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Back to login
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void, unknown, [string, string]>(
|
||||||
|
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 <CallbackError message={params.errorDescription || params.error} />;
|
||||||
|
}
|
||||||
|
if (params.kind === 'invalid') {
|
||||||
|
return (
|
||||||
|
<CallbackError message="This sign-in link is invalid or has expired. Please start over." />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === AsyncStatus.Error) {
|
||||||
|
return <CallbackError message="Failed to complete sign-in. Please try again." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ height: '100dvh' }}
|
||||||
|
>
|
||||||
|
<Spinner size="600" variant="Secondary" />
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Signing you in…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user