somratpro commited on
Commit
00136ee
·
1 Parent(s): e04b24a

Use token-only login for app route

Browse files
Files changed (2) hide show
  1. README.md +3 -3
  2. health-server.js +200 -21
README.md CHANGED
@@ -58,9 +58,9 @@ GATEWAY_TOKEN=your-strong-password-or-token
58
 
59
  Then:
60
 
61
- - Opening `/app/` asks for browser Basic Auth.
62
- - Use any username.
63
- - Use `GATEWAY_TOKEN` as the password.
64
  - API routes under `/v1/*` accept `Authorization: Bearer <GATEWAY_TOKEN>`.
65
 
66
  ## LLM Providers
 
58
 
59
  Then:
60
 
61
+ - Opening `/app/` shows a HuggingMess login page with one field.
62
+ - Paste `GATEWAY_TOKEN` into that field.
63
+ - HuggingMess stores an HTTP-only session cookie for the dashboard routes.
64
  - API routes under `/v1/*` accept `Authorization: Bearer <GATEWAY_TOKEN>`.
65
 
66
  ## LLM Providers
health-server.js CHANGED
@@ -3,6 +3,7 @@
3
  const http = require("http");
4
  const fs = require("fs");
5
  const net = require("net");
 
6
 
7
  const PORT = Number(process.env.PORT || 7861);
8
  const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
@@ -12,7 +13,9 @@ const GATEWAY_HOST = "127.0.0.1";
12
  const startTime = Date.now();
13
  const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
14
  const APP_BASE = "/app";
15
- const AUTH_REALM = "HuggingMess";
 
 
16
 
17
  const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
18
  const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
@@ -39,39 +42,206 @@ function readJson(path, fallback = null) {
39
  return fallback;
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  function getBearerToken(req) {
43
  const value = req.headers.authorization || "";
44
  const match = /^Bearer\s+(.+)$/i.exec(value);
45
  return match ? match[1] : "";
46
  }
47
 
48
- function getBasicPassword(req) {
49
- const value = req.headers.authorization || "";
50
- const match = /^Basic\s+(.+)$/i.exec(value);
51
- if (!match) return "";
52
- try {
53
- const decoded = Buffer.from(match[1], "base64").toString("utf8");
54
- const separator = decoded.indexOf(":");
55
- return separator >= 0 ? decoded.slice(separator + 1) : "";
56
- } catch {
57
- return "";
58
- }
59
- }
60
-
61
  function isAuthorized(req) {
62
  if (!API_SERVER_KEY) return true;
63
- return getBearerToken(req) === API_SERVER_KEY || getBasicPassword(req) === API_SERVER_KEY;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
 
66
  function requireAuth(req, res) {
67
  if (isAuthorized(req)) return true;
68
- res.writeHead(401, {
69
- "content-type": "text/plain; charset=utf-8",
70
- "www-authenticate": `Basic realm="${AUTH_REALM}", charset="UTF-8"`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  "cache-control": "no-store",
72
  });
73
- res.end("Authentication required. Use any username and your GATEWAY_TOKEN as the password.");
74
- return false;
75
  }
76
 
77
  function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
@@ -220,6 +390,16 @@ const server = http.createServer(async (req, res) => {
220
  const parsed = new URL(req.url, "http://localhost");
221
  const path = parsed.pathname;
222
 
 
 
 
 
 
 
 
 
 
 
223
  if (path === "/health" || path === `${APP_BASE}/health`) {
224
  const data = await statusPayload();
225
  res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
@@ -293,7 +473,6 @@ const server = http.createServer(async (req, res) => {
293
  if (!isAuthorized(req)) {
294
  res.writeHead(401, {
295
  "content-type": "application/json",
296
- "www-authenticate": `Bearer realm="${AUTH_REALM}"`,
297
  "cache-control": "no-store",
298
  });
299
  res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));
 
3
  const http = require("http");
4
  const fs = require("fs");
5
  const net = require("net");
6
+ const crypto = require("crypto");
7
 
8
  const PORT = Number(process.env.PORT || 7861);
9
  const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
 
13
  const startTime = Date.now();
14
  const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
15
  const APP_BASE = "/app";
16
+ const LOGIN_PATH = "/login";
17
+ const LOGOUT_PATH = "/logout";
18
+ const SESSION_COOKIE = "huggingmess_session";
19
 
20
  const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
21
  const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
 
42
  return fallback;
43
  }
44
 
45
+ function timingSafeEqualString(left, right) {
46
+ if (!left || !right) return false;
47
+ const leftBuffer = Buffer.from(left);
48
+ const rightBuffer = Buffer.from(right);
49
+ if (leftBuffer.length !== rightBuffer.length) return false;
50
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
51
+ }
52
+
53
+ function expectedSessionValue() {
54
+ if (!API_SERVER_KEY) return "";
55
+ return crypto
56
+ .createHmac("sha256", API_SERVER_KEY)
57
+ .update("huggingmess-session-v1")
58
+ .digest("hex");
59
+ }
60
+
61
+ function parseCookies(req) {
62
+ const header = req.headers.cookie || "";
63
+ const cookies = {};
64
+ for (const item of header.split(";")) {
65
+ const separator = item.indexOf("=");
66
+ if (separator < 0) continue;
67
+ const name = item.slice(0, separator).trim();
68
+ const value = item.slice(separator + 1).trim();
69
+ if (!name) continue;
70
+ try {
71
+ cookies[name] = decodeURIComponent(value);
72
+ } catch {
73
+ cookies[name] = value;
74
+ }
75
+ }
76
+ return cookies;
77
+ }
78
+
79
+ function isHttpsRequest(req) {
80
+ return req.headers["x-forwarded-proto"] === "https";
81
+ }
82
+
83
+ function buildSessionCookie(req) {
84
+ const secure = isHttpsRequest(req) ? "; Secure" : "";
85
+ return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
86
+ }
87
+
88
+ function buildClearSessionCookie(req) {
89
+ const secure = isHttpsRequest(req) ? "; Secure" : "";
90
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
91
+ }
92
+
93
  function getBearerToken(req) {
94
  const value = req.headers.authorization || "";
95
  const match = /^Bearer\s+(.+)$/i.exec(value);
96
  return match ? match[1] : "";
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  function isAuthorized(req) {
100
  if (!API_SERVER_KEY) return true;
101
+ return (
102
+ timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
103
+ timingSafeEqualString(parseCookies(req)[SESSION_COOKIE], expectedSessionValue())
104
+ );
105
+ }
106
+
107
+ function sanitizeNext(value) {
108
+ if (!value || typeof value !== "string") return `${APP_BASE}/`;
109
+ if (!value.startsWith("/") || value.startsWith("//")) return `${APP_BASE}/`;
110
+ return value;
111
+ }
112
+
113
+ function loginUrl(nextPath) {
114
+ return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
115
+ }
116
+
117
+ function renderLoginPage(nextPath, errorMessage = "") {
118
+ const safeNext = sanitizeNext(nextPath);
119
+ const errorHtml = errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : "";
120
+ return `<!doctype html>
121
+ <html lang="en">
122
+ <head>
123
+ <meta charset="utf-8" />
124
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
125
+ <title>HuggingMess Login</title>
126
+ <style>
127
+ :root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; }
128
+ * { box-sizing:border-box; }
129
+ body { margin:0; min-height:100vh; display:grid; place-items:center; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); padding:20px; }
130
+ main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; }
131
+ h1 { margin:0 0 8px; font-size:1.55rem; letter-spacing:0; }
132
+ p { margin:0 0 22px; color:var(--muted); line-height:1.5; }
133
+ label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; }
134
+ input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; }
135
+ button { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:7px; color:#07111f; background:var(--accent); font:inherit; font-weight:750; cursor:pointer; }
136
+ .error { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#fecaca; border-radius:7px; padding:10px 12px; margin-bottom:16px; }
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <main>
141
+ <h1>Open HuggingMess</h1>
142
+ <p>Enter the <code>GATEWAY_TOKEN</code> from your Space secrets.</p>
143
+ ${errorHtml}
144
+ <form method="post" action="${LOGIN_PATH}">
145
+ <input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
146
+ <label for="token">GATEWAY_TOKEN</label>
147
+ <input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
148
+ <button type="submit">Continue</button>
149
+ </form>
150
+ </main>
151
+ </body>
152
+ </html>`;
153
+ }
154
+
155
+ function escapeHtml(value) {
156
+ return String(value)
157
+ .replace(/&/g, "&amp;")
158
+ .replace(/</g, "&lt;")
159
+ .replace(/>/g, "&gt;")
160
+ .replace(/"/g, "&quot;");
161
+ }
162
+
163
+ function readRequestBody(req, limit = 64 * 1024) {
164
+ return new Promise((resolve, reject) => {
165
+ let body = "";
166
+ req.on("data", (chunk) => {
167
+ body += chunk;
168
+ if (body.length > limit) {
169
+ reject(new Error("Request body is too large."));
170
+ req.destroy();
171
+ }
172
+ });
173
+ req.on("end", () => resolve(body));
174
+ req.on("error", reject);
175
+ });
176
  }
177
 
178
  function requireAuth(req, res) {
179
  if (isAuthorized(req)) return true;
180
+ const parsed = new URL(req.url, "http://localhost");
181
+ redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
182
+ return false;
183
+ }
184
+
185
+ async function handleLogin(req, res, parsed) {
186
+ const nextPath = sanitizeNext(parsed.searchParams.get("next") || `${APP_BASE}/`);
187
+
188
+ if (!API_SERVER_KEY) {
189
+ redirect(res, nextPath);
190
+ return;
191
+ }
192
+
193
+ if (req.method === "GET") {
194
+ res.writeHead(200, {
195
+ "content-type": "text/html; charset=utf-8",
196
+ "cache-control": "no-store",
197
+ });
198
+ res.end(renderLoginPage(nextPath));
199
+ return;
200
+ }
201
+
202
+ if (req.method !== "POST") {
203
+ res.writeHead(405, { allow: "GET, POST" });
204
+ res.end("Method not allowed");
205
+ return;
206
+ }
207
+
208
+ try {
209
+ const body = await readRequestBody(req);
210
+ const params = new URLSearchParams(body);
211
+ const submittedToken = params.get("token") || "";
212
+ const submittedNext = sanitizeNext(params.get("next") || nextPath);
213
+
214
+ if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
215
+ res.writeHead(401, {
216
+ "content-type": "text/html; charset=utf-8",
217
+ "cache-control": "no-store",
218
+ });
219
+ res.end(renderLoginPage(submittedNext, "That token did not match GATEWAY_TOKEN."));
220
+ return;
221
+ }
222
+
223
+ res.writeHead(302, {
224
+ location: submittedNext,
225
+ "set-cookie": buildSessionCookie(req),
226
+ "cache-control": "no-store",
227
+ });
228
+ res.end();
229
+ } catch (error) {
230
+ res.writeHead(400, {
231
+ "content-type": "text/plain; charset=utf-8",
232
+ "cache-control": "no-store",
233
+ });
234
+ res.end(error.message || "Invalid login request.");
235
+ }
236
+ }
237
+
238
+ function handleLogout(req, res) {
239
+ res.writeHead(302, {
240
+ location: LOGIN_PATH,
241
+ "set-cookie": buildClearSessionCookie(req),
242
  "cache-control": "no-store",
243
  });
244
+ res.end();
 
245
  }
246
 
247
  function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
 
390
  const parsed = new URL(req.url, "http://localhost");
391
  const path = parsed.pathname;
392
 
393
+ if (path === LOGIN_PATH) {
394
+ await handleLogin(req, res, parsed);
395
+ return;
396
+ }
397
+
398
+ if (path === LOGOUT_PATH) {
399
+ handleLogout(req, res);
400
+ return;
401
+ }
402
+
403
  if (path === "/health" || path === `${APP_BASE}/health`) {
404
  const data = await statusPayload();
405
  res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
 
473
  if (!isAuthorized(req)) {
474
  res.writeHead(401, {
475
  "content-type": "application/json",
 
476
  "cache-control": "no-store",
477
  });
478
  res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));