Bind Reddit and Matrix session identities
This commit is contained in:
1 parent
34ae532cdd
commit
492efe0f8a
12 files changed
+234
-55
No files matched your search
+12
-12
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user