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:
@@ -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
|
||||
});
|
||||
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||
'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<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 (
|
||||
request: typeof fetch,
|
||||
server: string,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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<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 = {
|
||||
password?: LoginFlow;
|
||||
token?: LoginFlow;
|
||||
|
||||
@@ -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()],
|
||||
};
|
||||
};
|
||||
@@ -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/';
|
||||
|
||||
Reference in New Issue
Block a user