import { createHash, randomBytes } from 'node:crypto' import type { Database } from 'bun:sqlite' const ACCESS_TTL_SECONDS = 3600 // 1 hour const REFRESH_TTL_SECONDS = 2592000 // 30 days function generateToken(): string { const bytes = randomBytes(32) return `rct_${bytes.toString('hex')}` } function sha256hex(value: string): string { return createHash('sha256').update(value).digest('hex') } function insertToken( db: Database, token: string, userId: string, kind: 'access' | 'refresh', ttlSeconds: number, ): void { const tokenHash = sha256hex(token) const now = new Date() const expiresAt = new Date(now.getTime() + ttlSeconds * 1000) db.query( `INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at) VALUES ($tokenHash, $userId, $kind, $expiresAt, $createdAt)`, ).run({ $tokenHash: tokenHash, $userId: userId, $kind: kind, $expiresAt: expiresAt.toISOString(), $createdAt: now.toISOString(), }) } /** * Issue a new access + refresh token pair for the given user. * Stores sha256(token) in the session_tokens table. */ export function issueSessionToken( userId: string, db: Database, ): { accessToken: string; refreshToken: string } { const accessToken = generateToken() const refreshToken = generateToken() insertToken(db, accessToken, userId, 'access', ACCESS_TTL_SECONDS) insertToken(db, refreshToken, userId, 'refresh', REFRESH_TTL_SECONDS) return { accessToken, refreshToken } } /** * Resolve a session token to a userId. * Returns null if token is invalid, expired, or revoked. * * @param kind - The expected token kind. Defaults to `'access'`. * Pass `'refresh'` when resolving a refresh token (e.g. in the * `/refresh` endpoint). This prevents an access token from being * accepted where a refresh token is expected, and vice versa. */ export function resolveSessionToken( token: string, db: Database, kind: 'access' | 'refresh' = 'access', ): string | null { if (!token) return null const tokenHash = sha256hex(token) const row = db .query( `SELECT user_id, expires_at, revoked_at FROM session_tokens WHERE token_hash = $tokenHash AND kind = $kind`, ) .get({ $tokenHash: tokenHash, $kind: kind }) as { user_id: string expires_at: string revoked_at: string | null } | null if (!row) return null // Check revoked if (row.revoked_at) return null // Check expired const expiresAt = new Date(row.expires_at).getTime() if (expiresAt < Date.now()) return null return row.user_id } /** * Revoke a specific session token. */ export function revokeSessionToken(token: string, db: Database): void { const tokenHash = sha256hex(token) const now = new Date().toISOString() db.query( `UPDATE session_tokens SET revoked_at = $now WHERE token_hash = $tokenHash`, ).run({ $now: now, $tokenHash: tokenHash }) } /** * Revoke all tokens for a given user. */ export function revokeAllUserTokens(userId: string, db: Database): void { const now = new Date().toISOString() db.query( `UPDATE session_tokens SET revoked_at = $now WHERE user_id = $userId`, ).run({ $now: now, $userId: userId }) } /** * Delete all expired session tokens from the database. * Returns the number of deleted rows. * * Should be called once at server startup and optionally on a periodic * timer (e.g. every hour) to keep the session_tokens table tidy. */ export function reapExpiredSessions(db: Database): number { const result = db .query( `DELETE FROM session_tokens WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')`, ) .run() return result.changes }