Sign panel sessions and isolate test runs
Some checks failed
Deploy to Production / test (push) Failing after 29s

- Replace in-memory auth sessions with signed cookies and signed OAuth state
- Add auth route coverage and update panel/web server wiring
- Switch test script to per-file Bun processes and clean up type checks
This commit is contained in:
syntaxbullet
2026-04-09 21:44:05 +02:00
parent 6abbd4652a
commit 25a0bd3431
25 changed files with 354 additions and 157 deletions

View File

@@ -10,7 +10,7 @@
## Authentication and authorization
- OAuth routes live in `api/src/routes/auth.routes.ts`.
- Sessions are stored in memory and keyed by the `aurora_session` cookie.
- Sessions are stored in signed `aurora_session` cookies.
- Session TTL is 7 days.
- Login succeeds only for users already present in the `users` table.
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
@@ -62,7 +62,7 @@ Hard limits:
## Gotchas
- Sessions and some caches are in-memory only and are lost on restart.
- Some runtime caches are in-memory only and are lost on restart.
- The server registers game plugins at startup; duplicate registration throws.
- BigInt-safe JSON matters for nearly every domain route.
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.

View File

@@ -16,7 +16,7 @@ export class GameServer {
readonly roomManager = new RoomManager();
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
private bunServer: Server | null = null;
private bunServer: Server<WsConnectionData> | null = null;
constructor() {
// Subscribe to room events and route them to the right clients
@@ -141,7 +141,7 @@ export class GameServer {
});
}
setServer(server: Server): void {
setServer(server: Server<WsConnectionData>): void {
this.bunServer = server;
}

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
const findFirst = mock(async () => ({ id: 123n }));
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
query: {
users: {
findFirst,
},
},
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { authRoutes, getSession } from "./auth.routes";
describe("Auth Routes", () => {
let fetchSpy: ReturnType<typeof spyOn> | null = null;
beforeEach(() => {
process.env.DISCORD_CLIENT_ID = "client-id";
process.env.DISCORD_CLIENT_SECRET = "client-secret";
process.env.SESSION_SECRET = "session-secret";
process.env.PANEL_BASE_URL = "http://localhost:3000";
process.env.ADMIN_USER_IDS = "123";
findFirst.mockClear();
});
afterEach(() => {
fetchSpy?.mockRestore();
fetchSpy = null;
});
it("creates a signed session cookie during OAuth callback", async () => {
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
const loginRes = await authRoutes.handler({
req: new Request(loginUrl, { method: "GET" }),
url: loginUrl,
method: "GET",
pathname: "/auth/discord",
});
expect(loginRes?.status).toBe(302);
const redirectLocation = loginRes?.headers.get("Location");
expect(redirectLocation).not.toBeNull();
const state = new URL(redirectLocation!).searchParams.get("state");
expect(state).not.toBeNull();
fetchSpy = spyOn(globalThis, "fetch");
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
id: "123",
username: "aurora-admin",
avatar: null,
}), { status: 200 }));
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
const callbackRes = await authRoutes.handler({
req: new Request(callbackUrl, { method: "GET" }),
url: callbackUrl,
method: "GET",
pathname: "/auth/callback",
});
expect(callbackRes?.status).toBe(302);
expect(callbackRes?.headers.get("Location")).toBe("/admin");
const setCookie = callbackRes?.headers.get("Set-Cookie");
expect(setCookie).toContain("aurora_session=");
const sessionCookie = setCookie!.split(";")[0]!;
const session = getSession(new Request("http://localhost/api/me", {
headers: { cookie: sessionCookie },
}));
expect(session).toEqual({
discordId: "123",
username: "aurora-admin",
avatar: null,
role: "admin",
expiresAt: expect.any(Number),
});
});
it("rejects tampered session cookies", () => {
const session = getSession(new Request("http://localhost/api/me", {
headers: { cookie: "aurora_session=not-a-valid-token" },
}));
expect(session).toBeNull();
});
});

View File

@@ -3,6 +3,8 @@
* Handles login flow, callback, logout, and session management.
*/
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
@@ -10,7 +12,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
// In-memory session store: token → { discordId, username, avatar, role, expiresAt }
// Signed session payload stored in the aurora_session cookie.
export interface Session {
discordId: string;
username: string;
@@ -19,10 +21,21 @@ export interface Session {
expiresAt: number;
}
const sessions = new Map<string, Session>();
const redirects = new Map<string, string>(); // redirect token -> return_to URL
interface SessionTokenPayload extends Session {
v: 1;
}
interface OAuthStatePayload {
exp: number;
returnTo: string;
v: 1;
}
const COOKIE_NAME = "aurora_session";
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
const TOKEN_NAMESPACE = "aurora.auth";
const TOKEN_VERSION = "v1";
function getEnv(key: string): string {
const val = process.env[key];
@@ -30,15 +43,70 @@ function getEnv(key: string): string {
return val;
}
function getSessionSecret(required: boolean = false): string | null {
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
if (!secret && required) {
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
}
return secret;
}
function requireSessionSecret(): string {
return getSessionSecret(true)!;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
function decodeBase64Url(value: string): string {
return Buffer.from(value, "base64url").toString("utf8");
}
function signValue(kind: string, encodedPayload: string, secret: string): string {
return createHmac("sha256", secret)
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
.digest("base64url");
}
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
const signature = signValue(kind, encodedPayload, secret);
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
}
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
if (!token) return null;
const secret = getSessionSecret();
if (!secret) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const version = parts[0];
const encodedPayload = parts[1];
const providedSignature = parts[2];
if (version !== TOKEN_VERSION) return null;
if (!encodedPayload || !providedSignature) return null;
const expectedSignature = signValue(kind, encodedPayload, secret);
const providedBuffer = Buffer.from(providedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (providedBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
try {
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
} catch {
return null;
}
}
function getBaseUrl(): string {
@@ -55,18 +123,65 @@ function parseCookies(header: string | null): Record<string, string> {
return cookies;
}
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
try {
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
return rawReturnTo;
}
const parsed = new URL(rawReturnTo, baseUrl);
const allowedBase = new URL(baseUrl);
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
return "/";
}
return "/";
}
function buildCookieAttributes(maxAgeSeconds?: number): string {
const attrs = [
"Path=/",
"HttpOnly",
"SameSite=Lax",
];
try {
if (new URL(getBaseUrl()).protocol === "https:") {
attrs.push("Secure");
}
} catch {
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
}
if (typeof maxAgeSeconds === "number") {
attrs.push(`Max-Age=${maxAgeSeconds}`);
}
return attrs.join("; ");
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session;
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
if (!payload || payload.v !== 1) return null;
if (Date.now() > payload.expiresAt) return null;
return {
discordId: payload.discordId,
username: payload.username,
avatar: payload.avatar,
role: payload.role,
expiresAt: payload.expiresAt,
};
}
/** Check if request is authenticated as admin */
@@ -84,20 +199,22 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email";
const secret = requireSessionSecret();
// Store return_to URL if provided
const returnTo = ctx.url.searchParams.get("return_to") || "/";
const redirectToken = generateToken();
redirects.set(redirectToken, returnTo);
// Store return_to URL in signed OAuth state
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
const state = serializeSignedToken("oauth", {
exp: Date.now() + OAUTH_STATE_MAX_AGE,
returnTo,
v: 1,
}, secret);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
// Set a temporary cookie with the redirect token
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
},
});
} catch (e) {
@@ -116,8 +233,13 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`;
const secret = requireSessionSecret();
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
return errorResponse("Invalid OAuth state", 400);
}
// Exchange code for token
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -165,40 +287,24 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const adminIds = getAdminIds();
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
// Create session
const token = generateToken();
sessions.set(token, {
// Create signed session cookie
const sessionToken = serializeSignedToken("session", {
discordId: user.id,
username: user.username,
avatar: user.avatar,
role,
expiresAt: Date.now() + SESSION_MAX_AGE,
});
v: 1,
}, secret);
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const redirectToken = cookies["aurora_redirect"];
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
if (redirectToken) redirects.delete(redirectToken);
// Only allow redirects to localhost or relative paths (prevent open redirect)
try {
const parsed = new URL(returnTo, baseUrl);
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
returnTo = "/";
}
} catch {
returnTo = "/";
}
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: returnTo,
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
},
});
} catch (e) {
@@ -209,14 +315,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") {
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (token) sessions.delete(token);
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
"Content-Type": "application/json",
},
});

View File

@@ -240,18 +240,3 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
},
};
}
/**
* Starts the web server from the main application root.
* Kept for backward compatibility.
*
* @param webProjectPath - Deprecated, no longer used
* @param config - Server configuration options
* @returns Promise resolving to server instance
*/
export async function startWebServerFromRoot(
webProjectPath: string,
config: WebServerConfig = {}
): Promise<WebServerInstance> {
return createWebServer(config);
}