darkfire514 commited on
Commit
18278d0
·
verified ·
1 Parent(s): 5dddd5b

Upload 2 files

Browse files
src/gateway/control-ui.ts CHANGED
@@ -1,4 +1,5 @@
1
  import type { IncomingMessage, ServerResponse } from "node:http";
 
2
  import fs from "node:fs";
3
  import path from "node:path";
4
  import { fileURLToPath } from "node:url";
@@ -173,6 +174,631 @@ function serveFile(res: ServerResponse, filePath: string) {
173
  res.end(fs.readFileSync(filePath));
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  interface ControlUiInjectionOpts {
177
  basePath: string;
178
  assistantName?: string;
 
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import crypto from "node:crypto";
3
  import fs from "node:fs";
4
  import path from "node:path";
5
  import { fileURLToPath } from "node:url";
 
174
  res.end(fs.readFileSync(filePath));
175
  }
176
 
177
+ type ControlUiOauthProvider = "google" | "github";
178
+
179
+ type ControlUiOauthConfig = {
180
+ sessionSecret: string;
181
+ sessionTtlSeconds: number;
182
+ allowedEmails: Set<string> | null;
183
+ allowedGoogleEmails: Set<string> | null;
184
+ allowedGithubLogins: Set<string> | null;
185
+ google: { clientId: string; clientSecret: string } | null;
186
+ github: { clientId: string; clientSecret: string } | null;
187
+ };
188
+
189
+ type ControlUiSessionPayload = {
190
+ v: 1;
191
+ exp: number;
192
+ provider: ControlUiOauthProvider;
193
+ sub: string;
194
+ email?: string;
195
+ login?: string;
196
+ name?: string;
197
+ picture?: string;
198
+ };
199
+
200
+ const CONTROL_UI_SESSION_COOKIE = "openclaw_ui_session";
201
+ const CONTROL_UI_OAUTH_STATE_COOKIE = "openclaw_ui_oauth_state";
202
+ const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24;
203
+
204
+ const fallbackSessionSecret = crypto.randomBytes(32).toString("hex");
205
+
206
+ function normalizeIdentifier(value: string) {
207
+ return value.trim().toLowerCase();
208
+ }
209
+
210
+ function parseCsvSet(value: string | undefined): Set<string> | null {
211
+ const raw = value?.trim();
212
+ if (!raw) return null;
213
+ const items = raw
214
+ .split(/[,\n]/g)
215
+ .map((item) => normalizeIdentifier(item))
216
+ .filter(Boolean);
217
+ return items.length ? new Set(items) : null;
218
+ }
219
+
220
+ function resolveControlUiOauthConfig(): ControlUiOauthConfig {
221
+ const googleClientId = process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID?.trim() ?? "";
222
+ const googleClientSecret = process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET?.trim() ?? "";
223
+ const githubClientId = process.env.OPENCLAW_CONTROL_UI_GITHUB_CLIENT_ID?.trim() ?? "";
224
+ const githubClientSecret = process.env.OPENCLAW_CONTROL_UI_GITHUB_CLIENT_SECRET?.trim() ?? "";
225
+
226
+ const sessionSecret =
227
+ process.env.OPENCLAW_CONTROL_UI_SESSION_SECRET?.trim() ??
228
+ process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ??
229
+ fallbackSessionSecret;
230
+
231
+ const ttlRaw = process.env.OPENCLAW_CONTROL_UI_SESSION_TTL_SECONDS?.trim();
232
+ const sessionTtlSeconds =
233
+ ttlRaw && Number.isFinite(Number(ttlRaw)) && Number(ttlRaw) > 0
234
+ ? Math.floor(Number(ttlRaw))
235
+ : DEFAULT_SESSION_TTL_SECONDS;
236
+
237
+ return {
238
+ sessionSecret,
239
+ sessionTtlSeconds,
240
+ allowedEmails: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_EMAILS),
241
+ allowedGoogleEmails: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS),
242
+ allowedGithubLogins: parseCsvSet(process.env.OPENCLAW_CONTROL_UI_ALLOWED_GITHUB_LOGINS),
243
+ google: googleClientId && googleClientSecret ? { clientId: googleClientId, clientSecret: googleClientSecret } : null,
244
+ github: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : null,
245
+ };
246
+ }
247
+
248
+ function isControlUiOauthEnabled(cfg: ControlUiOauthConfig) {
249
+ return Boolean(cfg.google || cfg.github);
250
+ }
251
+
252
+ function base64UrlEncode(value: string) {
253
+ return Buffer.from(value, "utf8").toString("base64url");
254
+ }
255
+
256
+ function base64UrlDecode(value: string) {
257
+ return Buffer.from(value, "base64url").toString("utf8");
258
+ }
259
+
260
+ function signToken(secret: string, payload: unknown) {
261
+ const body = base64UrlEncode(JSON.stringify(payload));
262
+ const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
263
+ return `${body}.${sig}`;
264
+ }
265
+
266
+ function verifyToken<T>(secret: string, token: string): { ok: true; value: T } | { ok: false } {
267
+ const parts = token.split(".");
268
+ if (parts.length !== 2) return { ok: false };
269
+ const [body, sig] = parts;
270
+ const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
271
+ try {
272
+ const a = Buffer.from(sig);
273
+ const b = Buffer.from(expected);
274
+ if (a.length !== b.length) return { ok: false };
275
+ if (!crypto.timingSafeEqual(a, b)) return { ok: false };
276
+ } catch {
277
+ return { ok: false };
278
+ }
279
+ try {
280
+ const parsed = JSON.parse(base64UrlDecode(body)) as T;
281
+ return { ok: true, value: parsed };
282
+ } catch {
283
+ return { ok: false };
284
+ }
285
+ }
286
+
287
+ function parseCookies(header: string | undefined): Record<string, string> {
288
+ if (!header) return {};
289
+ const out: Record<string, string> = {};
290
+ for (const part of header.split(";")) {
291
+ const idx = part.indexOf("=");
292
+ if (idx === -1) continue;
293
+ const k = part.slice(0, idx).trim();
294
+ const v = part.slice(idx + 1).trim();
295
+ if (!k) continue;
296
+ out[k] = decodeURIComponent(v);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function appendSetCookie(res: ServerResponse, value: string) {
302
+ const prev = res.getHeader("Set-Cookie");
303
+ if (!prev) {
304
+ res.setHeader("Set-Cookie", value);
305
+ return;
306
+ }
307
+ if (Array.isArray(prev)) {
308
+ res.setHeader("Set-Cookie", [...prev, value]);
309
+ return;
310
+ }
311
+ res.setHeader("Set-Cookie", [String(prev), value]);
312
+ }
313
+
314
+ function isSecureRequest(req: IncomingMessage) {
315
+ const xfProto = req.headers["x-forwarded-proto"];
316
+ const proto = Array.isArray(xfProto) ? xfProto[0] : xfProto;
317
+ if (proto && proto.toLowerCase().includes("https")) return true;
318
+ return Boolean((req.socket as { encrypted?: boolean }).encrypted);
319
+ }
320
+
321
+ function getRequestOrigin(req: IncomingMessage) {
322
+ const proto = isSecureRequest(req) ? "https" : "http";
323
+ const xfHost = req.headers["x-forwarded-host"];
324
+ const hostRaw = (Array.isArray(xfHost) ? xfHost[0] : xfHost) ?? req.headers.host ?? "localhost";
325
+ const host = String(hostRaw).split(",")[0]?.trim() || "localhost";
326
+ return `${proto}://${host}`;
327
+ }
328
+
329
+ function controlUiCookiePath(basePath: string) {
330
+ return basePath || "/";
331
+ }
332
+
333
+ function buildBaseUrlPath(basePath: string, suffix: string) {
334
+ return basePath ? `${basePath}${suffix}` : suffix;
335
+ }
336
+
337
+ function setCookie(
338
+ res: ServerResponse,
339
+ opts: { name: string; value: string; basePath: string; maxAgeSeconds?: number; secure: boolean },
340
+ ) {
341
+ const parts = [
342
+ `${opts.name}=${encodeURIComponent(opts.value)}`,
343
+ `Path=${controlUiCookiePath(opts.basePath)}`,
344
+ "HttpOnly",
345
+ "SameSite=Lax",
346
+ ];
347
+ if (typeof opts.maxAgeSeconds === "number") {
348
+ parts.push(`Max-Age=${Math.max(0, Math.floor(opts.maxAgeSeconds))}`);
349
+ }
350
+ if (opts.secure) parts.push("Secure");
351
+ appendSetCookie(res, parts.join("; "));
352
+ }
353
+
354
+ function clearCookie(res: ServerResponse, opts: { name: string; basePath: string; secure: boolean }) {
355
+ setCookie(res, { name: opts.name, value: "", basePath: opts.basePath, maxAgeSeconds: 0, secure: opts.secure });
356
+ }
357
+
358
+ function sendHtml(res: ServerResponse, status: number, html: string) {
359
+ res.statusCode = status;
360
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
361
+ res.setHeader("Cache-Control", "no-cache");
362
+ res.end(html);
363
+ }
364
+
365
+ function sendRedirect(res: ServerResponse, location: string) {
366
+ res.statusCode = 302;
367
+ res.setHeader("Location", location);
368
+ res.end();
369
+ }
370
+
371
+ function isAllowedAccount(cfg: ControlUiOauthConfig, payload: ControlUiSessionPayload) {
372
+ const email = payload.email ? normalizeIdentifier(payload.email) : null;
373
+ const login = payload.login ? normalizeIdentifier(payload.login) : null;
374
+
375
+ const hasAnyAllowList = Boolean(
376
+ cfg.allowedEmails || cfg.allowedGoogleEmails || cfg.allowedGithubLogins,
377
+ );
378
+ if (!hasAnyAllowList) {
379
+ return false;
380
+ }
381
+
382
+ if (cfg.allowedEmails) {
383
+ if (!email || !cfg.allowedEmails.has(email)) return false;
384
+ }
385
+
386
+ if (payload.provider === "google" && cfg.allowedGoogleEmails) {
387
+ if (!email || !cfg.allowedGoogleEmails.has(email)) return false;
388
+ }
389
+
390
+ if (payload.provider === "github" && cfg.allowedGithubLogins) {
391
+ if (!login || !cfg.allowedGithubLogins.has(login)) return false;
392
+ }
393
+
394
+ return true;
395
+ }
396
+
397
+ function readSessionFromRequest(req: IncomingMessage, cfg: ControlUiOauthConfig): ControlUiSessionPayload | null {
398
+ const cookies = parseCookies(req.headers.cookie);
399
+ const raw = cookies[CONTROL_UI_SESSION_COOKIE];
400
+ if (!raw) return null;
401
+ const verified = verifyToken<ControlUiSessionPayload>(cfg.sessionSecret, raw);
402
+ if (!verified.ok) return null;
403
+ const value = verified.value;
404
+ if (!value || value.v !== 1) return null;
405
+ if (typeof value.exp !== "number" || value.exp <= Math.floor(Date.now() / 1000)) return null;
406
+ if (value.provider !== "google" && value.provider !== "github") return null;
407
+ if (typeof value.sub !== "string" || !value.sub) return null;
408
+ if (!isAllowedAccount(cfg, value)) return null;
409
+ return value;
410
+ }
411
+
412
+ type OAuthStatePayload = { v: 1; exp: number; nonce: string; provider: ControlUiOauthProvider };
413
+
414
+ function issueOAuthState(res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean, provider: ControlUiOauthProvider) {
415
+ const exp = Math.floor(Date.now() / 1000) + 60 * 10;
416
+ const statePayload: OAuthStatePayload = { v: 1, exp, nonce: crypto.randomBytes(16).toString("hex"), provider };
417
+ const token = signToken(cfg.sessionSecret, statePayload);
418
+ setCookie(res, { name: CONTROL_UI_OAUTH_STATE_COOKIE, value: token, basePath, maxAgeSeconds: 60 * 10, secure });
419
+ return token;
420
+ }
421
+
422
+ function consumeOAuthState(req: IncomingMessage, res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean) {
423
+ const cookies = parseCookies(req.headers.cookie);
424
+ const raw = cookies[CONTROL_UI_OAUTH_STATE_COOKIE];
425
+ if (!raw) return null;
426
+ const verified = verifyToken<OAuthStatePayload>(cfg.sessionSecret, raw);
427
+ clearCookie(res, { name: CONTROL_UI_OAUTH_STATE_COOKIE, basePath, secure });
428
+ if (!verified.ok) return null;
429
+ const value = verified.value;
430
+ if (!value || value.v !== 1) return null;
431
+ if (typeof value.exp !== "number" || value.exp <= Math.floor(Date.now() / 1000)) return null;
432
+ if (value.provider !== "google" && value.provider !== "github") return null;
433
+ if (typeof value.nonce !== "string" || !value.nonce) return null;
434
+ return { token: raw, payload: value };
435
+ }
436
+
437
+ function renderLoginPage(opts: { basePath: string; hasGoogle: boolean; hasGithub: boolean }) {
438
+ const loginPath = buildBaseUrlPath(opts.basePath, "/auth/login");
439
+ const googlePath = buildBaseUrlPath(opts.basePath, "/auth/google");
440
+ const githubPath = buildBaseUrlPath(opts.basePath, "/auth/github");
441
+
442
+ const buttons = [
443
+ opts.hasGoogle
444
+ ? `<a class="btn google" href="${googlePath}">Continue with Google</a>`
445
+ : `<div class="btn disabled">Google login not configured</div>`,
446
+ opts.hasGithub
447
+ ? `<a class="btn github" href="${githubPath}">Continue with GitHub</a>`
448
+ : `<div class="btn disabled">GitHub login not configured</div>`,
449
+ ].join("");
450
+
451
+ return `<!doctype html>
452
+ <html lang="en">
453
+ <head>
454
+ <meta charset="utf-8" />
455
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
456
+ <title>OpenClaw Control UI Login</title>
457
+ <style>
458
+ :root { color-scheme: light dark; }
459
+ body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
460
+ .wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
461
+ .card { width: 100%; max-width: 420px; border: 1px solid rgba(127,127,127,.35); border-radius: 14px; padding: 18px; }
462
+ h1 { font-size: 18px; margin: 0 0 10px; }
463
+ p { margin: 0 0 16px; opacity: .85; font-size: 13px; line-height: 1.45; }
464
+ .btn { display: block; text-decoration: none; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(127,127,127,.35); margin: 10px 0; text-align: center; font-weight: 600; }
465
+ .btn.google { background: rgba(66,133,244,.12); }
466
+ .btn.github { background: rgba(36,41,46,.12); }
467
+ .btn.disabled { opacity: .55; cursor: not-allowed; }
468
+ .small { font-size: 12px; opacity: .75; margin-top: 12px; }
469
+ .small a { color: inherit; }
470
+ </style>
471
+ </head>
472
+ <body>
473
+ <div class="wrap">
474
+ <div class="card">
475
+ <h1>Sign in to OpenClaw Control UI</h1>
476
+ <p>Only accounts allowed by environment variables can access the console.</p>
477
+ ${buttons}
478
+ <div class="small">This page is served from <a href="${loginPath}">${loginPath}</a>.</div>
479
+ </div>
480
+ </div>
481
+ </body>
482
+ </html>`;
483
+ }
484
+
485
+ async function exchangeGoogleUser(opts: {
486
+ code: string;
487
+ redirectUri: string;
488
+ clientId: string;
489
+ clientSecret: string;
490
+ }) {
491
+ const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
494
+ body: new URLSearchParams({
495
+ code: opts.code,
496
+ client_id: opts.clientId,
497
+ client_secret: opts.clientSecret,
498
+ redirect_uri: opts.redirectUri,
499
+ grant_type: "authorization_code",
500
+ }),
501
+ });
502
+ if (!tokenRes.ok) {
503
+ throw new Error(`google token exchange failed: ${tokenRes.status}`);
504
+ }
505
+ const tokenJson = (await tokenRes.json()) as { access_token?: unknown };
506
+ const accessToken = typeof tokenJson.access_token === "string" ? tokenJson.access_token : null;
507
+ if (!accessToken) throw new Error("google access token missing");
508
+
509
+ const userRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
510
+ headers: { Authorization: `Bearer ${accessToken}` },
511
+ });
512
+ if (!userRes.ok) {
513
+ throw new Error(`google userinfo failed: ${userRes.status}`);
514
+ }
515
+ const userJson = (await userRes.json()) as {
516
+ sub?: unknown;
517
+ email?: unknown;
518
+ email_verified?: unknown;
519
+ name?: unknown;
520
+ picture?: unknown;
521
+ };
522
+ const sub = typeof userJson.sub === "string" ? userJson.sub : null;
523
+ const email = typeof userJson.email === "string" ? userJson.email : null;
524
+ const emailVerified = userJson.email_verified === true;
525
+ const name = typeof userJson.name === "string" ? userJson.name : null;
526
+ const picture = typeof userJson.picture === "string" ? userJson.picture : null;
527
+ if (!sub) throw new Error("google subject missing");
528
+ if (email && !emailVerified) throw new Error("google email not verified");
529
+ return { sub, email, name, picture };
530
+ }
531
+
532
+ async function exchangeGithubUser(opts: { code: string; redirectUri: string; clientId: string; clientSecret: string }) {
533
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
534
+ method: "POST",
535
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
536
+ body: new URLSearchParams({
537
+ code: opts.code,
538
+ client_id: opts.clientId,
539
+ client_secret: opts.clientSecret,
540
+ redirect_uri: opts.redirectUri,
541
+ }),
542
+ });
543
+ if (!tokenRes.ok) {
544
+ throw new Error(`github token exchange failed: ${tokenRes.status}`);
545
+ }
546
+ const tokenJson = (await tokenRes.json()) as { access_token?: unknown; token_type?: unknown };
547
+ const accessToken = typeof tokenJson.access_token === "string" ? tokenJson.access_token : null;
548
+ if (!accessToken) throw new Error("github access token missing");
549
+
550
+ const userRes = await fetch("https://api.github.com/user", {
551
+ headers: {
552
+ Authorization: `Bearer ${accessToken}`,
553
+ Accept: "application/vnd.github+json",
554
+ "User-Agent": "openclaw-control-ui",
555
+ },
556
+ });
557
+ if (!userRes.ok) throw new Error(`github user fetch failed: ${userRes.status}`);
558
+ const userJson = (await userRes.json()) as { id?: unknown; login?: unknown; name?: unknown; avatar_url?: unknown };
559
+ const sub = typeof userJson.id === "number" ? String(userJson.id) : typeof userJson.id === "string" ? userJson.id : null;
560
+ const login = typeof userJson.login === "string" ? userJson.login : null;
561
+ const name = typeof userJson.name === "string" ? userJson.name : null;
562
+ const picture = typeof userJson.avatar_url === "string" ? userJson.avatar_url : null;
563
+ if (!sub || !login) throw new Error("github user identity missing");
564
+
565
+ const emailsRes = await fetch("https://api.github.com/user/emails", {
566
+ headers: {
567
+ Authorization: `Bearer ${accessToken}`,
568
+ Accept: "application/vnd.github+json",
569
+ "User-Agent": "openclaw-control-ui",
570
+ },
571
+ });
572
+ let email: string | null = null;
573
+ if (emailsRes.ok) {
574
+ const emailsJson = (await emailsRes.json()) as Array<{
575
+ email?: unknown;
576
+ verified?: unknown;
577
+ primary?: unknown;
578
+ }>;
579
+ const verified = emailsJson
580
+ .map((item) => ({
581
+ email: typeof item.email === "string" ? item.email : null,
582
+ verified: item.verified === true,
583
+ primary: item.primary === true,
584
+ }))
585
+ .filter((item) => item.email && item.verified);
586
+ const primary = verified.find((item) => item.primary);
587
+ email = primary?.email ?? verified[0]?.email ?? null;
588
+ }
589
+
590
+ return { sub, login, name, picture, email };
591
+ }
592
+
593
+ export async function handleControlUiAuthRequest(
594
+ req: IncomingMessage,
595
+ res: ServerResponse,
596
+ opts?: ControlUiRequestOptions,
597
+ ): Promise<boolean> {
598
+ const urlRaw = req.url;
599
+ if (!urlRaw) return false;
600
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
601
+
602
+ const cfg = resolveControlUiOauthConfig();
603
+ if (!isControlUiOauthEnabled(cfg)) return false;
604
+
605
+ const url = new URL(urlRaw, "http://localhost");
606
+ const basePath = normalizeControlUiBasePath(opts?.basePath);
607
+ const pathname = url.pathname;
608
+
609
+ if (basePath) {
610
+ if (pathname === basePath) {
611
+ const loginUrl = buildBaseUrlPath(basePath, "/auth/login");
612
+ sendRedirect(res, `${loginUrl}${url.search}`);
613
+ return true;
614
+ }
615
+ if (!pathname.startsWith(`${basePath}/`)) {
616
+ return false;
617
+ }
618
+ }
619
+
620
+ const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
621
+ const secure = isSecureRequest(req);
622
+ const session = readSessionFromRequest(req, cfg);
623
+
624
+ if (uiPath === "/auth/login") {
625
+ sendHtml(
626
+ res,
627
+ 200,
628
+ renderLoginPage({ basePath, hasGoogle: Boolean(cfg.google), hasGithub: Boolean(cfg.github) }),
629
+ );
630
+ return true;
631
+ }
632
+
633
+ if (uiPath === "/auth/logout") {
634
+ clearCookie(res, { name: CONTROL_UI_SESSION_COOKIE, basePath, secure });
635
+ sendRedirect(res, buildBaseUrlPath(basePath, "/auth/login"));
636
+ return true;
637
+ }
638
+
639
+ if (uiPath === "/auth/google") {
640
+ if (!cfg.google) {
641
+ respondNotFound(res);
642
+ return true;
643
+ }
644
+ const state = issueOAuthState(res, cfg, basePath, secure, "google");
645
+ const origin = getRequestOrigin(req);
646
+ const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/google/callback")}`;
647
+ const authorize = new URL("https://accounts.google.com/o/oauth2/v2/auth");
648
+ authorize.searchParams.set("client_id", cfg.google.clientId);
649
+ authorize.searchParams.set("redirect_uri", redirectUri);
650
+ authorize.searchParams.set("response_type", "code");
651
+ authorize.searchParams.set("scope", "openid email profile");
652
+ authorize.searchParams.set("state", state);
653
+ authorize.searchParams.set("access_type", "online");
654
+ authorize.searchParams.set("prompt", "select_account");
655
+ sendRedirect(res, authorize.toString());
656
+ return true;
657
+ }
658
+
659
+ if (uiPath === "/auth/google/callback") {
660
+ if (!cfg.google) {
661
+ respondNotFound(res);
662
+ return true;
663
+ }
664
+ const code = url.searchParams.get("code")?.trim() ?? "";
665
+ const state = url.searchParams.get("state")?.trim() ?? "";
666
+ const consumed = consumeOAuthState(req, res, cfg, basePath, secure);
667
+ if (!code || !state || !consumed || state !== consumed.token || consumed.payload.provider !== "google") {
668
+ res.statusCode = 400;
669
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
670
+ res.end("Invalid OAuth callback");
671
+ return true;
672
+ }
673
+ try {
674
+ const origin = getRequestOrigin(req);
675
+ const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/google/callback")}`;
676
+ const user = await exchangeGoogleUser({
677
+ code,
678
+ redirectUri,
679
+ clientId: cfg.google.clientId,
680
+ clientSecret: cfg.google.clientSecret,
681
+ });
682
+ const exp = Math.floor(Date.now() / 1000) + cfg.sessionTtlSeconds;
683
+ const payload: ControlUiSessionPayload = {
684
+ v: 1,
685
+ exp,
686
+ provider: "google",
687
+ sub: user.sub,
688
+ email: user.email ?? undefined,
689
+ name: user.name ?? undefined,
690
+ picture: user.picture ?? undefined,
691
+ };
692
+ if (!isAllowedAccount(cfg, payload)) {
693
+ res.statusCode = 403;
694
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
695
+ res.end("Forbidden");
696
+ return true;
697
+ }
698
+ setCookie(res, {
699
+ name: CONTROL_UI_SESSION_COOKIE,
700
+ value: signToken(cfg.sessionSecret, payload),
701
+ basePath,
702
+ maxAgeSeconds: cfg.sessionTtlSeconds,
703
+ secure,
704
+ });
705
+ sendRedirect(res, basePath ? `${basePath}/` : "/");
706
+ return true;
707
+ } catch {
708
+ res.statusCode = 502;
709
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
710
+ res.end("OAuth provider error");
711
+ return true;
712
+ }
713
+ }
714
+
715
+ if (uiPath === "/auth/github") {
716
+ if (!cfg.github) {
717
+ respondNotFound(res);
718
+ return true;
719
+ }
720
+ const state = issueOAuthState(res, cfg, basePath, secure, "github");
721
+ const origin = getRequestOrigin(req);
722
+ const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/github/callback")}`;
723
+ const authorize = new URL("https://github.com/login/oauth/authorize");
724
+ authorize.searchParams.set("client_id", cfg.github.clientId);
725
+ authorize.searchParams.set("redirect_uri", redirectUri);
726
+ authorize.searchParams.set("scope", "read:user user:email");
727
+ authorize.searchParams.set("state", state);
728
+ sendRedirect(res, authorize.toString());
729
+ return true;
730
+ }
731
+
732
+ if (uiPath === "/auth/github/callback") {
733
+ if (!cfg.github) {
734
+ respondNotFound(res);
735
+ return true;
736
+ }
737
+ const code = url.searchParams.get("code")?.trim() ?? "";
738
+ const state = url.searchParams.get("state")?.trim() ?? "";
739
+ const consumed = consumeOAuthState(req, res, cfg, basePath, secure);
740
+ if (!code || !state || !consumed || state !== consumed.token || consumed.payload.provider !== "github") {
741
+ res.statusCode = 400;
742
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
743
+ res.end("Invalid OAuth callback");
744
+ return true;
745
+ }
746
+ try {
747
+ const origin = getRequestOrigin(req);
748
+ const redirectUri = `${origin}${buildBaseUrlPath(basePath, "/auth/github/callback")}`;
749
+ const user = await exchangeGithubUser({
750
+ code,
751
+ redirectUri,
752
+ clientId: cfg.github.clientId,
753
+ clientSecret: cfg.github.clientSecret,
754
+ });
755
+ const exp = Math.floor(Date.now() / 1000) + cfg.sessionTtlSeconds;
756
+ const payload: ControlUiSessionPayload = {
757
+ v: 1,
758
+ exp,
759
+ provider: "github",
760
+ sub: user.sub,
761
+ email: user.email ?? undefined,
762
+ login: user.login ?? undefined,
763
+ name: user.name ?? undefined,
764
+ picture: user.picture ?? undefined,
765
+ };
766
+ if (!isAllowedAccount(cfg, payload)) {
767
+ res.statusCode = 403;
768
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
769
+ res.end("Forbidden");
770
+ return true;
771
+ }
772
+ setCookie(res, {
773
+ name: CONTROL_UI_SESSION_COOKIE,
774
+ value: signToken(cfg.sessionSecret, payload),
775
+ basePath,
776
+ maxAgeSeconds: cfg.sessionTtlSeconds,
777
+ secure,
778
+ });
779
+ sendRedirect(res, basePath ? `${basePath}/` : "/");
780
+ return true;
781
+ } catch {
782
+ res.statusCode = 502;
783
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
784
+ res.end("OAuth provider error");
785
+ return true;
786
+ }
787
+ }
788
+
789
+ if (session) {
790
+ return false;
791
+ }
792
+
793
+ if (uiPath.startsWith("/auth/")) {
794
+ respondNotFound(res);
795
+ return true;
796
+ }
797
+
798
+ sendRedirect(res, buildBaseUrlPath(basePath, "/auth/login"));
799
+ return true;
800
+ }
801
+
802
  interface ControlUiInjectionOpts {
803
  basePath: string;
804
  assistantName?: string;
src/gateway/server-http.ts CHANGED
@@ -13,7 +13,7 @@ import { resolveAgentAvatar } from "../agents/identity-avatar.js";
13
  import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
14
  import { loadConfig } from "../config/config.js";
15
  import { handleSlackHttpRequest } from "../slack/http/index.js";
16
- import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
17
  import { applyHookMappings } from "./hooks-mapping.js";
18
  import {
19
  extractHookToken,
@@ -289,6 +289,14 @@ export function createGatewayHttpServer(opts: {
289
  }
290
  }
291
  if (controlUiEnabled) {
 
 
 
 
 
 
 
 
292
  if (
293
  handleControlUiAvatarRequest(req, res, {
294
  basePath: controlUiBasePath,
 
13
  import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
14
  import { loadConfig } from "../config/config.js";
15
  import { handleSlackHttpRequest } from "../slack/http/index.js";
16
+ import { handleControlUiAuthRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
17
  import { applyHookMappings } from "./hooks-mapping.js";
18
  import {
19
  extractHookToken,
 
289
  }
290
  }
291
  if (controlUiEnabled) {
292
+ if (
293
+ await handleControlUiAuthRequest(req, res, {
294
+ basePath: controlUiBasePath,
295
+ config: configSnapshot,
296
+ })
297
+ ) {
298
+ return;
299
+ }
300
  if (
301
  handleControlUiAvatarRequest(req, res, {
302
  basePath: controlUiBasePath,