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:
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user