import { FastifyPluginAsync } from "fastify"; import { AuthService } from "../../services/auth.service"; import { AuditService } from "../../services/audit.service"; import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types"; const authRoutes: FastifyPluginAsync = async (app) => { const authService = new AuthService(app); /** * Discord OAuth callback. */ app.get("/callback", async (request, reply) => { try { const tokenResult = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow( request ); // Get user data from Discord API const discordResponse = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${tokenResult.token.access_token}`, }, }); if (!discordResponse.ok) { throw new Error("Failed to fetch Discord user data"); } const userData = await discordResponse.json(); // Check if user is in our Discord server and has special roles let inDiscord = false; let isVip = false; let isMod = false; let isStaff = false; const guildId = process.env.DISCORD_GUILD_ID; const sponsorRoleId = process.env.SPONSOR_ROLE_ID; const modRoleId = process.env.MOD_ROLE_ID; const staffRoleId = process.env.STAFF_ROLE_ID; if (guildId) { const guildsResponse = await fetch("https://discord.com/api/users/@me/guilds", { headers: { Authorization: `Bearer ${tokenResult.token.access_token}`, }, }); if (guildsResponse.ok) { const guilds = await guildsResponse.json() as Array<{ id: string }>; inDiscord = guilds.some(guild => guild.id === guildId); } // If user is in Discord, check for special roles if (inDiscord) { const memberResponse = await fetch( `https://discord.com/api/users/@me/guilds/${guildId}/member`, { headers: { Authorization: `Bearer ${tokenResult.token.access_token}`, }, } ); if (memberResponse.ok) { const memberData = await memberResponse.json() as { roles: string[] }; if (sponsorRoleId) { isVip = memberData.roles.includes(sponsorRoleId); } if (modRoleId) { isMod = memberData.roles.includes(modRoleId); } if (staffRoleId) { isStaff = memberData.roles.includes(staffRoleId); } } } } // Create or update user in database const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff); // Generate access token and refresh token const accessToken = await authService.generateToken(user); const refreshToken = await authService.generateRefreshToken(user.id); // Log successful login await AuditService.log({ action: AuditAction.LOGIN, category: AuditCategory.AUTH, userId: user.id, details: `User ${user.username} logged in via Discord`, success: true, }, request); // Set signed cookies and redirect to frontend reply .setCookie("auth-token", accessToken, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 15 * 60, // 15 minutes signed: true, }) .setCookie("refresh-token", refreshToken, { path: "/api/auth", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 7 * 24 * 60 * 60, // 7 days signed: true, }) .redirect("/"); // Redirect to root since API serves frontend } catch (error) { // Log failed login attempt await AuditService.log({ action: AuditAction.LOGIN_FAILED, category: AuditCategory.SECURITY, details: error instanceof Error ? error.message : String(error), success: false, }, request); app.log.error({ err: error }, "Auth callback error"); reply .code(401) .send({ error: "Authentication failed" }); } }); /** * Get current user. */ app.get<{ Reply: AuthResponse | { error: string } }>( "/me", { preValidation: [app.authenticate], }, async (request, reply) => { const jwtUser = request.user as { id: string }; const user = await authService.getUserById(jwtUser.id); if (!user) { return reply.code(404).send({ error: "User not found" }); } const token = await authService.generateToken(user); return { user, accessToken: token, }; } ); /** * Refresh access token using refresh token. */ app.post("/refresh", async (request, reply) => { const signedRefreshToken = request.cookies["refresh-token"]; if (!signedRefreshToken) { return reply.code(401).send({ error: "No refresh token provided" }); } const unsignedResult = request.unsignCookie(signedRefreshToken); if (!unsignedResult.valid || !unsignedResult.value) { return reply.code(401).send({ error: "Invalid refresh token" }); } const refreshToken = unsignedResult.value; const user = await authService.validateRefreshToken(refreshToken); if (!user) { reply.clearCookie("refresh-token", { path: "/api/auth", signed: true }); return reply.code(401).send({ error: "Refresh token expired or invalid" }); } if (user.isBanned) { await authService.revokeAllUserTokens(user.id); reply .clearCookie("auth-token", { path: "/", signed: true }) .clearCookie("refresh-token", { path: "/api/auth", signed: true }); return reply.code(403).send({ error: "Account is banned" }); } // Rotate refresh token for security const newRefreshToken = await authService.rotateRefreshToken(refreshToken, user.id); if (!newRefreshToken) { return reply.code(401).send({ error: "Failed to rotate refresh token" }); } const newAccessToken = await authService.generateToken(user); reply .setCookie("auth-token", newAccessToken, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 15 * 60, // 15 minutes signed: true, }) .setCookie("refresh-token", newRefreshToken, { path: "/api/auth", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 7 * 24 * 60 * 60, // 7 days signed: true, }) .send({ user, accessToken: newAccessToken }); }); /** * Logout. */ app.post("/logout", async (request, reply) => { // Revoke refresh token if present const signedRefreshToken = request.cookies["refresh-token"]; if (signedRefreshToken) { const unsignedResult = request.unsignCookie(signedRefreshToken); if (unsignedResult.valid && unsignedResult.value) { await authService.revokeRefreshToken(unsignedResult.value); } } // Try to get user ID from JWT for audit logging try { await request.jwtVerify(); const user = request.user as { id?: string; username?: string }; if (user?.id) { await AuditService.log({ action: AuditAction.LOGOUT, category: AuditCategory.AUTH, userId: user.id, details: `User ${user.username ?? "unknown"} logged out`, success: true, }, request); } } catch { // User wasn't authenticated, just proceed with logout } reply .clearCookie("auth-token", { path: "/", signed: true, }) .clearCookie("refresh-token", { path: "/api/auth", signed: true, }) .send({ message: "Logged out successfully" }); }); /** * Get CSRF token for state-changing requests. */ app.get("/csrf-token", async (request, reply) => { const token = reply.generateCsrf(); return { csrfToken: token }; }); }; export default authRoutes;