/** * @fileoverview Discord OAuth2 authentication routes for the admin panel. * Handles login flow, callback, logout, and session management. */ import type { RouteContext, RouteModule } from "./types"; import { jsonResponse, errorResponse } from "./utils"; import { logger } from "@shared/lib/logger"; // In-memory session store: token → { discordId, username, avatar, expiresAt } export interface Session { discordId: string; username: string; avatar: string | null; expiresAt: number; } const sessions = new Map(); const redirects = new Map(); // redirect token -> return_to URL const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days function getEnv(key: string): string { const val = process.env[key]; if (!val) throw new Error(`Missing env: ${key}`); return val; } 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 getBaseUrl(): string { return process.env.PANEL_BASE_URL ?? `http://localhost:3000`; } function parseCookies(header: string | null): Record { if (!header) return {}; const cookies: Record = {}; for (const pair of header.split(";")) { const [key, ...rest] = pair.trim().split("="); if (key) cookies[key] = rest.join("="); } return cookies; } /** 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; } /** Check if request is authenticated as admin */ export function isAuthenticated(req: Request): boolean { return getSession(req) !== null; } async function handler(ctx: RouteContext): Promise { const { pathname, method } = ctx; // GET /auth/discord — redirect to Discord OAuth if (pathname === "/auth/discord" && method === "GET") { try { const clientId = getEnv("DISCORD_CLIENT_ID"); const baseUrl = getBaseUrl(); const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`); const scope = "identify+email"; // Store return_to URL if provided const returnTo = ctx.url.searchParams.get("return_to") || "/"; const redirectToken = generateToken(); redirects.set(redirectToken, returnTo); const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`; // 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) { logger.error("auth", "Failed to initiate OAuth", e); return errorResponse("OAuth not configured", 500); } } // GET /auth/callback — handle Discord OAuth callback if (pathname === "/auth/callback" && method === "GET") { const code = ctx.url.searchParams.get("code"); if (!code) return errorResponse("Missing code parameter", 400); try { const clientId = getEnv("DISCORD_CLIENT_ID"); const clientSecret = getEnv("DISCORD_CLIENT_SECRET"); const baseUrl = getBaseUrl(); const redirectUri = `${baseUrl}/auth/callback`; // Exchange code for token const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "authorization_code", code, redirect_uri: redirectUri, }), }); if (!tokenRes.ok) { logger.error("auth", `Token exchange failed: ${tokenRes.status}`); return errorResponse("OAuth token exchange failed", 401); } const tokenData = await tokenRes.json() as { access_token: string }; // Fetch user info const userRes = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); if (!userRes.ok) { return errorResponse("Failed to fetch Discord user", 401); } const user = await userRes.json() as { id: string; username: string; avatar: string | null }; // Check allowlist const adminIds = getAdminIds(); if (adminIds.length > 0 && !adminIds.includes(user.id)) { logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`); return new Response( `

Access Denied

Your Discord account is not authorized.

`, { status: 403, headers: { "Content-Type": "text/html" } } ); } // Create session const token = generateToken(); sessions.set(token, { discordId: user.id, username: user.username, avatar: user.avatar, expiresAt: Date.now() + SESSION_MAX_AGE, }); logger.info("auth", `Admin login: ${user.username} (${user.id})`); // 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}`, }, }); } catch (e) { logger.error("auth", "OAuth callback error", e); return errorResponse("Authentication failed", 500); } } // 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", "Content-Type": "application/json", }, }); } // GET /auth/me — return current session info if (pathname === "/auth/me" && method === "GET") { const session = getSession(ctx.req); if (!session) return jsonResponse({ authenticated: false }, 401); return jsonResponse({ authenticated: true, user: { discordId: session.discordId, username: session.username, avatar: session.avatar, }, }); } return null; } export const authRoutes: RouteModule = { name: "auth", handler, };