Sign panel sessions and isolate test runs
Some checks failed
Deploy to Production / test (push) Failing after 29s
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
103
api/src/routes/auth.routes.test.ts
Normal file
103
api/src/routes/auth.routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user