Improve remote Matrix token refresh

This commit is contained in:
jjbrunton committed 2026-08-08 14:30:40 +01:00
1 parent b9df8459e3
commit bffda95251
6 files changed
+226 -14

No files matched your search

+9 -10
View File
@@ -22,9 +22,9 @@ import {
ChatService,
MatrixClient,
MatrixMediaDownloader,
MatrixTokenRefresher,
extractMatrixCredentials,
loadCachedCredentials,
type TokenRefresher,
type UsernameResolver,
type MatrixCredentials,
} from './services/chat/index.js';
@@ -187,16 +187,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger);
// 3. Token refresher — on 401, re-sniff via the running browser session
// and swap the new credentials into the chat service.
// 3. Token refresher — on 401, restore the persistent Reddit session when
// possible, re-sniff once, and swap credentials into the chat service.
// Concurrent callers share that refresh rather than opening extra pages.
let chatService: ChatService;
const tokenRefresher: TokenRefresher = {
async refresh() {
const fresh = await extractMatrixCredentials(browserClient.getContext()!, logger);
const next = new MatrixClient(fresh, logger);
chatService.setMatrixClient(next);
},
};
const tokenRefresher = new MatrixTokenRefresher(
browserClient,
logger,
(fresh) => chatService.setMatrixClient(new MatrixClient(fresh, logger)),
);
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
+5
View File
@@ -20,6 +20,11 @@ export type { MatrixCredentials } from './matrix/types.js';
export {
extractMatrixCredentials,
loadCachedCredentials,
MatrixBrowserLoginRequiredError,
saveCredentials,
} from './matrix/token-extractor.js';
export {
MatrixRemoteLoginRequiredError,
MatrixTokenRefresher,
} from './matrix/token-refresher.js';
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
+32 -4
View File
@@ -16,6 +16,16 @@ export interface TokenExtractorOptions {
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> {
@@ -101,6 +111,20 @@ export async function extractMatrixCredentials(
};
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
@@ -124,17 +148,19 @@ export async function extractMatrixCredentials(
);
});
const timeout = new Promise<MatrixCredentials>((_, reject) =>
setTimeout(
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.*
@@ -156,6 +182,8 @@ export async function extractMatrixCredentials(
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.
@@ -0,0 +1,69 @@
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 } 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.',
);
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.
*/
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();
if (!sessionValid) {
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');
}
const context = this.browser.getContext();
if (!context) {
throw new Error('Matrix token refresh requires an initialized browser context');
}
const credentials = await this.extract(context, this.logger);
this.applyCredentials(credentials);
}
}