@openparachute/hub 0.5.10-rc.5 → 0.5.10-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.10-rc.5",
3
+ "version": "0.5.10-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@ describe("generateCsrfToken", () => {
29
29
  });
30
30
 
31
31
  describe("buildCsrfCookie", () => {
32
- test("emits the expected attributes", () => {
32
+ test("emits the expected attributes (default secure)", () => {
33
33
  const v = buildCsrfCookie("abc");
34
34
  expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
35
35
  expect(v).toContain("HttpOnly");
@@ -38,6 +38,19 @@ describe("buildCsrfCookie", () => {
38
38
  expect(v).toContain("Path=/");
39
39
  expect(v).toContain("Max-Age=");
40
40
  });
41
+
42
+ test("omits Secure when secure: false (HTTP localhost)", () => {
43
+ const v = buildCsrfCookie("abc", { secure: false });
44
+ expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
45
+ expect(v).toContain("HttpOnly");
46
+ expect(v).not.toContain("Secure");
47
+ expect(v).toContain("SameSite=Lax");
48
+ });
49
+
50
+ test("keeps Secure when secure: true (explicit)", () => {
51
+ const v = buildCsrfCookie("abc", { secure: true });
52
+ expect(v).toContain("Secure");
53
+ });
41
54
  });
42
55
 
43
56
  describe("parseCsrfCookie", () => {
@@ -71,6 +84,32 @@ describe("ensureCsrfToken", () => {
71
84
  expect(result.token.length).toBeGreaterThan(0);
72
85
  expect(result.setCookie).toBeDefined();
73
86
  });
87
+
88
+ // Bug 1 (rc.5 → rc.6) regression: cookies minted over plain HTTP must
89
+ // NOT carry the Secure attribute or browsers silently drop them on
90
+ // http://localhost:1939, breaking the very next double-submit POST.
91
+ test("sets Secure when the request URL is https://", () => {
92
+ const req = new Request("https://hub.example/admin/setup");
93
+ const result = ensureCsrfToken(req);
94
+ expect(result.setCookie).toContain("Secure");
95
+ });
96
+
97
+ test("omits Secure when the request URL is http://localhost", () => {
98
+ const req = new Request("http://localhost:1939/admin/setup");
99
+ const result = ensureCsrfToken(req);
100
+ expect(result.setCookie).not.toContain("Secure");
101
+ // The rest of the cookie shape stays intact — only Secure flips.
102
+ expect(result.setCookie).toContain("HttpOnly");
103
+ expect(result.setCookie).toContain("SameSite=Lax");
104
+ });
105
+
106
+ test("sets Secure when http:// request carries X-Forwarded-Proto: https", () => {
107
+ const req = new Request("http://hub.internal/admin/setup", {
108
+ headers: { "x-forwarded-proto": "https" },
109
+ });
110
+ const result = ensureCsrfToken(req);
111
+ expect(result.setCookie).toContain("Secure");
112
+ });
74
113
  });
75
114
 
76
115
  describe("verifyCsrfToken", () => {
@@ -74,10 +74,16 @@ describe("hubFetch routing", () => {
74
74
  // The dynamic path takes over from the static disk file the moment a
75
75
  // DB is configured. With no session cookie, we still render — just
76
76
  // with the "Sign in" affordance.
77
+ //
78
+ // hub#259 rc.6: requires an admin row to bypass the fresh-hub
79
+ // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
80
+ // test continues to exercise the signed-out-but-setup-done branch.
77
81
  const h = makeHarness();
78
82
  try {
79
83
  const db = openHubDb(hubDbPath(h.dir));
80
84
  try {
85
+ const { createUser } = await import("../users.ts");
86
+ await createUser(db, "owner", "pw");
81
87
  const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
82
88
  expect(res.status).toBe(200);
83
89
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -1083,7 +1089,7 @@ describe("hubFetch routing", () => {
1083
1089
  }
1084
1090
  });
1085
1091
 
1086
- test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
1092
+ test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1087
1093
  const h = makeHarness();
1088
1094
  try {
1089
1095
  writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
@@ -1100,9 +1106,11 @@ describe("hubFetch routing", () => {
1100
1106
  // /health open
1101
1107
  const healthRes = await handler(req("/health"));
1102
1108
  expect(healthRes.status).toBe(200);
1103
- // / open
1109
+ // / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
1110
+ // static portal pre-setup is useless; redirect to setup).
1104
1111
  const rootRes = await handler(req("/"));
1105
- expect(rootRes.status).toBe(200);
1112
+ expect(rootRes.status).toBe(302);
1113
+ expect(rootRes.headers.get("location")).toBe("/admin/setup");
1106
1114
  // /.well-known/parachute.json open
1107
1115
  const wkRes = await handler(req("/.well-known/parachute.json"));
1108
1116
  expect(wkRes.status).toBe(200);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `isHttpsRequest` is the single signal cookie-mint helpers use to
3
+ * decide whether to set the `Secure` attribute. Wrong answer here
4
+ * causes browsers to silently drop cookies on HTTP localhost (Bug 1
5
+ * from the rc.5 fresh-machine test) OR mint Secure-less cookies behind
6
+ * a TLS-terminating reverse proxy (the opposite failure mode).
7
+ *
8
+ * Three signals tested, in priority order: direct URL scheme,
9
+ * X-Forwarded-Proto header, plain HTTP default.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+ import { isHttpsRequest } from "../request-protocol.ts";
13
+
14
+ describe("isHttpsRequest", () => {
15
+ test("returns true for https:// request URL", () => {
16
+ expect(isHttpsRequest(new Request("https://hub.example/x"))).toBe(true);
17
+ });
18
+
19
+ test("returns false for http:// request URL", () => {
20
+ expect(isHttpsRequest(new Request("http://localhost:1939/x"))).toBe(false);
21
+ });
22
+
23
+ test("returns true when X-Forwarded-Proto: https on an http:// request", () => {
24
+ const req = new Request("http://hub.internal/x", {
25
+ headers: { "x-forwarded-proto": "https" },
26
+ });
27
+ expect(isHttpsRequest(req)).toBe(true);
28
+ });
29
+
30
+ test("returns false when X-Forwarded-Proto: http", () => {
31
+ const req = new Request("http://hub.internal/x", {
32
+ headers: { "x-forwarded-proto": "http" },
33
+ });
34
+ expect(isHttpsRequest(req)).toBe(false);
35
+ });
36
+
37
+ test("tolerates uppercase / whitespace / list shape in X-Forwarded-Proto", () => {
38
+ // Some proxies emit `https,http` (chain of two hops) or " HTTPS "
39
+ // with whitespace. The first token is what we honor.
40
+ expect(
41
+ isHttpsRequest(new Request("http://hub/x", { headers: { "x-forwarded-proto": " HTTPS " } })),
42
+ ).toBe(true);
43
+ expect(
44
+ isHttpsRequest(
45
+ new Request("http://hub/x", { headers: { "x-forwarded-proto": "https,http" } }),
46
+ ),
47
+ ).toBe(true);
48
+ expect(
49
+ isHttpsRequest(
50
+ new Request("http://hub/x", { headers: { "x-forwarded-proto": "http,https" } }),
51
+ ),
52
+ ).toBe(false);
53
+ });
54
+ });
@@ -81,7 +81,7 @@ describe("deleteSession", () => {
81
81
  });
82
82
 
83
83
  describe("buildSessionCookie", () => {
84
- test("emits the expected attributes", () => {
84
+ test("emits the expected attributes (default secure)", () => {
85
85
  const v = buildSessionCookie("abc", 86400);
86
86
  expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
87
87
  expect(v).toContain("HttpOnly");
@@ -91,16 +91,39 @@ describe("buildSessionCookie", () => {
91
91
  expect(v).not.toContain("Path=/oauth");
92
92
  expect(v).toContain("Max-Age=86400");
93
93
  });
94
+
95
+ // Bug 1 (rc.5 → rc.6) regression: session cookies minted over plain
96
+ // HTTP must NOT carry Secure or browsers drop them, leaving the
97
+ // operator un-signed-in on the very next request.
98
+ test("omits Secure when secure: false (HTTP localhost)", () => {
99
+ const v = buildSessionCookie("abc", 86400, { secure: false });
100
+ expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
101
+ expect(v).toContain("HttpOnly");
102
+ expect(v).not.toContain("Secure");
103
+ expect(v).toContain("SameSite=Lax");
104
+ });
105
+
106
+ test("keeps Secure when secure: true (explicit)", () => {
107
+ const v = buildSessionCookie("abc", 86400, { secure: true });
108
+ expect(v).toContain("Secure");
109
+ });
94
110
  });
95
111
 
96
112
  describe("buildSessionClearCookie", () => {
97
- test("emits Max-Age=0", () => {
113
+ test("emits Max-Age=0 (default secure)", () => {
98
114
  const v = buildSessionClearCookie();
99
115
  expect(v).toContain(`${SESSION_COOKIE_NAME}=`);
100
116
  expect(v).toContain("Max-Age=0");
117
+ expect(v).toContain("Secure");
101
118
  expect(v).toContain("Path=/");
102
119
  expect(v).not.toContain("Path=/oauth");
103
120
  });
121
+
122
+ test("omits Secure when secure: false (HTTP localhost)", () => {
123
+ const v = buildSessionClearCookie({ secure: false });
124
+ expect(v).not.toContain("Secure");
125
+ expect(v).toContain("Max-Age=0");
126
+ });
104
127
  });
105
128
 
106
129
  describe("parseSessionCookie", () => {
@@ -130,12 +130,28 @@ describe("setup gate (no admin yet)", () => {
130
130
  }
131
131
  });
132
132
 
133
- test("/ passes through the gate (renders discovery page)", async () => {
133
+ // Bug 2 (rc.5 rc.6) regression: on a fresh hub, GET `/` should
134
+ // funnel straight to the wizard rather than render the static
135
+ // portal — the operator otherwise has to manually navigate to
136
+ // `/admin/setup`. The portal pre-setup carries no usable signal
137
+ // (no installed services to discover, no admin to sign in as).
138
+ test("/ 302s to /admin/setup when no admin exists (fresh-hub funnel)", async () => {
134
139
  const db = openHubDb(hubDbPath(h.dir));
135
140
  try {
136
141
  const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
137
- expect(res.status).toBe(200);
138
- expect(res.headers.get("content-type")).toContain("text/html");
142
+ expect(res.status).toBe(302);
143
+ expect(res.headers.get("location")).toBe("/admin/setup");
144
+ } finally {
145
+ db.close();
146
+ }
147
+ });
148
+
149
+ test("/hub.html 302s to /admin/setup when no admin exists", async () => {
150
+ const db = openHubDb(hubDbPath(h.dir));
151
+ try {
152
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/hub.html"));
153
+ expect(res.status).toBe(302);
154
+ expect(res.headers.get("location")).toBe("/admin/setup");
139
155
  } finally {
140
156
  db.close();
141
157
  }
@@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite";
14
14
  import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
15
15
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
16
16
  import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
17
+ import { isHttpsRequest } from "./request-protocol.ts";
17
18
  import {
18
19
  SESSION_TTL_MS,
19
20
  buildSessionClearCookie,
@@ -124,7 +125,9 @@ export async function handleAdminLoginPost(
124
125
  );
125
126
  }
126
127
  const session = createSession(db, { userId: user.id });
127
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
128
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
129
+ secure: isHttpsRequest(req),
130
+ });
128
131
  return redirect(next, { "set-cookie": cookie });
129
132
  }
130
133
 
@@ -164,5 +167,7 @@ export async function handleAdminLogoutPost(db: Database, req: Request): Promise
164
167
  }
165
168
  const sid = parseSessionCookie(req.headers.get("cookie"));
166
169
  if (sid) deleteSession(db, sid);
167
- return redirect("/login", { "set-cookie": buildSessionClearCookie() });
170
+ return redirect("/login", {
171
+ "set-cookie": buildSessionClearCookie({ secure: isHttpsRequest(req) }),
172
+ });
168
173
  }
package/src/csrf.ts CHANGED
@@ -23,14 +23,24 @@
23
23
  * server-rendered HTML form (cookie + embedded value, classic double-submit)
24
24
  * or via the JSON body of `/api/me` (cookie alongside body — same pattern,
25
25
  * just JSON instead of HTML). Neither path needs JS to read the cookie
26
- * directly. SameSite=Lax (matches the session cookie), Secure, and Path=/
27
- * (covers every admin form, OAuth flow, and `/api/me` consumer).
26
+ * directly. SameSite=Lax (matches the session cookie), Secure conditional
27
+ * on the request protocol (see below), and Path=/ (covers every admin
28
+ * form, OAuth flow, and `/api/me` consumer).
29
+ *
30
+ * `Secure` is set when the request arrived over HTTPS (direct or behind a
31
+ * reverse proxy that set `X-Forwarded-Proto: https`) — `isHttpsRequest` in
32
+ * `request-protocol.ts` is the single source of truth. On
33
+ * `http://localhost:1939` the attribute is omitted so the browser actually
34
+ * keeps the cookie; setting `Secure` unconditionally silently drops it on
35
+ * HTTP and breaks the double-submit handshake on the very next POST
36
+ * ("Invalid form submission" page on the wizard, etc.).
28
37
  *
29
38
  * Token entropy: 32 random bytes, base64url-encoded — same shape as session
30
39
  * IDs. No HMAC needed: the value is opaque to the client and only ever
31
40
  * compared to itself across the cookie/form boundary.
32
41
  */
33
42
  import { randomBytes, timingSafeEqual } from "node:crypto";
43
+ import { isHttpsRequest } from "./request-protocol.ts";
34
44
 
35
45
  export const CSRF_COOKIE_NAME = "parachute_hub_csrf";
36
46
  export const CSRF_FIELD_NAME = "__csrf";
@@ -42,15 +52,18 @@ export function generateCsrfToken(): string {
42
52
  return randomBytes(32).toString("base64url");
43
53
  }
44
54
 
45
- export function buildCsrfCookie(token: string): string {
46
- return [
47
- `${CSRF_COOKIE_NAME}=${token}`,
48
- "HttpOnly",
49
- "Secure",
50
- "SameSite=Lax",
51
- "Path=/",
52
- `Max-Age=${CSRF_TTL_SECONDS}`,
53
- ].join("; ");
55
+ /**
56
+ * Build a Set-Cookie header value for a CSRF token. `secure` defaults to
57
+ * true (the production posture behind a TLS terminator); callers that
58
+ * mint the cookie for a known-HTTP request — `ensureCsrfToken` does
59
+ * this via `isHttpsRequest` — pass `secure: false` to omit the
60
+ * attribute so the browser keeps the cookie on plain HTTP.
61
+ */
62
+ export function buildCsrfCookie(token: string, opts: { secure?: boolean } = {}): string {
63
+ const parts = [`${CSRF_COOKIE_NAME}=${token}`, "HttpOnly"];
64
+ if (opts.secure !== false) parts.push("Secure");
65
+ parts.push("SameSite=Lax", "Path=/", `Max-Age=${CSRF_TTL_SECONDS}`);
66
+ return parts.join("; ");
54
67
  }
55
68
 
56
69
  export function parseCsrfCookie(cookieHeader: string | null): string | null {
@@ -72,12 +85,17 @@ export interface EnsuredCsrf {
72
85
  * Ensure the request carries a CSRF token cookie; mint and return one if not.
73
86
  * Callers embed `result.token` in the rendered form and attach
74
87
  * `result.setCookie` (if defined) to the response.
88
+ *
89
+ * Protocol-aware: when the request is plain HTTP (`http://localhost:1939`
90
+ * during local dev / on-box CLI), the minted cookie omits the `Secure`
91
+ * attribute so the browser keeps it. When the request is HTTPS (or
92
+ * forwarded via `X-Forwarded-Proto: https`), `Secure` is set.
75
93
  */
76
94
  export function ensureCsrfToken(req: Request): EnsuredCsrf {
77
95
  const existing = parseCsrfCookie(req.headers.get("cookie"));
78
96
  if (existing && existing.length > 0) return { token: existing };
79
97
  const token = generateCsrfToken();
80
- return { token, setCookie: buildCsrfCookie(token) };
98
+ return { token, setCookie: buildCsrfCookie(token, { secure: isHttpsRequest(req) }) };
81
99
  }
82
100
 
83
101
  /**
package/src/hub-server.ts CHANGED
@@ -1013,14 +1013,34 @@ export function hubFetch(
1013
1013
  return new Response("not found", { status: 404 });
1014
1014
  }
1015
1015
 
1016
+ // Fresh-hub redirect: on a hub with no admin row yet, the discovery
1017
+ // page (`/`, `/hub.html`) funnels straight to the wizard. The static
1018
+ // portal isn't useful pre-setup — nothing's installed, the
1019
+ // "Signed in" affordance has no session to surface — and the
1020
+ // operator landing on `/` in a browser otherwise has to manually
1021
+ // type `/admin/setup` to escape. 302 (not 301) so the redirect
1022
+ // disappears the moment the wizard finishes.
1023
+ //
1024
+ // Sits before the JSON-shaped 503 gate below because `/` is an
1025
+ // HTML surface — a JSON 503 there would render as raw text in the
1026
+ // operator's browser tab. The 503 gate handles API + admin SPA +
1027
+ // OAuth callers that branch on the structured body.
1028
+ if (getDb && (pathname === "/" || pathname === "/hub.html") && userCount(getDb()) === 0) {
1029
+ return new Response(null, {
1030
+ status: 302,
1031
+ headers: { location: "/admin/setup" },
1032
+ });
1033
+ }
1034
+
1016
1035
  // Pre-admin lockout. When the hub has booted with no admin row (the
1017
1036
  // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1018
1037
  // /admin/setup is walked), every operator-facing surface that requires
1019
1038
  // identity is meaningless — auth flows can't validate, the SPA can't
1020
1039
  // mint a host-admin token, OAuth can't issue codes. Route those to a
1021
1040
  // 503 that points at /admin/setup. Health, well-known, /admin/setup
1022
- // itself, and the static discovery page (/) ran above and are
1023
- // unaffected; OAuth + admin + api endpoints fall here.
1041
+ // itself, OAuth third-party endpoints, and content proxies pass
1042
+ // through; the fresh-hub `/` and `/hub.html` redirect above handled
1043
+ // the discovery-page case.
1024
1044
  //
1025
1045
  // `shouldGateForSetup` runs first so non-gated paths (well-known, /,
1026
1046
  // /health, /admin/setup) never touch getDb — keeping the
@@ -63,6 +63,7 @@ import {
63
63
  renderLogin,
64
64
  } from "./oauth-ui.ts";
65
65
  import { isSameOriginRequest } from "./origin-check.ts";
66
+ import { isHttpsRequest } from "./request-protocol.ts";
66
67
  import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
67
68
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
68
69
  import {
@@ -717,7 +718,7 @@ export async function handleAuthorizePost(
717
718
 
718
719
  async function handleLoginSubmit(
719
720
  db: Database,
720
- _req: Request,
721
+ req: Request,
721
722
  form: Awaited<ReturnType<Request["formData"]>>,
722
723
  _deps: OAuthDeps,
723
724
  csrfToken: string,
@@ -746,7 +747,9 @@ async function handleLoginSubmit(
746
747
  );
747
748
  }
748
749
  const session = createSession(db, { userId: user.id });
749
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
750
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
751
+ secure: isHttpsRequest(req),
752
+ });
750
753
  // Redirect back to GET /oauth/authorize with the original query string so
751
754
  // the user lands on the consent screen with full params re-validated.
752
755
  const u = new URL("/oauth/authorize", "http://placeholder");
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Detect whether an incoming `Request` arrived over HTTPS — the signal
3
+ * cookie-mint helpers (`buildCsrfCookie`, `buildSessionCookie`,
4
+ * `buildSessionClearCookie`) use to decide whether to set the `Secure`
5
+ * attribute. Browsers DROP `Secure` cookies on `http://` connections; on
6
+ * `http://localhost:1939` (the normal local-dev / on-box-CLI shape)
7
+ * unconditionally setting `Secure` means the browser silently swallows
8
+ * the cookie, the next POST has no cookie to double-submit against, and
9
+ * CSRF verification fails with a "reload and try again" page.
10
+ *
11
+ * Signals checked, in priority order:
12
+ *
13
+ * 1. `new URL(req.url).protocol === "https:"` — direct HTTPS. Hub
14
+ * itself binds 127.0.0.1:1939 over plain HTTP, but a downstream
15
+ * caller (a test, an internal proxy) may rewrite the URL.
16
+ *
17
+ * 2. `X-Forwarded-Proto: https` — the standard reverse-proxy header.
18
+ * Tailscale Serve, cloudflared, and Render all terminate TLS at the
19
+ * edge and forward HTTP to the hub with this header set. Without
20
+ * this check, every cookie minted behind a reverse proxy would
21
+ * drop `Secure` and downgrade the security posture.
22
+ *
23
+ * 3. Otherwise: HTTP. The cookie is minted without `Secure` so the
24
+ * browser keeps it on `http://localhost:1939`.
25
+ *
26
+ * The pattern matches the standard double-submit / session-cookie
27
+ * convention: secure-by-default unless explicit evidence the connection
28
+ * is plain HTTP. We do NOT default to "secure" when the protocol is
29
+ * ambiguous — an ambiguous request is one we can't prove is HTTPS, and
30
+ * setting `Secure` on it would re-create the original bug. A real
31
+ * MITM-able HTTP connection is a different problem (operators on
32
+ * untrusted networks should expose the hub through tailnet/funnel, not
33
+ * raw HTTP); the cookie-Secure attribute isn't the defense against it.
34
+ */
35
+ export function isHttpsRequest(req: Request): boolean {
36
+ // 1. Direct protocol on the parsed URL.
37
+ try {
38
+ if (new URL(req.url).protocol === "https:") return true;
39
+ } catch {
40
+ // Malformed URL on req — fall through to header sniffing.
41
+ }
42
+ // 2. Reverse-proxy header. The forwarded-proto header is a single
43
+ // token in practice ("http" or "https"); we lowercase + trim before
44
+ // compare so a stray "HTTPS" or " https" doesn't slip past.
45
+ const xfp = req.headers.get("x-forwarded-proto");
46
+ if (xfp && xfp.split(",")[0]?.trim().toLowerCase() === "https") return true;
47
+ return false;
48
+ }
package/src/sessions.ts CHANGED
@@ -83,26 +83,38 @@ export function deleteSession(db: Database, id: string): void {
83
83
 
84
84
  /**
85
85
  * Build a `Set-Cookie` header value for the given session id. HttpOnly +
86
- * SameSite=Lax + Secure (we always assume a TLS terminator; localhost dev
87
- * still sets Secure because Tailscale serves with HTTPS even on the tailnet
88
- * mount). Path=/ covers the whole hub origin: the operator's session is "logged
89
- * into this hub", and admin pages outside /oauth/ (config portal, etc.) ride
90
- * the same session. State-changing admin POSTs require a CSRF token (see
91
- * src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site CSRF.
86
+ * SameSite=Lax + Secure (conditional) + Path=/.
87
+ *
88
+ * Path=/ covers the whole hub origin: the operator's session is "logged
89
+ * into this hub", and admin pages outside /oauth/ (config portal, etc.)
90
+ * ride the same session. State-changing admin POSTs require a CSRF token
91
+ * (see src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site
92
+ * CSRF.
93
+ *
94
+ * `Secure` defaults to true (production behind a TLS terminator).
95
+ * Callers minting the cookie for a known-HTTP request — `/login` POST
96
+ * over `http://localhost:1939`, the wizard's account POST same — pass
97
+ * `secure: false` (computed from `isHttpsRequest(req)`) so the
98
+ * browser actually keeps the cookie. Setting `Secure` unconditionally
99
+ * over plain HTTP silently drops the cookie and breaks the very next
100
+ * authenticated request.
92
101
  */
93
- export function buildSessionCookie(sessionId: string, maxAgeSeconds: number): string {
94
- return [
95
- `${SESSION_COOKIE_NAME}=${sessionId}`,
96
- "HttpOnly",
97
- "Secure",
98
- "SameSite=Lax",
99
- "Path=/",
100
- `Max-Age=${maxAgeSeconds}`,
101
- ].join("; ");
102
+ export function buildSessionCookie(
103
+ sessionId: string,
104
+ maxAgeSeconds: number,
105
+ opts: { secure?: boolean } = {},
106
+ ): string {
107
+ const parts = [`${SESSION_COOKIE_NAME}=${sessionId}`, "HttpOnly"];
108
+ if (opts.secure !== false) parts.push("Secure");
109
+ parts.push("SameSite=Lax", "Path=/", `Max-Age=${maxAgeSeconds}`);
110
+ return parts.join("; ");
102
111
  }
103
112
 
104
- export function buildSessionClearCookie(): string {
105
- return `${SESSION_COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
113
+ export function buildSessionClearCookie(opts: { secure?: boolean } = {}): string {
114
+ const parts = [`${SESSION_COOKIE_NAME}=`, "HttpOnly"];
115
+ if (opts.secure !== false) parts.push("Secure");
116
+ parts.push("SameSite=Lax", "Path=/", "Max-Age=0");
117
+ return parts.join("; ");
106
118
  }
107
119
 
108
120
  export function parseSessionCookie(cookieHeader: string | null): string | null {
@@ -47,6 +47,7 @@ import {
47
47
  verifyCsrfToken,
48
48
  } from "./csrf.ts";
49
49
  import { escapeHtml } from "./oauth-ui.ts";
50
+ import { isHttpsRequest } from "./request-protocol.ts";
50
51
  import { findService, readManifest } from "./services-manifest.ts";
51
52
  import {
52
53
  SESSION_TTL_MS,
@@ -551,7 +552,9 @@ export async function handleSetupAccountPost(
551
552
  try {
552
553
  const user = await createUser(deps.db, username, password);
553
554
  const session = createSession(deps.db, { userId: user.id });
554
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
555
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
556
+ secure: isHttpsRequest(req),
557
+ });
555
558
  return redirect("/admin/setup", { "set-cookie": cookie });
556
559
  } catch (err) {
557
560
  // Log the raw error server-side for the operator's debugging, but