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:
1 parent
f34129666e
commit
82be197b79
7 files changed
+426
-8
No files matched your search
@@ -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';
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface TokenCapturePage {
|
||||
goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface TokenCaptureContext {
|
||||
on(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown;
|
||||
off(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown;
|
||||
newPage(): Promise<TokenCapturePage>;
|
||||
}
|
||||
|
||||
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<string>;
|
||||
/** 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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
|
||||
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)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<boolean> => {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user