From 70206d6033aefc8a7658edce4742d49db829b5d9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 24 Oct 2026 00:27:44 -0400 Subject: [PATCH] chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201) Picks up three releases of MatrixRTC / delayed-event fixes and matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0. SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so the native-OAuth sign-in is ported: - login discovers the auth server via /auth_metadata, registers with OAuth2.registerClient, and keeps the PKCE verifier + device in its own sessionStorage entry keyed by `state` (single use; CSRF check); - refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16 cross-tab protection is kept by a narrow guard on refresh-token grants (the SDK calls the global fetch for OAuth, not fetchFn), which serialises them with a Web Lock and adopts a sibling tab's tokens; - logout and forced logout revoke via mx.logout() (the SDK revokes at the auth server for OAuth sessions); - the callback page parsed its params on every render, re-running the single-use code exchange in a loop; parsed once now. Also: notification_type is deprecated in MSC4075 (ring in DMs, notify elsewhere when omitted), _onRTCSessionMemberUpdate -> ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the 7.8 MB crypto wasm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/oauth-sdk44.spec.ts | 234 +++++++++++++++++++++ package-lock.json | 69 +++--- package.json | 2 +- src/app/components/CallEmbedProvider.tsx | 8 +- src/app/components/ServerConfigsLoader.tsx | 17 +- src/app/pages/auth/login/Login.tsx | 6 +- src/app/pages/auth/login/OidcLogin.tsx | 5 +- src/app/pages/auth/oidc/OidcCallback.tsx | 41 ++-- src/app/pages/auth/oidc/oidcConfig.ts | 25 ++- src/app/pages/auth/oidc/oidcLoginUtil.ts | 77 ++++--- src/app/pages/auth/oidc/oidcState.test.ts | 32 ++- src/app/pages/auth/oidc/oidcState.ts | 63 ++++-- src/app/pages/client/ClientRoot.tsx | 11 +- src/app/state/sessions.ts | 6 +- src/client/initMatrix.ts | 30 +-- src/client/oidcLogout.ts | 29 --- src/client/oidcTokenRefresher.test.ts | 118 +++++++++++ src/client/oidcTokenRefresher.ts | 170 ++++++++------- vite.config.js | 7 +- 19 files changed, 682 insertions(+), 268 deletions(-) create mode 100644 e2e/oauth-sdk44.spec.ts delete mode 100644 src/client/oidcLogout.ts create mode 100644 src/client/oidcTokenRefresher.test.ts diff --git a/e2e/oauth-sdk44.spec.ts b/e2e/oauth-sdk44.spec.ts new file mode 100644 index 000000000..20e139fc1 --- /dev/null +++ b/e2e/oauth-sdk44.spec.ts @@ -0,0 +1,234 @@ +import { createHash } from 'node:crypto'; +import { test, expect, Page, Route, BrowserContext } from '@playwright/test'; +import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs'; + +// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login, +// refresh on an expired token, the #16 two-tab refresh race, and logout +// revocation. The homeserver is the real local Synapse; the auth server is +// faked here and hands out REAL Synapse tokens for the device the client asked +// for, so sync works after every step. "Expiry" is simulated by answering +// M_UNKNOWN_TOKEN for a token we mark expired. +const AUTH = 'https://auth.fake.test'; + +type Fake = { + registered: number; + tokenCalls: { grant: string; refresh?: string }[]; + revoked: string[]; + expired: Set; + issuedAccess: string[]; +}; + +const b64url = (buf: Buffer) => + buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +async function synapseToken(localpart: string, deviceId: string): Promise { + const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, { + type: 'm.login.password', + identifier: { type: 'm.id.user', user: localpart }, + password: PASSWORD, + device_id: deviceId, + }); + return res.access_token; +} + +async function fakeOAuth(context: BrowserContext, localpart: string): Promise { + const fake: Fake = { + registered: 0, + tokenCalls: [], + revoked: [], + expired: new Set(), + issuedAccess: [], + }; + const codes = new Map(); + const refreshes = new Map(); // live refresh token -> deviceId (rotating) + let n = 0; + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; + + const issue = async (deviceId: string) => { + n += 1; + const access = await synapseToken(localpart, deviceId); + const refresh = `r${n}`; + refreshes.set(refresh, deviceId); + fake.issuedAccess.push(access); + return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 }; + }; + + // Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens. + await context.route(`${HS}/.well-known/matrix/client`, (route) => + route.fulfill({ + headers: cors, + json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } }, + }), + ); + await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) => + route.fulfill({ + headers: cors, + json: { + issuer: `${AUTH}/`, + authorization_endpoint: `${AUTH}/authorize`, + token_endpoint: `${AUTH}/token`, + registration_endpoint: `${AUTH}/register`, + revocation_endpoint: `${AUTH}/revoke`, + response_types_supported: ['code'], + response_modes_supported: ['query', 'fragment'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + }, + }), + ); + await context.route(`${HS}/_matrix/**`, async (route: Route) => { + const auth = route.request().headers().authorization ?? ''; + const token = auth.replace(/^Bearer /, ''); + if (token && fake.expired.has(token)) { + await route.fulfill({ + status: 401, + headers: cors, + json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true }, + }); + return; + } + await route.fallback(); + }); + + // Auth server. + await context.route(`${AUTH}/**`, async (route: Route) => { + const req = route.request(); + const url = new URL(req.url()); + if (req.method() === 'OPTIONS') { + await route.fulfill({ status: 204, headers: cors }); + return; + } + if (url.pathname === '/register') { + fake.registered += 1; + await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } }); + return; + } + if (url.pathname === '/authorize') { + const deviceId = /urn:matrix:client:device:(\S+)/.exec( + url.searchParams.get('scope') ?? '', + )?.[1]; + expect(url.searchParams.get('response_mode')).toBe('query'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + const code = `code-${Date.now()}`; + codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! }); + const back = new URL(url.searchParams.get('redirect_uri')!); + back.searchParams.set('code', code); + back.searchParams.set('state', url.searchParams.get('state')!); + await route.fulfill({ status: 302, headers: { location: back.href } }); + return; + } + const form = new URLSearchParams(req.postData() ?? ''); + if (url.pathname === '/token') { + const grant = form.get('grant_type') ?? ''; + fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined }); + if (grant === 'authorization_code') { + const pending = codes.get(form.get('code') ?? ''); + const verifier = form.get('code_verifier') ?? ''; + if ( + !pending || + b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge + ) { + await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } }); + return; + } + codes.delete(form.get('code')!); + await route.fulfill({ headers: cors, json: await issue(pending.deviceId) }); + return; + } + if (grant === 'refresh_token') { + const deviceId = refreshes.get(form.get('refresh_token') ?? ''); + if (!deviceId) { + // rotating refresh tokens: a used one is dead + await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } }); + return; + } + refreshes.delete(form.get('refresh_token')!); + await route.fulfill({ headers: cors, json: await issue(deviceId) }); + return; + } + } + if (url.pathname === '/revoke') { + fake.revoked.push(form.get('token') ?? ''); + await route.fulfill({ status: 200, headers: cors, body: '' }); + return; + } + await route.fulfill({ status: 404, headers: cors, body: '' }); + }); + return fake; +} + +const storedSession = (page: Page) => + page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null')); + +async function oauthLogin(page: Page) { + await page.goto(`/login/${enc(HS)}/`); + await page.getByRole('button', { name: /Continue with/ }).click(); + await page.waitForURL(/\/home/, { timeout: 60_000 }); + await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 }); +} + +test.describe('OAuth sign-in on SDK 44', () => { + test.beforeAll(async () => { + test.skip(!(await hsReachable()), 'local homeserver not running'); + }); + + test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => { + test.setTimeout(240_000); + const user = await ensureUser(uniq('oa')); + const fake = await fakeOAuth(context, user.localpart); + const page = await context.newPage(); + + // 1. Login: registration, PKCE-checked code exchange, session persisted. + await oauthLogin(page); + expect(fake.registered).toBe(1); + expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']); + let s = await storedSession(page); + expect(s.userId).toBe(user.userId); + expect(s.refreshToken).toBe('r1'); + expect(s.oidc.clientId).toBe('fake-client'); + const t1 = s.accessToken; + + // 2. Second tab on the same session. + const page2 = await context.newPage(); + await page2.goto('/home/'); + await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 }); + + // 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request + // and refresh at once. Exactly one refresh may reach the auth server + // (r1 is single-use); the other tab must adopt its tokens. + // Sync long-polls continuously, so both tabs hit it within ~30 s. + fake.expired.add(t1); + await expect + .poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 }) + .not.toBe(t1); + await page.waitForTimeout(5000); + const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token'); + expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']); + s = await storedSession(page); + expect(s.refreshToken).toBe('r2'); + await Promise.all( + [page, page2].map((p) => + expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0), + ), + ); + + // 4. Logout through the UI revokes both tokens at the auth server + // (the SDK does it for OAuth sessions instead of calling /logout). + await page2.close(); + const latest = await storedSession(page); + await page.getByRole('button', { name: 'User Settings' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Logout', exact: true }) + .last() + .click(); + await expect( + page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })), + ).toBeVisible({ + timeout: 60_000, + }); + expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort()); + expect(await storedSession(page)).toBeNull(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 1e00fac47..166ae2d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "katex": "0.18.11", "linkify-react": "4.3.3", "linkifyjs": "4.3.3", - "matrix-js-sdk": "41.7.0", + "matrix-js-sdk": "44.0.0", "matrix-widget-api": "1.18.0", "millify": "6.1.0", "pdfjs-dist": "6.3.289", @@ -2701,9 +2701,9 @@ "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz", - "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==", + "version": "18.9.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz", + "integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==", "license": "Apache-2.0", "engines": { "node": ">= 18" @@ -5580,12 +5580,12 @@ "dev": true }, "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "type": "opencollective", @@ -9152,15 +9152,6 @@ "node": ">=4.0" } }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/katex": { "version": "0.18.11", "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz", @@ -10037,21 +10028,19 @@ "license": "Apache-2.0" }, "node_modules/matrix-js-sdk": { - "version": "41.7.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz", - "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==", + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz", + "integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1", + "@babel/runtime": "^8.0.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0", "another-json": "^0.2.0", "bs58": "^6.0.0", - "content-type": "^2.0.0", - "jwt-decode": "^4.0.0", + "content-type": "^3.0.0", "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.16.1", - "oidc-client-ts": "^3.0.1", + "matrix-widget-api": "^1.19.0", "p-retry": "8", "sdp-transform": "^3.0.0", "unhomoglyph": "^1.0.6" @@ -10060,6 +10049,22 @@ "node": ">=22.0.0" } }, + "node_modules/matrix-js-sdk/node_modules/@babel/runtime": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz", + "integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==", + "license": "MIT" + }, + "node_modules/matrix-js-sdk/node_modules/matrix-widget-api": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz", + "integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==", + "license": "Apache-2.0", + "dependencies": { + "@types/events": "^3.0.0", + "events": "^3.2.0" + } + }, "node_modules/matrix-widget-api": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz", @@ -10405,18 +10410,6 @@ ], "license": "MIT" }, - "node_modules/oidc-client-ts": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", - "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", - "license": "Apache-2.0", - "dependencies": { - "jwt-decode": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 472baf480..1072386f6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "katex": "0.18.11", "linkify-react": "4.3.3", "linkifyjs": "4.3.3", - "matrix-js-sdk": "41.7.0", + "matrix-js-sdk": "44.0.0", "matrix-widget-api": "1.18.0", "millify": "6.1.0", "pdfjs-dist": "6.3.289", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index aeb37870d..a0ef936b5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -571,7 +571,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) ? content.sender_ts : event.getTs(); const lifetime = Math.min(content.lifetime, 120000); - const notificationType = content.notification_type; + // `notification_type` is deprecated in MSC4075 (matrix-js-sdk 43+): newer + // senders may omit it and leave the presentation to us. Then ring in a + // direct chat and notify quietly elsewhere, like Element Web. + const notificationType = + content.notification_type ?? (directs.has(room.roomId) ? 'ring' : 'notification'); const relation = event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined; const refEventId = relation?.event_id; @@ -653,7 +657,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) mx.getRooms().forEach((r) => { mx.matrixRTC .getActiveRoomSession(r) - ?._onRTCSessionMemberUpdate() + ?.ensureRecalculateSessionMembers() .catch(() => undefined); }); }; diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 8b541c4b7..48d578941 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -1,5 +1,5 @@ import { ReactNode, useCallback, useMemo } from 'react'; -import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { Capabilities, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; @@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { const capabilities = promiseFulfilledResult(result[0]); const mediaConfig = promiseFulfilledResult(result[1]); + // getAuthMetadata validates (and rejects when the server has no native + // OAuth, which is normal for servers using traditional SSO). const authMetadata = promiseFulfilledResult(result[2]); - let validatedAuthMetadata: ValidatedAuthMetadata | undefined; - - // Only validate if the server returned metadata — a rejected promise means no native - // Matrix OIDC (MSC3861/MAS), which is normal for servers using traditional SSO. - if (authMetadata !== undefined) { - try { - validatedAuthMetadata = validateAuthMetadata(authMetadata); - } catch (e) { - console.error(e); - } - } return { capabilities, mediaConfig, - authMetadata: validatedAuthMetadata, + authMetadata, }; }, [mx]), ); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index fb8082fda..93fbd6935 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -68,11 +68,7 @@ export function Login() { Login - + Do not have an account? Register diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx index eaf4e9f6a..0bf37ef25 100644 --- a/src/app/pages/auth/login/OidcLogin.tsx +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -4,7 +4,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { startOidcLogin } from '../oidc/oidcLoginUtil'; type OidcLoginProps = { - issuer: string; homeserverBaseUrl: string; serverName: string; }; @@ -13,7 +12,7 @@ type OidcLoginProps = { * with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s * single-button styling. */ -export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) { +export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) { const [state, startLogin] = useAsyncCallback>( useCallback(startOidcLogin, []), ); @@ -35,7 +34,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr variant="Secondary" fill="Soft" outlined - onClick={() => startLogin(issuer, homeserverBaseUrl)} + onClick={() => startLogin(homeserverBaseUrl)} disabled={ongoing} before={ongoing ? : undefined} > diff --git a/src/app/pages/auth/oidc/OidcCallback.tsx b/src/app/pages/auth/oidc/OidcCallback.tsx index 5037adabd..9d20cf1e2 100644 --- a/src/app/pages/auth/oidc/OidcCallback.tsx +++ b/src/app/pages/auth/oidc/OidcCallback.tsx @@ -1,43 +1,45 @@ import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { createClient } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { setFallbackSession } from '../../../state/sessions'; -import { completeAuthorizationCodeGrant } from './oidcLoginUtil'; -import { getOidcCallbackUrl } from './oidcConfig'; +import { completeOidcCodeGrant } from './oidcLoginUtil'; import { invalidateCachedClient, isStaleClientError, parseOidcCallbackParams, readStoredOidcIssuer, + takePendingOidcLogin, } from './oidcState'; /** - * Exchange the authorization code for a Matrix session and persist it. The SDK - * (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage - * before the redirect and validates `state` (CSRF). `redirectUri` is the - * deterministic callback URL, recomputed here. + * Exchange the authorization code for a Matrix session and persist it. The + * pending sign-in saved before the redirect must exist for this `state` + * (CSRF check) and is consumed here. */ const completeOidcLogin = async (code: string, state: string): Promise => { - const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } = - await completeAuthorizationCodeGrant(code, state); + const pending = takePendingOidcLogin(state); + if (!pending) throw new Error('No pending sign-in for this state.'); + const tokenResponse = await completeOidcCodeGrant(code, pending); // Derive Matrix user_id + device_id from the freshly-issued access token (the // device is owned by the authentication service under MSC3861). - const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token }); + const tmp = createClient({ + baseUrl: pending.homeserverUrl, + accessToken: tokenResponse.access_token, + }); const { user_id: userId, device_id: deviceId } = await tmp.whoami(); if (!userId || !deviceId) { throw new Error('Homeserver did not return a user/device for the OIDC session.'); } - setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, { + setFallbackSession(tokenResponse.access_token, deviceId, userId, pending.homeserverUrl, { refreshToken: tokenResponse.refresh_token, expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined, oidc: { - issuer: oidcClientSettings.issuer, - clientId: oidcClientSettings.clientId, - redirectUri: getOidcCallbackUrl(), - idTokenClaims: idTokenClaims as unknown as Record, + issuer: pending.issuer, + clientId: pending.clientId, + redirectUri: pending.redirectUri, }, }); }; @@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) { * router, since the redirect_uri must be a real non-hash path). */ export function OidcCallback() { - const params = parseOidcCallbackParams(window.location.search); + // Parsed once: a fresh object per render would re-run the effects below on + // every state change, and the code (and its pending sign-in) is single-use. + const params = useMemo(() => parseOidcCallbackParams(window.location.search), []); + const redeemed = useRef(false); const [state, complete] = useAsyncCallback( useCallback(completeOidcLogin, []), ); useEffect(() => { - if (params.kind === 'success') complete(params.code, params.state); + if (params.kind !== 'success' || redeemed.current) return; + redeemed.current = true; + complete(params.code, params.state); }, [params, complete]); useEffect(() => { diff --git a/src/app/pages/auth/oidc/oidcConfig.ts b/src/app/pages/auth/oidc/oidcConfig.ts index 73c5e2338..6338a3dad 100644 --- a/src/app/pages/auth/oidc/oidcConfig.ts +++ b/src/app/pages/auth/oidc/oidcConfig.ts @@ -1,4 +1,4 @@ -import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk'; +import type { OAuthRegistrationRequest } from 'matrix-js-sdk'; import { OIDC_CALLBACK_PATH } from '../../paths'; import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils'; @@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string => withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH); /** - * Client metadata sent during MSC2966 dynamic client registration. + * Client metadata sent during dynamic client registration. * - * `registerOidcClient` drops any URI that doesn't share `clientUri` as a common - * base, so every URI here lives under our origin base. + * The SDK drops any URI that doesn't share `client_uri` as a common base, so + * every URI here lives under our origin base. */ -export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => { +export const getOidcClientMetadata = (): OAuthRegistrationRequest => { // `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs. const clientUri = getOriginBaseUrl(); return { - clientName: 'Lotus Chat', - clientUri, - logoUri: new URL(LotusLogo, window.location.origin).href, - applicationType: 'web', - contacts: ['support@lotusguild.org'], - tosUri: clientUri, - policyUri: clientUri, - redirectUris: [getOidcCallbackUrl()], + client_name: 'Lotus Chat', + client_uri: clientUri, + logo_uri: new URL(LotusLogo, window.location.origin).href, + application_type: 'web', + tos_uri: clientUri, + policy_uri: clientUri, + redirect_uris: [getOidcCallbackUrl()], }; }; diff --git a/src/app/pages/auth/oidc/oidcLoginUtil.ts b/src/app/pages/auth/oidc/oidcLoginUtil.ts index a23f8739a..b6955cabe 100644 --- a/src/app/pages/auth/oidc/oidcLoginUtil.ts +++ b/src/app/pages/auth/oidc/oidcLoginUtil.ts @@ -1,57 +1,80 @@ -import { - completeAuthorizationCodeGrant, - discoverAndValidateOIDCIssuerWellKnown, - generateOidcAuthorizationUrl, - OidcClientConfig, - registerOidcClient, -} from 'matrix-js-sdk'; +import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig'; import { cacheClientId, getCachedClientId, invalidateCachedClient, isStaleClientError, + PendingOidcLogin, + savePendingOidcLogin, } from './oidcState'; -export { completeAuthorizationCodeGrant }; +/** + * The homeserver's OAuth 2.0 metadata (`GET /_matrix/client/v1/auth_metadata`). + * This is also how the SDK finds the auth server for token refresh and logout, + * so login uses the same source. + */ +export const discoverAuthMetadata = (homeserverBaseUrl: string): Promise => + createClient({ baseUrl: homeserverBaseUrl }).getAuthMetadata(); export const getOrRegisterClientId = async ( - config: OidcClientConfig, - issuer: string, + metadata: ValidatedAuthMetadata, redirectUri: string, ): Promise => { - const cached = getCachedClientId(issuer, redirectUri); + const cached = getCachedClientId(metadata.issuer, redirectUri); if (cached) return cached; - const clientId = await registerOidcClient(config, getOidcClientMetadata()); - cacheClientId(issuer, clientId, redirectUri); + const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata()); + cacheClientId(metadata.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. + * 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 + * result from the query string (`response_mode=query`). */ -export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise => { +export const startOidcLogin = async (homeserverBaseUrl: string): Promise => { const redirectUri = getOidcCallbackUrl(); + let issuer: string | undefined; try { - const config = await discoverAndValidateOIDCIssuerWellKnown(issuer); - const clientId = await getOrRegisterClientId(config, issuer, redirectUri); - const url = await generateOidcAuthorizationUrl({ - metadata: config, - redirectUri, + const metadata = await discoverAuthMetadata(homeserverBaseUrl); + issuer = metadata.issuer; + const clientId = await getOrRegisterClientId(metadata, redirectUri); + const oauth = new OAuth2(metadata, { clientId }); + const state = crypto.randomUUID(); + const pending: PendingOidcLogin = { + issuer, clientId, + deviceId: oauth.context.deviceId, + codeVerifier: oauth.context.codeVerifier, homeserverUrl: homeserverBaseUrl, - nonce: crypto.randomUUID(), - }); - window.location.assign(url); + redirectUri, + }; + savePendingOidcLogin(state, pending); + window.location.assign( + await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'), + ); } catch (e) { // #67 — drop the cached client so the next attempt re-registers, but ONLY // when the failure says the client id itself was rejected. Network and // discovery failures keep the cache, otherwise each retry would register // yet another throwaway dynamic client at the provider. - if (isStaleClientError(e)) invalidateCachedClient(issuer); + if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer); throw e; } }; + +/** + * Exchange the authorization code using the pending sign-in saved before the + * redirect. The metadata is fetched again (it isn't stored). + */ +export const completeOidcCodeGrant = async (code: string, pending: PendingOidcLogin) => { + const metadata = await discoverAuthMetadata(pending.homeserverUrl); + const oauth = new OAuth2(metadata, { + clientId: pending.clientId, + deviceId: pending.deviceId, + codeVerifier: pending.codeVerifier, + }); + return oauth.completeAuthorizationCodeGrant(code, pending.redirectUri); +}; diff --git a/src/app/pages/auth/oidc/oidcState.test.ts b/src/app/pages/auth/oidc/oidcState.test.ts index fbb114e3a..0405093cc 100644 --- a/src/app/pages/auth/oidc/oidcState.test.ts +++ b/src/app/pages/auth/oidc/oidcState.test.ts @@ -7,6 +7,8 @@ import { parseOidcCallbackParams, isStaleClientError, readStoredOidcIssuer, + savePendingOidcLogin, + takePendingOidcLogin, } from './oidcState'; const installStorage = (): Map => { @@ -131,21 +133,29 @@ test('isStaleClientError: also recognises the OidcCallback error-redirect shape ); }); -test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => { - const store = installSessionStorage(); +const pending = { + issuer: 'https://issuer.example/', + clientId: 'client1', + deviceId: 'DEVICEABCD', + codeVerifier: 'v'.repeat(96), + homeserverUrl: 'https://hs.example', + redirectUri: 'https://app.example/oidc/callback', +}; + +test('pending sign-in: saved by state, read for the issuer, consumed once', () => { + installSessionStorage(); assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet - store.set( - 'mx_oidc_xyz', - JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }), - ); - assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example'); + savePendingOidcLogin('xyz', pending); + assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/'); assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss + assert.deepEqual(takePendingOidcLogin('xyz'), pending); + assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use'); }); -test('readStoredOidcIssuer tolerates corrupt or missing storage', () => { +test('pending sign-in tolerates corrupt or incomplete storage', () => { const store = installSessionStorage(); - store.set('mx_oidc_bad', '{ not json'); + store.set('lotus_oidc_pending_bad', '{ not json'); assert.equal(readStoredOidcIssuer('bad'), undefined); - store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' })); - assert.equal(readStoredOidcIssuer('noauth'), undefined); + store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' })); + assert.equal(takePendingOidcLogin('partial'), undefined); }); diff --git a/src/app/pages/auth/oidc/oidcState.ts b/src/app/pages/auth/oidc/oidcState.ts index f52d69c97..39a9c3de8 100644 --- a/src/app/pages/auth/oidc/oidcState.ts +++ b/src/app/pages/auth/oidc/oidcState.ts @@ -97,23 +97,60 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => { }; /** - * #102 — the issuer for a pending sign-in isn't available on the callback's - * error branch (we bail out before `completeAuthorizationCodeGrant`, which is - * what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl` - * persists the pending sign-in's `SigninState` (including `authority` and - * `client_id`) into sessionStorage before redirecting, keyed by - * `mx_oidc_` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same - * `state` value the provider echoes back on redirect). Read it directly so a - * genuinely stale cached client id (`invalid_client`) can be evicted even when - * the callback errors before a token exchange is attempted. + * [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's + * OAuth2 class no longer persists anything). Saved in sessionStorage right + * before the redirect, keyed by the `state` we send, which the provider echoes + * back: a callback whose state has no entry is rejected (CSRF), and an entry is + * used once. Holds the PKCE code verifier, so it never goes to localStorage. */ -export const readStoredOidcIssuer = (state: string): string | undefined => { +export type PendingOidcLogin = { + issuer: string; + clientId: string; + deviceId: string; + codeVerifier: string; + homeserverUrl: string; + redirectUri: string; +}; +const PENDING_PREFIX = 'lotus_oidc_pending_'; + +const isPending = (v: unknown): v is PendingOidcLogin => { + if (!v || typeof v !== 'object') return false; + const p = v as Record; + return ['issuer', 'clientId', 'deviceId', 'codeVerifier', 'homeserverUrl', 'redirectUri'].every( + (k) => typeof p[k] === 'string' && (p[k] as string).length > 0, + ); +}; + +export const savePendingOidcLogin = (state: string, pending: PendingOidcLogin): void => { + sessionStorage.setItem(`${PENDING_PREFIX}${state}`, JSON.stringify(pending)); +}; + +/** Read the pending sign-in for `state` without consuming it. */ +export const readPendingOidcLogin = (state: string): PendingOidcLogin | undefined => { try { - const raw = sessionStorage.getItem(`mx_oidc_${state}`); + const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`); if (!raw) return undefined; - const { authority } = JSON.parse(raw) as { authority?: unknown }; - return typeof authority === 'string' ? authority : undefined; + const parsed: unknown = JSON.parse(raw); + return isPending(parsed) ? parsed : undefined; } catch { return undefined; } }; + +/** Read and remove: an authorization code can only be redeemed once anyway. */ +export const takePendingOidcLogin = (state: string): PendingOidcLogin | undefined => { + const pending = readPendingOidcLogin(state); + try { + sessionStorage.removeItem(`${PENDING_PREFIX}${state}`); + } catch { + // storage unavailable — nothing to clean up + } + return pending; +}; + +/** + * #102 — on the callback's error branch, the pending sign-in's issuer tells + * us whose cached client id to evict when the provider rejected it. + */ +export const readStoredOidcIssuer = (state: string): string | undefined => + readPendingOidcLogin(state)?.issuer; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index d7d2fa0b8..1c2b7c379 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -55,7 +55,6 @@ import { SecurityBanner } from '../../features/security-nudge/SecurityBanner'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { pushSessionToSW } from '../../../sw-session'; -import { revokeOidcTokens } from '../../../client/oidcLogout'; import { useSessionSync } from '../../hooks/useSessionSync'; import { installCryptoDiagLog } from '../../utils/cryptoDiagLog'; import { AutoDiscovery } from './AutoDiscovery'; @@ -159,11 +158,11 @@ const useLogoutListener = (mx?: MatrixClient) => { pushSessionToSW(); mx?.stopClient(); // Best-effort issuer revocation for OIDC sessions (the token is already - // server-revoked here, but revoke the refresh token too). Before we drop - // the stored session below. - const loggedOutSession = getFallbackSession(); - if (loggedOutSession?.oidc) { - await revokeOidcTokens(loggedOutSession).catch(() => undefined); + // server-revoked here, but revoke the refresh token too). For an OAuth + // session the SDK's logout() only revokes at the auth server (no /logout + // call). Before we drop the stored session below. + if (getFallbackSession()?.oidc) { + await mx?.logout().catch(() => undefined); } await mx?.clearStores(); // The opt-in local search index holds DECRYPTED message plaintext. Wipe it diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index a0c187b48..f14db4cb0 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -5,9 +5,9 @@ // setLocalStorageItem, // } from './utils/atomWithLocalStorage'; -// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept -// as a generic claims object so this core module stays decoupled from -// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims). +// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Since +// matrix-js-sdk 43 only `clientId` is used (the SDK finds the auth server via +// /auth_metadata); `idTokenClaims` is kept for sessions saved by older builds. export type OidcSessionMeta = { issuer: string; clientId: string; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 883fb1ebd..f49d53e46 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,8 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions'; -import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; -import { revokeOidcTokens } from './oidcLogout'; +import { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher'; import { pushSessionToSW } from '../sw-session'; import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache'; import { clearPlaintextCaches } from '../app/state/plaintextCaches'; @@ -62,12 +61,19 @@ export const initClient = async (session: Session): Promise => { const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store'); - // OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh - // (the client calls this reactively on a 401) and persist rotated tokens. - const oidcRefresher = + // OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client + // refreshes them itself; we persist the rotated tokens and serialise + // refreshes across tabs (oidcTokenRefresher). + const oidcIdentity: OidcIdentity | undefined = session.refreshToken && session.oidc - ? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl) + ? { + userId: session.userId, + deviceId: session.deviceId, + baseUrl: session.baseUrl, + oidc: session.oidc, + } : undefined; + if (oidcIdentity) installRefreshGuard(oidcIdentity); const mx = createClient({ baseUrl: session.baseUrl, @@ -84,8 +90,9 @@ export const initClient = async (session: Session): Promise => { cryptoCallbacks: cryptoCallbacks as any, // SAS (emoji) + QR-code verification (show/scan/reciprocate). verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'], - tokenRefreshFunction: oidcRefresher - ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) + oauthClientId: oidcIdentity?.oidc.clientId, + onTokenRefresh: oidcIdentity + ? (tokens) => persistRefreshedTokens(oidcIdentity, tokens) : undefined, }); @@ -130,11 +137,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); - // For OIDC sessions, revoke the tokens at the issuer too (best-effort). - const session = getFallbackSession(); - if (session?.oidc) { - await revokeOidcTokens(session); - } + // For OIDC sessions the SDK revokes the tokens at the auth server instead of + // calling /logout (matrix-js-sdk 43+); best-effort either way. try { await mx.logout(); } catch { diff --git a/src/client/oidcLogout.ts b/src/client/oidcLogout.ts deleted file mode 100644 index c137a45b6..000000000 --- a/src/client/oidcLogout.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk'; -import { Session } from '../app/state/sessions'; - -/** - * Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation - * endpoint during logout. Tolerant of any failure — logout proceeds regardless - * (the local session is cleared by the caller either way). - */ -export const revokeOidcTokens = async (session: Session): Promise => { - if (!session.oidc) return; - try { - const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer); - const endpoint = config.revocation_endpoint; - if (!endpoint) return; - const { clientId } = session.oidc; - const revoke = (token: string, hint: string): Promise => - fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }), - }); - const requests: Promise[] = []; - if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token')); - if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token')); - await Promise.allSettled(requests); - } catch { - /* issuer unreachable / no revocation endpoint — ignore */ - } -}; diff --git a/src/client/oidcTokenRefresher.test.ts b/src/client/oidcTokenRefresher.test.ts new file mode 100644 index 000000000..814c0b6f4 --- /dev/null +++ b/src/client/oidcTokenRefresher.test.ts @@ -0,0 +1,118 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const store = new Map(); +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, +}; + +const sessions = await import('../app/state/sessions'); +const { guardRefreshFetch, refreshGrantToken } = await import('./oidcTokenRefresher'); + +const id = { + userId: '@a:hs.example', + deviceId: 'DEV1', + baseUrl: 'https://hs.example', + oidc: { issuer: 'https://auth.example/', clientId: 'c1', redirectUri: 'https://app/cb' }, +}; +const grant = (refresh: string): RequestInit => { + const body = new URLSearchParams(); + body.append('grant_type', 'refresh_token'); + body.append('client_id', 'c1'); + body.append('refresh_token', refresh); + return { method: 'POST', body }; +}; +const persist = (access: string, refresh: string) => + sessions.setFallbackSession(access, id.deviceId, id.userId, id.baseUrl, { + refreshToken: refresh, + oidc: id.oidc, + expiresInMs: 300_000, + }); + +test('[#16] recognises only refresh-token grants', () => { + assert.equal(refreshGrantToken(grant('r1')), 'r1'); + assert.equal( + refreshGrantToken({ method: 'POST', body: 'grant_type=refresh_token&refresh_token=r2' }), + 'r2', + ); + assert.equal( + refreshGrantToken({ method: 'POST', body: 'grant_type=authorization_code&code=x' }), + undefined, + ); + assert.equal(refreshGrantToken({ method: 'GET' }), undefined); + assert.equal(refreshGrantToken(undefined), undefined); +}); + +test('[#16] a real refresh is persisted before the lock is released', async () => { + store.clear(); + persist('at1', 'r1'); + let calls = 0; + const inner: typeof fetch = async () => { + calls += 1; + return new Response( + JSON.stringify({ + token_type: 'Bearer', + access_token: 'at2', + refresh_token: 'r2', + expires_in: 300, + }), + { status: 200 }, + ); + }; + const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1')); + assert.equal(calls, 1); + assert.equal((await res.json()).access_token, 'at2', 'the SDK still reads the body'); + const s = sessions.getFallbackSession(); + assert.equal(s?.accessToken, 'at2'); + assert.equal(s?.refreshToken, 'r2'); + assert.ok((s?.expiresInMs ?? 0) > 290_000); +}); + +test('[#16] the losing tab adopts the winner instead of spending a used token', async () => { + store.clear(); + persist('at-winner', 'r-winner'); // a sibling tab already rotated r-old + let calls = 0; + const inner: typeof fetch = async () => { + calls += 1; + return new Response('{"error":"invalid_grant"}', { status: 400 }); + }; + const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r-old')); + assert.equal(calls, 0, 'never hits the issuer'); + const body = await res.json(); + assert.equal(res.status, 200); + assert.equal(body.token_type, 'Bearer'); + assert.equal(body.access_token, 'at-winner'); + assert.equal(body.refresh_token, 'r-winner'); +}); + +test('[#16] another account in storage is never adopted', async () => { + store.clear(); + sessions.setFallbackSession('at-other', 'DEV9', '@b:hs.example', id.baseUrl, { + refreshToken: 'r-other', + }); + let calls = 0; + const inner: typeof fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ token_type: 'Bearer', access_token: 'at2' }), { + status: 200, + }); + }; + await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1')); + assert.equal(calls, 1); +}); + +test('[#16] other requests pass straight through', async () => { + let seen = ''; + const inner: typeof fetch = async (input) => { + seen = String(input); + return new Response('{}'); + }; + await guardRefreshFetch(inner, id)('https://hs.example/_matrix/client/v3/sync'); + assert.equal(seen, 'https://hs.example/_matrix/client/v3/sync'); +}); diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts index 5da445f96..a702eb7bd 100644 --- a/src/client/oidcTokenRefresher.ts +++ b/src/client/oidcTokenRefresher.ts @@ -1,9 +1,13 @@ -import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk'; -import type { IdTokenClaims } from 'oidc-client-ts'; +import type { AccessTokens } from 'matrix-js-sdk'; import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions'; +// Since matrix-js-sdk 43 the SDK refreshes OAuth tokens itself (MatrixClient +// `oauthClientId` + `onTokenRefresh`). This module keeps the two things Lotus +// added on top: rotated tokens are persisted (F5), and refreshes are +// serialised across tabs (Gitea #16). + // Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab -// runs its own refresher against the SAME stored refresh token; with rotating +// runs its own client against the SAME stored refresh token; with rotating // refresh tokens the second tab to hit the issuer gets `invalid_grant` and is // signed out. Holding the lock while refreshing (and re-reading storage once // inside it) makes the loser adopt the winner's tokens instead. @@ -19,82 +23,106 @@ export const withRefreshLock = (fn: () => Promise): Promise => { return locks.request(REFRESH_LOCK_NAME, fn); }; +export type OidcIdentity = { + userId: string; + deviceId: string; + baseUrl: string; + oidc: OidcSessionMeta; +}; + +/** F5 — keep the stored session (and its expiry) in step with every refresh. */ +export const persistRefreshedTokens = (id: OidcIdentity, tokens: AccessTokens): void => { + const expiresInMs = + tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined; + setFallbackSession(tokens.accessToken, id.deviceId, id.userId, id.baseUrl, { + refreshToken: tokens.refreshToken, + oidc: id.oidc, + expiresInMs, + }); +}; + +/** The refresh token a request is spending, if it is an OAuth refresh grant. */ +export const refreshGrantToken = (init?: RequestInit): string | undefined => { + if (!init || (init.method ?? 'GET').toUpperCase() !== 'POST') return undefined; + const { body } = init; + let params: URLSearchParams | undefined; + if (body instanceof URLSearchParams) params = body; + else if (typeof body === 'string') params = new URLSearchParams(body); + if (params?.get('grant_type') !== 'refresh_token') return undefined; + return params.get('refresh_token') ?? undefined; +}; + /** - * OidcTokenRefresher that persists rotated tokens back to the fallback session, - * so a page reload keeps the freshest access/refresh token. The matrix client - * calls this automatically (reactively on a 401) when a refresh token is set. + * #16 — wrap `fetch` so OAuth refresh grants run one at a time across tabs. + * Inside the lock: if storage already holds a DIFFERENT refresh token for this + * user + device, a sibling tab won the race, so answer with its tokens (a + * well-formed token response) instead of spending a consumed refresh token. + * Comparing refresh tokens (not access tokens) can never adopt the token that + * just failed, so this cannot loop. A real refresh is persisted BEFORE the lock + * is released, so the next tab in line sees it. Every other request passes + * straight through. */ -export class LotusOidcTokenRefresher extends OidcTokenRefresher { - private readonly deviceIdRef: string; - - private readonly userIdRef: string; - - private readonly baseUrlRef: string; - - private readonly oidcRef: OidcSessionMeta; - - constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) { - super( - oidc.issuer, - oidc.clientId, - oidc.redirectUri, - deviceId, - (oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims, - ); - this.deviceIdRef = deviceId; - this.userIdRef = userId; - this.baseUrlRef = baseUrl; - this.oidcRef = oidc; - } - - // #16 — before touching the issuer, check whether another tab already rotated - // the tokens. `refreshToken` is exactly what the SDK currently holds, so a - // DIFFERENT stored refresh token (same user + device) means a sibling tab won - // the race: adopt its tokens instead of burning a possibly-consumed refresh - // token. Comparing refresh tokens (not access tokens) can never adopt the very - // token that just 401'd, so this cannot loop. The whole thing runs under a - // cross-tab Web Lock so concurrent refreshes serialise and the waiter sees - // the winner's write. - public doRefreshAccessToken(refreshToken: string): Promise { +export const guardRefreshFetch = + (inner: typeof fetch, id: OidcIdentity): typeof fetch => + (input, init) => { + const spending = refreshGrantToken(init); + if (!spending) return inner(input, init); return withRefreshLock(async () => { const stored = getFallbackSession(); if ( stored && - stored.userId === this.userIdRef && - stored.deviceId === this.deviceIdRef && + stored.userId === id.userId && + stored.deviceId === id.deviceId && stored.refreshToken && - stored.refreshToken !== refreshToken + stored.refreshToken !== spending ) { - return { - accessToken: stored.accessToken, - refreshToken: stored.refreshToken, - expiry: - typeof stored.expiresInMs === 'number' - ? new Date(Date.now() + stored.expiresInMs) - : undefined, - }; + return new Response( + JSON.stringify({ + token_type: 'Bearer', + access_token: stored.accessToken, + refresh_token: stored.refreshToken, + ...(typeof stored.expiresInMs === 'number' + ? { expires_in: Math.floor(stored.expiresInMs / 1000) } + : {}), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); } - return super.doRefreshAccessToken(refreshToken); + const started = Date.now(); + const res = await inner(input, init); + if (res.ok) { + try { + const body = (await res.clone().json()) as { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + }; + if (typeof body.access_token === 'string') { + persistRefreshedTokens(id, { + accessToken: body.access_token, + refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined, + expiry: + typeof body.expires_in === 'number' + ? new Date(started + body.expires_in * 1000) + : undefined, + }); + } + } catch { + // unreadable body: the SDK will report the failure itself + } + } + return res; }); - } + }; - // F5 — persist the new expiry so the stored `expiresAt` stays fresh across - // reloads instead of going stale. The SDK invokes persistTokens synchronously - // inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the - // tokens object at runtime, even though its published type omits it — so read - // it here directly (a doRefreshAccessToken override would run too late, since - // persistTokens is called before that method returns). - protected async persistTokens(tokens: { - accessToken: string; - refreshToken?: string; - expiry?: Date; - }): Promise { - const expiresInMs = - tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined; - setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, { - refreshToken: tokens.refreshToken, - oidc: this.oidcRef, - expiresInMs, - }); - } -} +let unguardedFetch: typeof fetch | undefined; + +/** + * Install the #16 guard on `globalThis.fetch`. The SDK's OAuth calls use the + * global fetch directly (not the client's `fetchFn`), so this is the only + * place to catch them. Idempotent: re-installing replaces the guard. + */ +export const installRefreshGuard = (id: OidcIdentity): void => { + unguardedFetch ??= globalThis.fetch.bind(globalThis); + globalThis.fetch = guardRefreshFetch(unguardedFetch, id); +}; diff --git a/vite.config.js b/vite.config.js index 739304f0e..9557eb18e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -327,9 +327,10 @@ export default defineConfig({ // Assets are content-hashed, so the filename is the cache key — don't // append a revision cache-busting param. dontCacheBustURLsMatching: /assets\//, - // Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm - // (hash-busted and hot on every session) is precached deliberately. - maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + // Raised above the 2 MB default so the matrix-sdk crypto wasm (~7.8 MB + // since crypto-wasm 18.9 / matrix-js-sdk 44; hash-busted and hot on every + // session) is precached deliberately. + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0; // the inlineDynamicImports deprecation warning from Vite is from pwa internal build }, -- 2.47.3