fix: delegate cloud web auth to cloud api

This commit is contained in:
Codex
2026-05-30 10:23:59 +08:00
parent b52c569d17
commit aee03fab68
10 changed files with 237 additions and 270 deletions
+56
View File
@@ -848,6 +848,62 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
} }
}); });
test("cloud api bootstrap password synchronizes existing admin", async () => {
const now = () => "2026-05-28T00:00:00.000Z";
const firstAccessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
now
});
const firstServer = createCloudApiServer({
accessController: firstAccessController,
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
now
});
await new Promise((resolve) => firstServer.listen(0, "127.0.0.1", resolve));
try {
const { port } = firstServer.address();
const setup = await postJson(port, "/v1/setup/first-admin", {
username: "admin",
password: "old-pass"
});
assert.equal(setup.status, 201);
} finally {
await new Promise((resolve, reject) => firstServer.close((error) => (error ? reject(error) : resolve())));
}
const syncAccessController = createAccessController({
store: firstAccessController.store,
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "rotated-pass"
},
now
});
const server = createCloudApiServer({
accessController: syncAccessController,
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
now
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const oldLogin = await postJson(port, "/auth/login", { username: "admin", password: "old-pass" });
assert.equal(oldLogin.status, 401);
const rotatedLogin = await postJson(port, "/auth/login", { username: "admin", password: "rotated-pass" });
assert.equal(rotatedLogin.status, 200);
assert.equal(rotatedLogin.body.actor.username, "admin");
assert.equal(rotatedLogin.body.actor.role, "admin");
assert.equal(JSON.stringify(rotatedLogin.body).includes("rotated-pass"), false);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api first-admin setup validates device-pod seed before creating admin", async () => { test("cloud api first-admin setup validates device-pod seed before creating admin", async () => {
const server = createCloudApiServer({ const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
+21 -1
View File
@@ -471,8 +471,16 @@ class AccessController {
this.bootstrapAttempted = true; this.bootstrapAttempted = true;
await this.store.ensureSchema?.(); await this.store.ensureSchema?.();
const count = await this.store.countUsers(); const count = await this.store.countUsers();
if (count > 0) return;
const passwordHash = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH, "") || (this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD ? hashPassword(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD) : ""); const passwordHash = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH, "") || (this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD ? hashPassword(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD) : "");
if (count > 0) {
if (passwordHash) await this.store.syncBootstrapAdminPassword?.({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin",
passwordHash,
now: this.now()
});
return;
}
if (!passwordHash) return; if (!passwordHash) return;
await this.store.createUser({ await this.store.createUser({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
@@ -999,6 +1007,13 @@ class MemoryAccessStore {
async countUsers() { return this.users.size; } async countUsers() { return this.users.size; }
async getUserById(id) { return this.users.get(id) ?? null; } async getUserById(id) { return this.users.get(id) ?? null; }
async findUserByUsername(username) { return [...this.users.values()].find((user) => user.username === username) ?? null; } async findUserByUsername(username) { return [...this.users.values()].find((user) => user.username === username) ?? null; }
async syncBootstrapAdminPassword(input) {
const existing = await this.findUserByUsername(input.username) ?? await this.getUserById(input.id);
if (!existing || existing.role !== "admin") return null;
const user = { ...existing, passwordHash: input.passwordHash, updatedAt: input.now ?? this.now() };
this.users.set(user.id, user);
return user;
}
async createUser(input) { async createUser(input) {
const now = input.now ?? this.now(); const now = input.now ?? this.now();
const existing = await this.findUserByUsername(input.username); const existing = await this.findUserByUsername(input.username);
@@ -1090,6 +1105,11 @@ class PostgresAccessStore extends MemoryAccessStore {
async countUsers() { await this.ensureSchema(); const result = await this.query("SELECT COUNT(*)::int AS count FROM users", []); return Number(result.rows?.[0]?.count ?? 0); } async countUsers() { await this.ensureSchema(); const result = await this.query("SELECT COUNT(*)::int AS count FROM users", []); return Number(result.rows?.[0]?.count ?? 0); }
async getUserById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE id = $1 LIMIT 1", [id]); return pgUser(result.rows?.[0]); } async getUserById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE id = $1 LIMIT 1", [id]); return pgUser(result.rows?.[0]); }
async findUserByUsername(username) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE username = $1 LIMIT 1", [username]); return pgUser(result.rows?.[0]); } async findUserByUsername(username) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE username = $1 LIMIT 1", [username]); return pgUser(result.rows?.[0]); }
async syncBootstrapAdminPassword(input) {
await this.ensureSchema();
const result = await this.query("UPDATE users SET password_hash = $3, updated_at = $4 WHERE role = 'admin' AND (username = $1 OR id = $2) RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.username, input.id, input.passwordHash, input.now ?? this.now()]);
return pgUser(result.rows?.[0]);
}
async createUser(input) { async createUser(input) {
await this.ensureSchema(); await this.ensureSchema();
const now = input.now ?? this.now(); const now = input.now ?? this.now();
@@ -6,6 +6,7 @@ const STREAMING_HEADER_DEFAULTS = Object.freeze({
}); });
const FORWARDED_REQUEST_HEADERS = Object.freeze([ const FORWARDED_REQUEST_HEADERS = Object.freeze([
"cookie",
"prefer", "prefer",
"x-hwlab-short-connection", "x-hwlab-short-connection",
"x-trace-id", "x-trace-id",
@@ -17,6 +17,7 @@ test("cloud web proxy preserves Code Agent short-connection headers", () => {
"x-hwlab-short-connection": "1", "x-hwlab-short-connection": "1",
"x-trace-id": "trc_proxy_short_connection", "x-trace-id": "trc_proxy_short_connection",
"x-request-id": "req_proxy_short_connection", "x-request-id": "req_proxy_short_connection",
cookie: "hwlab_session=must-forward",
authorization: "Bearer must-not-forward" authorization: "Bearer must-not-forward"
} }
}, "{\"message\":\"hello\"}"); }, "{\"message\":\"hello\"}");
@@ -27,6 +28,7 @@ test("cloud web proxy preserves Code Agent short-connection headers", () => {
assert.equal(headers["x-hwlab-short-connection"], "1"); assert.equal(headers["x-hwlab-short-connection"], "1");
assert.equal(headers["x-trace-id"], "trc_proxy_short_connection"); assert.equal(headers["x-trace-id"], "trc_proxy_short_connection");
assert.equal(headers["x-request-id"], "req_proxy_short_connection"); assert.equal(headers["x-request-id"], "req_proxy_short_connection");
assert.equal(headers.cookie, "hwlab_session=must-forward");
assert.equal(headers.authorization, undefined); assert.equal(headers.authorization, undefined);
assert.equal(headers["content-length"], Buffer.byteLength("{\"message\":\"hello\"}")); assert.equal(headers["content-length"], Buffer.byteLength("{\"message\":\"hello\"}"));
}); });
+6 -1
View File
@@ -1,12 +1,17 @@
const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]); const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
const GET_PROXY_ROUTES = new Set(["/v1"]); const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session"]);
const POST_PROXY_ROUTES = new Set([ const POST_PROXY_ROUTES = new Set([
"/auth/login",
"/auth/logout",
"/json-rpc", "/json-rpc",
"/v1/agent/chat", "/v1/agent/chat",
"/v1/agent/chat/cancel", "/v1/agent/chat/cancel",
"/v1/m3/io" "/v1/m3/io"
]); ]);
const PUBLIC_PROXY_ROUTES = new Set([ const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/session",
"POST /auth/login",
"POST /auth/logout",
"POST /v1/agent/chat", "POST /v1/agent/chat",
"POST /v1/agent/chat/cancel" "POST /v1/agent/chat/cancel"
]); ]);
+21
View File
@@ -62,6 +62,27 @@ test("cloud web route policy proxies public Code Agent chat without gating other
}); });
}); });
test("cloud web route policy delegates auth authority to cloud-api", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/session"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "GET /auth/session"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/login"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /auth/login"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/logout"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /auth/logout"
});
});
test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => { test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => {
const upstream = createServer((request, response) => { const upstream = createServer((request, response) => {
request.resume(); request.resume();
+5 -122
View File
@@ -753,7 +753,6 @@ function runtimeScriptBase64() {
const source = String.raw` const source = String.raw`
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import { readFile, stat } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -770,14 +769,6 @@ const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TI
min: 1000, min: 1000,
max: 2400000 max: 2400000
}); });
const authUsername = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_USERNAME", "admin");
const authPassword = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_PASSWORD", "hwlab2026");
const authSessionTtlMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_AUTH_SESSION_TTL_MS, 12 * 60 * 60 * 1000, {
min: 60 * 1000,
max: 7 * 24 * 60 * 60 * 1000
});
const authCookieName = "hwlab_cloud_web_session";
const authSessions = new Map();
const readOnlyRpcMethods = new Set([ const readOnlyRpcMethods = new Set([
"system.health", "system.health",
"cloud.adapter.describe", "cloud.adapter.describe",
@@ -934,112 +925,12 @@ function runBunEntrypoint(file) {
runEntrypoint(command, [file]); runEntrypoint(command, [file]);
} }
function cookieValue(request, name) {
const raw = request.headers.cookie || "";
for (const part of raw.split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === name) return decodeURIComponent(rest.join("=") || "");
}
return "";
}
function authCookie(token, expiresAt) {
const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
return authCookieName + "=" + encodeURIComponent(token) + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + maxAgeSeconds;
}
function clearAuthCookie() {
return authCookieName + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
}
function activeAuthSession(request) {
const token = cookieValue(request, authCookieName);
if (!token) return null;
const session = authSessions.get(token);
if (!session) return null;
if (session.expiresAt <= Date.now()) {
authSessions.delete(token);
return null;
}
return { token, ...session };
}
function cleanupExpiredAuthSessions() {
const now = Date.now();
for (const [token, session] of authSessions) {
if (session.expiresAt <= now) authSessions.delete(token);
}
}
function constantTimeEquals(a, b) {
const left = Buffer.from(String(a));
const right = Buffer.from(String(b));
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
async function handleCloudWebAuth(request, response, url) { async function handleCloudWebAuth(request, response, url) {
if (url.pathname === "/auth/session" && request.method === "GET") { if (url.pathname === "/auth/session" && request.method === "GET") return proxyCloudApi(request, response, url);
const session = activeAuthSession(request); if (url.pathname === "/auth/login" && request.method === "POST") return proxyCloudApi(request, response, url);
sendJson(response, 200, session ? { if (url.pathname === "/auth/logout" && request.method === "POST") return proxyCloudApi(request, response, url);
authenticated: true, if (!url.pathname.startsWith("/auth/")) return false;
user: { username: session.username }, return proxyCloudApi(request, response, url);
expiresAt: new Date(session.expiresAt).toISOString()
} : { authenticated: false });
return true;
}
if (url.pathname === "/auth/login" && request.method === "POST") {
let body = {};
try {
body = JSON.parse(await readRequestBody(request) || "{}");
} catch {
sendJson(response, 400, { authenticated: false, error: "invalid_request" });
return true;
}
const username = typeof body.username === "string" ? body.username.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
if (!constantTimeEquals(username, authUsername) || !constantTimeEquals(password, authPassword)) {
sendJson(response, 401, { authenticated: false, error: "invalid_credentials" });
return true;
}
cleanupExpiredAuthSessions();
const token = randomBytes(32).toString("hex");
const expiresAt = Date.now() + authSessionTtlMs;
authSessions.set(token, { username: authUsername, expiresAt });
const payload = JSON.stringify({
authenticated: true,
user: { username: authUsername },
expiresAt: new Date(expiresAt).toISOString()
}, null, 2);
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"set-cookie": authCookie(token, expiresAt)
});
response.end(payload);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
const session = activeAuthSession(request);
if (session) authSessions.delete(session.token);
const payload = JSON.stringify({ authenticated: false }, null, 2);
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"set-cookie": clearAuthCookie()
});
response.end(payload);
return true;
}
if (url.pathname.startsWith("/auth/")) {
sendJson(response, 404, { authenticated: false, error: "not_found" });
return true;
}
return false;
} }
async function readRequestBody(request) { async function readRequestBody(request) {
@@ -1163,14 +1054,6 @@ async function serveCloudWeb() {
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname); const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) { if (proxyPolicy.proxy) {
if (proxyPolicy.authRequired && !activeAuthSession(request)) {
sendJson(response, 401, {
status: "failed",
error: "auth_required",
serviceId
});
return;
}
await proxyCloudApi(request, response, url); await proxyCloudApi(request, response, url);
return; return;
} }
@@ -1952,16 +1952,15 @@ function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = "
/DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) && /DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) &&
/DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) && /DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) &&
/HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) && /HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) &&
/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) && !/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) &&
/const localSession = readLocalSession\(config\)/u.test(auth) && !/const localSession = readLocalSession\(config\)/u.test(auth) &&
/writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) && !/writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) &&
/账号或密码不正确,请重新输入。/u.test(auth) && /账号或密码不正确,请重新输入。/u.test(auth) &&
/ensureWorkbenchAuth/u.test(app) && /ensureWorkbenchAuth/u.test(app) &&
/await\s+ensureWorkbenchAuth/u.test(app) && /await\s+ensureWorkbenchAuth/u.test(app) &&
/initWorkbenchLogout/u.test(app) && /initWorkbenchLogout/u.test(app) &&
/HWLAB_CLOUD_WEB_AUTH_USERNAME/u.test(authSource) && /proxyCloudApi\(request, response, url\)/u.test(artifactPublisher) &&
/HWLAB_CLOUD_WEB_AUTH_PASSWORD/u.test(authSource) && !/activeAuthSession/u.test(artifactPublisher) &&
/auth_required/u.test(authSource) &&
/\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) && /\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) &&
/\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) && /\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) &&
/\.workbench-shell\[hidden\]/u.test(styles) /\.workbench-shell\[hidden\]/u.test(styles)
@@ -5931,7 +5930,7 @@ async function handleAuthFixtureApi({ request, response, url, authFixtureSession
}); });
response.writeHead(200, { response.writeHead(200, {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"set-cookie": `hwlab_cloud_web_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600` "set-cookie": `hwlab_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600`
}); });
response.end(payload); response.end(payload);
return true; return true;
@@ -5941,7 +5940,7 @@ async function handleAuthFixtureApi({ request, response, url, authFixtureSession
if (token) authFixtureSessions.delete(token); if (token) authFixtureSessions.delete(token);
response.writeHead(200, { response.writeHead(200, {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"set-cookie": "hwlab_cloud_web_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" "set-cookie": "hwlab_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
}); });
response.end(JSON.stringify({ authenticated: false })); response.end(JSON.stringify({ authenticated: false }));
return true; return true;
@@ -5957,7 +5956,7 @@ function authFixtureCookie(request) {
const cookie = request.headers.cookie ?? ""; const cookie = request.headers.cookie ?? "";
for (const part of cookie.split(";")) { for (const part of cookie.split(";")) {
const [name, ...value] = part.trim().split("="); const [name, ...value] = part.trim().split("=");
if (name === "hwlab_cloud_web_session") return decodeURIComponent(value.join("=") || ""); if (name === "hwlab_session") return decodeURIComponent(value.join("=") || "");
} }
return ""; return "";
} }
+92 -28
View File
@@ -3,9 +3,35 @@ import test from "node:test";
import { ensureWorkbenchAuth } from "./auth.ts"; import { ensureWorkbenchAuth } from "./auth.ts";
const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1"; test("server auth is authoritative when restoring the workbench", async () => {
const harness = installAuthHarness({
localSession: validLocalSession(),
responses: {
"/auth/session": {
authenticated: true,
user: { username: "server-admin" },
expiresAt: futureIso()
}
}
});
test("local auth restore refreshes the server cookie before showing the workbench", async () => { try {
const session = await ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell });
assert.equal(session.authenticated, true);
assert.equal(session.mode, "server");
assert.deepEqual(session.user, { username: "server-admin" });
assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session"]);
assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET"]);
assert.equal(harness.loginShell.hidden, true);
assert.equal(harness.appShell.hidden, false);
assert.equal(globalThis.document.body.dataset.authState, "authenticated");
} finally {
harness.restore();
}
});
test("login uses cloud-api auth instead of local storage fallback", async () => {
const harness = installAuthHarness({ const harness = installAuthHarness({
localSession: validLocalSession(), localSession: validLocalSession(),
responses: { responses: {
@@ -19,12 +45,19 @@ test("local auth restore refreshes the server cookie before showing the workbenc
}); });
try { try {
const session = await ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell }); const pendingSession = ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell });
await harness.waitForLoginReady();
assert.equal(harness.loginShell.hidden, false);
assert.equal(harness.appShell.hidden, true);
harness.usernameInput.value = "admin";
harness.passwordInput.value = "hwlab2026";
await harness.submitLogin();
const session = await pendingSession;
assert.equal(session.authenticated, true); assert.equal(session.authenticated, true);
assert.equal(session.mode, "server"); assert.equal(session.mode, "server");
assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session", "/auth/login"]); assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session", "/auth/login"]);
assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET", "POST"]);
assert.deepEqual(JSON.parse(harness.fetchCalls[1].body), { username: "admin", password: "hwlab2026" }); assert.deepEqual(JSON.parse(harness.fetchCalls[1].body), { username: "admin", password: "hwlab2026" });
assert.equal(harness.loginShell.hidden, true); assert.equal(harness.loginShell.hidden, true);
assert.equal(harness.appShell.hidden, false); assert.equal(harness.appShell.hidden, false);
@@ -34,29 +67,6 @@ test("local auth restore refreshes the server cookie before showing the workbenc
} }
}); });
test("local auth restore remains usable when server cookie refresh is unavailable", async () => {
const harness = installAuthHarness({
localSession: validLocalSession(),
responses: {
"/auth/session": { authenticated: false },
"/auth/login": { authenticated: false, error: "invalid_credentials", status: 401 }
}
});
try {
const session = await ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell });
assert.equal(session.authenticated, true);
assert.equal(session.mode, "local");
assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session", "/auth/login"]);
assert.equal(harness.loginShell.hidden, true);
assert.equal(harness.appShell.hidden, false);
assert.equal(globalThis.document.body.dataset.authState, "authenticated");
} finally {
harness.restore();
}
});
function installAuthHarness({ localSession, responses }) { function installAuthHarness({ localSession, responses }) {
const previous = { const previous = {
window: globalThis.window, window: globalThis.window,
@@ -65,10 +75,22 @@ function installAuthHarness({ localSession, responses }) {
config: globalThis.HWLAB_CLOUD_WEB_CONFIG config: globalThis.HWLAB_CLOUD_WEB_CONFIG
}; };
const storage = new Map(); const storage = new Map();
if (localSession) storage.set(AUTH_STORAGE_KEY, JSON.stringify(localSession)); if (localSession) storage.set("hwlab.cloudWorkbench.auth.v1", JSON.stringify(localSession));
const fetchCalls = []; const fetchCalls = [];
const loginShell = elementStub(); const loginShell = elementStub();
const appShell = elementStub(); const appShell = elementStub();
const loginForm = formStub();
const usernameInput = inputStub();
const passwordInput = inputStub();
const submitButton = elementStub();
const loginError = elementStub();
loginShell.querySelector = (selector) => ({
"#login-form": loginForm,
"#login-username": usernameInput,
"#login-password": passwordInput,
"#login-submit": submitButton,
"#login-error": loginError
})[selector] ?? null;
globalThis.HWLAB_CLOUD_WEB_CONFIG = { auth: { mode: "auto", username: "admin", password: "hwlab2026" } }; globalThis.HWLAB_CLOUD_WEB_CONFIG = { auth: { mode: "auto", username: "admin", password: "hwlab2026" } };
globalThis.document = { body: { dataset: {} } }; globalThis.document = { body: { dataset: {} } };
@@ -78,6 +100,10 @@ function installAuthHarness({ localSession, responses }) {
setItem: (key, value) => storage.set(key, String(value)), setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key) removeItem: (key) => storage.delete(key)
}, },
requestAnimationFrame: (callback) => {
callback();
return 0;
},
setTimeout: () => 0, setTimeout: () => 0,
clearTimeout: () => {} clearTimeout: () => {}
}; };
@@ -100,7 +126,11 @@ function installAuthHarness({ localSession, responses }) {
return { return {
loginShell, loginShell,
appShell, appShell,
usernameInput,
passwordInput,
fetchCalls, fetchCalls,
waitForLoginReady: () => loginForm.waitForListener("submit"),
submitLogin: () => loginForm.dispatchSubmit(),
restore() { restore() {
globalThis.window = previous.window; globalThis.window = previous.window;
globalThis.document = previous.document; globalThis.document = previous.document;
@@ -132,6 +162,40 @@ function elementStub() {
}, },
removeAttribute(name) { removeAttribute(name) {
this.attributes.delete(name); this.attributes.delete(name);
},
focus() {},
select() {}
};
}
function inputStub() {
return {
...elementStub(),
value: ""
};
}
function formStub() {
const listeners = new Map();
const waiters = new Map();
return {
...elementStub(),
addEventListener(type, nextListener) {
listeners.set(type, nextListener);
const resolve = waiters.get(type);
if (resolve) {
waiters.delete(type);
resolve();
}
},
waitForListener(type) {
if (listeners.has(type)) return Promise.resolve();
return new Promise((resolve) => waiters.set(type, resolve));
},
async dispatchSubmit() {
const listener = listeners.get("submit");
assert.equal(typeof listener, "function");
await listener({ preventDefault() {} });
} }
}; };
} }
+25 -109
View File
@@ -1,7 +1,6 @@
const DEFAULT_AUTH_USERNAME = "admin"; const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026"; const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1";
const AUTH_ROUTE = Object.freeze({ const AUTH_ROUTE = Object.freeze({
session: "/auth/session", session: "/auth/session",
login: "/auth/login", login: "/auth/login",
@@ -68,36 +67,24 @@ function clampSessionTtl(value) {
} }
async function readActiveSession(config) { async function readActiveSession(config) {
if (config.mode !== "local") { const serverSession = await fetchServerSession();
const serverSession = await fetchServerSession(); if (serverSession.available) {
if (serverSession.available) { return {
if (!serverSession.authenticated && config.mode !== "server") { authenticated: serverSession.authenticated,
const localSession = readLocalSession(config); mode: "server",
if (localSession.authenticated) { user: serverSession.user,
const restoredServerSession = await restoreServerSessionFromLocal(config); expiresAt: serverSession.expiresAt
if (restoredServerSession.authenticated) return restoredServerSession; };
return localSession;
}
}
return {
authenticated: serverSession.authenticated,
mode: "server",
user: serverSession.user,
expiresAt: serverSession.expiresAt
};
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
} }
return readLocalSession(config); if (config.mode !== "auto" || !config.username || !config.password) {
} return { authenticated: false, mode: "server" };
}
async function restoreServerSessionFromLocal(config) {
const serverResult = await attemptServerLogin(config.username, config.password); const serverResult = await attemptServerLogin(config.username, config.password);
if (!serverResult.available || !serverResult.authenticated) return { authenticated: false }; if (!serverResult.available || !serverResult.authenticated) {
writeLocalSession(config); return { authenticated: false, mode: "server" };
}
return { return {
authenticated: true, authenticated: true,
mode: "server", mode: "server",
@@ -128,67 +115,9 @@ async function fetchServerSession() {
} }
} }
function readLocalSession(config) {
const now = Date.now();
const payload = readStoredLocalSession();
if (!payload || payload.expiresAt <= now || payload.username !== config.username) {
clearLocalSession();
return { authenticated: false, mode: "local" };
}
return {
authenticated: true,
mode: "local",
user: { username: payload.username },
expiresAt: new Date(payload.expiresAt).toISOString()
};
}
function readStoredLocalSession() {
try {
const raw = window.localStorage?.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
const expiresAt = Number(payload.expiresAt);
if (!Number.isFinite(expiresAt)) return null;
return {
username: typeof payload.username === "string" ? payload.username : "",
expiresAt
};
} catch {
return null;
}
}
function writeLocalSession(config) {
const expiresAt = Date.now() + config.sessionTtlMs;
try {
window.localStorage?.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
version: 1,
username: config.username,
issuedAt: Date.now(),
expiresAt
})
);
} catch {
// A session can still continue for the current page load when storage is unavailable.
}
return {
authenticated: true,
mode: "local",
user: { username: config.username },
expiresAt: new Date(expiresAt).toISOString()
};
}
function clearLocalSession() { function clearLocalSession() {
try { // Legacy local-only workbench auth was removed; keep cleanup idempotent for old browsers.
window.localStorage?.removeItem(AUTH_STORAGE_KEY); try { window.localStorage?.removeItem("hwlab.cloudWorkbench.auth.v1"); } catch {}
} catch {
// Ignore storage availability errors.
}
} }
function initLoginForm({ loginShell, appShell, config, onAuthenticated }) { function initLoginForm({ loginShell, appShell, config, onAuthenticated }) {
@@ -227,29 +156,16 @@ function initLoginForm({ loginShell, appShell, config, onAuthenticated }) {
} }
async function attemptLogin({ username, password, config }) { async function attemptLogin({ username, password, config }) {
if (config.mode !== "local") { const serverResult = await attemptServerLogin(username, password);
const serverResult = await attemptServerLogin(username, password); if (serverResult.available && serverResult.authenticated) {
if (serverResult.available) { return {
if (serverResult.authenticated) { authenticated: true,
writeLocalSession(config); mode: "server",
return { user: serverResult.user,
authenticated: true, expiresAt: serverResult.expiresAt
mode: "server", };
user: serverResult.user,
expiresAt: serverResult.expiresAt
};
}
return { authenticated: false, mode: "server" };
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
} }
return { authenticated: false, mode: "server" };
if (username === config.username && password === config.password) {
return writeLocalSession(config);
}
return { authenticated: false, mode: "local" };
} }
async function attemptServerLogin(username, password) { async function attemptServerLogin(username, password) {