diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index cc877a5d..9f3719ba 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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 () => { const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 08dd33da..b147c519 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -471,8 +471,16 @@ class AccessController { this.bootstrapAttempted = true; await this.store.ensureSchema?.(); 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) : ""); + 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; await this.store.createUser({ id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", @@ -999,6 +1007,13 @@ class MemoryAccessStore { async countUsers() { return this.users.size; } async getUserById(id) { return this.users.get(id) ?? 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) { const now = input.now ?? this.now(); 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 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 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) { await this.ensureSchema(); const now = input.now ?? this.now(); diff --git a/internal/dev-entrypoint/cloud-web-proxy.mjs b/internal/dev-entrypoint/cloud-web-proxy.mjs index 56cb0a7a..aa27a688 100644 --- a/internal/dev-entrypoint/cloud-web-proxy.mjs +++ b/internal/dev-entrypoint/cloud-web-proxy.mjs @@ -6,6 +6,7 @@ const STREAMING_HEADER_DEFAULTS = Object.freeze({ }); const FORWARDED_REQUEST_HEADERS = Object.freeze([ + "cookie", "prefer", "x-hwlab-short-connection", "x-trace-id", diff --git a/internal/dev-entrypoint/cloud-web-proxy.test.mjs b/internal/dev-entrypoint/cloud-web-proxy.test.mjs index 26742114..7739d7b9 100644 --- a/internal/dev-entrypoint/cloud-web-proxy.test.mjs +++ b/internal/dev-entrypoint/cloud-web-proxy.test.mjs @@ -17,6 +17,7 @@ test("cloud web proxy preserves Code Agent short-connection headers", () => { "x-hwlab-short-connection": "1", "x-trace-id": "trc_proxy_short_connection", "x-request-id": "req_proxy_short_connection", + cookie: "hwlab_session=must-forward", authorization: "Bearer must-not-forward" } }, "{\"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-trace-id"], "trc_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["content-length"], Buffer.byteLength("{\"message\":\"hello\"}")); }); diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 05d7c04e..766eba23 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -1,12 +1,17 @@ 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([ + "/auth/login", + "/auth/logout", "/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", "/v1/m3/io" ]); const PUBLIC_PROXY_ROUTES = new Set([ + "GET /auth/session", + "POST /auth/login", + "POST /auth/logout", "POST /v1/agent/chat", "POST /v1/agent/chat/cancel" ]); diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 9db23cdb..626d9ae9 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -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 () => { const upstream = createServer((request, response) => { request.resume(); diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index e1bd34c2..6814b66b 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -753,7 +753,6 @@ function runtimeScriptBase64() { const source = String.raw` import { createServer } from "node:http"; import { spawn } from "node:child_process"; -import { randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; @@ -770,14 +769,6 @@ const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TI min: 1000, 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([ "system.health", "cloud.adapter.describe", @@ -934,112 +925,12 @@ function runBunEntrypoint(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) { - if (url.pathname === "/auth/session" && request.method === "GET") { - const session = activeAuthSession(request); - sendJson(response, 200, session ? { - authenticated: true, - user: { username: session.username }, - 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; + if (url.pathname === "/auth/session" && request.method === "GET") return proxyCloudApi(request, response, url); + if (url.pathname === "/auth/login" && request.method === "POST") return proxyCloudApi(request, response, url); + if (url.pathname === "/auth/logout" && request.method === "POST") return proxyCloudApi(request, response, url); + if (!url.pathname.startsWith("/auth/")) return false; + return proxyCloudApi(request, response, url); } async function readRequestBody(request) { @@ -1163,14 +1054,6 @@ async function serveCloudWeb() { const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname); if (proxyPolicy.proxy) { - if (proxyPolicy.authRequired && !activeAuthSession(request)) { - sendJson(response, 401, { - status: "failed", - error: "auth_required", - serviceId - }); - return; - } await proxyCloudApi(request, response, url); return; } diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index a9519fd4..5726e40b 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1952,16 +1952,15 @@ function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = " /DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) && /DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) && /HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) && - /AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) && - /const localSession = readLocalSession\(config\)/u.test(auth) && - /writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) && + !/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) && + !/const localSession = readLocalSession\(config\)/u.test(auth) && + !/writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) && /账号或密码不正确,请重新输入。/u.test(auth) && /ensureWorkbenchAuth/u.test(app) && /await\s+ensureWorkbenchAuth/u.test(app) && /initWorkbenchLogout/u.test(app) && - /HWLAB_CLOUD_WEB_AUTH_USERNAME/u.test(authSource) && - /HWLAB_CLOUD_WEB_AUTH_PASSWORD/u.test(authSource) && - /auth_required/u.test(authSource) && + /proxyCloudApi\(request, response, url\)/u.test(artifactPublisher) && + !/activeAuthSession/u.test(artifactPublisher) && /\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) && /\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) && /\.workbench-shell\[hidden\]/u.test(styles) @@ -5931,7 +5930,7 @@ async function handleAuthFixtureApi({ request, response, url, authFixtureSession }); response.writeHead(200, { "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); return true; @@ -5941,7 +5940,7 @@ async function handleAuthFixtureApi({ request, response, url, authFixtureSession if (token) authFixtureSessions.delete(token); response.writeHead(200, { "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 })); return true; @@ -5957,7 +5956,7 @@ function authFixtureCookie(request) { const cookie = request.headers.cookie ?? ""; for (const part of cookie.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 ""; } diff --git a/web/hwlab-cloud-web/auth.test.ts b/web/hwlab-cloud-web/auth.test.ts index f35a8f3f..087a8d21 100644 --- a/web/hwlab-cloud-web/auth.test.ts +++ b/web/hwlab-cloud-web/auth.test.ts @@ -3,9 +3,35 @@ import test from "node:test"; 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({ localSession: validLocalSession(), responses: { @@ -19,12 +45,19 @@ test("local auth restore refreshes the server cookie before showing the workbenc }); 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.mode, "server"); 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.equal(harness.loginShell.hidden, true); 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 }) { const previous = { window: globalThis.window, @@ -65,10 +75,22 @@ function installAuthHarness({ localSession, responses }) { config: globalThis.HWLAB_CLOUD_WEB_CONFIG }; 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 loginShell = 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.document = { body: { dataset: {} } }; @@ -78,6 +100,10 @@ function installAuthHarness({ localSession, responses }) { setItem: (key, value) => storage.set(key, String(value)), removeItem: (key) => storage.delete(key) }, + requestAnimationFrame: (callback) => { + callback(); + return 0; + }, setTimeout: () => 0, clearTimeout: () => {} }; @@ -100,7 +126,11 @@ function installAuthHarness({ localSession, responses }) { return { loginShell, appShell, + usernameInput, + passwordInput, fetchCalls, + waitForLoginReady: () => loginForm.waitForListener("submit"), + submitLogin: () => loginForm.dispatchSubmit(), restore() { globalThis.window = previous.window; globalThis.document = previous.document; @@ -132,6 +162,40 @@ function elementStub() { }, removeAttribute(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() {} }); } }; } diff --git a/web/hwlab-cloud-web/auth.ts b/web/hwlab-cloud-web/auth.ts index c51d67e1..4c1a393d 100644 --- a/web/hwlab-cloud-web/auth.ts +++ b/web/hwlab-cloud-web/auth.ts @@ -1,7 +1,6 @@ const DEFAULT_AUTH_USERNAME = "admin"; const DEFAULT_AUTH_PASSWORD = "hwlab2026"; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; -const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1"; const AUTH_ROUTE = Object.freeze({ session: "/auth/session", login: "/auth/login", @@ -68,36 +67,24 @@ function clampSessionTtl(value) { } async function readActiveSession(config) { - if (config.mode !== "local") { - const serverSession = await fetchServerSession(); - if (serverSession.available) { - if (!serverSession.authenticated && config.mode !== "server") { - const localSession = readLocalSession(config); - if (localSession.authenticated) { - const restoredServerSession = await restoreServerSessionFromLocal(config); - 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" }; - } + const serverSession = await fetchServerSession(); + if (serverSession.available) { + return { + authenticated: serverSession.authenticated, + mode: "server", + user: serverSession.user, + expiresAt: serverSession.expiresAt + }; } - 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); - if (!serverResult.available || !serverResult.authenticated) return { authenticated: false }; - writeLocalSession(config); + if (!serverResult.available || !serverResult.authenticated) { + return { authenticated: false, mode: "server" }; + } return { authenticated: true, 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() { - try { - window.localStorage?.removeItem(AUTH_STORAGE_KEY); - } catch { - // Ignore storage availability errors. - } + // Legacy local-only workbench auth was removed; keep cleanup idempotent for old browsers. + try { window.localStorage?.removeItem("hwlab.cloudWorkbench.auth.v1"); } catch {} } function initLoginForm({ loginShell, appShell, config, onAuthenticated }) { @@ -227,29 +156,16 @@ function initLoginForm({ loginShell, appShell, config, onAuthenticated }) { } async function attemptLogin({ username, password, config }) { - if (config.mode !== "local") { - const serverResult = await attemptServerLogin(username, password); - if (serverResult.available) { - if (serverResult.authenticated) { - writeLocalSession(config); - return { - authenticated: true, - mode: "server", - user: serverResult.user, - expiresAt: serverResult.expiresAt - }; - } - return { authenticated: false, mode: "server" }; - } - if (config.mode === "server") { - return { authenticated: false, mode: "server" }; - } + const serverResult = await attemptServerLogin(username, password); + if (serverResult.available && serverResult.authenticated) { + return { + authenticated: true, + mode: "server", + user: serverResult.user, + expiresAt: serverResult.expiresAt + }; } - - if (username === config.username && password === config.password) { - return writeLocalSession(config); - } - return { authenticated: false, mode: "local" }; + return { authenticated: false, mode: "server" }; } async function attemptServerLogin(username, password) {