Files
windmill/cli/src/core/auth.ts
T
hugocasa ba4b368706 fix: prevent variable push from corrupting is_secret variables (#9705)
* fix: prevent variable push from corrupting is_secret variables

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): unit-test looksLikeWorkspaceCiphertext shape detection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): scope is_secret downgrade to single-file push, not sync push

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): warn when variable push stores a secret value as already-encrypted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): route workspace-resolution and auth diagnostics to stderr

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): rephrase comments to describe current behavior, not history

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:45:36 +02:00

146 lines
6.0 KiB
TypeScript

import { colors } from "@cliffy/ansi/colors";
import * as log from "./log.ts";
import { setClient } from "./client.ts";
import * as wmill from "../../gen/services.gen.ts";
import { GlobalUserInfo, User } from "../../gen/types.gen.ts";
import { loginInteractive, tryGetLoginInfo } from "./login.ts";
import { GlobalOptions } from "../types.ts";
// Workspace-scoped tokens (tokens bound to a workspace via token.workspace_id)
// cannot call /api/users/whoami because the backend rejects any token with a
// workspace binding when the request path has no workspace in it. Fall back to
// the workspace-scoped whoami endpoint in that case so the CLI still works.
// Uses a name-based ApiError check rather than `instanceof` to match the
// pattern in cli/src/main.ts: bundling (bun build for npm, JSR dev path) can
// produce multiple module instances of gen/core/ApiError.ts, making
// `instanceof` silently return false and reintroducing the bug this fixes.
async function fetchWhoami(workspaceId: string): Promise<GlobalUserInfo> {
try {
return await wmill.globalWhoami();
} catch (error) {
if (
error && typeof error === "object" &&
"name" in error && (error as { name: unknown }).name === "ApiError" &&
(error as { status?: number }).status === 401
) {
const user = await wmill.whoami({ workspace: workspaceId });
return workspaceUserToGlobalUserInfo(user);
}
throw error;
}
}
// Adapter for the 401 fallback path. `login_type`, `verified`, `first_time_user`
// and `role_source` are NOT derivable from the workspace-scoped User response
// and are filled with best-effort defaults — do not trust them downstream after
// a fallback whoami. Today only cli/src/commands/hub/hub.ts reads this return
// value, and only `.email`.
function workspaceUserToGlobalUserInfo(user: User): GlobalUserInfo {
return {
email: user.email,
login_type: "password",
super_admin: user.is_super_admin,
verified: true,
name: user.name,
username: user.username,
operator_only: user.operator,
first_time_user: false,
role_source: "manual",
disabled: user.disabled,
};
}
/**
* Main authentication function - moved from context.ts to break circular dependencies
* This function maintains the original API signature from context.ts
*/
export async function requireLogin(
opts: GlobalOptions
): Promise<GlobalUserInfo> {
// Import resolveWorkspace to avoid circular dependency at module level
const { resolveWorkspace } = await import("./context.ts");
const workspace = await resolveWorkspace(opts);
let token = await tryGetLoginInfo(opts);
if (!token) {
token = workspace.token;
}
setClient(token, workspace.remote.substring(0, workspace.remote.length - 1));
try {
return await fetchWhoami(workspace.workspaceId);
} catch (error) {
// Check for network errors and provide clearer messages
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.includes('fetch') || errorMsg.includes('connection') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('refused')) {
throw new Error(`Network error: Could not connect to Windmill server at ${workspace.remote}`);
}
// If the user explicitly provided credentials via flags, fail immediately
// rather than falling back to interactive login — they expect their explicit
// credentials to work and should fix them if they don't.
if (opts.token || opts.baseUrl) {
const isApiError = error && typeof error === "object" &&
"name" in error && (error as { name: unknown }).name === "ApiError";
if (isApiError) {
const status = (error as { status?: number }).status;
const body = (error as { body?: unknown }).body;
let bodyStr = typeof body === "object" && body !== null
? JSON.stringify(body)
: String(body ?? "").trim();
// Strip backend source-file refs like "(flows.rs:1400)" or
// "@scopes.rs:509" from the surfaced message — same pattern used in
// main.ts for ApiError display.
bodyStr = bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, "");
// The backend's `Error::PermissionDenied` formats with a
// "Permission denied: " prefix; the new CLI message also leads with
// that phrase, so strip it from the body to avoid duplication.
bodyStr = bodyStr.replace(/^(Permission denied|Not authorized): /, "");
if (status === 403) {
// 403 means the token authenticated but lacks scope — re-issuing
// won't help. Keep this distinct from the 401 message so the user
// doesn't waste time reproducing the token.
log.infoStderr(colors.red(
`Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else if (status === 401) {
log.infoStderr(colors.red(
`Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else {
log.infoStderr(colors.red(
`Request failed (${status ?? "unknown"}): ${bodyStr}`
));
}
return process.exit(1);
}
log.infoStderr(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
return process.exit(1);
}
log.infoStderr(
"! Could not reach API given existing credentials. Attempting to reauth..."
);
const newToken = await loginInteractive(workspace.remote);
if (!newToken) {
throw new Error("Unauthorized: Could not authenticate with the provided credentials");
}
// Update workspace token
const { removeWorkspace, addWorkspace } = await import("../commands/workspace/workspace.ts");
removeWorkspace(workspace.name, false, opts);
workspace.token = newToken;
addWorkspace(workspace, opts);
setClient(
newToken,
workspace.remote.substring(0, workspace.remote.length - 1)
);
return await fetchWhoami(workspace.workspaceId);
}
}