From 3c3bb2e41c6665dc93fa307918b0d4b9fd2eef33 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 24 Oct 2026 16:15:24 -0400 Subject: [PATCH] feat(auth): offer native OAuth sign-in on servers like matrix.org 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 " 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/oauth-sdk44.spec.ts | 26 ++++++++++++++++++++++++ src/app/components/AuthFlowsLoader.tsx | 8 +++++++- src/app/hooks/useAuthFlows.ts | 9 +++++++- src/app/pages/auth/login/Login.tsx | 15 +++++++------- src/app/pages/auth/login/OidcLogin.tsx | 10 ++++++--- src/app/pages/auth/oidc/oidcLoginUtil.ts | 14 ++++++++++--- src/app/pages/auth/register/Register.tsx | 26 +++++++++++++++++++++++- 7 files changed, 92 insertions(+), 16 deletions(-) diff --git a/e2e/oauth-sdk44.spec.ts b/e2e/oauth-sdk44.spec.ts index 20e139fc1..955d15bf5 100644 --- a/e2e/oauth-sdk44.spec.ts +++ b/e2e/oauth-sdk44.spec.ts @@ -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 { const fake: Fake = { + prompts: [], registered: 0, tokenCalls: [], revoked: [], @@ -73,6 +75,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise { 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]); + }); }); diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index b9a7ed335..2bc178eff 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -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; diff --git a/src/app/hooks/useAuthFlows.ts b/src/app/hooks/useAuthFlows.ts index 7bb7ddc55..785a4fc60 100644 --- a/src/app/hooks/useAuthFlows.ts +++ b/src/app/hooks/useAuthFlows.ts @@ -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(null); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 93fbd6935..b748475a4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -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 ( diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx index 0bf37ef25..7490571a5 100644 --- a/src/app/pages/auth/login/OidcLogin.tsx +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -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>( useCallback(startOidcLogin, []), ); @@ -25,7 +27,9 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) { {state.status === AsyncStatus.Error && ( - 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.'} )}