import crypto from 'crypto'; import bcrypt from 'bcryptjs'; import { ApiToken } from '../models/ApiToken'; import { User } from '../models/User'; import logger from '../utils/logger'; export class ApiTokenService { private static readonly TOKEN_PREFIX = 're_'; private static readonly TOKEN_LENGTH = 32; /** * Generate a new API token for a user */ async createToken(userId: string, name: string, expiresInDays?: number): Promise<{ token: string; apiToken: ApiToken }> { try { // 1. Generate secure random token const randomBytes = crypto.randomBytes(ApiTokenService.TOKEN_LENGTH); const randomString = randomBytes.toString('hex'); const token = `${ApiTokenService.TOKEN_PREFIX}${randomString}`; // 2. Hash the token const tokenHash = await bcrypt.hash(token, 10); // 3. Calculate expiry let expiresAt: Date | undefined; if (expiresInDays) { expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + expiresInDays); } // 4. Save to DB const apiToken = await ApiToken.create({ userId, name, prefix: token.substring(0, 8), // Store first 8 chars (prefix + 5 chars) for lookup tokenHash, expiresAt, isActive: true, }); logger.info(`API Token created for user ${userId}: ${name}`); // Return raw token ONLY here return { token, apiToken }; } catch (error) { logger.error('Error creating API token:', error); throw new Error('Failed to create API token'); } } /** * List all active tokens for a user */ async listTokens(userId: string): Promise { return await ApiToken.findAll({ where: { userId, isActive: true }, order: [['createdAt', 'DESC']], attributes: { exclude: ['tokenHash'] } // Never return hash }); } /** * Revoke (delete) a token */ async revokeToken(userId: string, tokenId: string): Promise { const result = await ApiToken.destroy({ where: { id: tokenId, userId } }); return result > 0; } /** * Verify an API token and return the associated user */ async verifyToken(rawToken: string): Promise { try { if (!rawToken.startsWith(ApiTokenService.TOKEN_PREFIX)) { return null; } // 1. Find potential tokens by prefix (optimization) const prefix = rawToken.substring(0, 8); const tokens = await ApiToken.findAll({ where: { prefix, isActive: true }, include: [{ model: User, as: 'user', where: { isActive: true } // Ensure user is also active }] }); // 2. check hash for each candidate for (const tokenRecord of tokens) { const isValid = await bcrypt.compare(rawToken, tokenRecord.tokenHash); if (isValid) { // 3. Check expiry if (tokenRecord.expiresAt && new Date() > tokenRecord.expiresAt) { logger.warn(`Expired API token used: ${tokenRecord.id}`); return null; } // 4. Update last used await tokenRecord.update({ lastUsedAt: new Date() }); // Return the user (casted as any because include types can be tricky) return (tokenRecord as any).user; } } return null; } catch (error) { logger.error('Error verifying API token:', error); return null; } } }