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 () => {
|
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" },
|
||||||
|
|||||||
@@ -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\"}"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user