Files
cinny/src/app/pages/auth/oidc/OidcCallback.tsx
T

114 lines
4.1 KiB
TypeScript
Raw Normal View History

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>
);
}