Some checks failed
Deploy to Production / test (push) Failing after 37s
Adds a React admin panel (panel/) with Discord OAuth2 login, live dashboard via WebSocket, and settings/management pages. Includes Docker build support, Vite proxy config for dev, game_settings migration, and open-redirect protection on auth callback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
/**
|
|
* @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<string, Session>();
|
|
const redirects = new Map<string, string>(); // 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<string, string> {
|
|
if (!header) return {};
|
|
const cookies: Record<string, string> = {};
|
|
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<Response | null> {
|
|
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(
|
|
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
|
|
{ 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,
|
|
};
|