feat(auth): offer native OAuth sign-in on servers like matrix.org
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m45s

The login page looked for an OAuth issuer in .well-known
(m.authentication), which matrix.org no longer publishes, so its users
got the password / SSO compatibility forms. Detect native OAuth the way
the SDK finds the auth server for refresh: load /auth_metadata next to
the login flows. When present:
- Login shows "Continue with <server>" only (as Element does); a legacy
  SSO loginToken coming back from a redirect still completes.
- Register opens the auth server's sign-up page (prompt=create, where
  prompt_values_supported says so) instead of the legacy forms.
Servers without it (Lotus) are unchanged.

Checked against the live servers: matrix.org login and register show
"Continue with matrix.org" and no password form; matrix.lotusguild.org
keeps its password form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-24 16:15:24 -04:00
co-authored by Claude Opus 5.5
parent 88446e8953
commit 3c3bb2e41c
7 changed files with 92 additions and 16 deletions
+26
View File
@@ -11,6 +11,7 @@ import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs
const AUTH = 'https://auth.fake.test';
type Fake = {
prompts: (string | null)[];
registered: number;
tokenCalls: { grant: string; refresh?: string }[];
revoked: string[];
@@ -33,6 +34,7 @@ async function synapseToken(localpart: string, deviceId: string): Promise<string
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
const fake: Fake = {
prompts: [],
registered: 0,
tokenCalls: [],
revoked: [],
@@ -73,6 +75,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fa
response_modes_supported: ['query', 'fragment'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
prompt_values_supported: ['create'],
},
}),
);
@@ -107,6 +110,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fa
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
url.searchParams.get('scope') ?? '',
)?.[1];
fake.prompts.push(url.searchParams.get('prompt'));
expect(url.searchParams.get('response_mode')).toBe('query');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
const code = `code-${Date.now()}`;
@@ -231,4 +235,26 @@ test.describe('OAuth sign-in on SDK 44', () => {
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
expect(await storedSession(page)).toBeNull();
});
test('Register opens the auth server sign-up page (prompt=create)', async ({ context }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('or'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
await page.goto(`/register/${enc(HS)}/`);
// Native-OAuth server: no legacy registration form, just the auth server.
await expect(page.getByLabel('Username', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: /Continue with/ }).click();
await page.waitForURL(/\/home/, { timeout: 60_000 });
expect(fake.prompts).toEqual(['create']);
});
test('Login sends no prompt', async ({ context }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('ol'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
await oauthLogin(page);
expect(fake.prompts).toEqual([null]);
});
});
+7 -1
View File
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const [state, load] = useAsyncCallback(
useCallback(async () => {
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
const result = await Promise.allSettled([
mx.loginFlows(),
mx.registerRequest({}),
// Rejects when the server has no native OAuth: normal for most servers.
mx.getAuthMetadata(),
]);
const loginFlows = promiseFulfilledResult(result[0]);
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const authFlows: AuthFlows = {
loginFlows,
registerFlows,
authMetadata: promiseFulfilledResult(result[2]),
};
return authFlows;
+8 -1
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
import { IAuthData, MatrixError } from 'matrix-js-sdk';
import { IAuthData, MatrixError, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
export enum RegisterFlowStatus {
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
export type AuthFlows = {
loginFlows: ILoginFlowsResponse;
registerFlows: RegisterFlowsResponse;
/**
* The homeserver's OAuth 2.0 metadata (`/_matrix/client/v1/auth_metadata`),
* when it uses native OAuth (MSC3861, e.g. matrix.org via MAS). The same
* source the SDK uses to refresh tokens, so sign-in is only offered when
* refresh will work too.
*/
authMetadata?: ValidatedAuthMetadata;
};
const AuthFlowsContext = createContext<AuthFlows | null>(null);
+8 -7
View File
@@ -10,7 +10,6 @@ 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';
@@ -41,7 +40,7 @@ export function Login() {
const server = useAuthServer();
const discovery = useAutoDiscoveryInfo();
const { hashRouter } = useClientConfig();
const { loginFlows } = useAuthFlows();
const { loginFlows, authMetadata } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
@@ -58,11 +57,13 @@ 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) {
// Next-gen auth (MSC3861): when the homeserver serves OAuth metadata
// (`/auth_metadata`, e.g. matrix.org via MAS), use the native OAuth flow and
// suppress the password / legacy-SSO forms, as Element does. Detected the way
// the SDK finds the auth server for refresh; the old `.well-known`
// `m.authentication` key is gone from matrix.org. A legacy SSO `loginToken`
// coming back from a redirect still completes below.
if (authMetadata && !loginSearchParams.loginToken) {
return (
<Box direction="Column" gap="500">
<Text as="h1" size="H2" priority="400">
+7 -3
View File
@@ -4,6 +4,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { startOidcLogin } from '../oidc/oidcLoginUtil';
type OidcLoginProps = {
/** Register: open the auth server's sign-up page. */
register?: boolean;
homeserverBaseUrl: string;
serverName: string;
};
@@ -12,7 +14,7 @@ type OidcLoginProps = {
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
* single-button styling.
*/
export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
export function OidcLogin({ homeserverBaseUrl, serverName, register }: OidcLoginProps) {
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
useCallback(startOidcLogin, []),
);
@@ -25,7 +27,9 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
<Box direction="Column" gap="300">
{state.status === AsyncStatus.Error && (
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
Failed to start single sign-on. Please try again.
{register
? 'Failed to open sign-up. Please try again.'
: 'Failed to start single sign-on. Please try again.'}
</Text>
)}
<Button
@@ -34,7 +38,7 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
variant="Secondary"
fill="Soft"
outlined
onClick={() => startLogin(homeserverBaseUrl)}
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
disabled={ongoing}
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
>
+11 -3
View File
@@ -31,10 +31,14 @@ export const getOrRegisterClientId = async (
/**
* Begin the OAuth authorization-code flow: discover the auth server through
* the homeserver, (re)register a client, remember the PKCE verifier + device
* under a fresh `state` (oidcState), then redirect. The callback reads the
* under a fresh `state` (oidcState), then redirect. `prompt: 'create'` opens
* the auth server's sign-up page instead, where supported. The callback reads the
* result from the query string (`response_mode=query`).
*/
export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> => {
export const startOidcLogin = async (
homeserverBaseUrl: string,
prompt?: 'create',
): Promise<void> => {
const redirectUri = getOidcCallbackUrl();
let issuer: string | undefined;
try {
@@ -52,8 +56,12 @@ export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> =
redirectUri,
};
savePendingOidcLogin(state, pending);
// `prompt=create` asks the auth server for its sign-up page (Register),
// only where it says it supports that.
const usePrompt =
prompt && metadata.prompt_values_supported?.includes(prompt) ? prompt : undefined;
window.location.assign(
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'),
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query', usePrompt),
);
} catch (e) {
// #67 — drop the cached client so the next attempt re-registers, but ONLY
+25 -1
View File
@@ -12,6 +12,8 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
import { getLoginPath } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '../../paths';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { OidcLogin } from '../login/OidcLogin';
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
useMemo(
@@ -25,7 +27,8 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
export function Register() {
const server = useAuthServer();
const { loginFlows, registerFlows } = useAuthFlows();
const { loginFlows, registerFlows, authMetadata } = useAuthFlows();
const discovery = useAutoDiscoveryInfo();
const [searchParams] = useSearchParams();
const registerSearchParams = useRegisterSearchParams(searchParams);
const { sso } = useParsedLoginFlows(loginFlows.flows);
@@ -33,6 +36,27 @@ export function Register() {
// redirect to /login because only that path handle m.login.token
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
// Native OAuth servers (e.g. matrix.org via MAS) create accounts at the auth
// server; /register here would only offer the legacy forms. Mirrors Login.
if (authMetadata) {
return (
<Box direction="Column" gap="500">
<Text as="h2" size="H2" priority="400">
Register
</Text>
<OidcLogin
register
homeserverBaseUrl={discovery['m.homeserver'].base_url}
serverName={server}
/>
<span data-spacing-node />
<Text align="Center">
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
</Text>
</Box>
);
}
return (
<Box direction="Column" gap="500">
<Text as="h2" size="H2" priority="400">