happy-server/sources/modules/auth.ts
2025-08-26 20:08:13 -07:00

189 lines
5.7 KiB
TypeScript

import * as privacyKit from "privacy-kit";
import { log } from "@/utils/log";
interface TokenCacheEntry {
userId: string;
extras?: any;
cachedAt: number;
}
interface AuthTokens {
generator: Awaited<ReturnType<typeof privacyKit.createPersistentTokenGenerator>>;
verifier: Awaited<ReturnType<typeof privacyKit.createPersistentTokenVerifier>>;
githubVerifier: Awaited<ReturnType<typeof privacyKit.createEphemeralTokenVerifier>>;
githubGenerator: Awaited<ReturnType<typeof privacyKit.createEphemeralTokenGenerator>>;
}
class AuthModule {
private tokenCache = new Map<string, TokenCacheEntry>();
private tokens: AuthTokens | null = null;
async init(): Promise<void> {
if (this.tokens) {
return; // Already initialized
}
log({ module: 'auth' }, 'Initializing auth module...');
const generator = await privacyKit.createPersistentTokenGenerator({
service: 'handy',
seed: process.env.HANDY_MASTER_SECRET!
});
const verifier = await privacyKit.createPersistentTokenVerifier({
service: 'handy',
publicKey: generator.publicKey
});
const githubVerifier = await privacyKit.createEphemeralTokenVerifier({
service: 'github-happy',
publicKey: generator.publicKey,
});
const githubGenerator = await privacyKit.createEphemeralTokenGenerator({
service: 'github-happy',
seed: process.env.HANDY_MASTER_SECRET!,
ttl: 5 * 60 * 1000 // 5 minutes
});
this.tokens = { generator, verifier, githubVerifier, githubGenerator };
log({ module: 'auth' }, 'Auth module initialized');
}
async createToken(userId: string, extras?: any): Promise<string> {
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
const payload: any = { user: userId };
if (extras) {
payload.extras = extras;
}
const token = await this.tokens.generator.new(payload);
// Cache the token immediately
this.tokenCache.set(token, {
userId,
extras,
cachedAt: Date.now()
});
return token;
}
async verifyToken(token: string): Promise<{ userId: string; extras?: any } | null> {
// Check cache first
const cached = this.tokenCache.get(token);
if (cached) {
return {
userId: cached.userId,
extras: cached.extras
};
}
// Cache miss - verify token
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
try {
const verified = await this.tokens.verifier.verify(token);
if (!verified) {
return null;
}
const userId = verified.user as string;
const extras = verified.extras;
// Cache the result permanently
this.tokenCache.set(token, {
userId,
extras,
cachedAt: Date.now()
});
return { userId, extras };
} catch (error) {
log({ module: 'auth', level: 'error' }, `Token verification failed: ${error}`);
return null;
}
}
invalidateUserTokens(userId: string): void {
// Remove all tokens for a specific user
// This is expensive but rarely needed
for (const [token, entry] of this.tokenCache.entries()) {
if (entry.userId === userId) {
this.tokenCache.delete(token);
}
}
log({ module: 'auth' }, `Invalidated tokens for user: ${userId}`);
}
invalidateToken(token: string): void {
this.tokenCache.delete(token);
}
getCacheStats(): { size: number; oldestEntry: number | null } {
if (this.tokenCache.size === 0) {
return { size: 0, oldestEntry: null };
}
let oldest = Date.now();
for (const entry of this.tokenCache.values()) {
if (entry.cachedAt < oldest) {
oldest = entry.cachedAt;
}
}
return {
size: this.tokenCache.size,
oldestEntry: oldest
};
}
async createGithubToken(userId: string): Promise<string> {
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
const payload = { user: userId, purpose: 'github-oauth' };
const token = await this.tokens.githubGenerator.new(payload);
return token;
}
async verifyGithubToken(token: string): Promise<{ userId: string } | null> {
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
try {
const verified = await this.tokens.githubVerifier.verify(token);
if (!verified) {
return null;
}
return { userId: verified.user as string };
} catch (error) {
log({ module: 'auth', level: 'error' }, `GitHub token verification failed: ${error}`);
return null;
}
}
// Cleanup old entries (optional - can be called periodically)
cleanup(): void {
// Note: Since tokens are cached "forever" as requested,
// we don't do automatic cleanup. This method exists if needed later.
const stats = this.getCacheStats();
log({ module: 'auth' }, `Token cache size: ${stats.size} entries`);
}
}
// Global instance
export const auth = new AuthModule();