feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+1 -6
View File
@@ -17,12 +17,7 @@ export type {
} from './types.js';
export { MatrixClient, MatrixAuthError } from './matrix/client.js';
export type { MatrixCredentials } from './matrix/types.js';
export {
extractMatrixCredentials,
loadCachedCredentials,
MatrixBrowserLoginRequiredError,
saveCredentials,
} from './matrix/token-extractor.js';
export { loadCachedCredentials, saveCredentials } from './matrix/credential-store.js';
export {
MatrixRemoteLoginRequiredError,
MatrixTokenRefresher,
@@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
import type { MatrixCredentials } from './types.js';
const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json');
export async function loadCachedCredentials(
cachePath = DEFAULT_CACHE_PATH,
): Promise<MatrixCredentials | null> {
try {
if (!fs.existsSync(cachePath)) return null;
const parsed: unknown = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
if (!parsed || typeof parsed !== 'object') return null;
const value = parsed as Partial<MatrixCredentials>;
if (!value.accessToken || !value.homeserver || !value.userId || !value.capturedAt) return null;
return value as MatrixCredentials;
} catch {
return null;
}
}
export function saveCredentials(
credentials: MatrixCredentials,
cachePath = DEFAULT_CACHE_PATH,
): void {
const dir = path.dirname(cachePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(credentials, null, 2), 'utf-8');
}
-210
View File
@@ -1,210 +0,0 @@
import fs from 'fs';
import path from 'path';
import type { BrowserContext } from 'playwright';
import type { Logger } from '../../../utils/logger.js';
import type { MatrixCredentials } from './types.js';
import { fetchWithTimeout } from '../../../utils/fetch-with-timeout.js';
const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json');
/** Reddit chat homeserver. The bearer token is host-scoped to this origin. */
const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i;
export interface TokenExtractorOptions {
/** Where to persist the captured token. */
cachePath?: string;
/** Max ms to wait for a Matrix-bound request to appear. */
timeoutMs?: number;
}
export class MatrixBrowserLoginRequiredError extends Error {
constructor(loginUrl: string) {
super(
`Reddit redirected Matrix token capture to the login page (${loginUrl}). ` +
'Restore the browser session before retrying.',
);
this.name = 'MatrixBrowserLoginRequiredError';
}
}
export async function loadCachedCredentials(
cachePath = DEFAULT_CACHE_PATH,
): Promise<MatrixCredentials | null> {
try {
if (!fs.existsSync(cachePath)) return null;
const raw = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(raw) as MatrixCredentials;
if (!parsed.accessToken || !parsed.homeserver || !parsed.userId) return null;
return parsed;
} catch {
return null;
}
}
export function saveCredentials(
creds: MatrixCredentials,
cachePath = DEFAULT_CACHE_PATH,
): void {
const dir = path.dirname(cachePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(creds, null, 2), 'utf-8');
}
/**
* Sniffs the Matrix bearer token by opening chat.reddit.com in the supplied
* (already-logged-in) browser context and watching outbound requests for any
* call to the Matrix homeserver. The first request carrying an
* `Authorization: Bearer ...` header is captured.
*
* Requires the context to already hold a valid Reddit session — call this
* after BrowserClient.login() or with a persistent context that has cookies.
*/
export async function extractMatrixCredentials(
context: BrowserContext,
logger: Logger,
options: TokenExtractorOptions = {},
): Promise<MatrixCredentials> {
const timeoutMs = options.timeoutMs ?? 30_000;
const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH;
const page = await context.newPage();
try {
let resolveCreds: (c: MatrixCredentials) => void;
let rejectCreds: (err: Error) => void;
const credsPromise = new Promise<MatrixCredentials>((resolve, reject) => {
resolveCreds = resolve;
rejectCreds = reject;
});
let done = false;
// Track every host the page touches so we can tell, on timeout, whether
// chat.reddit.com failed to load at all, loaded but never reached the
// Matrix homeserver, or reached it but without a bearer header.
const seenHosts = new Map<string, { count: number; sawAuth: boolean }>();
const onRequest = (request: import('playwright').Request) => {
if (done) return;
try {
const url = new URL(request.url());
const isMatrix = MATRIX_HOST_RE.test(url.hostname);
const headers = request.headers();
const auth = headers['authorization'] || headers['Authorization'];
const hasAuth = !!auth && auth.toLowerCase().startsWith('bearer ');
const entry = seenHosts.get(url.hostname) ?? { count: 0, sawAuth: false };
entry.count += 1;
if (hasAuth) entry.sawAuth = true;
seenHosts.set(url.hostname, entry);
if (!isMatrix) return;
if (!hasAuth) return;
const token = auth!.slice('bearer '.length).trim();
if (!token) return;
const homeserver = `${url.protocol}//${url.host}`;
done = true;
logger.info(`[matrix] captured bearer token for ${homeserver}`);
resolveCreds({
homeserver,
accessToken: token,
userId: '',
capturedAt: Date.now(),
});
} catch {
// swallow malformed URLs
}
};
page.on('request', onRequest);
page.on('framenavigated', (frame) => {
if (done || frame !== page.mainFrame()) return;
try {
const url = new URL(frame.url());
const isRedditHost = url.hostname === 'reddit.com' || url.hostname === 'www.reddit.com';
if (isRedditHost && url.pathname.startsWith('/login')) {
done = true;
logger.warn('[matrix] chat page redirected to Reddit login; token capture aborted');
rejectCreds(new MatrixBrowserLoginRequiredError(url.toString()));
}
} catch {
// Ignore transient or malformed navigation URLs.
}
});
// Reddit now redirects `chat.reddit.com` → `www.reddit.com/chat/`. Going
// direct to the canonical URL avoids one redirect hop and means the
// session cookie the bot already has for www.reddit.com is applied to
// the very first request, rather than only after a cross-subdomain
// bounce that's been bouncing logged-in sessions to the login wall.
//
// We start the navigation but don't await it — the request listener
// resolves credsPromise the moment the SPA fires off its first authed
// call to matrix.redditspace.com, which may happen well before the page
// reaches `networkidle`. Awaiting goto would block past that point.
logger.info('[matrix] loading www.reddit.com/chat/ to capture access token...');
void page
.goto('https://www.reddit.com/chat/', {
waitUntil: 'domcontentloaded',
timeout: timeoutMs,
})
.catch((err) => {
logger.warn(
`[matrix] page.goto error (ignored, still listening): ${err instanceof Error ? err.message : String(err)}`,
);
});
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<MatrixCredentials>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error(`Matrix token capture timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
let captured: MatrixCredentials;
try {
captured = await Promise.race([credsPromise, timeout]);
} catch (err) {
if (err instanceof MatrixBrowserLoginRequiredError) throw err;
// On timeout, surface enough info to tell the three failure modes apart:
// (a) chat.reddit.com never loaded → page.url() still about:blank, almost no hosts
// (b) page loaded, no Matrix host hit → reddit hosts present, no matrix.*
// (c) Matrix hit, but no Authorization → matrix.* present with sawAuth: false
try {
const finalUrl = page.url();
const hostSummary = Array.from(seenHosts.entries())
.map(([host, info]) => `${host}=${info.count}${info.sawAuth ? '*' : ''}`)
.join(', ');
logger.error(
`[matrix] timeout diagnostics: finalUrl=${finalUrl} hosts=[${hostSummary || 'none'}]`,
);
const shotDir = path.dirname(cachePath);
if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir, { recursive: true });
const shotPath = path.join(shotDir, 'matrix-debug.png');
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => undefined);
logger.error(`[matrix] saved debug screenshot to ${shotPath}`);
} catch (diagErr) {
logger.warn('[matrix] failed to collect timeout diagnostics:', diagErr);
}
throw err;
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
}
// Resolve the user_id by calling /whoami so the caller knows who we are.
const whoamiRes = await fetchWithTimeout(
`${captured.homeserver}/_matrix/client/v3/account/whoami`,
{
headers: { Authorization: `Bearer ${captured.accessToken}` },
timeoutMs: 15_000,
},
);
if (whoamiRes.ok) {
const body = (await whoamiRes.json()) as { user_id?: string };
if (body.user_id) captured.userId = body.user_id;
} else {
logger.warn(`[matrix] whoami returned ${whoamiRes.status}; userId left blank`);
}
saveCredentials(captured, cachePath);
logger.info(`[matrix] credentials saved to ${cachePath} (user=${captured.userId || 'unknown'})`);
return captured;
} finally {
await page.close().catch(() => undefined);
}
}
+4 -86
View File
@@ -1,97 +1,15 @@
import type { BrowserContext } from 'playwright';
import type { Logger } from '../../../utils/logger.js';
import type { TokenRefresher } from '../service.js';
import type { MatrixCredentials } from './types.js';
import {
extractMatrixCredentials,
MatrixBrowserLoginRequiredError,
} from './token-extractor.js';
export interface MatrixSessionBrowser {
getContext(): BrowserContext | null;
isSessionValid(): Promise<boolean>;
login(): Promise<boolean>;
}
export type MatrixCredentialExtractor = (
context: BrowserContext,
logger: Logger,
) => Promise<MatrixCredentials>;
export class MatrixRemoteLoginRequiredError extends Error {
constructor() {
super(
'Reddit browser session has expired and automatic login could not restore it. ' +
'Open the dashboard and use the noVNC console to log in to Reddit, then retry chat sync.',
);
super('Matrix credentials expired. Import a fresh session dump from the extension, then retry.');
this.name = 'MatrixRemoteLoginRequiredError';
}
}
/**
* Restores the browser login when needed, then captures and applies one fresh
* Matrix credential set. Concurrent callers share the same browser operation.
*/
/** Matrix credentials are replaced only by a newly verified extension dump. */
export class MatrixTokenRefresher implements TokenRefresher {
private refreshInFlight: Promise<void> | null = null;
constructor(
private readonly browser: MatrixSessionBrowser,
private readonly logger: Logger,
private readonly applyCredentials: (credentials: MatrixCredentials) => void,
private readonly extract: MatrixCredentialExtractor = extractMatrixCredentials,
) {}
refresh(): Promise<void> {
if (this.refreshInFlight) return this.refreshInFlight;
this.refreshInFlight = this.performRefresh().finally(() => {
this.refreshInFlight = null;
});
return this.refreshInFlight;
}
private async performRefresh(): Promise<void> {
const sessionValid = await this.browser.isSessionValid();
let sessionRestored = false;
if (!sessionValid) {
await this.restoreSession();
sessionRestored = true;
}
const context = this.browser.getContext();
if (!context) {
throw new Error('Matrix token refresh requires an initialized browser context');
}
let credentials: MatrixCredentials;
try {
credentials = await this.extract(context, this.logger);
} catch (error) {
if (!(error instanceof MatrixBrowserLoginRequiredError)) throw error;
if (sessionRestored) throw new MatrixRemoteLoginRequiredError();
this.logger.warn(
'[matrix] Reddit API session looked valid but chat requires login; attempting recovery',
);
await this.restoreSession();
try {
credentials = await this.extract(context, this.logger);
} catch (retryError) {
if (retryError instanceof MatrixBrowserLoginRequiredError) {
throw new MatrixRemoteLoginRequiredError();
}
throw retryError;
}
}
this.applyCredentials(credentials);
}
private async restoreSession(): Promise<void> {
this.logger.warn('[matrix] Reddit browser session expired; attempting login recovery');
if (!(await this.browser.login())) {
throw new MatrixRemoteLoginRequiredError();
}
this.logger.info('[matrix] Reddit browser session restored');
async refresh(): Promise<void> {
throw new MatrixRemoteLoginRequiredError();
}
}