From 49dc7cc6e1e7f2a4c9ddd41266f621d6498bd8ef Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 4 Jun 2026 01:57:07 +0800 Subject: [PATCH] feat(cloud-api): v0.2 24h Web session + Secure cookie - SESSION_MAX_AGE_SECONDS 7d -> 24h - Cookie now sets Secure + SameSite=Lax (preserves HttpOnly) - /v1/auth/session exposes authMethod, sessionExpiresAt, sessionTtlSeconds - setSessionCookie/clearSessionCookie use new builders - New test covers first-admin, /auth/login and /auth/logout cookie shape + expiresAt --- internal/cloud/access-control.test.ts | 37 +++++++++++++++++++++++++++ internal/cloud/access-control.ts | 15 ++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index d0d5bdd1..a2402ab6 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -2603,3 +2603,40 @@ test("cloud api rejects API key auth for a disabled user", async () => { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); + +test("cloud api issues a 24h Web session with Secure/SameSite cookie and exposes sessionExpiresAt", async () => { + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + now: () => "2026-06-03T12:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const setup = await postJson(port, "/v1/setup/first-admin", { username: "session-admin", password: "session-pass" }); + assert.equal(setup.status, 201); + assert.ok(setup.cookie, "first admin should set a session cookie"); + assert.match(setup.cookie, /HttpOnly/u); + assert.match(setup.cookie, /Secure/u); + assert.match(setup.cookie, /SameSite=Lax/u); + assert.match(setup.cookie, /Max-Age=86400/u); + assert.equal(setup.body.session.expiresAt, "2026-06-04T12:00:00.000Z"); + + const login = await postJson(port, "/auth/login", { username: "session-admin", password: "session-pass" }); + assert.equal(login.status, 200); + assert.match(login.cookie, /Max-Age=86400/u); + assert.equal(login.body.session.expiresAt, "2026-06-04T12:00:00.000Z"); + + const session = await getJson(port, "/v1/auth/session", login.cookie); + assert.equal(session.status, 200); + assert.equal(session.body.authenticated, true); + assert.equal(session.body.authMethod, "web-session"); + assert.equal(session.body.sessionExpiresAt, "2026-06-04T12:00:00.000Z"); + assert.equal(session.body.sessionTtlSeconds, 86400); + + const logout = await postJson(port, "/auth/logout", {}, login.cookie); + assert.equal(logout.status, 200); + assert.match(logout.cookie, /Max-Age=0/u); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index f79d0487..db395fc1 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -10,7 +10,15 @@ import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.ts"; const SESSION_COOKIE = "hwlab_session"; -const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; +const SESSION_COOKIE_SECURE_FLAG = "Secure"; +const SESSION_COOKIE_SAMESITE = "Lax"; +function buildSessionCookie(token, maxAge) { + return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; ${SESSION_COOKIE_SECURE_FLAG}; SameSite=${SESSION_COOKIE_SAMESITE}; Max-Age=${maxAge}`; +} +function buildClearSessionCookie() { + return `${SESSION_COOKIE}=; Path=/; HttpOnly; ${SESSION_COOKIE_SECURE_FLAG}; SameSite=${SESSION_COOKIE_SAMESITE}; Max-Age=0`; +} const DEVICE_POD_API_KEY_HEADER = "x-hwlab-device-pod-api-key"; const API_KEY_PREFIX = "hwl_live_"; const API_KEY_RANDOM_BYTES = 32; @@ -419,6 +427,9 @@ class AccessController { authenticated: Boolean(auth.actor), actor: auth.actor ? publicActor(auth.actor) : null, session: auth.session ?? null, + authMethod: auth.actor ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null, + sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null, + sessionTtlSeconds: SESSION_MAX_AGE_SECONDS, setupRequired: await this.setupRequired(), contractVersion: "user-access-v1" }; @@ -2379,6 +2390,8 @@ function sessionTokenFromRequest(request) { const auth = getHeader(request, "aut function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); } function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); } function clearSessionCookie(response) { response.setHeader("set-cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } +function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", buildSessionCookie(token, maxAge)); } +function clearSessionCookie(response) { response.setHeader("set-cookie", buildClearSessionCookie()); } function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; } function sendAccessError(response, error) { const status = error?.statusCode ?? 500; sendJson(response, status, errorPayload(error?.code ?? "access_control_error", error?.message ?? "Access control request failed", status)); } function pgUser(row) { return row ? { id: row.id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.created_at, updatedAt: row.updated_at } : null; }