import bcrypt from 'bcryptjs'; import jwt, { SignOptions } from 'jsonwebtoken'; import crypto from 'crypto'; import { CcpRole } from '@prisma/client'; import { prisma } from '../../lib/prisma'; import { env } from '../../config/env'; import { AppError } from '../../middleware/error-handler'; interface TokenPayload { id: string; email: string; role: CcpRole; } function signAccessToken(payload: TokenPayload): string { return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: env.JWT_ACCESS_EXPIRES_IN as SignOptions['expiresIn'], }); } function signRefreshToken(payload: TokenPayload): string { return jwt.sign(payload, env.JWT_REFRESH_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'], }); } function parseExpiry(expiresIn: string): Date { const match = expiresIn.match(/^(\d+)([smhd])$/); if (!match) return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // default 7d const [, num, unit] = match; const multipliers: Record = { s: 1000, m: 60000, h: 3600000, d: 86400000 }; return new Date(Date.now() + parseInt(num) * multipliers[unit]); } export async function login(email: string, password: string) { const user = await prisma.ccpUser.findUnique({ where: { email } }); if (!user) { throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); } const valid = await bcrypt.compare(password, user.password); if (!valid) { throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); } const payload: TokenPayload = { id: user.id, email: user.email, role: user.role }; const accessToken = signAccessToken(payload); const refreshToken = signRefreshToken(payload); // Store refresh token await prisma.ccpRefreshToken.create({ data: { token: crypto.createHash('sha256').update(refreshToken).digest('hex'), userId: user.id, expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN), }, }); return { user: { id: user.id, email: user.email, name: user.name, role: user.role }, accessToken, refreshToken, }; } export async function refresh(refreshToken: string) { let payload: TokenPayload; try { payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload; } catch { throw new AppError(401, 'Invalid refresh token', 'INVALID_TOKEN'); } const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); // Atomic rotation: delete old, create new const result = await prisma.$transaction(async (tx) => { const existing = await tx.ccpRefreshToken.findUnique({ where: { token: tokenHash } }); if (!existing || existing.expiresAt < new Date()) { throw new AppError(401, 'Refresh token expired or revoked', 'TOKEN_EXPIRED'); } await tx.ccpRefreshToken.delete({ where: { token: tokenHash } }); const user = await tx.ccpUser.findUnique({ where: { id: payload.id } }); if (!user) { throw new AppError(401, 'User not found', 'USER_NOT_FOUND'); } const newPayload: TokenPayload = { id: user.id, email: user.email, role: user.role }; const newAccessToken = signAccessToken(newPayload); const newRefreshToken = signRefreshToken(newPayload); await tx.ccpRefreshToken.create({ data: { token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'), userId: user.id, expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN), }, }); return { user: { id: user.id, email: user.email, name: user.name, role: user.role }, accessToken: newAccessToken, refreshToken: newRefreshToken, }; }); return result; } export async function logout(refreshToken: string) { const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); await prisma.ccpRefreshToken.deleteMany({ where: { token: tokenHash } }); } export async function verifyPassword(userId: string, password: string): Promise { const user = await prisma.ccpUser.findUnique({ where: { id: userId } }); if (!user) { throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); } return bcrypt.compare(password, user.password); } export async function getMe(userId: string) { const user = await prisma.ccpUser.findUnique({ where: { id: userId } }); if (!user) { throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); } return { id: user.id, email: user.email, name: user.name, role: user.role }; }