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
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:
co-authored by
Claude Opus 5.5
parent
88446e8953
commit
3c3bb2e41c
@@ -11,6 +11,7 @@ import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs
|
|||||||
const AUTH = 'https://auth.fake.test';
|
const AUTH = 'https://auth.fake.test';
|
||||||
|
|
||||||
type Fake = {
|
type Fake = {
|
||||||
|
prompts: (string | null)[];
|
||||||
registered: number;
|
registered: number;
|
||||||
tokenCalls: { grant: string; refresh?: string }[];
|
tokenCalls: { grant: string; refresh?: string }[];
|
||||||
revoked: 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> {
|
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
|
||||||
const fake: Fake = {
|
const fake: Fake = {
|
||||||
|
prompts: [],
|
||||||
registered: 0,
|
registered: 0,
|
||||||
tokenCalls: [],
|
tokenCalls: [],
|
||||||
revoked: [],
|
revoked: [],
|
||||||
@@ -73,6 +75,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fa
|
|||||||
response_modes_supported: ['query', 'fragment'],
|
response_modes_supported: ['query', 'fragment'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
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(
|
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
|
||||||
url.searchParams.get('scope') ?? '',
|
url.searchParams.get('scope') ?? '',
|
||||||
)?.[1];
|
)?.[1];
|
||||||
|
fake.prompts.push(url.searchParams.get('prompt'));
|
||||||
expect(url.searchParams.get('response_mode')).toBe('query');
|
expect(url.searchParams.get('response_mode')).toBe('query');
|
||||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
const code = `code-${Date.now()}`;
|
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(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
|
||||||
expect(await storedSession(page)).toBeNull();
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||||||
|
|
||||||
const [state, load] = useAsyncCallback(
|
const [state, load] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
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 loginFlows = promiseFulfilledResult(result[0]);
|
||||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||||
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||||||
const authFlows: AuthFlows = {
|
const authFlows: AuthFlows = {
|
||||||
loginFlows,
|
loginFlows,
|
||||||
registerFlows,
|
registerFlows,
|
||||||
|
authMetadata: promiseFulfilledResult(result[2]),
|
||||||
};
|
};
|
||||||
|
|
||||||
return authFlows;
|
return authFlows;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react';
|
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';
|
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
|
||||||
|
|
||||||
export enum RegisterFlowStatus {
|
export enum RegisterFlowStatus {
|
||||||
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
|
|||||||
export type AuthFlows = {
|
export type AuthFlows = {
|
||||||
loginFlows: ILoginFlowsResponse;
|
loginFlows: ILoginFlowsResponse;
|
||||||
registerFlows: RegisterFlowsResponse;
|
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);
|
const AuthFlowsContext = createContext<AuthFlows | null>(null);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { SSOLogin } from '../SSOLogin';
|
|||||||
import { TokenLogin } from './TokenLogin';
|
import { TokenLogin } from './TokenLogin';
|
||||||
import { OidcLogin } from './OidcLogin';
|
import { OidcLogin } from './OidcLogin';
|
||||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
import { getOidcIssuer } from '../../../cs-api';
|
|
||||||
import { OrDivider } from '../OrDivider';
|
import { OrDivider } from '../OrDivider';
|
||||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||||
@@ -41,7 +40,7 @@ export function Login() {
|
|||||||
const server = useAuthServer();
|
const server = useAuthServer();
|
||||||
const discovery = useAutoDiscoveryInfo();
|
const discovery = useAutoDiscoveryInfo();
|
||||||
const { hashRouter } = useClientConfig();
|
const { hashRouter } = useClientConfig();
|
||||||
const { loginFlows } = useAuthFlows();
|
const { loginFlows, authMetadata } = useAuthFlows();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||||
@@ -58,11 +57,13 @@ export function Login() {
|
|||||||
|
|
||||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||||
|
|
||||||
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
|
// Next-gen auth (MSC3861): when the homeserver serves OAuth metadata
|
||||||
// its `.well-known` discovery, use the native OIDC flow and suppress the
|
// (`/auth_metadata`, e.g. matrix.org via MAS), use the native OAuth flow and
|
||||||
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
|
// suppress the password / legacy-SSO forms, as Element does. Detected the way
|
||||||
const oidc = getOidcIssuer(discovery);
|
// the SDK finds the auth server for refresh; the old `.well-known`
|
||||||
if (oidc.issuer) {
|
// `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 (
|
return (
|
||||||
<Box direction="Column" gap="500">
|
<Box direction="Column" gap="500">
|
||||||
<Text as="h1" size="H2" priority="400">
|
<Text as="h1" size="H2" priority="400">
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||||||
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||||
|
|
||||||
type OidcLoginProps = {
|
type OidcLoginProps = {
|
||||||
|
/** Register: open the auth server's sign-up page. */
|
||||||
|
register?: boolean;
|
||||||
homeserverBaseUrl: string;
|
homeserverBaseUrl: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
};
|
};
|
||||||
@@ -12,7 +14,7 @@ type OidcLoginProps = {
|
|||||||
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
||||||
* single-button styling.
|
* 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>>(
|
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||||
useCallback(startOidcLogin, []),
|
useCallback(startOidcLogin, []),
|
||||||
);
|
);
|
||||||
@@ -25,7 +27,9 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
|||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{state.status === AsyncStatus.Error && (
|
{state.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -34,7 +38,7 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => startLogin(homeserverBaseUrl)}
|
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
|
||||||
disabled={ongoing}
|
disabled={ongoing}
|
||||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ export const getOrRegisterClientId = async (
|
|||||||
/**
|
/**
|
||||||
* Begin the OAuth authorization-code flow: discover the auth server through
|
* Begin the OAuth authorization-code flow: discover the auth server through
|
||||||
* the homeserver, (re)register a client, remember the PKCE verifier + device
|
* 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`).
|
* 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();
|
const redirectUri = getOidcCallbackUrl();
|
||||||
let issuer: string | undefined;
|
let issuer: string | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -52,8 +56,12 @@ export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> =
|
|||||||
redirectUri,
|
redirectUri,
|
||||||
};
|
};
|
||||||
savePendingOidcLogin(state, pending);
|
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(
|
window.location.assign(
|
||||||
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'),
|
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query', usePrompt),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
|
|||||||
import { getLoginPath } from '../../pathUtils';
|
import { getLoginPath } from '../../pathUtils';
|
||||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||||
import { RegisterPathSearchParams } from '../../paths';
|
import { RegisterPathSearchParams } from '../../paths';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { OidcLogin } from '../login/OidcLogin';
|
||||||
|
|
||||||
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
|
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
|
||||||
useMemo(
|
useMemo(
|
||||||
@@ -25,7 +27,8 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
|
|||||||
|
|
||||||
export function Register() {
|
export function Register() {
|
||||||
const server = useAuthServer();
|
const server = useAuthServer();
|
||||||
const { loginFlows, registerFlows } = useAuthFlows();
|
const { loginFlows, registerFlows, authMetadata } = useAuthFlows();
|
||||||
|
const discovery = useAutoDiscoveryInfo();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const registerSearchParams = useRegisterSearchParams(searchParams);
|
const registerSearchParams = useRegisterSearchParams(searchParams);
|
||||||
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
||||||
@@ -33,6 +36,27 @@ export function Register() {
|
|||||||
// redirect to /login because only that path handle m.login.token
|
// redirect to /login because only that path handle m.login.token
|
||||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
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 (
|
return (
|
||||||
<Box direction="Column" gap="500">
|
<Box direction="Column" gap="500">
|
||||||
<Text as="h2" size="H2" priority="400">
|
<Text as="h2" size="H2" priority="400">
|
||||||
|
|||||||
Reference in New Issue
Block a user