Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions apps/dashboard/src/server/features/auth/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyInstance } from "fastify";
import { config } from "@fluxcore/config";
import {
buildCallbackUrl,
getAuthorizationUrl,
Expand All @@ -7,6 +8,7 @@ import {
fetchGuilds,
} from "../../shared/auth.js";
import { createSession, deleteSession, getSession } from "../../shared/session.js";
import { generateCsrfToken } from "../../shared/csrf.js";
import { logger } from "@fluxcore/utils";

const isProduction = process.env.NODE_ENV === "production";
Expand All @@ -18,17 +20,14 @@ const authRateLimit = {
};

export function registerAuthRoutes(app: FastifyInstance): void {
app.get("/auth/login", { ...authRateLimit }, async (request, reply) => {
const proto = (request.headers["x-forwarded-proto"] as string) || request.protocol;
const host = request.headers["x-forwarded-host"] as string || request.hostname;
const origin = `${proto}://${host}`;
const callbackUrl = buildCallbackUrl(origin);
app.get("/auth/login", { ...authRateLimit }, async (_request, reply) => {
const callbackUrl = buildCallbackUrl(config.dashboardPublicUrl);
const { url, state } = getAuthorizationUrl(callbackUrl);
reply
.setCookie("oauth_state", state, {
path: "/",
httpOnly: true,
sameSite: "lax",
sameSite: "strict",
secure: isProduction,
signed: true,
maxAge: 300, // 5 minutes
Expand All @@ -55,15 +54,19 @@ export function registerAuthRoutes(app: FastifyInstance): void {

const unsignedState = request.unsignCookie(stateCookie);
if (!unsignedState.valid || unsignedState.value !== state) {
reply.code(403).send({ error: "Invalid state parameter" });
reply
.clearCookie("oauth_state", { path: "/" })
.code(403)
.send({ error: "Invalid state parameter" });
return;
}

// State is valid — burn it immediately so it cannot be replayed
// regardless of whether the rest of the flow succeeds.
reply.clearCookie("oauth_state", { path: "/" });

try {
const proto = (request.headers["x-forwarded-proto"] as string) || request.protocol;
const host = request.headers["x-forwarded-host"] as string || request.hostname;
const origin = `${proto}://${host}`;
const callbackUrl = buildCallbackUrl(origin);
const callbackUrl = buildCallbackUrl(config.dashboardPublicUrl);
const token = await exchangeCode(code, callbackUrl);
const [user, guilds] = await Promise.all([
fetchUser(token.access_token),
Expand All @@ -79,14 +82,13 @@ export function registerAuthRoutes(app: FastifyInstance): void {
});

reply
.clearCookie("oauth_state", { path: "/" })
.setCookie("session", sessionId, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: isProduction,
signed: true,
maxAge: 604800, // 7 days
maxAge: 86400, // 24 hours
})
.redirect("/");
} catch (error) {
Expand All @@ -98,6 +100,19 @@ export function registerAuthRoutes(app: FastifyInstance): void {
}
});

app.get("/auth/csrf", async (_request, reply) => {
const token = generateCsrfToken();
reply
.setCookie("csrf_token", token, {
path: "/",
httpOnly: false, // double-submit: JS must read it
sameSite: "lax",
secure: isProduction,
maxAge: 604800,
})
.send({ token });
});

app.get("/auth/logout", async (request, reply) => {
const sessionCookie = request.cookies?.session;
if (sessionCookie) {
Expand Down
39 changes: 32 additions & 7 deletions apps/dashboard/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fastifyHelmet from "@fastify/helmet";
import fastifyRateLimit from "@fastify/rate-limit";
import fastifyStatic from "@fastify/static";
import { join, dirname } from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { config } from "@fluxcore/config";
import { logger } from "@fluxcore/utils";
Expand Down Expand Up @@ -34,17 +35,15 @@ import { registerStarboardRoutes } from "./features/starboard/routes.js";
import { registerDashboardRoleRoutes } from "./features/permissions/roles-routes.js";
import { registerDashboardPermissionRoutes } from "./features/permissions/routes.js";
import { registerI18n } from "./shared/i18n.js";
import { requireCsrf } from "./shared/csrf.js";

async function main(): Promise<void> {
if (!config.dashboardClientSecret) {
logger.error("DASHBOARD_CLIENT_SECRET is required");
process.exit(1);
}

if (process.env.NODE_ENV === "production" && !process.env.DASHBOARD_SESSION_SECRET) {
logger.error("DASHBOARD_SESSION_SECRET is required in production");
process.exit(1);
}
// DASHBOARD_SESSION_SECRET fail-fast is enforced inside @fluxcore/config

await connectDatabase();

Expand All @@ -55,11 +54,21 @@ async function main(): Promise<void> {
});

await app.register(fastifyHelmet, {
enableCSPNonces: true,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
scriptSrc: [
"'self'",
(_req, reply) =>
`'nonce-${(reply as unknown as { cspNonce: { script: string } }).cspNonce.script}'`,
],
styleSrc: [
"'self'",
"https://fonts.googleapis.com",
(_req, reply) =>
`'nonce-${(reply as unknown as { cspNonce: { style: string } }).cspNonce.style}'`,
],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https://cdn.discordapp.com"],
},
Expand All @@ -71,6 +80,16 @@ async function main(): Promise<void> {
timeWindow: "1 minute",
});

// CSRF double-submit enforcement on mutating /api/* routes
app.addHook("preHandler", async (request, reply) => {
if (
request.url.startsWith("/api/") &&
!["GET", "HEAD", "OPTIONS"].includes(request.method)
) {
await requireCsrf(request, reply);
}
});

const __dirname = dirname(fileURLToPath(import.meta.url));

// In production, serve the built React SPA
Expand Down Expand Up @@ -128,12 +147,18 @@ async function main(): Promise<void> {

// SPA fallback: serve index.html for non-API/auth routes in production
if (process.env.NODE_ENV === "production") {
const indexHtmlPath = join(__dirname, "../client/index.html");
const indexHtmlTemplate = await readFile(indexHtmlPath, "utf8");

app.setNotFoundHandler(async (request, reply) => {
if (request.url.startsWith("/api/") || request.url.startsWith("/auth/")) {
reply.code(404).send({ error: "Not found" });
return;
}
return reply.sendFile("index.html");
const nonce = (reply as unknown as { cspNonce: { style: string } })
.cspNonce.style;
const html = indexHtmlTemplate.replace(/<style/g, `<style nonce="${nonce}"`);
reply.type("text/html").send(html);
});
}

Expand Down
44 changes: 44 additions & 0 deletions apps/dashboard/src/server/shared/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { FastifyRequest, FastifyReply } from "fastify";

const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const TOKEN_BYTES = 32;
const MIN_TOKEN_LENGTH = TOKEN_BYTES * 2; // hex

export function generateCsrfToken(): string {
return randomBytes(TOKEN_BYTES).toString("hex");
}

function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
} catch {
return false;
}
}

export async function requireCsrf(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (SAFE_METHODS.has(request.method)) return;

const cookieToken = request.cookies?.csrf_token;
const headerToken = request.headers["x-csrf-token"];
const headerStr = Array.isArray(headerToken) ? headerToken[0] : headerToken;

if (
!cookieToken ||
!headerStr ||
cookieToken.length < MIN_TOKEN_LENGTH ||
headerStr.length < MIN_TOKEN_LENGTH ||
!safeEqual(cookieToken, headerStr)
) {
reply.code(403).send({
error: "CSRF token missing or invalid",
errorKey: "errors:csrf.invalid",
});
return;
}
}
25 changes: 23 additions & 2 deletions apps/dashboard/src/server/shared/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import { getSession, touchSession, type Session } from "./session.js";
import {
getSession,
touchSession,
ensureFreshGuilds,
type Session,
} from "./session.js";
import { isBotInGuild } from "./discordApi.js";
import {
resolveUserPermissions,
Expand Down Expand Up @@ -63,8 +68,24 @@ export async function requireGuildAdmin(
): Promise<void> {
const { guildId } = request.params as { guildId: string };
const session = request.session!;
const sessionId = request.sessionId!;

// Re-validate guild membership against fresh Discord data (max 5 min stale)
let guilds = session.guilds;
if (sessionId) {
try {
const fresh = await ensureFreshGuilds(sessionId);
if (fresh) {
guilds = fresh;
session.guilds = fresh;
}
} catch {
// If Discord is unreachable, fall back to cached guilds — this is the
// existing behavior. We still re-check permissions below.
}
}

const userGuild = session.guilds.find((g) => g.id === guildId);
const userGuild = guilds.find((g) => g.id === guildId);
if (!userGuild || !(BigInt(userGuild.permissions) & MANAGE_GUILD)) {
reply.code(403).send({
error: request.t("errors:permissions.noGuildPermission"),
Expand Down
51 changes: 48 additions & 3 deletions apps/dashboard/src/server/shared/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export interface Session {
createdAt: number;
}

const SESSION_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours (sliding-renewed for active users)
const CACHE_TTL = 30_000; // 30 seconds
const GUILD_REFRESH_INTERVAL = 30 * 60 * 1000; // 30 minutes

interface CachedSession {
export interface CachedSession {
session: Session;
cacheExpiresAt: number;
sessionExpiresAt: number;
Expand All @@ -52,6 +52,17 @@ export async function createSession(
const expiresAt = new Date(now.getTime() + SESSION_TTL);

const prisma = getPrisma();

// Invalidate any prior sessions for this user (session fixation defense
// and prevents unbounded session row growth on repeated logins).
await prisma.dashboardSession.deleteMany({ where: { userId: data.userId } });
// Also drop any cached entries for this user
for (const [cacheId, entry] of sessionCache) {
if (entry.session.userId === data.userId) {
sessionCache.delete(cacheId);
}
}

await prisma.dashboardSession.create({
data: {
id,
Expand Down Expand Up @@ -146,7 +157,7 @@ export async function touchSession(
sameSite: "lax",
secure: isProduction,
signed: true,
maxAge: 604800, // 7 days
maxAge: 86400, // 24 hours
});
}

Expand Down Expand Up @@ -197,6 +208,40 @@ export async function forceRefreshSessionGuilds(
return refreshSessionGuilds(id, cached.session.accessToken, cached);
}

const FRESH_GUILD_THRESHOLD = 5 * 60 * 1000; // 5 minutes

/**
* Ensure session.guilds is no older than FRESH_GUILD_THRESHOLD.
* Used by requireGuildAdmin to fail closed for revoked admins quickly.
*/
export async function ensureFreshGuilds(
id: string,
): Promise<OAuthGuild[] | null> {
const cached = sessionCache.get(id);
if (!cached) {
const session = await getSession(id);
if (!session) return null;
const reloaded = sessionCache.get(id);
if (!reloaded) return session.guilds;
if (Date.now() - reloaded.guildsRefreshedAt <= FRESH_GUILD_THRESHOLD) {
return reloaded.session.guilds;
}
return refreshSessionGuilds(id, session.accessToken, reloaded);
}
if (Date.now() - cached.guildsRefreshedAt <= FRESH_GUILD_THRESHOLD) {
return cached.session.guilds;
}
return refreshSessionGuilds(id, cached.session.accessToken, cached);
}

// Test-only hook
export function __setSessionCacheForTest(
id: string,
entry: CachedSession,
): void {
sessionCache.set(id, entry);
}

export async function deleteSession(id: string): Promise<void> {
sessionCache.delete(id);
const prisma = getPrisma();
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard/tests/server/features/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ vi.mock("@fluxcore/config", () => ({
clientId: "test-client-id",
dashboardClientSecret: "test-secret",
dashboardCallbackUrl: "http://localhost:3000/auth/callback",
dashboardPublicUrl: "http://localhost:3000",
dashboardSessionSecret: "session-secret",
logLevel: "info",
},
Expand Down Expand Up @@ -117,7 +118,10 @@ describe("auth routes", () => {
cookies: { oauth_state: signedStateCookie },
});
expect(res.statusCode).toBe(302);
expect(mockExchangeCode).toHaveBeenCalledWith("test-code");
expect(mockExchangeCode).toHaveBeenCalledWith(
"test-code",
"http://localhost:3000/auth/callback",
);
expect(mockCreateSession).toHaveBeenCalled();

const sessionCookie = res.cookies.find((c) => c.name === "session");
Expand Down
Loading