From 98ad5674a848f11a272b648c35cccf05700c5536 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 15:51:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20phase=200+1=20=E2=80=94=20?= =?UTF-8?q?discovery,=20flow=20detection,=20client=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward MSC3861/MSC2965 next-gen-auth login (P4-6), client-only. - cs-api.ts: type the stable `m.authentication` well-known key + getOidcIssuer() (stable preferred over the unstable msc2965 key; {} for non-OIDC servers). - useParsedLoginFlows.ts: getOidcCompatibilityFlag() (MSC3824 oauth_aware_preferred / delegated_oidc_compatibility) as a secondary OIDC hint. - New pages/auth/oidc/oidcConfig.ts: dynamic-registration client metadata + the non-hash callback URL (redirect_uris can't contain a fragment). - paths.ts: OIDC_CALLBACK_PATH. - 8 unit tests for the pure helpers. Co-Authored-By: Claude Opus 4.8 --- src/app/cs-api.test.ts | 37 +++++++++++++++++++ src/app/cs-api.ts | 25 +++++++++++++ src/app/hooks/useParsedLoginFlows.test.ts | 45 +++++++++++++++++++++++ src/app/hooks/useParsedLoginFlows.ts | 24 +++++++++++- src/app/pages/auth/oidc/oidcConfig.ts | 37 +++++++++++++++++++ src/app/pages/paths.ts | 6 +++ 6 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/app/cs-api.test.ts create mode 100644 src/app/hooks/useParsedLoginFlows.test.ts create mode 100644 src/app/pages/auth/oidc/oidcConfig.ts diff --git a/src/app/cs-api.test.ts b/src/app/cs-api.test.ts new file mode 100644 index 000000000..878ba6a7a --- /dev/null +++ b/src/app/cs-api.test.ts @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getOidcIssuer, AutoDiscoveryInfo } from './cs-api'; + +const info = (extra: Record): AutoDiscoveryInfo => + ({ 'm.homeserver': { base_url: 'https://hs' }, ...extra }) as AutoDiscoveryInfo; + +test('getOidcIssuer reads the stable m.authentication key', () => { + assert.deepEqual( + getOidcIssuer(info({ 'm.authentication': { issuer: 'https://i', account: 'https://a' } })), + { issuer: 'https://i', account: 'https://a' }, + ); +}); + +test('getOidcIssuer falls back to the unstable msc2965 key', () => { + assert.deepEqual( + getOidcIssuer(info({ 'org.matrix.msc2965.authentication': { issuer: 'https://u' } })), + { issuer: 'https://u', account: undefined }, + ); +}); + +test('getOidcIssuer prefers stable over unstable when both present', () => { + assert.equal( + getOidcIssuer( + info({ + 'm.authentication': { issuer: 'https://stable' }, + 'org.matrix.msc2965.authentication': { issuer: 'https://unstable' }, + }), + ).issuer, + 'https://stable', + ); +}); + +test('getOidcIssuer returns {} for non-OIDC servers', () => { + assert.deepEqual(getOidcIssuer(info({})), {}); + assert.deepEqual(getOidcIssuer(info({ 'm.authentication': {} })), {}); // present but no issuer +}); diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts index 8445a3f07..8d24ca61f 100644 --- a/src/app/cs-api.ts +++ b/src/app/cs-api.ts @@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record & { 'm.identity_server'?: { base_url: string; }; + // v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that + // delegate to a Matrix Authentication Service (e.g. mozilla.org). The + // `org.matrix.msc2965.authentication` key below is the unstable predecessor. + 'm.authentication'?: { + issuer?: string; + account?: string; + }; 'org.matrix.msc2965.authentication'?: { account?: string; issuer?: string; @@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record & { ]; }; +/** + * Resolve the OIDC issuer (and account-management URL) advertised by a homeserver + * in its `.well-known/matrix/client`, preferring the v1.15 stable + * `m.authentication` key over the unstable `org.matrix.msc2965.authentication`. + * Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org). + */ +export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => { + const stable = info['m.authentication']; + if (stable && typeof stable.issuer === 'string') { + return { issuer: stable.issuer, account: stable.account }; + } + const unstable = info['org.matrix.msc2965.authentication']; + if (unstable && typeof unstable.issuer === 'string') { + return { issuer: unstable.issuer, account: unstable.account }; + } + return {}; +}; + export const autoDiscovery = async ( request: typeof fetch, server: string, diff --git a/src/app/hooks/useParsedLoginFlows.test.ts b/src/app/hooks/useParsedLoginFlows.test.ts new file mode 100644 index 000000000..c9a26eff3 --- /dev/null +++ b/src/app/hooks/useParsedLoginFlows.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LoginFlow } from 'matrix-js-sdk/lib/@types/auth'; +import { + getOidcCompatibilityFlag, + getSSOFlow, + getPasswordFlow, + getTokenFlow, +} from './useParsedLoginFlows'; + +const flows = (...f: Record[]): LoginFlow[] => f as unknown as LoginFlow[]; + +test('flow getters pick the right flow', () => { + const f = flows({ type: 'm.login.password' }, { type: 'm.login.sso' }, { type: 'm.login.token' }); + assert.equal(getPasswordFlow(f)?.type, 'm.login.password'); + assert.equal(getSSOFlow(f)?.type, 'm.login.sso'); + assert.equal(getTokenFlow(f)?.type, 'm.login.token'); + assert.equal(getSSOFlow(flows({ type: 'm.login.cas' }))?.type, 'm.login.cas'); +}); + +test('getOidcCompatibilityFlag detects the stable flag name', () => { + assert.equal( + getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: true })), + true, + ); +}); + +test('getOidcCompatibilityFlag detects the msc3824 alt name', () => { + assert.equal( + getOidcCompatibilityFlag( + flows({ type: 'm.login.sso', 'org.matrix.msc3824.delegated_oidc_compatibility': true }), + ), + true, + ); +}); + +test('getOidcCompatibilityFlag is false without sso or without the flag', () => { + assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.password' })), false); + assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.sso' })), false); + // non-true values do not count + assert.equal( + getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: 'yes' })), + false, + ); +}); diff --git a/src/app/hooks/useParsedLoginFlows.ts b/src/app/hooks/useParsedLoginFlows.ts index b2333cc1b..b6e8aa723 100644 --- a/src/app/hooks/useParsedLoginFlows.ts +++ b/src/app/hooks/useParsedLoginFlows.ts @@ -1,5 +1,11 @@ import { useMemo } from 'react'; -import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth'; +import { + ILoginFlow, + IPasswordFlow, + ISSOFlow, + LoginFlow, + OAUTH_AWARE_PREFERRED_FLOW_FIELD, +} from 'matrix-js-sdk/lib/@types/auth'; export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined => loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as @@ -13,6 +19,22 @@ export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => type: 'm.login.token'; }; +/** + * MSC3824: a server may advertise `m.login.sso` while signalling (via the + * `oauth_aware_preferred` / `org.matrix.msc3824.delegated_oidc_compatibility` + * flow field) that it actually prefers native OIDC. This is a *secondary* hint — + * the authoritative signal for next-gen auth is an issuer in `.well-known` + * discovery (see `getOidcIssuer` in cs-api.ts). + */ +export const getOidcCompatibilityFlag = (loginFlows: LoginFlow[]): boolean => { + const sso = getSSOFlow(loginFlows) as (ISSOFlow & Record) | undefined; + if (!sso) return false; + return ( + sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name] === true || + sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName] === true + ); +}; + export type ParsedLoginFlows = { password?: LoginFlow; token?: LoginFlow; diff --git a/src/app/pages/auth/oidc/oidcConfig.ts b/src/app/pages/auth/oidc/oidcConfig.ts new file mode 100644 index 000000000..015c25f33 --- /dev/null +++ b/src/app/pages/auth/oidc/oidcConfig.ts @@ -0,0 +1,37 @@ +import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk'; +import LotusLogo from '../../../../../public/res/Lotus.png'; +import { OIDC_CALLBACK_PATH } from '../../paths'; +import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils'; + +/** + * Absolute URL the OIDC provider redirects back to after authorization. + * + * It MUST be a real (non-hash) path on our origin: OAuth redirect_uris cannot + * contain a fragment, and with hashRouter the app's routes live after `#`. We + * therefore always build it against the plain origin base — `getOriginBaseUrl()` + * with NO hashRouter arg returns `${origin}${BASE_URL}` (no `#`) — and App.tsx + * short-circuits this path before the router mounts. + */ +export const getOidcCallbackUrl = (): string => + withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH); + +/** + * Client metadata sent during MSC2966 dynamic client registration. + * + * `registerOidcClient` drops any URI that doesn't share `clientUri` as a common + * base, so every URI here lives under our origin base. + */ +export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => { + // `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs. + const clientUri = getOriginBaseUrl(); + return { + clientName: 'Lotus Chat', + clientUri, + logoUri: new URL(LotusLogo, window.location.origin).href, + applicationType: 'web', + contacts: ['support@lotusguild.org'], + tosUri: clientUri, + policyUri: clientUri, + redirectUris: [getOidcCallbackUrl()], + }; +}; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index f0f416364..9bbf4e8d9 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -19,6 +19,12 @@ export type ResetPasswordPathSearchParams = { }; export const RESET_PASSWORD_PATH = '/reset-password/:server?/'; +// OIDC/next-gen-auth (MSC3861) authorization-code callback. This is a REAL +// (non-hash) path: OAuth redirect_uris cannot contain a fragment, so it must not +// live under the hash router. App.tsx short-circuits this path before the router +// mounts. The provider returns `?code&state` (or `?error`) on the query string. +export const OIDC_CALLBACK_PATH = '/auth/oidc/callback'; + export const _CREATE_PATH = 'create/'; export const _JOIN_PATH = 'join/'; export const _LOBBY_PATH = 'lobby/';