fix: harden hwlab web probe script login
This commit is contained in:
+231
-11
@@ -3815,39 +3815,259 @@ try {
|
||||
}
|
||||
|
||||
async function authenticate(browserContext) {
|
||||
const apiAuth = await authenticateWithApiRetries(browserContext);
|
||||
if (apiAuth.ok) return apiAuth;
|
||||
return authenticateWithFormFallback(browserContext, apiAuth);
|
||||
}
|
||||
|
||||
async function authenticateWithApiRetries(browserContext) {
|
||||
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
||||
const response = await browserContext.request.post(loginUrl, {
|
||||
data: { username, password },
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const attempts = [];
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const response = await browserContext.request.post(loginUrl, {
|
||||
data: { username, password },
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
timeout: Math.min(timeoutMs, 12000),
|
||||
});
|
||||
const summary = await responseSummary(response);
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
const item = {
|
||||
attempt,
|
||||
method: "api",
|
||||
...summary,
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
};
|
||||
attempts.push(item);
|
||||
if (response.ok() && cookieState.cookiePresent) {
|
||||
return {
|
||||
ok: true,
|
||||
method: "api",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
cookiePresent: true,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (response.status() < 500 && response.status() !== 429) break;
|
||||
} catch (error) {
|
||||
attempts.push({
|
||||
attempt,
|
||||
method: "api",
|
||||
status: 0,
|
||||
statusText: "request-error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
cookiePresent: false,
|
||||
});
|
||||
}
|
||||
if (attempt < 3) await sleep(300 * attempt);
|
||||
}
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
const last = attempts[attempts.length - 1] ?? null;
|
||||
return {
|
||||
ok: false,
|
||||
method: "api",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: typeof last?.status === "number" ? last.status : 0,
|
||||
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts,
|
||||
errorSummary: last,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateWithFormFallback(browserContext, apiAuth) {
|
||||
const loginPage = await browserContext.newPage();
|
||||
const fallback = {
|
||||
method: "form",
|
||||
startPath: "/workbench",
|
||||
surface: null,
|
||||
finalPath: null,
|
||||
response: null,
|
||||
outcome: null,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
await loginPage.goto(new URL("/workbench", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
||||
const surface = await waitForAuthSurface(loginPage);
|
||||
fallback.surface = surface;
|
||||
if (surface === "workspace") {
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
fallback.finalPath = new URL(loginPage.url()).pathname;
|
||||
return {
|
||||
ok: cookieState.cookiePresent,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: cookieState.cookiePresent ? 200 : 0,
|
||||
statusText: cookieState.cookiePresent ? "already-authenticated" : "workspace-without-session-cookie",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (surface !== "login-card" && surface !== "legacy-id") {
|
||||
throw new Error("login form not visible");
|
||||
}
|
||||
|
||||
const usernameInput = loginPage.locator('#login-username, input[autocomplete="username"], .login-card input').first();
|
||||
const passwordInput = loginPage.locator('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
|
||||
const submitButton = loginPage.locator('#login-submit, .login-card button[type="submit"], button[type="submit"]').first();
|
||||
await usernameInput.fill(username);
|
||||
await passwordInput.fill(password);
|
||||
await loginPage.waitForFunction((expected) => {
|
||||
const user = document.querySelector('#login-username, input[autocomplete="username"], .login-card input');
|
||||
const pass = document.querySelector('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]');
|
||||
const submit = document.querySelector('#login-submit, .login-card button[type="submit"], button[type="submit"]');
|
||||
return Boolean(user && pass && user.value === expected.user && pass.value.length === expected.passwordLength && !submit?.disabled);
|
||||
}, { user: username, passwordLength: password.length }, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
||||
|
||||
const loginResponsePromise = loginPage.waitForResponse((response) => responsePathMatches(response.url(), "/auth/login"), { timeout: Math.min(timeoutMs, 15000) }).catch(() => null);
|
||||
await Promise.allSettled([
|
||||
loginPage.waitForFunction(() => document.querySelector("#workspace") || document.querySelector("#command-input"), null, { timeout: Math.min(timeoutMs, 15000) }),
|
||||
submitButton.click(),
|
||||
]);
|
||||
const loginResponse = await loginResponsePromise;
|
||||
if (loginResponse) fallback.response = await responseSummary(loginResponse);
|
||||
fallback.outcome = await formLoginOutcome(loginPage);
|
||||
fallback.finalPath = new URL(loginPage.url()).pathname;
|
||||
assertNoCredentialLeak(loginPage.url());
|
||||
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
return {
|
||||
ok: cookieState.cookiePresent,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: fallback.response?.status ?? (cookieState.cookiePresent ? 200 : 0),
|
||||
statusText: fallback.response?.statusText ?? (cookieState.cookiePresent ? "OK" : "form-login-no-cookie"),
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
fallback.error = error instanceof Error ? error.message : String(error);
|
||||
fallback.finalPath = loginPage ? new URL(loginPage.url()).pathname : null;
|
||||
const cookieState = await readAuthCookieState(browserContext).catch(() => ({ cookiePresent: false, cookieNames: [] }));
|
||||
return {
|
||||
ok: false,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: fallback.response?.status ?? 0,
|
||||
statusText: fallback.response?.statusText ?? "form-login-failed",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} finally {
|
||||
await loginPage.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuthSurface(targetPage) {
|
||||
const handle = await targetPage.waitForFunction(() => {
|
||||
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return "workspace";
|
||||
if (document.querySelector("#login-username")) return "legacy-id";
|
||||
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
|
||||
if (document.querySelector(".login-card input")) return "login-card";
|
||||
return "";
|
||||
}, null, { timeout: Math.min(timeoutMs, 12000) }).catch(() => null);
|
||||
if (!handle) return null;
|
||||
return handle.jsonValue().catch(() => null);
|
||||
}
|
||||
|
||||
async function formLoginOutcome(targetPage) {
|
||||
const handle = await targetPage.waitForFunction(() => {
|
||||
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return { kind: "workspace" };
|
||||
const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim();
|
||||
if (errorText) return { kind: "error", errorText };
|
||||
return null;
|
||||
}, null, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
||||
if (!handle) return null;
|
||||
return handle.jsonValue().catch(() => null);
|
||||
}
|
||||
|
||||
async function responseSummary(response) {
|
||||
const status = response.status();
|
||||
let bodyPreview = null;
|
||||
if (status >= 400) {
|
||||
try {
|
||||
bodyPreview = redactString(await response.text()).replace(/\s+/gu, " ").slice(0, 500);
|
||||
} catch {
|
||||
bodyPreview = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
statusText: response.statusText(),
|
||||
bodyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
async function readAuthCookieState(browserContext) {
|
||||
const cookies = await browserContext.cookies(baseUrl);
|
||||
cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean);
|
||||
const cookieNames = cookies.map((cookie) => cookie.name).sort();
|
||||
const cookiePresent = cookieNames.includes("hwlab_session");
|
||||
return {
|
||||
ok: response.ok() && cookiePresent,
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
cookiePresent,
|
||||
cookiePresent: cookieNames.includes("hwlab_session"),
|
||||
cookieNames,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function responsePathMatches(rawUrl, path) {
|
||||
try {
|
||||
return new URL(rawUrl).pathname === path;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoCredentialLeak(value) {
|
||||
const finalUrl = new URL(value);
|
||||
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
|
||||
throw new Error("login credentials leaked into URL query");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function publicAuth(value) {
|
||||
return {
|
||||
ok: value.ok,
|
||||
method: value.method ?? null,
|
||||
loginPath: value.loginUrl,
|
||||
status: value.status,
|
||||
statusText: value.statusText,
|
||||
cookiePresent: value.cookiePresent,
|
||||
cookieNames: value.cookieNames,
|
||||
attempts: value.attempts ?? null,
|
||||
fallback: value.fallback ?? null,
|
||||
errorSummary: cloneSummary(value.errorSummary ?? value.apiErrorSummary ?? null),
|
||||
username,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSummary(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw) {
|
||||
if (!raw) throw new Error("missing HWLAB_WEB_BASE_URL");
|
||||
const parsed = new URL(raw);
|
||||
|
||||
Reference in New Issue
Block a user