|
1 | 1 | import type { Request, Response, NextFunction } from "express"; |
| 2 | +import { getGithubLogin } from "auth.js" |
2 | 3 |
|
3 | 4 | function getAuthToken(req: Request): string | null { |
4 | 5 | const h = req.header("authorization"); |
5 | 6 | if (!h) return null; |
6 | | - |
7 | | - // Accept: "Bearer xxx" or "token xxx" |
8 | 7 | const m = /^(Bearer|token)\s+(.+)$/i.exec(h.trim()); |
9 | 8 | return m?.[2] ?? null; |
10 | 9 | } |
11 | 10 |
|
| 11 | +function parseAllowlist(envValue?: string): string[] { |
| 12 | + return String(envValue ?? "") |
| 13 | + .split(",") |
| 14 | + .map(s => s.trim().toLowerCase()) |
| 15 | + .filter(Boolean); |
| 16 | +} |
| 17 | + |
| 18 | +function getSessionLogin(req: Request): string | null { |
| 19 | + // passport-github2 typically gives profile.username |
| 20 | + const u: any = (req as any).user; |
| 21 | + const login = (u?.username ?? u?.login ?? "").toString().trim(); |
| 22 | + return login ? login.toLowerCase() : null; |
| 23 | +} |
| 24 | + |
12 | 25 | // tiny cache: token -> { login, expiresAt } |
13 | 26 | const tokenCache = new Map<string, { login: string; expiresAt: number }>(); |
14 | | -const CACHE_MS = 60_000; // 1 minute (adjust as desired) |
15 | | - |
16 | | -export async function requireAdmin(req: Request, res: Response, next: NextFunction) { |
17 | | - const token = getAuthToken(req); |
18 | | - if (!token) { |
19 | | - return res.status(401).json({ error: "unauthorized", message: "Missing Authorization token" }); |
20 | | - } |
| 27 | +const CACHE_MS = 60_000; |
21 | 28 |
|
22 | | - // Cache hit? |
23 | | - const cached = tokenCache.get(token); |
24 | | - if (cached && cached.expiresAt > Date.now()) { |
25 | | - (req as any).adminLogin = cached.login; |
| 29 | +export const requireAdmin = (req: Request, res: Response, next: NextFunction) => { |
| 30 | + // ✅ DEV BYPASS (explicit opt-in, never in production) |
| 31 | + if ( |
| 32 | + process.env.NODE_ENV !== "production" && |
| 33 | + process.env.ADMIN_DEV_BYPASS === "true" |
| 34 | + ) { |
26 | 35 | return next(); |
27 | 36 | } |
| 37 | + // 1) Not logged in? -> 401 |
| 38 | + if (!req.isAuthenticated?.()) { |
| 39 | + return res.status(401).json({ error: "Unauthorized" }); |
| 40 | + } |
28 | 41 |
|
29 | | - try { |
30 | | - const ghRes = await fetch("https://api.github.com/user", { |
31 | | - headers: { |
32 | | - Authorization: `Bearer ${token}`, |
33 | | - Accept: "application/vnd.github+json", |
34 | | - "User-Agent": "human-pattern-lab-api", |
35 | | - }, |
36 | | - }); |
37 | | - |
38 | | - // Helpful differentiation for debugging |
39 | | - if (ghRes.status === 401) { |
40 | | - return res.status(401).json({ error: "unauthorized", message: "Invalid GitHub token" }); |
41 | | - } |
42 | | - if (ghRes.status === 403) { |
43 | | - // could be rate limiting or token restriction |
44 | | - const remaining = ghRes.headers.get("x-ratelimit-remaining"); |
45 | | - const msg = |
46 | | - remaining === "0" |
47 | | - ? "GitHub rate limit exceeded (try again later)" |
48 | | - : "Forbidden by GitHub (token may lack scopes or be restricted)"; |
49 | | - return res.status(403).json({ error: "forbidden", message: msg }); |
50 | | - } |
51 | | - if (!ghRes.ok) { |
52 | | - return res.status(502).json({ error: "bad_gateway", message: "GitHub auth check failed" }); |
53 | | - } |
54 | | - |
55 | | - const user = (await ghRes.json()) as { login?: string }; |
56 | | - const login = user.login?.trim(); |
| 42 | + const login = getGithubLogin((req as any).user); |
| 43 | + if (!login) { |
| 44 | + return res.status(401).json({ error: "Unauthorized" }); |
| 45 | + } |
57 | 46 |
|
58 | | - if (!login) { |
59 | | - return res.status(401).json({ error: "unauthorized", message: "GitHub token user unknown" }); |
60 | | - } |
| 47 | + // 2) Now check allowlist |
| 48 | + const allow = new Set<string>([ |
| 49 | + ...parseAllowlist(process.env.ADMIN_GITHUB_USERS), |
| 50 | + ...parseAllowlist(process.env.ADMIN_GITHUB_LOGINS), |
| 51 | + ...parseAllowlist(process.env.ALLOWED_GITHUB_USERNAME), |
| 52 | + ]); |
61 | 53 |
|
62 | | - // Optional allowlist: ADMIN_GITHUB_LOGINS="AdaVale,OtherAdmin" |
63 | | - const allow = (process.env.ADMIN_GITHUB_LOGINS ?? "") |
64 | | - .split(",") |
65 | | - .map((s) => s.trim()) |
66 | | - .filter(Boolean); |
| 54 | + // 3) If no allowlist is configured, fail closed (prod + everywhere if you prefer) |
| 55 | + if (allow.size === 0) { |
| 56 | + return res.status(403).json({ error: "Admin not configured" }); |
| 57 | + } |
67 | 58 |
|
68 | | - if (allow.length > 0 && !allow.includes(login)) { |
69 | | - return res.status(403).json({ error: "forbidden", message: "Not an admin" }); |
70 | | - } |
| 59 | + // 4) Logged in but not allowed -> 403 |
| 60 | + if (!allow.has(login)) { |
| 61 | + return res.status(403).json({ error: "Forbidden" }); |
| 62 | + } |
71 | 63 |
|
72 | | - tokenCache.set(token, { login, expiresAt: Date.now() + CACHE_MS }); |
73 | | - (req as any).adminLogin = login; |
| 64 | + return next(); |
| 65 | +}; |
74 | 66 |
|
75 | | - return next(); |
76 | | - } catch { |
77 | | - return res.status(500).json({ error: "server_error", message: "Auth check failed" }); |
78 | | - } |
79 | | -} |
0 commit comments