diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 04c49b15..fced5787 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -153,6 +153,14 @@ async function authCommand(context: any) { if (subcommand === "login") return authLogin(context); if (subcommand === "status") return authStatus(context); if (subcommand === "profiles") return authProfiles(context); + if (subcommand === "whoami") { + const response = await requestJson({ ...context, method: "GET", path: "/v1/users/me" }); + return responsePayload("client.auth.whoami", response, context, { + route: route("GET", "/v1/users/me"), + actor: response.body?.actor ?? null, + authMethod: response.body?.authMethod ?? null + }); + } if (subcommand === "session") { const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })); const response = await requestJson({ ...context, method: "GET", path: "/auth/session" }); @@ -173,6 +181,7 @@ function authHelp() { commands: [ "login --username USER --password-env HWLAB_PASSWORD [--profile NAME]", "status [--profile NAME]", + "whoami", "session [--profile NAME]", "profiles", "logout [--profile NAME]" @@ -183,11 +192,13 @@ function authHelp() { async function authStatus(context: any) { const endpoint = runtimeEndpoint(context.parsed, context.env, "web"); const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })); + const apiKey = explicitApiKey(context.parsed, context.env); return ok("client.auth.status", { baseUrl: endpoint.baseUrl, runtimeEndpoint: runtimeEndpointVisibility(endpoint), stateFile: localSession.stateFile, localSession, + apiKey: apiKey ? { source: "env-or-explicit", prefix: apiKeyPrefixSummary(apiKey) } : { source: null, prefix: null }, nextCommands: authNextCommands({ baseUrl: endpoint.baseUrl, username: text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin", stateFile: localSession.stateFile }) }); } @@ -1711,6 +1722,25 @@ async function requestJson(context: any) { const timer = setTimeout(() => controller.abort(), requestTimeoutMs); try { const effectiveAuth = auth && parsed.noAuth !== true; + const apiKey = effectiveAuth ? explicitApiKey(parsed, env) : ""; + if (apiKey) { + const response = await sendJsonRequest({ context, url, method, body, apiKey, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs }); + const authState = { + required: effectiveAuth, + baseUrl: resolvedBaseUrl, + username: null, + stateFile: null, + localSession: null, + cookieSource: null, + apiKeySource: "env-or-explicit", + apiKeyPrefix: apiKeyPrefixSummary(apiKey), + autoLoginAttempted: false, + autoLoginStatus: null, + autoLoginHttpStatus: null, + retryAfterLogin: false + }; + return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) }; + } const explicitCookie = explicitAuthCookie(parsed, env); const sessionState = effectiveAuth && !explicitCookie && parsed.noSession !== true ? await readSessionState({ parsed, env, cwd: cwd ?? process.cwd() }) @@ -1758,10 +1788,11 @@ async function requestJson(context: any) { } } -async function sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal, pathName, timeoutMs }: any) { +async function sendJsonRequest({ context, url, method, body, cookie, apiKey, extraHeaders, signal, pathName, timeoutMs }: any) { const headers = clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), ...(cookie ? { cookie } : {}), ...extraHeaders }); @@ -1875,6 +1906,20 @@ function explicitAuthCookie(parsed: ParsedArgs, env: EnvLike) { return explicit ? explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}` : null; } +const HWLAB_API_KEY_PREFIX = "hwl_live_"; +function explicitApiKey(parsed: ParsedArgs, env: EnvLike) { + const explicit = text(parsed.apiKey ?? parsed.bearerToken ?? env.HWLAB_API_KEY ?? env.HWLAB_BEARER_TOKEN); + if (!explicit) return ""; + if (explicit.startsWith(HWLAB_API_KEY_PREFIX)) return explicit; + return ""; +} +function apiKeyPrefixSummary(secret) { + const text = String(secret ?? ""); + if (!text) return ""; + if (text.length <= 12) return text; + return `${text.slice(0, 12)}\u2026`; +} + function autoAuthAllowed(parsed: ParsedArgs) { return parsed.noAuth !== true && parsed.noAutoAuth !== true; } @@ -2022,6 +2067,8 @@ function authVisibility(authState: any) { cookieSource: authState.cookieSource, stateFile: authState.stateFile, localSession: authState.localSession, + apiKeySource: authState.apiKeySource, + apiKeyPrefix: authState.apiKeyPrefix, autoLoginAttempted: authState.autoLoginAttempted, autoLoginStatus: authState.autoLoginStatus, autoLoginHttpStatus: authState.autoLoginHttpStatus, @@ -2032,6 +2079,17 @@ function authVisibility(authState: any) { function authDiagnosis(response: any, authState: any) { if (!authState?.required) return null; if (response.status !== 401 && response.status !== 403) return null; + if (response.status === 401 && authState.apiKeySource) { + return { + code: "api_key_invalid", + status: "unauthorized", + message: "HWLAB_API_KEY was rejected by the cloud-api; regenerate the key with `client auth default` or check that the env value matches the one shown in the Web API key page.", + httpStatus: response.status, + apiKeySource: authState.apiKeySource, + apiKeyPrefix: authState.apiKeyPrefix, + nextCommands: ["bun tools/hwlab-cli/bin/hwlab-cli.ts client request POST /v1/api-keys -H 'content-type: application/json' --body '{\"name\":\"rotated\"}' --help"] + }; + } if (response.status === 403) { return { code: "auth_forbidden",