178 lines
5.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|