import { randomBytes, createHash } from 'crypto'; import bcrypt from 'bcryptjs'; import { prisma } from '../prisma.js'; const BCRYPT_ROUNDS = 10; /** * Hash a token string using SHA-256 for fast lookup, then bcrypt for storage. * We use SHA-256 as an intermediate to create a fixed-length input for bcrypt * (bcrypt has a 72-byte limit). */ function sha256(token: string): string { return createHash('sha256').update(token).digest('hex'); } /** * Generate a new API token. Returns the plaintext token (shown once) and the DB record. */ export async function generateToken( userId: string, name: string, scope: string, expiresAt?: string ) { const plainToken = randomBytes(32).toString('hex'); const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS); const token = await prisma.apiToken.create({ data: { userId, name, tokenHash, scope, expiresAt: expiresAt ? new Date(expiresAt) : null } }); return { id: token.id, name: token.name, scope: token.scope, expiresAt: token.expiresAt, createdAt: token.createdAt, token: plainToken // Only returned once at creation time }; } /** * Revoke (delete) an API token. */ export async function revokeToken(tokenId: string, userId: string) { const token = await prisma.apiToken.findUnique({ where: { id: tokenId } }); if (!token || token.userId !== userId) { throw new Error('API token not found'); } await prisma.apiToken.delete({ where: { id: tokenId } }); } /** * List all tokens for a user (without the hash). */ export async function listTokens(userId: string) { const tokens = await prisma.apiToken.findMany({ where: { userId }, select: { id: true, name: true, scope: true, lastUsedAt: true, expiresAt: true, createdAt: true }, orderBy: { createdAt: 'desc' } }); return tokens; } /** * Validate a plaintext token string. Returns the user info if valid, null otherwise. * Updates lastUsedAt on successful validation. */ export async function validateToken(tokenString: string): Promise<{ readonly userId: string; readonly scope: string; } | null> { const tokenSha = sha256(tokenString); // We need to check against all tokens since bcrypt hashes are unique per-hash. // For better performance at scale, consider indexing on a prefix or using a different scheme. const allTokens = await prisma.apiToken.findMany({ select: { id: true, userId: true, tokenHash: true, scope: true, expiresAt: true } }); for (const token of allTokens) { const isMatch = await bcrypt.compare(tokenSha, token.tokenHash); if (isMatch) { // Check expiry if (token.expiresAt && token.expiresAt < new Date()) { return null; // Token expired } // Update lastUsedAt (fire-and-forget) prisma.apiToken .update({ where: { id: token.id }, data: { lastUsedAt: new Date() } }) .catch(() => { // Swallow errors from lastUsedAt update }); return { userId: token.userId, scope: token.scope }; } } return null; }