From a50d3e7ca7042a1d7f4a22713600d88473d1f93d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 16:01:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20phase=202=20=E2=80=94=20lo?= =?UTF-8?q?gin=20initiation=20(discover/register/authorize)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/pages/auth/login/Login.tsx | 27 ++++++++++ src/app/pages/auth/login/OidcLogin.tsx | 48 ++++++++++++++++++ src/app/pages/auth/oidc/oidcLoginUtil.ts | 49 ++++++++++++++++++ src/app/pages/auth/oidc/oidcState.test.ts | 62 +++++++++++++++++++++++ src/app/pages/auth/oidc/oidcState.ts | 60 ++++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/app/pages/auth/login/OidcLogin.tsx create mode 100644 src/app/pages/auth/oidc/oidcLoginUtil.ts create mode 100644 src/app/pages/auth/oidc/oidcState.test.ts create mode 100644 src/app/pages/auth/oidc/oidcState.ts diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 91634747d..fb8082fda 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -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 ( + + + Login + + + + + Do not have an account? Register + + + ); + } + return ( diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx new file mode 100644 index 000000000..eaf4e9f6a --- /dev/null +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -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>( + 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 ( + + {state.status === AsyncStatus.Error && ( + + Failed to start single sign-on. Please try again. + + )} + + + ); +} diff --git a/src/app/pages/auth/oidc/oidcLoginUtil.ts b/src/app/pages/auth/oidc/oidcLoginUtil.ts new file mode 100644 index 000000000..73c2e4a16 --- /dev/null +++ b/src/app/pages/auth/oidc/oidcLoginUtil.ts @@ -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 => { + 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 => { + 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; + } +}; diff --git a/src/app/pages/auth/oidc/oidcState.test.ts b/src/app/pages/auth/oidc/oidcState.test.ts new file mode 100644 index 000000000..9a4276764 --- /dev/null +++ b/src/app/pages/auth/oidc/oidcState.test.ts @@ -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 => { + const store = new Map(); + (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' }); +}); diff --git a/src/app/pages/auth/oidc/oidcState.ts b/src/app/pages/auth/oidc/oidcState.ts new file mode 100644 index 000000000..cb1300157 --- /dev/null +++ b/src/app/pages/auth/oidc/oidcState.ts @@ -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; + +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' }; +};