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 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 08:56:17 +01:00
1 parent f34129666e
commit 82be197b79
7 files changed
+426 -8

No files matched your search

+187
View File
@@ -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<string, string>): 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<typeof vi.fn>;
}
function createHarness(input: {
/** Requests emitted while the chat page loads. */
requestsOnGoto?: TokenCaptureRequest[];
verifiedUserId?: string | Error;
options?: BrowserMatrixTokenRefresherOptions;
deps?: Partial<BrowserMatrixTokenRefresherDeps>;
} = {}): 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);
});
});