From c7f7b7be9da4b47d23c3a553f77510b9774e2da3 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 20 Aug 2026 13:10:31 +0700 Subject: [PATCH 1/3] Enable login_via_existing_session in local dev/test Synapse Turn on Synapse's login_via_existing_session feature in the dev and test homeserver configs so the MSC3882 endpoint POST /_matrix/client/v1/login/get_token is available. An access-token holder can mint a short-lived (2m), single-use login token without being re-prompted for the password (require_ui_auth: false), which lets a pre-authenticated client hand off a session to the browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/matrix/support/synapse/dev/homeserver.yaml | 5 +++++ .../synapse/test-without-registration-token/homeserver.yaml | 5 +++++ packages/matrix/support/synapse/test/homeserver.yaml | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/packages/matrix/support/synapse/dev/homeserver.yaml b/packages/matrix/support/synapse/dev/homeserver.yaml index 0ccc3eccbe5..99f5eab99c9 100644 --- a/packages/matrix/support/synapse/dev/homeserver.yaml +++ b/packages/matrix/support/synapse/dev/homeserver.yaml @@ -93,6 +93,11 @@ suppress_key_server_warning: true ui_auth: session_timeout: "300s" +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: "2m" + email: smtp_host: "boxel-smtp" smtp_port: 25 diff --git a/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml b/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml index 1844a184610..c228a43062f 100644 --- a/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml +++ b/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml @@ -81,6 +81,11 @@ suppress_key_server_warning: true ui_auth: session_timeout: "300s" +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: "2m" + email: smtp_host: "boxel-smtp" smtp_port: 25 diff --git a/packages/matrix/support/synapse/test/homeserver.yaml b/packages/matrix/support/synapse/test/homeserver.yaml index 621a3deede9..d0409fa599f 100644 --- a/packages/matrix/support/synapse/test/homeserver.yaml +++ b/packages/matrix/support/synapse/test/homeserver.yaml @@ -84,6 +84,11 @@ suppress_key_server_warning: true ui_auth: session_timeout: "300s" +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: "2m" + email: smtp_host: "boxel-smtp" smtp_port: 25 From e51b78fc95d088975f0379906ef0442db67f4187 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 21 Aug 2026 15:40:47 +0700 Subject: [PATCH 2/3] Add matrix test for login_via_existing_session token exchange Verify the MSC3882 endpoint the test homeserver now enables: an access-token holder mints a short-lived login token via POST /_matrix/client/v1/login/get_token and exchanges it for a fresh session on a new device. Also assert the endpoint is recognized and auth-enforced (401 M_MISSING_TOKEN) and that a login token is single-use. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/login-via-existing-session.spec.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/matrix/tests/login-via-existing-session.spec.ts diff --git a/packages/matrix/tests/login-via-existing-session.spec.ts b/packages/matrix/tests/login-via-existing-session.spec.ts new file mode 100644 index 00000000000..3d026dad85b --- /dev/null +++ b/packages/matrix/tests/login-via-existing-session.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { getSynapseURL } from '../support/environment-config.ts'; +import { createUser } from '../helpers/index.ts'; + +// Exercises Synapse's login_via_existing_session feature (MSC3882), which the +// test homeserver config enables. A client holding an access token mints a +// short-lived, single-use login token and exchanges it for a fresh session — +// the mechanism that hands a pre-authenticated session off to the browser. +test.describe('login_via_existing_session', () => { + test('an access-token holder can mint a login token and exchange it for a session', async () => { + let { credentials } = await createUser('login-token'); + + // Mint a login token with the existing session's access token. + let getTokenResponse = await fetch( + `${getSynapseURL()}/_matrix/client/v1/login/get_token`, + { + method: 'POST', + headers: { Authorization: `Bearer ${credentials.accessToken}` }, + body: JSON.stringify({}), + }, + ); + expect( + getTokenResponse.status, + 'get_token succeeds for an authenticated caller', + ).toBe(200); + let { login_token, expires_in_ms } = (await getTokenResponse.json()) as { + login_token: string; + expires_in_ms: number; + }; + expect(login_token, 'a login token is returned').toBeTruthy(); + // token_timeout is configured as "2m" in the test homeserver.yaml. + expect(expires_in_ms).toBe(120_000); + + // Exchange the login token for a brand-new session belonging to the same user. + let loginResponse = await fetch( + `${getSynapseURL()}/_matrix/client/v3/login`, + { + method: 'POST', + body: JSON.stringify({ type: 'm.login.token', token: login_token }), + }, + ); + expect(loginResponse.status, 'login with the token succeeds').toBe(200); + let session = (await loginResponse.json()) as { + user_id: string; + access_token: string; + device_id: string; + }; + expect(session.user_id).toBe(credentials.userId); + expect(session.access_token, 'a fresh access token is issued').toBeTruthy(); + expect(session.device_id).toBeTruthy(); + // The handed-off session is independent of the caller's device. + expect(session.device_id).not.toBe(credentials.deviceId); + }); + + test('the endpoint is recognized and requires authentication', async () => { + // Before the feature is enabled Synapse returns M_UNRECOGNIZED for this + // route; with it enabled an unauthenticated call is rejected as + // M_MISSING_TOKEN, proving the endpoint is wired up and enforcing auth. + let response = await fetch( + `${getSynapseURL()}/_matrix/client/v1/login/get_token`, + { method: 'POST', body: JSON.stringify({}) }, + ); + expect(response.status).toBe(401); + let body = (await response.json()) as { errcode: string }; + expect(body.errcode).toBe('M_MISSING_TOKEN'); + }); + + test('a login token is single-use', async () => { + let { credentials } = await createUser('login-token-reuse'); + + let { login_token } = (await ( + await fetch(`${getSynapseURL()}/_matrix/client/v1/login/get_token`, { + method: 'POST', + headers: { Authorization: `Bearer ${credentials.accessToken}` }, + body: JSON.stringify({}), + }) + ).json()) as { login_token: string }; + + let first = await fetch(`${getSynapseURL()}/_matrix/client/v3/login`, { + method: 'POST', + body: JSON.stringify({ type: 'm.login.token', token: login_token }), + }); + expect(first.status, 'the first exchange succeeds').toBe(200); + + let second = await fetch(`${getSynapseURL()}/_matrix/client/v3/login`, { + method: 'POST', + body: JSON.stringify({ type: 'm.login.token', token: login_token }), + }); + expect(second.status, 'the token cannot be reused').toBe(403); + }); +}); From 7e61a15d63297f53f4794164394b5548388c7f37 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 24 Aug 2026 20:16:14 +0700 Subject: [PATCH 3/3] Add ?loginToken browser E2E and document get_token rate limit Address review feedback on the login_via_existing_session tests: - Add an end-to-end test that guards this repo's own path: mint a login token, hand it to the browser via ?loginToken, and assert the app lands pre-authenticated (no password entered) and the single-use token is stripped from the URL so a refresh doesn't re-trigger the spent exchange. - Drop the M_MISSING_TOKEN test: it was subsumed by the mint test (the route unregisters when the feature is off, so that test already fails) and only re-asserted upstream auth behavior. - Keep the expires_in_ms assertion (the one check that the yaml block reaches Synapse) and the single-use check. - Note in each homeserver.yaml that Synapse hardcodes get_token to one request per minute per user id, so each test mints for a fresh user. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../support/synapse/dev/homeserver.yaml | 4 + .../homeserver.yaml | 4 + .../support/synapse/test/homeserver.yaml | 4 + .../tests/login-via-existing-session.spec.ts | 115 +++++++++++------- 4 files changed, 82 insertions(+), 45 deletions(-) diff --git a/packages/matrix/support/synapse/dev/homeserver.yaml b/packages/matrix/support/synapse/dev/homeserver.yaml index 99f5eab99c9..8cb6c485467 100644 --- a/packages/matrix/support/synapse/dev/homeserver.yaml +++ b/packages/matrix/support/synapse/dev/homeserver.yaml @@ -97,6 +97,10 @@ login_via_existing_session: enabled: true require_ui_auth: false token_timeout: "2m" + # Synapse rate-limits get_token to one request per minute per user id. That + # limit is hardcoded in the servlet — no rc_* setting and no admin + # override_ratelimit relaxes it — so anything minting more than one token + # per minute needs a distinct account per mint. email: smtp_host: "boxel-smtp" diff --git a/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml b/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml index c228a43062f..95cfa2bb3e4 100644 --- a/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml +++ b/packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml @@ -85,6 +85,10 @@ login_via_existing_session: enabled: true require_ui_auth: false token_timeout: "2m" + # Synapse rate-limits get_token to one request per minute per user id. That + # limit is hardcoded in the servlet — no rc_* setting and no admin + # override_ratelimit relaxes it — so anything minting more than one token + # per minute needs a distinct account per mint. email: smtp_host: "boxel-smtp" diff --git a/packages/matrix/support/synapse/test/homeserver.yaml b/packages/matrix/support/synapse/test/homeserver.yaml index d0409fa599f..e6c961dc546 100644 --- a/packages/matrix/support/synapse/test/homeserver.yaml +++ b/packages/matrix/support/synapse/test/homeserver.yaml @@ -88,6 +88,10 @@ login_via_existing_session: enabled: true require_ui_auth: false token_timeout: "2m" + # Synapse rate-limits get_token to one request per minute per user id. That + # limit is hardcoded in the servlet — no rc_* setting and no admin + # override_ratelimit relaxes it — so anything minting more than one token + # per minute needs a distinct account per mint. email: smtp_host: "boxel-smtp" diff --git a/packages/matrix/tests/login-via-existing-session.spec.ts b/packages/matrix/tests/login-via-existing-session.spec.ts index 3d026dad85b..781fe396752 100644 --- a/packages/matrix/tests/login-via-existing-session.spec.ts +++ b/packages/matrix/tests/login-via-existing-session.spec.ts @@ -1,37 +1,81 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures.ts'; +import { appURL } from '../support/isolated-realm-server.ts'; import { getSynapseURL } from '../support/environment-config.ts'; -import { createUser } from '../helpers/index.ts'; +import { + createUser, + createSubscribedUser, + setupPermissions, + assertLoggedIn, +} from '../helpers/index.ts'; // Exercises Synapse's login_via_existing_session feature (MSC3882), which the // test homeserver config enables. A client holding an access token mints a -// short-lived, single-use login token and exchanges it for a fresh session — -// the mechanism that hands a pre-authenticated session off to the browser. +// short-lived, single-use login token and hands a session off to the browser +// via ?loginToken — the pre-authenticated hand-off this repo consumes in +// packages/host/app/components/matrix/login.gts. +// +// NOTE: Synapse rate-limits get_token to one request per minute per user id +// (hardcoded in the servlet — no rc_* setting relaxes it), so every test here +// mints for a freshly registered user. +async function mintLoginToken( + accessToken: string, +): Promise<{ login_token: string; expires_in_ms: number }> { + let response = await fetch( + `${getSynapseURL()}/_matrix/client/v1/login/get_token`, + { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({}), + }, + ); + expect( + response.status, + 'get_token succeeds for an authenticated caller', + ).toBe(200); + return response.json(); +} + test.describe('login_via_existing_session', () => { - test('an access-token holder can mint a login token and exchange it for a session', async () => { + test('a pre-authenticated client hands off a session to the browser via ?loginToken', async ({ + page, + }) => { + let { username, credentials } = await createSubscribedUser( + 'login-token-handoff', + ); + await setupPermissions(credentials.userId, `${appURL}/`); + + let { login_token } = await mintLoginToken(credentials.accessToken); + + // The browser lands pre-authenticated with only the login token — no + // username/password is ever entered. + await page.goto(`${appURL}?loginToken=${login_token}`); + + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + + // The single-use token is stripped from the URL so a refresh doesn't + // re-trigger the (now spent) exchange, and the session persists. + expect(new URL(page.url()).searchParams.has('loginToken')).toBe(false); + await page.reload(); + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + }); + + test('the minted token carries the configured 2-minute lifetime and exchanges for a new session', async () => { let { credentials } = await createUser('login-token'); - // Mint a login token with the existing session's access token. - let getTokenResponse = await fetch( - `${getSynapseURL()}/_matrix/client/v1/login/get_token`, - { - method: 'POST', - headers: { Authorization: `Bearer ${credentials.accessToken}` }, - body: JSON.stringify({}), - }, + let { login_token, expires_in_ms } = await mintLoginToken( + credentials.accessToken, ); - expect( - getTokenResponse.status, - 'get_token succeeds for an authenticated caller', - ).toBe(200); - let { login_token, expires_in_ms } = (await getTokenResponse.json()) as { - login_token: string; - expires_in_ms: number; - }; expect(login_token, 'a login token is returned').toBeTruthy(); - // token_timeout is configured as "2m" in the test homeserver.yaml. + // token_timeout is configured as "2m" in the test homeserver.yaml; this is + // the one assertion that catches the config block failing to reach Synapse. expect(expires_in_ms).toBe(120_000); - // Exchange the login token for a brand-new session belonging to the same user. let loginResponse = await fetch( `${getSynapseURL()}/_matrix/client/v3/login`, { @@ -47,34 +91,15 @@ test.describe('login_via_existing_session', () => { }; expect(session.user_id).toBe(credentials.userId); expect(session.access_token, 'a fresh access token is issued').toBeTruthy(); + // The hand-off mints a new device independent of the caller's — a + // separately-revocable session, not a copy of the minting one. expect(session.device_id).toBeTruthy(); - // The handed-off session is independent of the caller's device. expect(session.device_id).not.toBe(credentials.deviceId); }); - test('the endpoint is recognized and requires authentication', async () => { - // Before the feature is enabled Synapse returns M_UNRECOGNIZED for this - // route; with it enabled an unauthenticated call is rejected as - // M_MISSING_TOKEN, proving the endpoint is wired up and enforcing auth. - let response = await fetch( - `${getSynapseURL()}/_matrix/client/v1/login/get_token`, - { method: 'POST', body: JSON.stringify({}) }, - ); - expect(response.status).toBe(401); - let body = (await response.json()) as { errcode: string }; - expect(body.errcode).toBe('M_MISSING_TOKEN'); - }); - test('a login token is single-use', async () => { let { credentials } = await createUser('login-token-reuse'); - - let { login_token } = (await ( - await fetch(`${getSynapseURL()}/_matrix/client/v1/login/get_token`, { - method: 'POST', - headers: { Authorization: `Bearer ${credentials.accessToken}` }, - body: JSON.stringify({}), - }) - ).json()) as { login_token: string }; + let { login_token } = await mintLoginToken(credentials.accessToken); let first = await fetch(`${getSynapseURL()}/_matrix/client/v3/login`, { method: 'POST',