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); + }); +});