From 82be197b793de6df720360d4a34e6cf05f9722ec Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 08:56:17 +0100 Subject: [PATCH] feat: self-refresh Matrix tokens from the account browser session Expired Matrix bearers no longer pause an account until a new extension dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in the account's persistent browser profile (whose Reddit cookies stay valid), lifts the fresh bearer off the first authenticated Matrix request, verifies it via whoami, swaps it into the live MatrixClient and persists it to the account row. Wired into the per-account runtime at boot verification, the auth-gate authenticator, and ChatService's 401 retry path. A dump is now only needed when the Reddit cookies themselves no longer authenticate. Co-Authored-By: Claude Fable 5 --- src/bootstrap.ts | 4 +- src/database/account-repositories.ts | 15 ++ src/services/chat/index.ts | 8 + .../chat/matrix/browser-token-refresher.ts | 169 ++++++++++++++++ .../reddit-account-runtime-builder.ts | 35 +++- tests/account-repositories.test.ts | 16 ++ tests/browser-token-refresher.test.ts | 187 ++++++++++++++++++ 7 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 src/services/chat/matrix/browser-token-refresher.ts create mode 100644 tests/browser-token-refresher.test.ts diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 58ac039..1c5cf01 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -282,8 +282,8 @@ export async function bootstrap( logger.info(`Backfilled ${backfilledImageOutcomes} received-image performance outcome(s) from the media index`); } - // A 401 waits for a new extension dump instead of opening a browser login - // or token-sniff flow. + // Legacy single-account path: a 401 waits for a new extension dump. The + // per-account runtimes self-refresh via BrowserMatrixTokenRefresher instead. const tokenRefresher = new MatrixTokenRefresher(); // 4. Username resolver — Matrix profiles expose Reddit display names using diff --git a/src/database/account-repositories.ts b/src/database/account-repositories.ts index 4d0b3ac..db82ddc 100644 --- a/src/database/account-repositories.ts +++ b/src/database/account-repositories.ts @@ -104,6 +104,21 @@ export class RedditAccountsRepository { return publicFromPrivate(this.getRequired(input.accountId)); } + /** Store a self-refreshed Matrix token without touching cookies or status. */ + updateMatrixCredentials( + accountId: string, + matrixCredentialsJson: string, + now: number = Date.now(), + ): boolean { + requireText(accountId, 'accountId'); + requireJson(matrixCredentialsJson, 'matrixCredentialsJson'); + return this.db.prepare(` + UPDATE reddit_accounts + SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ? + WHERE account_id = ? AND status != 'legacy-unassigned' + `).run(matrixCredentialsJson, now, now, accountId).changes === 1; + } + setActive(accountId: string, active = true, now: number = Date.now()): boolean { const status = active ? 'verified' : 'disabled'; return this.db.prepare(` diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts index b06ab35..5ed4978 100644 --- a/src/services/chat/index.ts +++ b/src/services/chat/index.ts @@ -24,5 +24,13 @@ export { MatrixRemoteLoginRequiredError, MatrixTokenRefresher, } from './matrix/token-refresher.js'; +export { BrowserMatrixTokenRefresher } from './matrix/browser-token-refresher.js'; +export type { + BrowserMatrixTokenRefresherDeps, + BrowserMatrixTokenRefresherOptions, + TokenCaptureContext, + TokenCapturePage, + TokenCaptureRequest, +} from './matrix/browser-token-refresher.js'; export { MatrixMediaDownloader } from './matrix/http-media-downloader.js'; export { MatrixUsernameResolver } from './matrix/username-resolver.js'; diff --git a/src/services/chat/matrix/browser-token-refresher.ts b/src/services/chat/matrix/browser-token-refresher.ts new file mode 100644 index 0000000..dc74bb1 --- /dev/null +++ b/src/services/chat/matrix/browser-token-refresher.ts @@ -0,0 +1,169 @@ +import type { Logger } from '../../../utils/logger.js'; +import type { TokenRefresher } from '../service.js'; +import type { MatrixCredentials } from './types.js'; +import { MatrixRemoteLoginRequiredError } from './token-refresher.js'; + +/** + * Narrow structural views of Playwright's BrowserContext/Page/Request so the + * refresher can be unit-tested without a real browser. Request events are + * observed on the context (not the page) so Matrix traffic issued from + * workers is still visible. + */ +export interface TokenCaptureRequest { + url(): string; + headers(): Record; +} + +export interface TokenCapturePage { + goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise; + close(): Promise; +} + +export interface TokenCaptureContext { + on(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown; + off(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown; + newPage(): Promise; +} + +export interface BrowserMatrixTokenRefresherDeps { + /** The account's persistent browser context — its Reddit cookies mint the token. */ + context: TokenCaptureContext; + /** Matrix user the refreshed token must authenticate as. */ + expectedUserId: string; + /** whoami with candidate credentials, via a throwaway client. */ + verifyCredentials(credentials: MatrixCredentials): Promise; + /** Swap the live MatrixClient's bearer. */ + applyCredentials(credentials: MatrixCredentials): void; + /** Store the refreshed credentials so restarts reuse them. */ + persistCredentials(credentials: MatrixCredentials): void; + logger: Logger; +} + +export interface BrowserMatrixTokenRefresherOptions { + chatUrl?: string; + captureTimeoutMs?: number; + /** Back-off after a failed refresh so auth loops don't hammer the browser. */ + failureCooldownMs?: number; + now?: () => number; +} + +const MATRIX_HOSTNAME = /^matrix\.(?:redditspace|reddit)\.com$/i; + +/** + * Self-refreshes the Matrix access token the way the exporter extension + * captured it: load Reddit Chat with the account's own (still valid) Reddit + * cookies and lift the bearer off the first authenticated Matrix request. + * Falls back to requiring a fresh extension dump only when the browser + * session itself can no longer authenticate. + */ +export class BrowserMatrixTokenRefresher implements TokenRefresher { + private readonly chatUrl: string; + private readonly captureTimeoutMs: number; + private readonly failureCooldownMs: number; + private readonly now: () => number; + private inFlight: Promise | null = null; + private nextAttemptAt = 0; + + constructor( + private readonly deps: BrowserMatrixTokenRefresherDeps, + options: BrowserMatrixTokenRefresherOptions = {}, + ) { + this.chatUrl = options.chatUrl ?? 'https://chat.reddit.com/'; + this.captureTimeoutMs = options.captureTimeoutMs ?? 45_000; + this.failureCooldownMs = options.failureCooldownMs ?? 60_000; + this.now = options.now ?? Date.now; + } + + refresh(): Promise { + if (this.inFlight) return this.inFlight; + if (this.now() < this.nextAttemptAt) { + return Promise.reject(new MatrixRemoteLoginRequiredError()); + } + this.inFlight = this.doRefresh() + .then(() => { + this.nextAttemptAt = 0; + }) + .catch((error: unknown) => { + this.nextAttemptAt = this.now() + this.failureCooldownMs; + throw error; + }) + .finally(() => { + this.inFlight = null; + }); + return this.inFlight; + } + + private async doRefresh(): Promise { + const captured = await this.captureBearer(); + if (!captured) { + this.deps.logger.warn( + '[matrix] browser session did not produce a Matrix bearer — a fresh extension dump is required', + ); + throw new MatrixRemoteLoginRequiredError(); + } + + const credentials: MatrixCredentials = { + homeserver: captured.homeserver, + accessToken: captured.accessToken, + userId: this.deps.expectedUserId, + capturedAt: this.now(), + }; + const userId = await this.deps.verifyCredentials(credentials); + if (userId !== this.deps.expectedUserId) { + throw new Error( + `Browser session minted a Matrix token for ${userId}, expected ${this.deps.expectedUserId}`, + ); + } + this.deps.applyCredentials(credentials); + this.deps.persistCredentials(credentials); + this.deps.logger.info('[matrix] refreshed the Matrix access token from the account browser session'); + } + + private async captureBearer(): Promise<{ homeserver: string; accessToken: string } | null> { + let capture: (value: { homeserver: string; accessToken: string }) => void; + const captured = new Promise<{ homeserver: string; accessToken: string }>((resolve) => { + capture = resolve; + }); + const onRequest = (request: TokenCaptureRequest): void => { + try { + const url = new URL(request.url()); + if (url.protocol !== 'https:' || !MATRIX_HOSTNAME.test(url.hostname)) return; + const authorization = request.headers()['authorization']; + if (!authorization?.toLowerCase().startsWith('bearer ')) return; + capture({ + homeserver: `${url.protocol}//${url.host}`, + accessToken: authorization.slice('bearer '.length).trim(), + }); + } catch { + // Malformed request URL — ignore. + } + }; + + this.deps.context.on('request', onRequest); + let page: TokenCapturePage | null = null; + try { + page = await this.deps.context.newPage(); + await page.goto(this.chatUrl, { waitUntil: 'domcontentloaded', timeout: this.captureTimeoutMs }); + return await withTimeout(captured, this.captureTimeoutMs); + } finally { + this.deps.context.off('request', onRequest); + await page?.close().catch(() => undefined); + } + } +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 430bdd3..d5643b5 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -12,7 +12,7 @@ import { ActivityLogRepository, RecipientHistoryRepository, } from '../database/repositories.js'; -import { AccountContentRepository } from '../database/account-repositories.js'; +import { AccountContentRepository, RedditAccountsRepository } from '../database/account-repositories.js'; import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js'; import { RedditRequestCoordinator } from '../reddit/request-coordinator.js'; import { OpenRouterClient } from '../ai/openrouter.js'; @@ -28,8 +28,9 @@ import { } from './account-auth-context.js'; import { ChatCache } from './chat/cache.js'; import { - ChatService, MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher, - MatrixUsernameResolver, type UsernameResolver, + BrowserMatrixTokenRefresher, ChatService, MatrixAuthError, MatrixClient, + MatrixMediaDownloader, MatrixUsernameResolver, + type MatrixCredentials, type UsernameResolver, } from './chat/index.js'; import { MediaService } from './media.js'; import { MediaSyncService } from './media-sync.js'; @@ -116,7 +117,29 @@ async function buildRuntime( } const matrixClient = new MatrixClient(session.matrixCredentials, logger); - if (await matrixClient.whoami() !== session.matrixUserId) { + const accountsRepo = new RedditAccountsRepository(db); + const tokenRefresher = new BrowserMatrixTokenRefresher({ + context: browserClient.getContext()!, + expectedUserId: session.matrixUserId, + verifyCredentials: (credentials) => new MatrixClient(credentials, logger).whoami(), + applyCredentials: (credentials) => matrixClient.updateCredentials(credentials), + persistCredentials: (credentials: MatrixCredentials) => { + session.matrixCredentials = credentials; + accountsRepo.updateMatrixCredentials(session.accountId, JSON.stringify(credentials)); + }, + logger, + }); + const verifyMatrixSession = async (refreshOnAuthError: boolean): Promise => { + try { + return await matrixClient.whoami() === session.matrixUserId; + } catch (error) { + if (!refreshOnAuthError || !(error instanceof MatrixAuthError)) throw error; + logger.warn(`Stored Matrix token for u/${session.redditUsername} was rejected — refreshing from the browser session`); + await tokenRefresher.refresh(); + return await matrixClient.whoami() === session.matrixUserId; + } + }; + if (!(await verifyMatrixSession(true))) { throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`); } @@ -125,7 +148,7 @@ async function buildRuntime( accountAuth.setAuthenticator(async () => { const current = await browserClient.getAuthenticatedIdentity(); if (!current || matrixRedditId(session.matrixUserId) !== current.accountId.toLowerCase().replace(/^t2_/, '')) return null; - return await matrixClient.whoami() === session.matrixUserId + return await verifyMatrixSession(true) ? runtimeAuthenticatedAccount(session, current) : null; }); @@ -182,7 +205,7 @@ async function buildRuntime( const chatCache = new ChatCache(logger, undefined, accountContentRepo, session.accountId); const chatService = new ChatService( matrixClient, logger, successfulMessagesRepo, mediaService, - new MatrixTokenRefresher(), usernameResolver, chatCache, + tokenRefresher, usernameResolver, chatCache, messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo, ); await chatService.loadCache(); diff --git a/tests/account-repositories.test.ts b/tests/account-repositories.test.ts index 0393d97..347cc81 100644 --- a/tests/account-repositories.test.ts +++ b/tests/account-repositories.test.ts @@ -54,6 +54,22 @@ describe('multi-account database ownership', () => { }); }); + it('updates matrix credentials in place without touching cookies or status', () => { + addAccount('t2_alice', 'alice-bot', 100); + + const refreshed = JSON.stringify({ accessToken: 'refreshed-matrix-secret' }); + expect(accounts.updateMatrixCredentials('t2_alice', refreshed, 200)).toBe(true); + + expect(accounts.get('t2_alice')).toMatchObject({ + matrixCredentialsJson: refreshed, + cookiesJson: expect.stringContaining('alice-bot-secret'), + status: 'verified', + updatedAt: 200, + lastVerifiedAt: 200, + }); + expect(accounts.updateMatrixCredentials('t2_missing', refreshed, 200)).toBe(false); + }); + it('re-imports one account in place and supports multiple active accounts', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); diff --git a/tests/browser-token-refresher.test.ts b/tests/browser-token-refresher.test.ts new file mode 100644 index 0000000..b3f82d1 --- /dev/null +++ b/tests/browser-token-refresher.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '../src/utils/logger.js'; +import type { MatrixCredentials } from '../src/services/chat/matrix/types.js'; +import { + BrowserMatrixTokenRefresher, + type BrowserMatrixTokenRefresherDeps, + type BrowserMatrixTokenRefresherOptions, + type TokenCaptureRequest, +} from '../src/services/chat/matrix/browser-token-refresher.js'; +import { MatrixRemoteLoginRequiredError } from '../src/services/chat/matrix/token-refresher.js'; + +function createLogger(): Logger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger; +} + +function fakeRequest(url: string, headers: Record): TokenCaptureRequest { + return { url: () => url, headers: () => headers }; +} + +interface Harness { + refresher: BrowserMatrixTokenRefresher; + emit(request: TokenCaptureRequest): void; + handlers(): number; + pagesOpened(): number; + pageClosed(): boolean; + applied: MatrixCredentials[]; + persisted: MatrixCredentials[]; + verify: ReturnType; +} + +function createHarness(input: { + /** Requests emitted while the chat page loads. */ + requestsOnGoto?: TokenCaptureRequest[]; + verifiedUserId?: string | Error; + options?: BrowserMatrixTokenRefresherOptions; + deps?: Partial; +} = {}): Harness { + const handlers = new Set<(request: TokenCaptureRequest) => void>(); + const applied: MatrixCredentials[] = []; + const persisted: MatrixCredentials[] = []; + let pagesOpened = 0; + let pageClosed = false; + const verify = vi.fn((credentials: MatrixCredentials) => { + void credentials; + const outcome = input.verifiedUserId ?? '@t2_abc:reddit.com'; + return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome); + }); + const refresher = new BrowserMatrixTokenRefresher({ + context: { + on: (_event, handler) => handlers.add(handler), + off: (_event, handler) => handlers.delete(handler), + newPage: async () => { + pagesOpened += 1; + return { + goto: async () => { + for (const request of input.requestsOnGoto ?? []) { + for (const handler of handlers) handler(request); + } + }, + close: async () => { + pageClosed = true; + }, + }; + }, + }, + expectedUserId: '@t2_abc:reddit.com', + verifyCredentials: verify, + applyCredentials: (credentials) => applied.push(credentials), + persistCredentials: (credentials) => persisted.push(credentials), + logger: createLogger(), + ...input.deps, + }, { captureTimeoutMs: 50, failureCooldownMs: 60_000, ...input.options }); + return { + refresher, + emit: (request) => { for (const handler of handlers) handler(request); }, + handlers: () => handlers.size, + pagesOpened: () => pagesOpened, + pageClosed: () => pageClosed, + applied, + persisted, + verify, + }; +} + +const bearerRequest = fakeRequest( + 'https://matrix.redditspace.com/_matrix/client/v3/sync', + { authorization: 'Bearer fresh-token' }, +); + +describe('BrowserMatrixTokenRefresher', () => { + it('captures the bearer from Matrix traffic, verifies, applies and persists it', async () => { + const now = () => 1_000; + const harness = createHarness({ requestsOnGoto: [bearerRequest], options: { now } }); + + await harness.refresher.refresh(); + + const expected: MatrixCredentials = { + homeserver: 'https://matrix.redditspace.com', + accessToken: 'fresh-token', + userId: '@t2_abc:reddit.com', + capturedAt: 1_000, + }; + expect(harness.verify).toHaveBeenCalledWith(expected); + expect(harness.applied).toEqual([expected]); + expect(harness.persisted).toEqual([expected]); + expect(harness.pageClosed()).toBe(true); + expect(harness.handlers()).toBe(0); + }); + + it('ignores non-Matrix and non-bearer requests', async () => { + const harness = createHarness({ + requestsOnGoto: [ + fakeRequest('https://chat.reddit.com/api', { authorization: 'Bearer decoy' }), + fakeRequest('https://matrix.redditspace.com/versions', {}), + fakeRequest('http://matrix.reddit.com/insecure', { authorization: 'Bearer decoy' }), + fakeRequest('not a url', { authorization: 'Bearer decoy' }), + bearerRequest, + ], + }); + + await harness.refresher.refresh(); + + expect(harness.applied[0]?.accessToken).toBe('fresh-token'); + }); + + it('requires a fresh dump when no bearer appears before the timeout', async () => { + const harness = createHarness({ requestsOnGoto: [] }); + + await expect(harness.refresher.refresh()).rejects.toBeInstanceOf(MatrixRemoteLoginRequiredError); + expect(harness.applied).toEqual([]); + expect(harness.persisted).toEqual([]); + expect(harness.pageClosed()).toBe(true); + }); + + it('rejects a token minted for a different Matrix user without applying it', async () => { + const harness = createHarness({ + requestsOnGoto: [bearerRequest], + verifiedUserId: '@t2_other:reddit.com', + }); + + await expect(harness.refresher.refresh()).rejects.toThrow(/minted a Matrix token for @t2_other/); + expect(harness.applied).toEqual([]); + expect(harness.persisted).toEqual([]); + }); + + it('propagates verification failures without persisting', async () => { + const harness = createHarness({ + requestsOnGoto: [bearerRequest], + verifiedUserId: new Error('whoami exploded'), + }); + + await expect(harness.refresher.refresh()).rejects.toThrow('whoami exploded'); + expect(harness.persisted).toEqual([]); + }); + + it('deduplicates concurrent refreshes onto one browser pass', async () => { + const harness = createHarness({ requestsOnGoto: [bearerRequest] }); + + await Promise.all([harness.refresher.refresh(), harness.refresher.refresh()]); + + expect(harness.pagesOpened()).toBe(1); + expect(harness.applied).toHaveLength(1); + }); + + it('backs off after a failure and retries once the cooldown elapses', async () => { + let clock = 0; + const requests: TokenCaptureRequest[] = []; + const harness = createHarness({ + requestsOnGoto: requests, + options: { now: () => clock, failureCooldownMs: 60_000, captureTimeoutMs: 10 }, + }); + + await expect(harness.refresher.refresh()).rejects.toBeInstanceOf(MatrixRemoteLoginRequiredError); + expect(harness.pagesOpened()).toBe(1); + + // Inside the cooldown the browser is not reopened. + clock = 30_000; + await expect(harness.refresher.refresh()).rejects.toBeInstanceOf(MatrixRemoteLoginRequiredError); + expect(harness.pagesOpened()).toBe(1); + + clock = 60_001; + requests.push(bearerRequest); + await harness.refresher.refresh(); + expect(harness.pagesOpened()).toBe(2); + expect(harness.applied).toHaveLength(1); + }); +});