chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45c3b16694
commit
5206730c72
@@ -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<string>;
|
||||||
|
issuedAccess: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const b64url = (buf: Buffer) =>
|
||||||
|
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
|
||||||
|
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
|
||||||
|
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<Fake> {
|
||||||
|
const fake: Fake = {
|
||||||
|
registered: 0,
|
||||||
|
tokenCalls: [],
|
||||||
|
revoked: [],
|
||||||
|
expired: new Set(),
|
||||||
|
issuedAccess: [],
|
||||||
|
};
|
||||||
|
const codes = new Map<string, { challenge: string; deviceId: string }>();
|
||||||
|
const refreshes = new Map<string, string>(); // 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+31
-38
@@ -53,7 +53,7 @@
|
|||||||
"katex": "0.18.11",
|
"katex": "0.18.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "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",
|
"matrix-widget-api": "1.18.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "6.3.289",
|
"pdfjs-dist": "6.3.289",
|
||||||
@@ -2701,9 +2701,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.1",
|
"version": "18.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz",
|
||||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
"integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -5580,12 +5580,12 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "2.0.0",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -9152,15 +9152,6 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/katex": {
|
||||||
"version": "0.18.11",
|
"version": "0.18.11",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
||||||
@@ -10037,21 +10028,19 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "41.7.0",
|
"version": "44.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz",
|
||||||
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
"integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^8.0.0",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^3.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
"matrix-widget-api": "^1.16.1",
|
"matrix-widget-api": "^1.19.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
|
||||||
"p-retry": "8",
|
"p-retry": "8",
|
||||||
"sdp-transform": "^3.0.0",
|
"sdp-transform": "^3.0.0",
|
||||||
"unhomoglyph": "^1.0.6"
|
"unhomoglyph": "^1.0.6"
|
||||||
@@ -10060,6 +10049,22 @@
|
|||||||
"node": ">=22.0.0"
|
"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": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
|
||||||
@@ -10405,18 +10410,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@
|
|||||||
"katex": "0.18.11",
|
"katex": "0.18.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "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",
|
"matrix-widget-api": "1.18.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "6.3.289",
|
"pdfjs-dist": "6.3.289",
|
||||||
|
|||||||
@@ -571,7 +571,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
? content.sender_ts
|
? content.sender_ts
|
||||||
: event.getTs();
|
: event.getTs();
|
||||||
const lifetime = Math.min(content.lifetime, 120000);
|
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 =
|
const relation =
|
||||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||||
const refEventId = relation?.event_id;
|
const refEventId = relation?.event_id;
|
||||||
@@ -653,7 +657,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
mx.getRooms().forEach((r) => {
|
mx.getRooms().forEach((r) => {
|
||||||
mx.matrixRTC
|
mx.matrixRTC
|
||||||
.getActiveRoomSession(r)
|
.getActiveRoomSession(r)
|
||||||
?._onRTCSessionMemberUpdate()
|
?.ensureRecalculateSessionMembers()
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback, useMemo } from 'react';
|
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 { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||||
@@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
|||||||
|
|
||||||
const capabilities = promiseFulfilledResult(result[0]);
|
const capabilities = promiseFulfilledResult(result[0]);
|
||||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
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]);
|
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 {
|
return {
|
||||||
capabilities,
|
capabilities,
|
||||||
mediaConfig,
|
mediaConfig,
|
||||||
authMetadata: validatedAuthMetadata,
|
authMetadata,
|
||||||
};
|
};
|
||||||
}, [mx]),
|
}, [mx]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,11 +68,7 @@ export function Login() {
|
|||||||
<Text as="h1" size="H2" priority="400">
|
<Text as="h1" size="H2" priority="400">
|
||||||
Login
|
Login
|
||||||
</Text>
|
</Text>
|
||||||
<OidcLogin
|
<OidcLogin homeserverBaseUrl={discovery['m.homeserver'].base_url} serverName={server} />
|
||||||
issuer={oidc.issuer}
|
|
||||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
|
||||||
serverName={server}
|
|
||||||
/>
|
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
<Text align="Center">
|
<Text align="Center">
|
||||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||||||
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||||
|
|
||||||
type OidcLoginProps = {
|
type OidcLoginProps = {
|
||||||
issuer: string;
|
|
||||||
homeserverBaseUrl: string;
|
homeserverBaseUrl: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
};
|
};
|
||||||
@@ -13,7 +12,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({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
|
export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||||
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||||
useCallback(startOidcLogin, []),
|
useCallback(startOidcLogin, []),
|
||||||
);
|
);
|
||||||
@@ -35,7 +34,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => startLogin(issuer, homeserverBaseUrl)}
|
onClick={() => startLogin(homeserverBaseUrl)}
|
||||||
disabled={ongoing}
|
disabled={ongoing}
|
||||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
|
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 { createClient } from 'matrix-js-sdk';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { setFallbackSession } from '../../../state/sessions';
|
import { setFallbackSession } from '../../../state/sessions';
|
||||||
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
|
import { completeOidcCodeGrant } from './oidcLoginUtil';
|
||||||
import { getOidcCallbackUrl } from './oidcConfig';
|
|
||||||
import {
|
import {
|
||||||
invalidateCachedClient,
|
invalidateCachedClient,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
parseOidcCallbackParams,
|
parseOidcCallbackParams,
|
||||||
readStoredOidcIssuer,
|
readStoredOidcIssuer,
|
||||||
|
takePendingOidcLogin,
|
||||||
} from './oidcState';
|
} from './oidcState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange the authorization code for a Matrix session and persist it. The SDK
|
* Exchange the authorization code for a Matrix session and persist it. The
|
||||||
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
|
* pending sign-in saved before the redirect must exist for this `state`
|
||||||
* before the redirect and validates `state` (CSRF). `redirectUri` is the
|
* (CSRF check) and is consumed here.
|
||||||
* deterministic callback URL, recomputed here.
|
|
||||||
*/
|
*/
|
||||||
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
|
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
|
||||||
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
|
const pending = takePendingOidcLogin(state);
|
||||||
await completeAuthorizationCodeGrant(code, 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
|
// Derive Matrix user_id + device_id from the freshly-issued access token (the
|
||||||
// device is owned by the authentication service under MSC3861).
|
// 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();
|
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
|
||||||
if (!userId || !deviceId) {
|
if (!userId || !deviceId) {
|
||||||
throw new Error('Homeserver did not return a user/device for the OIDC session.');
|
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,
|
refreshToken: tokenResponse.refresh_token,
|
||||||
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
|
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: oidcClientSettings.issuer,
|
issuer: pending.issuer,
|
||||||
clientId: oidcClientSettings.clientId,
|
clientId: pending.clientId,
|
||||||
redirectUri: getOidcCallbackUrl(),
|
redirectUri: pending.redirectUri,
|
||||||
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) {
|
|||||||
* router, since the redirect_uri must be a real non-hash path).
|
* router, since the redirect_uri must be a real non-hash path).
|
||||||
*/
|
*/
|
||||||
export function OidcCallback() {
|
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<void, unknown, [string, string]>(
|
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
|
||||||
useCallback(completeOidcLogin, []),
|
useCallback(completeOidcLogin, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [params, complete]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 { OIDC_CALLBACK_PATH } from '../../paths';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||||
|
|
||||||
@@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string =>
|
|||||||
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
|
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
|
* The SDK drops any URI that doesn't share `client_uri` as a common base, so
|
||||||
* base, so every URI here lives under our origin base.
|
* 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.
|
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
|
||||||
const clientUri = getOriginBaseUrl();
|
const clientUri = getOriginBaseUrl();
|
||||||
return {
|
return {
|
||||||
clientName: 'Lotus Chat',
|
client_name: 'Lotus Chat',
|
||||||
clientUri,
|
client_uri: clientUri,
|
||||||
logoUri: new URL(LotusLogo, window.location.origin).href,
|
logo_uri: new URL(LotusLogo, window.location.origin).href,
|
||||||
applicationType: 'web',
|
application_type: 'web',
|
||||||
contacts: ['support@lotusguild.org'],
|
tos_uri: clientUri,
|
||||||
tosUri: clientUri,
|
policy_uri: clientUri,
|
||||||
policyUri: clientUri,
|
redirect_uris: [getOidcCallbackUrl()],
|
||||||
redirectUris: [getOidcCallbackUrl()],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,57 +1,80 @@
|
|||||||
import {
|
import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
completeAuthorizationCodeGrant,
|
|
||||||
discoverAndValidateOIDCIssuerWellKnown,
|
|
||||||
generateOidcAuthorizationUrl,
|
|
||||||
OidcClientConfig,
|
|
||||||
registerOidcClient,
|
|
||||||
} from 'matrix-js-sdk';
|
|
||||||
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
||||||
import {
|
import {
|
||||||
cacheClientId,
|
cacheClientId,
|
||||||
getCachedClientId,
|
getCachedClientId,
|
||||||
invalidateCachedClient,
|
invalidateCachedClient,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
|
PendingOidcLogin,
|
||||||
|
savePendingOidcLogin,
|
||||||
} from './oidcState';
|
} 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<ValidatedAuthMetadata> =>
|
||||||
|
createClient({ baseUrl: homeserverBaseUrl }).getAuthMetadata();
|
||||||
|
|
||||||
export const getOrRegisterClientId = async (
|
export const getOrRegisterClientId = async (
|
||||||
config: OidcClientConfig,
|
metadata: ValidatedAuthMetadata,
|
||||||
issuer: string,
|
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const cached = getCachedClientId(issuer, redirectUri);
|
const cached = getCachedClientId(metadata.issuer, redirectUri);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const clientId = await registerOidcClient(config, getOidcClientMetadata());
|
const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata());
|
||||||
cacheClientId(issuer, clientId, redirectUri);
|
cacheClientId(metadata.issuer, clientId, redirectUri);
|
||||||
return clientId;
|
return clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin the OIDC authorization-code flow: discover + validate the issuer,
|
* Begin the OAuth authorization-code flow: discover the auth server through
|
||||||
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
|
* the homeserver, (re)register a client, remember the PKCE verifier + device
|
||||||
* persists the transient code_verifier/state/nonce/homeserverUrl into
|
* under a fresh `state` (oidcState), then redirect. The callback reads the
|
||||||
* sessionStorage for the callback), then redirect.
|
* result from the query string (`response_mode=query`).
|
||||||
*/
|
*/
|
||||||
export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise<void> => {
|
export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> => {
|
||||||
const redirectUri = getOidcCallbackUrl();
|
const redirectUri = getOidcCallbackUrl();
|
||||||
|
let issuer: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
|
const metadata = await discoverAuthMetadata(homeserverBaseUrl);
|
||||||
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
|
issuer = metadata.issuer;
|
||||||
const url = await generateOidcAuthorizationUrl({
|
const clientId = await getOrRegisterClientId(metadata, redirectUri);
|
||||||
metadata: config,
|
const oauth = new OAuth2(metadata, { clientId });
|
||||||
redirectUri,
|
const state = crypto.randomUUID();
|
||||||
|
const pending: PendingOidcLogin = {
|
||||||
|
issuer,
|
||||||
clientId,
|
clientId,
|
||||||
|
deviceId: oauth.context.deviceId,
|
||||||
|
codeVerifier: oauth.context.codeVerifier,
|
||||||
homeserverUrl: homeserverBaseUrl,
|
homeserverUrl: homeserverBaseUrl,
|
||||||
nonce: crypto.randomUUID(),
|
redirectUri,
|
||||||
});
|
};
|
||||||
window.location.assign(url);
|
savePendingOidcLogin(state, pending);
|
||||||
|
window.location.assign(
|
||||||
|
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'),
|
||||||
|
);
|
||||||
} 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
|
||||||
// when the failure says the client id itself was rejected. Network and
|
// when the failure says the client id itself was rejected. Network and
|
||||||
// discovery failures keep the cache, otherwise each retry would register
|
// discovery failures keep the cache, otherwise each retry would register
|
||||||
// yet another throwaway dynamic client at the provider.
|
// yet another throwaway dynamic client at the provider.
|
||||||
if (isStaleClientError(e)) invalidateCachedClient(issuer);
|
if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer);
|
||||||
throw e;
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
parseOidcCallbackParams,
|
parseOidcCallbackParams,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
readStoredOidcIssuer,
|
readStoredOidcIssuer,
|
||||||
|
savePendingOidcLogin,
|
||||||
|
takePendingOidcLogin,
|
||||||
} from './oidcState';
|
} from './oidcState';
|
||||||
|
|
||||||
const installStorage = (): Map<string, string> => {
|
const installStorage = (): Map<string, string> => {
|
||||||
@@ -131,21 +133,29 @@ test('isStaleClientError: also recognises the OidcCallback error-redirect shape
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
|
const pending = {
|
||||||
const store = installSessionStorage();
|
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
|
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
|
||||||
store.set(
|
savePendingOidcLogin('xyz', pending);
|
||||||
'mx_oidc_xyz',
|
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
|
||||||
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
|
|
||||||
);
|
|
||||||
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
|
|
||||||
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
|
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();
|
const store = installSessionStorage();
|
||||||
store.set('mx_oidc_bad', '{ not json');
|
store.set('lotus_oidc_pending_bad', '{ not json');
|
||||||
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
||||||
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
|
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
|
||||||
assert.equal(readStoredOidcIssuer('noauth'), undefined);
|
assert.equal(takePendingOidcLogin('partial'), undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's
|
||||||
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
|
* OAuth2 class no longer persists anything). Saved in sessionStorage right
|
||||||
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
|
* before the redirect, keyed by the `state` we send, which the provider echoes
|
||||||
* persists the pending sign-in's `SigninState` (including `authority` and
|
* back: a callback whose state has no entry is rejected (CSRF), and an entry is
|
||||||
* `client_id`) into sessionStorage before redirecting, keyed by
|
* used once. Holds the PKCE code verifier, so it never goes to localStorage.
|
||||||
* `mx_oidc_<state>` (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.
|
|
||||||
*/
|
*/
|
||||||
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<string, unknown>;
|
||||||
|
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 {
|
try {
|
||||||
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
|
const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`);
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const { authority } = JSON.parse(raw) as { authority?: unknown };
|
const parsed: unknown = JSON.parse(raw);
|
||||||
return typeof authority === 'string' ? authority : undefined;
|
return isPending(parsed) ? parsed : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
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;
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
|
|||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
import { pushSessionToSW } from '../../../sw-session';
|
import { pushSessionToSW } from '../../../sw-session';
|
||||||
import { revokeOidcTokens } from '../../../client/oidcLogout';
|
|
||||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
@@ -158,11 +157,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
pushSessionToSW();
|
pushSessionToSW();
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
// Best-effort issuer revocation for OIDC sessions (the token is already
|
// Best-effort issuer revocation for OIDC sessions (the token is already
|
||||||
// server-revoked here, but revoke the refresh token too). Before we drop
|
// server-revoked here, but revoke the refresh token too). For an OAuth
|
||||||
// the stored session below.
|
// session the SDK's logout() only revokes at the auth server (no /logout
|
||||||
const loggedOutSession = getFallbackSession();
|
// call). Before we drop the stored session below.
|
||||||
if (loggedOutSession?.oidc) {
|
if (getFallbackSession()?.oidc) {
|
||||||
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
|
await mx?.logout().catch(() => undefined);
|
||||||
}
|
}
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// setLocalStorageItem,
|
// setLocalStorageItem,
|
||||||
// } from './utils/atomWithLocalStorage';
|
// } from './utils/atomWithLocalStorage';
|
||||||
|
|
||||||
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept
|
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Since
|
||||||
// as a generic claims object so this core module stays decoupled from
|
// matrix-js-sdk 43 only `clientId` is used (the SDK finds the auth server via
|
||||||
// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims).
|
// /auth_metadata); `idTokenClaims` is kept for sessions saved by older builds.
|
||||||
export type OidcSessionMeta = {
|
export type OidcSessionMeta = {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|||||||
+17
-13
@@ -3,8 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
|||||||
import { cryptoCallbacks } from './secretStorageKeys';
|
import { cryptoCallbacks } from './secretStorageKeys';
|
||||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||||
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
|
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
|
||||||
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
import { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
|
||||||
import { revokeOidcTokens } from './oidcLogout';
|
|
||||||
import { pushSessionToSW } from '../sw-session';
|
import { pushSessionToSW } from '../sw-session';
|
||||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||||
@@ -61,12 +60,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||||||
|
|
||||||
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
|
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
|
||||||
|
|
||||||
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
|
// OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client
|
||||||
// (the client calls this reactively on a 401) and persist rotated tokens.
|
// refreshes them itself; we persist the rotated tokens and serialise
|
||||||
const oidcRefresher =
|
// refreshes across tabs (oidcTokenRefresher).
|
||||||
|
const oidcIdentity: OidcIdentity | undefined =
|
||||||
session.refreshToken && session.oidc
|
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;
|
: undefined;
|
||||||
|
if (oidcIdentity) installRefreshGuard(oidcIdentity);
|
||||||
|
|
||||||
const mx = createClient({
|
const mx = createClient({
|
||||||
baseUrl: session.baseUrl,
|
baseUrl: session.baseUrl,
|
||||||
@@ -80,8 +86,9 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||||||
cryptoCallbacks: cryptoCallbacks as any,
|
cryptoCallbacks: cryptoCallbacks as any,
|
||||||
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
// 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'],
|
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
||||||
tokenRefreshFunction: oidcRefresher
|
oauthClientId: oidcIdentity?.oidc.clientId,
|
||||||
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
onTokenRefresh: oidcIdentity
|
||||||
|
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,11 +133,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
|||||||
export const logoutClient = async (mx: MatrixClient) => {
|
export const logoutClient = async (mx: MatrixClient) => {
|
||||||
pushSessionToSW();
|
pushSessionToSW();
|
||||||
mx.stopClient();
|
mx.stopClient();
|
||||||
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
// For OIDC sessions the SDK revokes the tokens at the auth server instead of
|
||||||
const session = getFallbackSession();
|
// calling /logout (matrix-js-sdk 43+); best-effort either way.
|
||||||
if (session?.oidc) {
|
|
||||||
await revokeOidcTokens(session);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await mx.logout();
|
await mx.logout();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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<void> => {
|
|
||||||
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<Response> =>
|
|
||||||
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<Response>[] = [];
|
|
||||||
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 */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
(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');
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
import type { AccessTokens } from 'matrix-js-sdk';
|
||||||
import type { IdTokenClaims } from 'oidc-client-ts';
|
|
||||||
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
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
|
// 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
|
// 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
|
// signed out. Holding the lock while refreshing (and re-reading storage once
|
||||||
// inside it) makes the loser adopt the winner's tokens instead.
|
// inside it) makes the loser adopt the winner's tokens instead.
|
||||||
@@ -19,82 +23,106 @@ export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
|
|||||||
return locks.request(REFRESH_LOCK_NAME, fn);
|
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,
|
* #16 — wrap `fetch` so OAuth refresh grants run one at a time across tabs.
|
||||||
* so a page reload keeps the freshest access/refresh token. The matrix client
|
* Inside the lock: if storage already holds a DIFFERENT refresh token for this
|
||||||
* calls this automatically (reactively on a 401) when a refresh token is set.
|
* 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 {
|
export const guardRefreshFetch =
|
||||||
private readonly deviceIdRef: string;
|
(inner: typeof fetch, id: OidcIdentity): typeof fetch =>
|
||||||
|
(input, init) => {
|
||||||
private readonly userIdRef: string;
|
const spending = refreshGrantToken(init);
|
||||||
|
if (!spending) return inner(input, init);
|
||||||
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<AccessTokens> {
|
|
||||||
return withRefreshLock(async () => {
|
return withRefreshLock(async () => {
|
||||||
const stored = getFallbackSession();
|
const stored = getFallbackSession();
|
||||||
if (
|
if (
|
||||||
stored &&
|
stored &&
|
||||||
stored.userId === this.userIdRef &&
|
stored.userId === id.userId &&
|
||||||
stored.deviceId === this.deviceIdRef &&
|
stored.deviceId === id.deviceId &&
|
||||||
stored.refreshToken &&
|
stored.refreshToken &&
|
||||||
stored.refreshToken !== refreshToken
|
stored.refreshToken !== spending
|
||||||
) {
|
) {
|
||||||
return {
|
return new Response(
|
||||||
accessToken: stored.accessToken,
|
JSON.stringify({
|
||||||
refreshToken: stored.refreshToken,
|
token_type: 'Bearer',
|
||||||
expiry:
|
access_token: stored.accessToken,
|
||||||
typeof stored.expiresInMs === 'number'
|
refresh_token: stored.refreshToken,
|
||||||
? new Date(Date.now() + stored.expiresInMs)
|
...(typeof stored.expiresInMs === 'number'
|
||||||
: undefined,
|
? { expires_in: Math.floor(stored.expiresInMs / 1000) }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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') {
|
||||||
return super.doRefreshAccessToken(refreshToken);
|
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
|
let unguardedFetch: typeof fetch | undefined;
|
||||||
// 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
|
* Install the #16 guard on `globalThis.fetch`. The SDK's OAuth calls use the
|
||||||
// it here directly (a doRefreshAccessToken override would run too late, since
|
* global fetch directly (not the client's `fetchFn`), so this is the only
|
||||||
// persistTokens is called before that method returns).
|
* place to catch them. Idempotent: re-installing replaces the guard.
|
||||||
protected async persistTokens(tokens: {
|
*/
|
||||||
accessToken: string;
|
export const installRefreshGuard = (id: OidcIdentity): void => {
|
||||||
refreshToken?: string;
|
unguardedFetch ??= globalThis.fetch.bind(globalThis);
|
||||||
expiry?: Date;
|
globalThis.fetch = guardRefreshFetch(unguardedFetch, id);
|
||||||
}): Promise<void> {
|
};
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-3
@@ -327,9 +327,10 @@ export default defineConfig({
|
|||||||
// Assets are content-hashed, so the filename is the cache key — don't
|
// Assets are content-hashed, so the filename is the cache key — don't
|
||||||
// append a revision cache-busting param.
|
// append a revision cache-busting param.
|
||||||
dontCacheBustURLsMatching: /assets\//,
|
dontCacheBustURLsMatching: /assets\//,
|
||||||
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
|
// Raised above the 2 MB default so the matrix-sdk crypto wasm (~7.8 MB
|
||||||
// (hash-busted and hot on every session) is precached deliberately.
|
// since crypto-wasm 18.9 / matrix-js-sdk 44; hash-busted and hot on every
|
||||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
// session) is precached deliberately.
|
||||||
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
|
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
|
||||||
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
|
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user