feat(auth): OIDC phase 0+1 — discovery, flow detection, client config

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:51:23 -04:00
parent 30d0331174
commit 98ad5674a8
6 changed files with 173 additions and 1 deletions
+37
View File
@@ -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<string, unknown>): 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
});
+25
View File
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: { 'm.identity_server'?: {
base_url: string; 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'?: { 'org.matrix.msc2965.authentication'?: {
account?: string; account?: string;
issuer?: string; issuer?: string;
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
]; ];
}; };
/**
* 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 ( export const autoDiscovery = async (
request: typeof fetch, request: typeof fetch,
server: string, server: string,
+45
View File
@@ -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<string, unknown>[]): 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,
);
});
+23 -1
View File
@@ -1,5 +1,11 @@
import { useMemo } from 'react'; 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 => export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined =>
loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as 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'; 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<string, unknown>) | 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 = { export type ParsedLoginFlows = {
password?: LoginFlow; password?: LoginFlow;
token?: LoginFlow; token?: LoginFlow;
+37
View File
@@ -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()],
};
};
+6
View File
@@ -19,6 +19,12 @@ export type ResetPasswordPathSearchParams = {
}; };
export const RESET_PASSWORD_PATH = '/reset-password/:server?/'; 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 _CREATE_PATH = 'create/';
export const _JOIN_PATH = 'join/'; export const _JOIN_PATH = 'join/';
export const _LOBBY_PATH = 'lobby/'; export const _LOBBY_PATH = 'lobby/';