Bind Reddit and Matrix session identities

This commit is contained in:
jjbrunton committed 2026-08-08 19:43:35 +01:00
1 parent 34ae532cdd
commit 492efe0f8a
12 files changed
+234 -55

No files matched your search

@@ -13,3 +13,5 @@ This local Chrome extension exports `reddit.com` cookies and the current Reddit
6. Delete the downloaded JSON file once the dashboard confirms that the session is authenticated.
The extension observes only requests to Reddit's Matrix homeservers, verifies the captured token with Matrix `whoami`, reads Reddit cookies, and saves the combined export locally. It does not send the dump to any server; you upload it directly to your RedditBot dashboard.
Before downloading, the extension independently resolves the current Reddit cookie identity and refuses the export unless its `t2` account ID matches Matrix `whoami`. Changing Reddit accounts clears the stored Matrix capture, so Reddit Chat must be reopened after every account switch.
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "RedditBot Session Exporter",
"version": "1.0.0",
"version": "1.1.0",
"description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.",
"permissions": ["cookies", "downloads", "storage", "webRequest"],
"host_permissions": [
@@ -1,4 +1,7 @@
import { createSessionExportDownload } from './session-export.js';
import {
assertMatchingSessionIdentity,
createSessionExportDownload,
} from './session-export.js';
const MATRIX_URLS = [
'https://matrix.redditspace.com/*',
@@ -27,6 +30,29 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
['requestHeaders', 'extraHeaders'],
);
chrome.cookies.onChanged.addListener(async ({ cookie }) => {
if (!['reddit_session', 'token_v2'].includes(cookie.name)) return;
await chrome.storage.local.remove('matrixCapture');
await chrome.action.setBadgeText({ text: '' });
await chrome.action.setTitle({
title: 'Reddit account changed. Open Reddit Chat before exporting.',
});
});
async function getRedditIdentity() {
const response = await fetch('https://www.reddit.com/api/me.json', {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) throw new Error('Reddit cookies did not authenticate. Sign in and try again.');
const payload = await response.json();
const account = payload?.data ?? payload;
if (!account?.name || !account?.id) {
throw new Error('Reddit did not return an authenticated account identity.');
}
return { username: account.name, accountId: account.id };
}
async function showError(message) {
await chrome.action.setBadgeText({ text: 'ERR' });
await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' });
@@ -48,6 +74,14 @@ chrome.action.onClicked.addListener(async () => {
const identity = await whoami.json();
if (!identity?.user_id) throw new Error('Reddit Chat did not return an account identity.');
const redditIdentity = await getRedditIdentity();
try {
assertMatchingSessionIdentity(redditIdentity, identity.user_id);
} catch (error) {
await chrome.storage.local.remove('matrixCapture');
throw error;
}
await chrome.downloads.download({
...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }),
saveAs: true,
@@ -1,5 +1,26 @@
const REDDITBOT_SESSION_FILENAME = 'redditbot-session.json';
export function normalizeRedditAccountId(accountId) {
const trimmed = String(accountId ?? '').trim();
const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`;
if (!/^t2_[a-z0-9]+$/i.test(normalized)) {
throw new Error('Reddit returned an invalid account ID.');
}
return normalized;
}
export function assertMatchingSessionIdentity(redditIdentity, matrixUserId) {
const accountId = normalizeRedditAccountId(redditIdentity?.accountId);
const username = String(redditIdentity?.username ?? '').trim();
if (!username) throw new Error('Reddit did not return an authenticated username.');
const expectedMatrixUserId = `@${accountId}:reddit.com`;
if (matrixUserId !== expectedMatrixUserId) {
throw new Error(
`Reddit is logged in as u/${username} (${accountId}), but Chat is authenticated as ${matrixUserId}. Reload Reddit Chat after switching accounts, then export again.`,
);
}
}
function isRedditDomain(domain) {
const normalised = domain.toLowerCase().replace(/^\./, '');
return normalised === 'reddit.com' || normalised.endsWith('.reddit.com');
+12 -12
View File
@@ -37,7 +37,7 @@ import { RateLimiter } from './utils/rate-limiter.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js';
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
export interface AppContainer {
@@ -164,17 +164,17 @@ export async function bootstrap(
};
}
let activeMatrixClient = new MatrixClient(matrixCreds, logger);
accountAuth.setAuthenticator(async () => {
const redditUsername = await browserClient.getAuthenticatedUsername();
if (!redditUsername) return null;
try {
const matrixUserId = await activeMatrixClient.whoami();
if (!matrixUserId) return null;
return { accountId: matrixUserId, redditUsername, matrixUserId };
} catch {
return null;
}
});
accountAuth.setAuthenticator(() => authenticateCurrentAccount({
getRedditIdentity: () => browserClient.getAuthenticatedIdentity(),
refreshMatrixCredentials: async () => {
try {
const userId = await activeMatrixClient.whoami();
return userId ? { userId } : null;
} catch {
return null;
}
},
}));
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
broadcaster.dashboardEvents.redditSession({
+27 -10
View File
@@ -3,6 +3,18 @@ import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import path from 'path';
import fs from 'fs';
import { z } from 'zod';
import type { RedditAccountIdentity } from '../services/account-auth-context.js';
const RedditAccountSchema = z.object({
name: z.string().trim().min(1),
id: z.string().trim().min(1),
}).passthrough();
const RedditIdentityResponseSchema = z.union([
RedditAccountSchema,
z.object({ data: RedditAccountSchema }).passthrough().transform(({ data }) => data),
]);
export interface BrowserRuntime {
launchPersistentContext(
@@ -112,6 +124,11 @@ export class BrowserClient {
/** Returns the account name from Reddit's authenticated identity endpoint. */
async getAuthenticatedUsername(): Promise<string | null> {
return (await this.getAuthenticatedIdentity())?.username ?? null;
}
/** Returns the username and stable t2 account ID for identity binding. */
async getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null> {
try {
return await this.getRedditIdentity(await this.getPage());
} catch {
@@ -168,24 +185,24 @@ export class BrowserClient {
return (await this.getRedditIdentity(page)) !== null;
}
private async getRedditIdentity(page: Page): Promise<string | null> {
private async getRedditIdentity(page: Page): Promise<RedditAccountIdentity | null> {
try {
return await page.evaluate(async () => {
const payload: unknown = await page.evaluate(async () => {
try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return null;
const data: unknown = await res.json();
if (!data || typeof data !== 'object') return null;
const account = 'data' in data && data.data && typeof data.data === 'object'
? data.data
: data;
if (!('name' in account) || typeof account.name !== 'string') return null;
const username = account.name.trim();
return username || null;
return await res.json();
} catch {
return null;
}
});
const parsed = RedditIdentityResponseSchema.safeParse(payload);
if (!parsed.success) return null;
const rawAccountId = parsed.data.id;
return {
username: parsed.data.name,
accountId: rawAccountId.startsWith('t2_') ? rawAccountId : `t2_${rawAccountId}`,
};
} catch {
return null;
}
+39 -10
View File
@@ -6,11 +6,18 @@ export type AccountAuthStatus =
| 'authenticated';
export interface AuthenticatedAccount {
/** Reddit account fullname, e.g. `t2_abc123`. */
accountId: string;
redditUsername: string;
matrixUserId: string;
}
export interface RedditAccountIdentity {
username: string;
/** Reddit's account ID, with or without the `t2_` prefix. */
accountId: string;
}
export interface AccountAuthSnapshot {
status: AccountAuthStatus;
accountId: string | null;
@@ -34,21 +41,43 @@ export interface AccountAuthContextOptions {
}
export interface CurrentAccountAuthentication {
getRedditUsername(): Promise<string | null>;
getRedditIdentity(): Promise<RedditAccountIdentity | null>;
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
}
export function normalizeRedditAccountId(accountId: string): string {
const trimmed = accountId.trim();
const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`;
if (!/^t2_[a-z0-9]+$/i.test(normalized)) {
throw new Error('Reddit returned an invalid account ID');
}
return normalized;
}
export function expectedMatrixUserId(accountId: string): string {
return `@${normalizeRedditAccountId(accountId)}:reddit.com`;
}
export function assertMatchingAccountIdentity(account: AuthenticatedAccount): void {
const expected = expectedMatrixUserId(account.accountId);
if (account.matrixUserId !== expected) {
throw new Error(
`Reddit account u/${account.redditUsername} (${normalizeRedditAccountId(account.accountId)}) does not match Matrix account ${account.matrixUserId}`,
);
}
}
export async function authenticateCurrentAccount(
authentication: CurrentAccountAuthentication,
): Promise<AuthenticatedAccount | null> {
const redditUsername = await authentication.getRedditUsername();
if (!redditUsername) return null;
const redditIdentity = await authentication.getRedditIdentity();
if (!redditIdentity) return null;
const credentials = await authentication.refreshMatrixCredentials();
if (!credentials?.userId) return null;
return {
accountId: credentials.userId,
redditUsername,
accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername: redditIdentity.username,
matrixUserId: credentials.userId,
};
}
@@ -104,15 +133,15 @@ export class AccountAuthContext implements AccountAuthGate {
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
}
if (account.accountId !== account.matrixUserId) {
throw new Error('Authenticated account ID must match the Matrix user ID');
}
assertMatchingAccountIdentity(account);
const changedAccount = this.snapshot.accountId !== account.accountId;
const accountId = normalizeRedditAccountId(account.accountId);
const changedAccount = this.snapshot.accountId !== accountId;
const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = {
status: 'authenticated',
accountId: account.accountId,
accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId,
reason: null,
+24 -7
View File
@@ -2,11 +2,16 @@ import type { Cookie } from 'playwright';
import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { AccountAuthContext } from './account-auth-context.js';
import {
assertMatchingAccountIdentity,
normalizeRedditAccountId,
type RedditAccountIdentity,
} from './account-auth-context.js';
interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>;
getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null>;
}
interface ChatSessionTarget {
@@ -41,17 +46,29 @@ export class RedditSessionImporter {
}
await this.browser.importRedditCookies(cookies);
if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit');
let redditIdentity: RedditAccountIdentity;
try {
if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit');
}
const identity = await this.browser.getAuthenticatedIdentity();
if (!identity) throw new Error('Reddit did not return an authenticated account identity');
redditIdentity = identity;
assertMatchingAccountIdentity({
accountId: identity.accountId,
redditUsername: identity.username,
matrixUserId: userId,
});
} catch (error) {
this.accountAuth.invalidate(error instanceof Error ? error.message : String(error));
throw error;
}
const redditUsername = await this.browser.getAuthenticatedUsername();
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient);
this.accountAuth.markAuthenticated({
accountId: userId,
redditUsername,
accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername: redditIdentity.username,
matrixUserId: userId,
});
}
+22 -6
View File
@@ -10,7 +10,7 @@ describe('AccountAuthContext', () => {
const refreshMatrixCredentials = vi.fn();
await expect(authenticateCurrentAccount({
getRedditUsername: vi.fn().mockResolvedValue(null),
getRedditIdentity: vi.fn().mockResolvedValue(null),
refreshMatrixCredentials,
})).resolves.toBeNull();
@@ -20,7 +20,7 @@ describe('AccountAuthContext', () => {
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
const auth = new AccountAuthContext(createMockLogger());
const authenticate = vi.fn().mockResolvedValue({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -34,7 +34,7 @@ describe('AccountAuthContext', () => {
expect(auth.isFullyAuthenticated()).toBe(true);
expect(auth.getSnapshot()).toMatchObject({
status: 'authenticated',
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
});
});
@@ -52,7 +52,7 @@ describe('AccountAuthContext', () => {
const second = auth.ensureFullyAuthenticated();
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
resolveAuth?.({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -63,7 +63,7 @@ describe('AccountAuthContext', () => {
it('invalidates the whole account context when either login layer expires', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -86,7 +86,7 @@ describe('AccountAuthContext', () => {
now: () => now,
});
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -115,4 +115,20 @@ describe('AccountAuthContext', () => {
await auth.ensureFullyAuthenticated();
expect(authenticate).toHaveBeenCalledTimes(2);
});
it('fails closed when Reddit and Matrix belong to different accounts', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.setAuthenticator(vi.fn().mockResolvedValue({
accountId: 't2_cookie',
redditUsername: 'cookie-account',
matrixUserId: '@t2_chat:reddit.com',
}));
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
expect(auth.getSnapshot()).toMatchObject({
status: 'login-required',
accountId: null,
reason: expect.stringMatching(/does not match Matrix account/),
});
});
});
+6 -2
View File
@@ -36,10 +36,10 @@ describe('BrowserClient', () => {
expect(JSON.stringify(logger)).not.toContain('secret');
});
it('reads the username from Reddit\'s nested me.json account response', async () => {
it('reads the username and stable account ID from Reddit\'s nested me.json response', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }),
});
vi.stubGlobal('fetch', fetchMock);
const page = {
@@ -50,6 +50,10 @@ describe('BrowserClient', () => {
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
await expect(client.getAuthenticatedIdentity()).resolves.toEqual({
username: 'bot-account',
accountId: 't2_abc123',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://www.reddit.com/api/me.json',
{ credentials: 'include' },
+20 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createSessionExportDownload } from '../extensions/reddit-session-exporter/session-export.js';
import {
assertMatchingSessionIdentity,
createSessionExportDownload,
} from '../extensions/reddit-session-exporter/session-export.js';
describe('createSessionExportDownload', () => {
it('creates a JSON data URL that Chrome can download from a service worker', () => {
@@ -49,3 +52,19 @@ describe('createSessionExportDownload', () => {
expect(() => createSessionExportDownload([], null)).toThrow(/Reddit Chat/i);
});
});
describe('assertMatchingSessionIdentity', () => {
it('accepts a Matrix user bound to the current Reddit account ID', () => {
expect(() => assertMatchingSessionIdentity(
{ username: 'bot-account', accountId: 'bot' },
'@t2_bot:reddit.com',
)).not.toThrow();
});
it('rejects a stale Matrix capture from another Reddit account', () => {
expect(() => assertMatchingSessionIdentity(
{ username: 'bot-account', accountId: 't2_bot' },
'@t2_other:reddit.com',
)).toThrow(/Reload Reddit Chat after switching accounts/);
});
});
+25 -5
View File
@@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => {
const browser = {
importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true),
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'bot-account', accountId: 't2_bot' })),
};
const matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() };
const save = vi.fn();
const accountAuth = { markAuthenticated: vi.fn() };
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await importer.import(cookies, credentials);
@@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => {
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
expect(save).toHaveBeenCalledWith(credentials);
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
accountId: credentials.userId,
accountId: 't2_bot',
redditUsername: 'bot-account',
matrixUserId: credentials.userId,
});
});
it('rejects a dump whose Reddit cookies and Matrix token belong to different accounts', async () => {
const browser = {
importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true),
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'other-account', accountId: 't2_other' })),
};
const matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() };
const save = vi.fn();
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await expect(importer.import(cookies, credentials)).rejects.toThrow(/does not match Matrix account/);
expect(accountAuth.invalidate).toHaveBeenCalledWith(expect.stringMatching(/does not match Matrix account/));
expect(save).not.toHaveBeenCalled();
expect(chat.setMatrixClient).not.toHaveBeenCalled();
expect(accountAuth.markAuthenticated).not.toHaveBeenCalled();
});
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
const createMatrix = vi.fn();
const importer = new RedditSessionImporter(
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedIdentity: vi.fn() },
{ setMatrixClient: vi.fn() } as never,
createMatrix,
vi.fn(),
{ markAuthenticated: vi.fn() } as never,
{ markAuthenticated: vi.fn(), invalidate: vi.fn() } as never,
);
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))