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

+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,
});
}