Files
RedditBot/src/services/account-auth-context.ts
T

178 lines
5.6 KiB
TypeScript

import type { Logger } from '../utils/logger.js';
export type AccountAuthStatus =
| 'login-required'
| 'checking'
| 'authenticated';
export interface AuthenticatedAccount {
accountId: string;
redditUsername: string;
matrixUserId: string;
}
export interface AccountAuthSnapshot {
status: AccountAuthStatus;
accountId: string | null;
redditUsername: string | null;
matrixUserId: string | null;
reason: string | null;
}
export interface AccountAuthGate {
isFullyAuthenticated(): boolean;
invalidate(reason: string): void;
}
export type AccountAuthenticator = () => Promise<AuthenticatedAccount | null>;
export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise<void>;
export interface AccountAuthContextOptions {
leaseMs?: number;
retryDelayMs?: number;
now?: () => number;
}
export interface CurrentAccountAuthentication {
getRedditUsername(): Promise<string | null>;
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
}
export async function authenticateCurrentAccount(
authentication: CurrentAccountAuthentication,
): Promise<AuthenticatedAccount | null> {
const redditUsername = await authentication.getRedditUsername();
if (!redditUsername) return null;
const credentials = await authentication.refreshMatrixCredentials();
if (!credentials?.userId) return null;
return {
accountId: credentials.userId,
redditUsername,
matrixUserId: credentials.userId,
};
}
export const denyAccountAuth: AccountAuthGate = {
isFullyAuthenticated: () => false,
invalidate: () => undefined,
};
export class AccountAuthContext implements AccountAuthGate {
private snapshot: AccountAuthSnapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason: 'Full Reddit and Matrix authentication has not been verified',
};
private authenticator: AccountAuthenticator | null = null;
private authenticationInFlight: Promise<boolean> | null = null;
private readonly listeners = new Set<AccountAuthenticatedListener>();
private authenticatedUntil = 0;
private nextAuthenticationAttemptAt = 0;
private readonly leaseMs: number;
private readonly retryDelayMs: number;
private readonly now: () => number;
constructor(
private readonly logger: Logger,
options: AccountAuthContextOptions = {},
) {
this.leaseMs = options.leaseMs ?? 30 * 60 * 1000;
this.retryDelayMs = options.retryDelayMs ?? 60 * 1000;
this.now = options.now ?? Date.now;
}
setAuthenticator(authenticator: AccountAuthenticator): void {
this.authenticator = authenticator;
}
isFullyAuthenticated(): boolean {
if (this.snapshot.status !== 'authenticated') return false;
if (this.now() < this.authenticatedUntil) return true;
this.invalidate('Full account authentication lease expired');
return false;
}
getSnapshot(): AccountAuthSnapshot {
this.isFullyAuthenticated();
return { ...this.snapshot };
}
markAuthenticated(account: AuthenticatedAccount): void {
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');
}
const changedAccount = this.snapshot.accountId !== account.accountId;
const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = {
status: 'authenticated',
accountId: account.accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId,
reason: null,
};
this.authenticatedUntil = this.now() + this.leaseMs;
this.nextAuthenticationAttemptAt = 0;
if (!wasAuthenticated || changedAccount) {
for (const listener of this.listeners) {
void Promise.resolve(listener(account)).catch((error) => {
this.logger.error('Authenticated account listener failed:', error);
});
}
}
}
invalidate(reason: string): void {
this.authenticatedUntil = 0;
this.snapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason,
};
}
onAuthenticated(listener: AccountAuthenticatedListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
ensureFullyAuthenticated(): Promise<boolean> {
if (this.isFullyAuthenticated()) return Promise.resolve(true);
if (this.authenticationInFlight) return this.authenticationInFlight;
if (!this.authenticator) return Promise.resolve(false);
if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false);
this.snapshot = { ...this.snapshot, status: 'checking', reason: null };
this.authenticationInFlight = this.authenticate().finally(() => {
this.authenticationInFlight = null;
});
return this.authenticationInFlight;
}
private async authenticate(): Promise<boolean> {
try {
const account = await this.authenticator!();
if (!account) {
this.invalidate('Full Reddit and Matrix authentication is required');
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
return false;
}
this.markAuthenticated(account);
return true;
} catch (error) {
this.invalidate(error instanceof Error ? error.message : String(error));
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
this.logger.warn('Full account authentication failed:', error);
return false;
}
}
}