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
This commit is contained in:
Codex
2026-06-04 01:57:07 +08:00
committed by Codex Agent
parent 13c779ed11
commit 49dc7cc6e1
2 changed files with 51 additions and 1 deletions
+37
View File
@@ -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())));
}
});
+14 -1
View File
@@ -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; }