fix(auth): unify imported sessions with account gating
This commit is contained in:
1 parent
654bbda457
commit
ea92a1a92b
15 files changed
+121
-47
No files matched your search
@@ -16,11 +16,15 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
||||
return (
|
||||
<article className="media-card">
|
||||
<div className="media-preview">
|
||||
{isRevealed ? (
|
||||
<button aria-label={`View media from ${item.sender} larger`} className="media-preview-button" onClick={onOpenPreview} type="button">
|
||||
<img alt={`Media from ${item.sender}`} className="media-image" loading="lazy" src={item.urlPath} />
|
||||
<button
|
||||
aria-label={`View media from ${item.sender} larger`}
|
||||
className="media-preview-button"
|
||||
disabled={!isRevealed}
|
||||
onClick={onOpenPreview}
|
||||
type="button"
|
||||
>
|
||||
<img alt={`Media from ${item.sender}`} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
|
||||
</button>
|
||||
) : <img alt={`Media from ${item.sender}`} className="media-image is-blurred" loading="lazy" src={item.urlPath} />}
|
||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||
</div>
|
||||
<div className="media-card-body">
|
||||
|
||||
+37
-14
@@ -37,6 +37,7 @@ import { fetchWithTimeout } from './utils/fetch-with-timeout.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';
|
||||
|
||||
export interface AppContainer {
|
||||
config: Config;
|
||||
@@ -44,6 +45,7 @@ export interface AppContainer {
|
||||
db: Database.Database;
|
||||
|
||||
browserClient: BrowserClient;
|
||||
accountAuth: AccountAuthContext;
|
||||
|
||||
postsRepo: PostsRepository;
|
||||
messagesRepo: MessagesRepository;
|
||||
@@ -102,23 +104,17 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
|
||||
// Browser
|
||||
const browserClient = new BrowserClient(config, logger);
|
||||
const accountAuth = new AccountAuthContext(logger);
|
||||
await browserClient.initialize();
|
||||
|
||||
// The extension import is the only session acquisition path. A missing
|
||||
// session is recoverable: the dashboard remains available while all
|
||||
// account-bound work waits for a verified dump.
|
||||
logger.info('Verifying Reddit login status...');
|
||||
const loggedIn = await browserClient.isSessionValid();
|
||||
if (!loggedIn) {
|
||||
const redditLoggedIn = await browserClient.isSessionValid();
|
||||
if (!redditLoggedIn) {
|
||||
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
|
||||
}
|
||||
if (loggedIn) {
|
||||
logger.info('Reddit login verified');
|
||||
}
|
||||
broadcaster.dashboardEvents.redditSession({
|
||||
state: loggedIn ? 'authenticated' : 'login-required',
|
||||
username: loggedIn ? await browserClient.getAuthenticatedUsername() ?? undefined : undefined,
|
||||
});
|
||||
|
||||
// Reddit API client (shares browser context for auth)
|
||||
const redditApi = new RedditApiClient(config, logger);
|
||||
@@ -143,6 +139,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
successfulMessagesRepo,
|
||||
postsRepo,
|
||||
logger,
|
||||
accountAuth,
|
||||
conversationOutcomesRepo,
|
||||
);
|
||||
|
||||
@@ -161,11 +158,28 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
const matrixClient = new MatrixClient(matrixCreds, logger);
|
||||
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;
|
||||
}
|
||||
});
|
||||
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
|
||||
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
|
||||
broadcaster.dashboardEvents.redditSession({
|
||||
state: fullyAuthenticated ? 'authenticated' : 'login-required',
|
||||
username: fullyAuthenticated ? accountAuth.getSnapshot().redditUsername ?? undefined : undefined,
|
||||
});
|
||||
|
||||
// 2. Build the media downloader + service on top of the Matrix client so
|
||||
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
|
||||
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
|
||||
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
|
||||
const mediaService = new MediaService(mediaDownloader, logger);
|
||||
|
||||
// A 401 waits for a new extension dump instead of opening a browser login
|
||||
@@ -204,7 +218,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
};
|
||||
|
||||
const chatService = new ChatService(
|
||||
matrixClient,
|
||||
activeMatrixClient,
|
||||
logger,
|
||||
successfulMessagesRepo,
|
||||
mediaService,
|
||||
@@ -213,9 +227,10 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
undefined,
|
||||
messagesRepo,
|
||||
redditUsersRepo,
|
||||
accountAuth,
|
||||
);
|
||||
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
|
||||
const rateLimiter = new RateLimiter(config, logger, undefined, {
|
||||
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
||||
});
|
||||
@@ -225,9 +240,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
|
||||
const sessionImporter = new RedditSessionImporter(
|
||||
browserClient,
|
||||
chatService,
|
||||
{
|
||||
setMatrixClient(client) {
|
||||
activeMatrixClient = client;
|
||||
chatService.setMatrixClient(client);
|
||||
},
|
||||
},
|
||||
(credentials) => new MatrixClient(credentials, logger),
|
||||
saveCredentials,
|
||||
accountAuth,
|
||||
);
|
||||
|
||||
let webServer: WebServer | null = null;
|
||||
@@ -296,6 +317,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
webServer.setStrategySelector(strategySelector);
|
||||
webServer.setBrowserClient(browserClient);
|
||||
webServer.setSessionImporter(sessionImporter);
|
||||
webServer.setAccountAuth(accountAuth);
|
||||
webServer.setConversationStateRepository(conversationStateRepo);
|
||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||
}
|
||||
@@ -316,6 +338,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
logger,
|
||||
db,
|
||||
browserClient,
|
||||
accountAuth,
|
||||
postsRepo,
|
||||
messagesRepo,
|
||||
successfulMessagesRepo,
|
||||
|
||||
+4
-1
@@ -77,6 +77,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
config,
|
||||
logger,
|
||||
browserClient,
|
||||
accountAuth,
|
||||
postsRepo,
|
||||
messagesRepo,
|
||||
redditApi,
|
||||
@@ -177,7 +178,9 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
|
||||
while (!isShuttingDown) {
|
||||
try {
|
||||
if (!sessionRuntime.reconcile(browserClient.getSessionState())) {
|
||||
const authenticated = accountAuth.isFullyAuthenticated()
|
||||
|| await accountAuth.ensureFullyAuthenticated();
|
||||
if (!sessionRuntime.reconcile(authenticated)) {
|
||||
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||
logger.info('Reddit is logged out. Waiting for a session dump from the extension.');
|
||||
lastPausedLog = Date.now();
|
||||
|
||||
@@ -19,9 +19,8 @@ const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||
|
||||
export interface TokenRefresher {
|
||||
/**
|
||||
* Re-extract a fresh Matrix bearer token (e.g. by re-running the browser
|
||||
* sniff against the persistent session) and apply it to the underlying
|
||||
* client. Resolves when the client is ready to be retried.
|
||||
* Requests replacement Matrix credentials. Extension-only authentication
|
||||
* deliberately rejects this until the user imports a fresh dump.
|
||||
*/
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
@@ -175,9 +174,8 @@ export class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Matrix call; if it 401/403s, re-extract the bearer via the
|
||||
* configured `TokenRefresher` and retry once. Single retry only — if the
|
||||
* refresh itself fails, we propagate.
|
||||
* Run a Matrix call; if it 401/403s, invalidate the shared account gate.
|
||||
* The configured refresher reports that a new extension dump is required.
|
||||
*/
|
||||
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
@@ -189,7 +187,7 @@ export class ChatService {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError && this.tokenRefresher) {
|
||||
this.logger.warn('[chat] Matrix token rejected — refreshing via browser session');
|
||||
this.logger.warn('[chat] Matrix token rejected — waiting for a new extension dump');
|
||||
try {
|
||||
await this.tokenRefresher.refresh();
|
||||
} catch (refreshError) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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';
|
||||
|
||||
interface BrowserSessionTarget {
|
||||
importRedditCookies(cookies: Cookie[]): Promise<void>;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
getAuthenticatedUsername(): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface ChatSessionTarget {
|
||||
@@ -20,6 +22,7 @@ export class RedditSessionImporter {
|
||||
private readonly chat: ChatSessionTarget,
|
||||
private readonly createMatrixClient: MatrixClientFactory,
|
||||
private readonly saveCredentials: CredentialStore,
|
||||
private readonly accountAuth: AccountAuthContext,
|
||||
) {}
|
||||
|
||||
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
|
||||
@@ -41,8 +44,15 @@ export class RedditSessionImporter {
|
||||
if (!(await this.browser.isSessionValid())) {
|
||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||
}
|
||||
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,
|
||||
matrixUserId: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from '../utils/logger.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
||||
import type { PostsRepository } from '../database/repositories.js';
|
||||
import { classifyConversationOutcome } from './conversation-outcome.js';
|
||||
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||
|
||||
export interface ResponseCheckResult {
|
||||
messageId: number;
|
||||
@@ -18,11 +19,13 @@ export class ResponseCheckerService {
|
||||
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||
private postsRepo: PostsRepository,
|
||||
private logger: Logger,
|
||||
private accountAuth: AccountAuthGate = denyAccountAuth,
|
||||
private outcomesRepo?: ConversationOutcomesRepository,
|
||||
) { }
|
||||
|
||||
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
||||
const results: ResponseCheckResult[] = [];
|
||||
if (!this.accountAuth.isFullyAuthenticated()) return results;
|
||||
|
||||
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
|
||||
const minAgeMs = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RedditSessionState } from '../browser/client.js';
|
||||
|
||||
/** Starts and stops account-bound work as authentication changes. */
|
||||
export class SessionBoundRuntime {
|
||||
private active = false;
|
||||
@@ -9,8 +7,7 @@ export class SessionBoundRuntime {
|
||||
private readonly stop: () => void,
|
||||
) {}
|
||||
|
||||
reconcile(state: RedditSessionState): boolean {
|
||||
const authenticated = state === 'authenticated';
|
||||
reconcile(authenticated: boolean): boolean {
|
||||
if (authenticated && !this.active) {
|
||||
this.active = true;
|
||||
this.start();
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ActivityLog } from './activity-log.js';
|
||||
import type { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { RuntimeConfig } from './types.js';
|
||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
|
||||
/** Bag of everything the route modules might need. */
|
||||
export interface WebContext {
|
||||
@@ -43,6 +44,7 @@ export interface WebContext {
|
||||
strategySelector?: StrategySelector;
|
||||
browser?: BrowserClient;
|
||||
sessionImporter?: RedditSessionImporter;
|
||||
accountAuth?: AccountAuthContext;
|
||||
conversationState?: ConversationStateRepository;
|
||||
};
|
||||
|
||||
|
||||
+19
-3
@@ -52,9 +52,25 @@ export function createLoginRouter(ctx: WebContext): Router {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedIn = await browser.isSessionValid();
|
||||
const username = loggedIn ? await browser.getAuthenticatedUsername() : null;
|
||||
res.json({ ready: true, loggedIn, status: browser.getSessionState(), username });
|
||||
const accountAuth = ctx.services.accountAuth;
|
||||
const fullyAuthenticated = accountAuth
|
||||
? await accountAuth.ensureFullyAuthenticated()
|
||||
: await browser.isSessionValid();
|
||||
if (fullyAuthenticated) {
|
||||
const username = accountAuth?.getSnapshot().redditUsername
|
||||
?? await browser.getAuthenticatedUsername();
|
||||
res.json({ ready: true, loggedIn: true, status: 'authenticated', username });
|
||||
return;
|
||||
}
|
||||
|
||||
await browser.isSessionValid();
|
||||
const browserState = browser.getSessionState();
|
||||
res.json({
|
||||
ready: true,
|
||||
loggedIn: false,
|
||||
status: browserState === 'unreachable' ? 'unreachable' : 'login-required',
|
||||
username: null,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to check login status:', error);
|
||||
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { StrategySelector } from '../services/strategy-selector.js';
|
||||
import type { BrowserClient } from '../browser/client.js';
|
||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import { ActivityLog } from './activity-log.js';
|
||||
import { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { WebContext } from './context.js';
|
||||
@@ -205,6 +206,10 @@ export class WebServer {
|
||||
this.services.sessionImporter = sessionImporter;
|
||||
}
|
||||
|
||||
setAccountAuth(accountAuth: AccountAuthContext): void {
|
||||
this.services.accountAuth = accountAuth;
|
||||
}
|
||||
|
||||
setConversationStateRepository(conversationState: ConversationStateRepository): void {
|
||||
this.services.conversationState = conversationState;
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ describe('settings and access', () => {
|
||||
renderSettings('/settings?section=access');
|
||||
|
||||
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/waiting for a session dump/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
|
||||
app.use(
|
||||
createLoginRouter({
|
||||
services: {
|
||||
accountAuth: {
|
||||
ensureFullyAuthenticated: async () => state === 'authenticated',
|
||||
getSnapshot: () => ({ redditUsername: state === 'authenticated' ? 'example-account' : null }),
|
||||
},
|
||||
browser: {
|
||||
isSessionValid: async () => state === 'authenticated',
|
||||
getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null,
|
||||
|
||||
@@ -15,11 +15,16 @@ const credentials = {
|
||||
|
||||
describe('RedditSessionImporter', () => {
|
||||
it('verifies both parts of the dump before activating the account', async () => {
|
||||
const browser = { importRedditCookies: vi.fn(), isSessionValid: vi.fn(async () => true) };
|
||||
const browser = {
|
||||
importRedditCookies: vi.fn(),
|
||||
isSessionValid: vi.fn(async () => true),
|
||||
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
|
||||
};
|
||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||
const chat = { setMatrixClient: vi.fn() };
|
||||
const save = vi.fn();
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save);
|
||||
const accountAuth = { markAuthenticated: vi.fn() };
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||
|
||||
await importer.import(cookies, credentials);
|
||||
|
||||
@@ -27,15 +32,21 @@ describe('RedditSessionImporter', () => {
|
||||
expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies);
|
||||
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
|
||||
expect(save).toHaveBeenCalledWith(credentials);
|
||||
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
|
||||
accountId: credentials.userId,
|
||||
redditUsername: 'bot-account',
|
||||
matrixUserId: credentials.userId,
|
||||
});
|
||||
});
|
||||
|
||||
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() },
|
||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
|
||||
{ setMatrixClient: vi.fn() } as never,
|
||||
createMatrix,
|
||||
vi.fn(),
|
||||
{ markAuthenticated: vi.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
|
||||
|
||||
@@ -7,22 +7,19 @@ describe('SessionBoundRuntime', () => {
|
||||
const stop = vi.fn();
|
||||
const runtime = new SessionBoundRuntime(start, stop);
|
||||
|
||||
expect(runtime.reconcile('login-required')).toBe(false);
|
||||
expect(runtime.reconcile(false)).toBe(false);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
expect(runtime.reconcile('authenticated')).toBe(true);
|
||||
expect(runtime.reconcile('authenticated')).toBe(true);
|
||||
expect(runtime.reconcile(true)).toBe(true);
|
||||
expect(runtime.reconcile(true)).toBe(true);
|
||||
expect(start).toHaveBeenCalledOnce();
|
||||
|
||||
expect(runtime.reconcile('login-required')).toBe(false);
|
||||
expect(runtime.reconcile(false)).toBe(false);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['unknown', 'login-required', 'unreachable'] as const)(
|
||||
'treats %s as waiting for an extension dump',
|
||||
(state) => {
|
||||
it('treats incomplete authentication as waiting for an extension dump', () => {
|
||||
const runtime = new SessionBoundRuntime(vi.fn(), vi.fn());
|
||||
expect(runtime.reconcile(state)).toBe(false);
|
||||
},
|
||||
);
|
||||
expect(runtime.reconcile(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ describe('control route realtime events', () => {
|
||||
const bot = vi.fn();
|
||||
const context = {
|
||||
config: { browser: { dryRun: true } },
|
||||
services: { accountAuth: { isFullyAuthenticated: () => true } },
|
||||
setBotRunning: (next: boolean) => { running = next; },
|
||||
activity: { push: vi.fn() },
|
||||
logger: { info: vi.fn() },
|
||||
|
||||
Reference in new issue
Block a user