Files
library/api/src/app/routes/auth/index.ts
T
naomi 9caf74945a
Node.js CI / CI (push) Failing after 10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m50s
feat: another security sweep
2026-02-04 22:02:24 -08:00

264 lines
8.1 KiB
TypeScript

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;