import { FastifyInstance } from "fastify"; import { JwtPayload, User } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import { randomBytes } from "crypto"; const ACCESS_TOKEN_EXPIRY = "15m"; const REFRESH_TOKEN_EXPIRY_DAYS = 7; export class AuthService { private prisma = prisma; constructor(private readonly app: FastifyInstance) {} /** * Generate short-lived access token for user. */ async generateToken(user: User): Promise { const payload: JwtPayload = { sub: user.id, email: user.email, username: user.username, isAdmin: user.isAdmin, }; return this.app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY, }); } /** * Generate a secure refresh token and store it in the database. */ async generateRefreshToken(userId: string): Promise { const token = randomBytes(64).toString("hex"); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRY_DAYS); await this.prisma.refreshToken.create({ data: { token, userId, expiresAt, }, }); return token; } /** * Validate and consume a refresh token, returning the user if valid. */ async validateRefreshToken(token: string): Promise { const refreshToken = await this.prisma.refreshToken.findUnique({ where: { token }, include: { user: true }, }); if (!refreshToken) { return null; } if (refreshToken.expiresAt < new Date()) { await this.prisma.refreshToken.delete({ where: { id: refreshToken.id } }); return null; } const dbUser = refreshToken.user; return { id: dbUser.id, discordId: dbUser.discordId, username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, isVip: dbUser.isVip, isMod: dbUser.isMod, isStaff: dbUser.isStaff, }; } /** * Rotate a refresh token (invalidate old, create new with same expiry). * Preserves original expiry so users must re-auth with Discord every 7 days. */ async rotateRefreshToken(oldToken: string, userId: string): Promise { const existingToken = await this.prisma.refreshToken.findUnique({ where: { token: oldToken }, }); if (!existingToken) { return null; } const originalExpiry = existingToken.expiresAt; await this.prisma.refreshToken.delete({ where: { id: existingToken.id }, }); const newToken = randomBytes(64).toString("hex"); await this.prisma.refreshToken.create({ data: { token: newToken, userId, expiresAt: originalExpiry, }, }); return newToken; } /** * Revoke a specific refresh token. */ async revokeRefreshToken(token: string): Promise { await this.prisma.refreshToken.deleteMany({ where: { token }, }); } /** * Revoke all refresh tokens for a user (logout from all devices). */ async revokeAllUserTokens(userId: string): Promise { await this.prisma.refreshToken.deleteMany({ where: { userId }, }); } /** * Clean up expired refresh tokens (can be called periodically). */ async cleanupExpiredTokens(): Promise { const result = await this.prisma.refreshToken.deleteMany({ where: { expiresAt: { lt: new Date() }, }, }); return result.count; } /** * Verify JWT token. */ async verifyToken(token: string): Promise { return this.app.jwt.verify(token) as JwtPayload; } /** * Get user by ID from database. */ async getUserById(id: string): Promise { const dbUser = await this.prisma.user.findUnique({ where: { id }, }); if (!dbUser) { return null; } return { id: dbUser.id, discordId: dbUser.discordId, username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, isVip: dbUser.isVip, isMod: dbUser.isMod, isStaff: dbUser.isStaff, }; } /** * Create or update user from Discord OAuth data. */ async createOrUpdateUserFromDiscord(discordData: DiscordUser, inDiscord: boolean, isVip: boolean, isMod: boolean, isStaff: boolean): Promise { const avatarUrl = discordData.avatar ? `https://cdn.discordapp.com/avatars/${discordData.id}/${discordData.avatar}.png` : undefined; // Upsert user in database const dbUser = await this.prisma.user.upsert({ where: { discordId: discordData.id, }, create: { discordId: discordData.id, username: discordData.username, email: discordData.email, avatar: avatarUrl, isAdmin: discordData.id === process.env.ADMIN_DISCORD_ID, inDiscord, isVip, isMod, isStaff, }, update: { username: discordData.username, email: discordData.email, avatar: avatarUrl, inDiscord, isVip, isMod, isStaff, }, }); return { id: dbUser.id, discordId: dbUser.discordId, username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, isVip: dbUser.isVip, isMod: dbUser.isMod, isStaff: dbUser.isStaff, }; } } interface DiscordUser { id: string; username: string; email: string; avatar?: string; }