feat(auth): OIDC phase 2 — login initiation (discover/register/authorize)
- oidc/oidcState.ts (pure, +3 tests): dynamic-registration cache (by issuer + redirectUri, corrupt-tolerant) and parseOidcCallbackParams (success/error/invalid). - oidc/oidcLoginUtil.ts: getOrRegisterClientId (cache + registerOidcClient) and startOidcLogin (discoverAndValidateOIDCIssuerWellKnown -> generateOidcAuthorization Url -> redirect; invalidates the cache on failure). redirectUri is the deterministic getOidcCallbackUrl(), and the SDK returns clientId/issuer on callback, so no hand-rolled transient state is needed. - login/OidcLogin.tsx: native-OIDC button mirroring SSOLogin + TokenLogin async/error. - login/Login.tsx: issuer-gated — when discovery advertises an issuer, render OidcLogin and suppress password/legacy-SSO; non-OIDC servers unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||
import { PasswordLoginForm } from './PasswordLoginForm';
|
||||
import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OidcLogin } from './OidcLogin';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { getOidcIssuer } from '../../../cs-api';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
@@ -36,6 +39,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
|
||||
|
||||
export function Login() {
|
||||
const server = useAuthServer();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -54,6 +58,29 @@ export function Login() {
|
||||
|
||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||
|
||||
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
|
||||
// its `.well-known` discovery, use the native OIDC flow and suppress the
|
||||
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
|
||||
const oidc = getOidcIssuer(discovery);
|
||||
if (oidc.issuer) {
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h1" size="H2" priority="400">
|
||||
Login
|
||||
</Text>
|
||||
<OidcLogin
|
||||
issuer={oidc.issuer}
|
||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
||||
serverName={server}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h1" size="H2" priority="400">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||
|
||||
type OidcLoginProps = {
|
||||
issuer: string;
|
||||
homeserverBaseUrl: string;
|
||||
serverName: string;
|
||||
};
|
||||
/**
|
||||
* Native next-gen-auth (MSC3861/MSC2965) login button. Discovers + registers
|
||||
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
||||
* single-button styling.
|
||||
*/
|
||||
export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||
useCallback(startOidcLogin, []),
|
||||
);
|
||||
|
||||
// Loading = discovering/registering; Success = redirect issued (page is leaving) —
|
||||
// keep the button busy in both cases.
|
||||
const ongoing = state.status === AsyncStatus.Loading || state.status === AsyncStatus.Success;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
{state.status === AsyncStatus.Error && (
|
||||
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
|
||||
Failed to start single sign-on. Please try again.
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
style={{ width: '100%' }}
|
||||
size="500"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
onClick={() => startLogin(issuer, homeserverBaseUrl)}
|
||||
disabled={ongoing}
|
||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||
>
|
||||
<Text align="Center" size="B500" truncate>
|
||||
{`Continue with ${serverName}`}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
completeAuthorizationCodeGrant,
|
||||
discoverAndValidateOIDCIssuerWellKnown,
|
||||
generateOidcAuthorizationUrl,
|
||||
OidcClientConfig,
|
||||
registerOidcClient,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
||||
import { cacheClientId, getCachedClientId, invalidateCachedClient } from './oidcState';
|
||||
|
||||
export { completeAuthorizationCodeGrant };
|
||||
|
||||
export const getOrRegisterClientId = async (
|
||||
config: OidcClientConfig,
|
||||
issuer: string,
|
||||
redirectUri: string,
|
||||
): Promise<string> => {
|
||||
const cached = getCachedClientId(issuer, redirectUri);
|
||||
if (cached) return cached;
|
||||
const clientId = await registerOidcClient(config, getOidcClientMetadata());
|
||||
cacheClientId(issuer, clientId, redirectUri);
|
||||
return clientId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Begin the OIDC authorization-code flow: discover + validate the issuer,
|
||||
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
|
||||
* persists the transient code_verifier/state/nonce/homeserverUrl into
|
||||
* sessionStorage for the callback), then redirect.
|
||||
*/
|
||||
export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise<void> => {
|
||||
const redirectUri = getOidcCallbackUrl();
|
||||
try {
|
||||
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
|
||||
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
|
||||
const url = await generateOidcAuthorizationUrl({
|
||||
metadata: config,
|
||||
redirectUri,
|
||||
clientId,
|
||||
homeserverUrl: homeserverBaseUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
});
|
||||
window.location.assign(url);
|
||||
} catch (e) {
|
||||
// Drop a possibly-stale cached client so the next attempt re-registers.
|
||||
invalidateCachedClient(issuer);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getCachedClientId,
|
||||
cacheClientId,
|
||||
invalidateCachedClient,
|
||||
parseOidcCallbackParams,
|
||||
} from './oidcState';
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, String(v));
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
return store;
|
||||
};
|
||||
|
||||
test('registration cache: get / put / invalidate, scoped by issuer + redirectUri', () => {
|
||||
installStorage();
|
||||
assert.equal(getCachedClientId('iss', 'rd'), undefined);
|
||||
cacheClientId('iss', 'client-1', 'rd');
|
||||
assert.equal(getCachedClientId('iss', 'rd'), 'client-1');
|
||||
assert.equal(getCachedClientId('iss', 'other-redirect'), undefined); // redirect mismatch = miss
|
||||
assert.equal(getCachedClientId('iss2', 'rd'), undefined); // different issuer = miss
|
||||
invalidateCachedClient('iss');
|
||||
assert.equal(getCachedClientId('iss', 'rd'), undefined);
|
||||
invalidateCachedClient('absent'); // no-op, no throw
|
||||
});
|
||||
|
||||
test('registration cache tolerates corrupt storage', () => {
|
||||
const store = installStorage();
|
||||
store.set('cinny_oidc_dynamic_clients', '{ not json');
|
||||
assert.equal(getCachedClientId('iss', 'rd'), undefined);
|
||||
cacheClientId('iss', 'c', 'rd');
|
||||
assert.equal(getCachedClientId('iss', 'rd'), 'c');
|
||||
});
|
||||
|
||||
test('parseOidcCallbackParams classifies success / error / invalid', () => {
|
||||
assert.deepEqual(parseOidcCallbackParams('?code=abc&state=xyz'), {
|
||||
kind: 'success',
|
||||
code: 'abc',
|
||||
state: 'xyz',
|
||||
});
|
||||
assert.deepEqual(parseOidcCallbackParams('?error=access_denied&error_description=nope'), {
|
||||
kind: 'error',
|
||||
error: 'access_denied',
|
||||
errorDescription: 'nope',
|
||||
});
|
||||
assert.deepEqual(parseOidcCallbackParams('?error=bad'), {
|
||||
kind: 'error',
|
||||
error: 'bad',
|
||||
errorDescription: undefined,
|
||||
});
|
||||
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
|
||||
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Pure OIDC-login state helpers (no imports) so they're unit-testable under the
|
||||
// tsx + node:test harness. The SDK-driven flow lives in oidcLoginUtil.ts.
|
||||
|
||||
/**
|
||||
* Dynamic-registration cache. MAS issues a `client_id` per dynamic registration;
|
||||
* caching by issuer (scoped to the current redirectUri) avoids re-registering on
|
||||
* every login. Invalidated when the redirectUri changes or the provider later
|
||||
* rejects the cached id (`invalid_client`).
|
||||
*/
|
||||
const REG_CACHE_KEY = 'cinny_oidc_dynamic_clients';
|
||||
type RegEntry = { clientId: string; redirectUri: string };
|
||||
type RegCache = Record<string, RegEntry>;
|
||||
|
||||
const readRegCache = (): RegCache => {
|
||||
try {
|
||||
const raw = localStorage.getItem(REG_CACHE_KEY);
|
||||
return raw ? (JSON.parse(raw) as RegCache) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const writeRegCache = (cache: RegCache): void => {
|
||||
localStorage.setItem(REG_CACHE_KEY, JSON.stringify(cache));
|
||||
};
|
||||
|
||||
export const getCachedClientId = (issuer: string, redirectUri: string): string | undefined => {
|
||||
const entry = readRegCache()[issuer];
|
||||
return entry && entry.redirectUri === redirectUri ? entry.clientId : undefined;
|
||||
};
|
||||
export const cacheClientId = (issuer: string, clientId: string, redirectUri: string): void => {
|
||||
const cache = readRegCache();
|
||||
cache[issuer] = { clientId, redirectUri };
|
||||
writeRegCache(cache);
|
||||
};
|
||||
export const invalidateCachedClient = (issuer: string): void => {
|
||||
const cache = readRegCache();
|
||||
if (cache[issuer]) {
|
||||
delete cache[issuer];
|
||||
writeRegCache(cache);
|
||||
}
|
||||
};
|
||||
|
||||
/** Parsed shape of the provider's redirect back to our callback URL. */
|
||||
export type OidcCallbackParams =
|
||||
| { kind: 'success'; code: string; state: string }
|
||||
| { kind: 'error'; error: string; errorDescription?: string }
|
||||
| { kind: 'invalid' };
|
||||
|
||||
/** Pure: classify the callback query string into success / error / invalid. */
|
||||
export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
|
||||
const params = new URLSearchParams(search);
|
||||
const error = params.get('error');
|
||||
if (error) {
|
||||
return { kind: 'error', error, errorDescription: params.get('error_description') ?? undefined };
|
||||
}
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (code && state) return { kind: 'success', code, state };
|
||||
return { kind: 'invalid' };
|
||||
};
|
||||
Reference in New Issue
Block a user