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' };
+};