fix: delegate cloud web auth to cloud api
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\"}"));
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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() {} });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+25
-109
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user