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'?: {
|
'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,
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
|||||||
@@ -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?/';
|
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/';
|
||||||
|
|||||||
Reference in New Issue
Block a user