UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

1,012 lines (1,011 loc) 41.7 kB
import * as http from "http"; import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import { execFileSync } from "child_process"; import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js"; import { MeshCliError } from "../utils/errors.js"; import { assumeRoleCredentials, assumeRoleWithWebIdentity, describeAssumeFailure, probeAwsIdentity, renderCredentialProcessProfile, tokenIssuer, resolveStableMeshBin, selectRoleForCaller, toCredentialProcessJson, upsertManagedAwsConfigSection, } from "../utils/aws-auth.js"; import { firstPartyDomainFor } from "../utils/first-party-contexts.js"; import { tailscaleBackendState } from "./vpn/index.js"; const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? "~", ".config"), "mesh"); const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); const REDIRECT_PORT = 9876; const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`; const SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud"; function readConfig() { if (!fs.existsSync(CONFIG_FILE)) return {}; try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { return {}; } } export function writeContextConfig(context, config) { const existing = readConfig(); existing[context] = config; fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2)); } export function clearContextConfig(context) { const existing = readConfig(); if (!(context in existing)) return; delete existing[context]; fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2)); } function getContextConfig(context) { const config = readConfig(); return config[context] ?? null; } function parseTenantEnv(context) { const parts = context.split("."); if (parts.length !== 2 || !parts[0] || !parts[1]) return null; return { tenant: parts[0], env: parts[1] }; } async function discoverConfigFromSsm(context) { const parsed = parseTenantEnv(context); if (!parsed) return null; const { tenant, env } = parsed; const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`; logInfo(`Attempting SSM discovery from ${ssmPath}...`); try { const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm"); const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; const ssm = new SSMClient({ region }); const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath })); const raw = resp.Parameter?.Value; if (!raw) { logWarn(`SSM parameter ${ssmPath} has no value`); return null; } const data = JSON.parse(raw); const issuer = data.endpoint; const clientId = data.cliClientId; if (typeof issuer !== "string" || !issuer) { logWarn("SSM zitadel entry missing 'endpoint' field"); return null; } if (typeof clientId !== "string" || !clientId) { logWarn("SSM zitadel entry missing 'cliClientId' field.\n" + " The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" + " that exports cliClientId. Until then, contact your platform admin."); return null; } const config = { issuer, clientId }; if (typeof data.vpn === "string") config.vpn = data.vpn; if (typeof data.vpnJoinBroker === "string") config.vpnJoinBroker = data.vpnJoinBroker; if (typeof data.registryBroker === "string") config.registryBroker = data.registryBroker; writeContextConfig(context, config); logSuccess(`Discovered platform configuration for ${context} via SSM`); return config; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("ExpiredToken") || message.includes("credentials")) { logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`); if (firstPartyDomainFor(context) || context.split(".").length - 1 >= 2) { logInfo(` Falling back to anonymous HTTPS discovery — no AWS needed.`); } else { logInfo(` If you have an AWS account: aws sso login --profile <profile>`); logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`); } } else if (message.includes("ParameterNotFound")) { logWarn(`SSM parameter not found: ${ssmPath}`); logInfo(" This platform context may not be deployed."); } else { logWarn(`SSM discovery failed: ${message}`); } return null; } } async function discoverConfig(domain, contextKey = domain, opts = {}) { const url = `https://cli.${domain}/.well-known/mesh.json`; if (!opts.quiet) logInfo(`Attempting discovery from ${url}...`); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const resp = await fetch(url, { signal: controller.signal }); clearTimeout(timeout); if (!resp.ok) { logWarn(`Discovery endpoint returned ${resp.status}`); return null; } const data = (await resp.json()); const issuer = data.issuer; const clientId = data.clientId; if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) { logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)"); return null; } const config = { issuer, clientId }; if (typeof data.vpn === "string") config.vpn = data.vpn; if (typeof data.vpnJoinBroker === "string") config.vpnJoinBroker = data.vpnJoinBroker; if (typeof data.registryBroker === "string") config.registryBroker = data.registryBroker; if (!opts.quiet) { writeContextConfig(contextKey, config); logSuccess(`Discovered platform configuration for ${contextKey}`); } return config; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("abort")) { logWarn("Discovery timed out"); } else { logWarn(`Discovery failed: ${message}`); } return null; } } export function discoverConfigFromWellKnown(domain, contextKey = domain, opts = {}) { return discoverConfig(domain, contextKey, opts); } function readAllCredentials() { if (!fs.existsSync(CREDENTIALS_FILE)) return {}; try { return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); } catch { return {}; } } function readCredentials(context) { return readAllCredentials()[context] ?? null; } let atomicWriteCounter = 0; export function atomicWriteFileSync(path, data, mode) { const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`; try { fs.writeFileSync(tmpPath, data, { mode }); fs.renameSync(tmpPath, path); } catch (err) { try { fs.unlinkSync(tmpPath); } catch { } throw err; } } function writeCredentials(context, creds) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); const all = readAllCredentials(); all[context] = creds; atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600); } function clearCredentials(context) { const all = readAllCredentials(); delete all[context]; fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), { mode: 0o600, }); } function base64url(buffer) { return buffer.toString("base64url"); } function generateCodeVerifier() { return base64url(crypto.randomBytes(32)); } function generateCodeChallenge(verifier) { return base64url(crypto.createHash("sha256").update(verifier).digest()); } function decodeJwtPayload(token) { const parts = token.split("."); if (parts.length !== 3) throw new Error("Invalid JWT"); return JSON.parse(Buffer.from(parts[1], "base64url").toString()); } async function exchangeCode(issuer, clientId, code, codeVerifier) { const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: clientId, code_verifier: codeVerifier, }); const resp = await fetch(`${issuer}/oauth/v2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`Token exchange failed (${resp.status}): ${text}`); } return resp.json(); } async function refreshTokens(issuer, clientId, refreshToken) { const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, }); const resp = await fetch(`${issuer}/oauth/v2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!resp.ok) { throw new Error(`Token refresh failed (${resp.status})`); } return resp.json(); } function login(context, config) { return new Promise((resolve, reject) => { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = base64url(crypto.randomBytes(16)); const authUrl = new URL(`${config.issuer}/oauth/v2/authorize`); authUrl.searchParams.set("client_id", config.clientId); authUrl.searchParams.set("redirect_uri", REDIRECT_URI); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", SCOPES); authUrl.searchParams.set("code_challenge", codeChallenge); authUrl.searchParams.set("code_challenge_method", "S256"); authUrl.searchParams.set("state", state); let timeoutId; const server = http.createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`); if (url.pathname !== "/callback") { res.writeHead(404); res.end("Not found"); return; } const error = url.searchParams.get("error"); if (error) { const desc = url.searchParams.get("error_description") ?? error; const safeDesc = desc.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); res.end(`<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`); teardown(); reject(new Error(desc)); return; } const returnedState = url.searchParams.get("state"); if (returnedState !== state) { res.writeHead(400); res.end("State mismatch"); teardown(); reject(new Error("State mismatch")); return; } const code = url.searchParams.get("code"); if (!code) { res.writeHead(400); res.end("No code"); teardown(); reject(new Error("No authorization code received")); return; } const tokens = await exchangeCode(config.issuer, config.clientId, code, codeVerifier); const idPayload = decodeJwtPayload(tokens.id_token); const email = idPayload.email ?? idPayload.preferred_username ?? "unknown"; const sub = idPayload.sub; let tenants = []; try { const accessPayload = decodeJwtPayload(tokens.access_token); tenants = accessPayload["urn:mesh:tenants"] ?? []; } catch { } writeCredentials(context, { idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), email, sub, }); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(`<html><body><h2>&#x2705; Logged in to ${context}!</h2><p>You can close this tab.</p></body></html>`); logSuccess(`Logged in as ${email} (${context})`); if (tenants.length > 0) { logInfo(`Deployable tenants: ${tenants.join(", ")}`); } hintVpnIfDisconnected(context); teardown(); resolve(); } catch (err) { teardown(); reject(err); } }); const teardown = () => { if (timeoutId) clearTimeout(timeoutId); server.closeAllConnections?.(); server.close(); }; server.listen(REDIRECT_PORT, () => { logInfo(`Opening browser for authentication (${context})...`); const url = authUrl.toString(); try { if (process.platform === "darwin") { execFileSync("open", [url], { stdio: "ignore" }); } else if (process.platform === "linux") { execFileSync("xdg-open", [url], { stdio: "ignore" }); } else { logInfo(`Open this URL in your browser:\n${url}`); } } catch { logInfo(`Open this URL in your browser:\n${url}`); } }); timeoutId = setTimeout(() => { teardown(); reject(new Error(loginTimeoutMessage(context))); }, 120_000); }); } const MAX_DEVICE_CODES = 3; export async function deviceLoginWithReissue(attempt, maxCodes = MAX_DEVICE_CODES) { for (let n = 1; n <= maxCodes; n++) { if (n > 1) logWarn(`That code expired — issuing a fresh one (${n}/${maxCodes})…`); if ((await attempt(n)) === "success") return; } throw new Error(`Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`); } async function deviceCodeLogin(context, config) { await deviceLoginWithReissue(() => attemptDeviceCode(context, config)); } async function attemptDeviceCode(context, config) { const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: config.clientId, scope: SCOPES, }).toString(), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`Device authorization request failed (${resp.status}): ${text}`); } const deviceAuth = await resp.json(); const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri; console.log(); logInfo(`Open this URL in your browser:\n`); logInfo(` ${openUrl}\n`); logInfo(`Code: ${deviceAuth.user_code}`); console.log(); logInfo("Waiting for authorization..."); try { if (process.platform === "darwin") { execFileSync("open", [openUrl], { stdio: "ignore" }); } else if (process.platform === "linux") { execFileSync("xdg-open", [openUrl], { stdio: "ignore" }); } } catch { } const deadline = Date.now() + deviceAuth.expires_in * 1000; let interval = deviceAuth.interval * 1000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, interval)); const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: config.clientId, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceAuth.device_code, }).toString(), }); if (tokenResp.ok) { const tokens = await tokenResp.json(); const idPayload = decodeJwtPayload(tokens.id_token); const email = idPayload.email ?? idPayload.preferred_username ?? "unknown"; const sub = idPayload.sub; let tenants = []; try { const accessPayload = decodeJwtPayload(tokens.access_token); tenants = accessPayload["urn:mesh:tenants"] ?? []; } catch { } writeCredentials(context, { idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), email, sub, }); logSuccess(`Logged in as ${email} (${context})`); if (tenants.length > 0) { logInfo(`Deployable tenants: ${tenants.join(", ")}`); } hintVpnIfDisconnected(context); return "success"; } const error = await tokenResp.json(); switch (error.error) { case "authorization_pending": continue; case "slow_down": interval += 1000; continue; case "expired_token": return "expired"; case "access_denied": throw new Error("Authorization denied by user."); default: throw new Error(`Token exchange failed: ${error.error} — ${error.error_description ?? ""}`); } } return "expired"; } export function loginTimeoutMessage(context) { const base = "Login timed out (2 minutes)"; if (context !== "local") return base; return (`${base}. The local platform already has a signed-up user — sign in as dev@local.mesh / LocalDev1! instead of registering. ` + "If you did register, its confirmation email is in the local mailbox at http://localhost:8025; confirm it there and run the command again."); } export function isRemoteEnvironment() { if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) return true; if (fs.existsSync("/.dockerenv")) return true; if (process.env.SSH_CLIENT || process.env.SSH_TTY) return true; if (!process.stdout.isTTY || !process.stdin.isTTY) return true; return false; } function hintVpnIfDisconnected(context) { if (context === RESERVED_REGISTRY_CONTEXT) return; try { const state = tailscaleBackendState(); if (state !== "Running") { console.log(""); logInfo(`VPN not connected. To access dev services, run:`); logInfo(` mesh vpn connect ${context}`); } } catch { } } export function accountIdFromRoleArn(roleArn) { return roleArn?.match(/^arn:aws:iam::(\d{12}):/)?.[1]; } export function renderAwsFallbackStatus(context, reason, identity, expectedAccountId) { if (!identity) { return { lines: [ { level: "warn", text: `${reason}, and no working AWS credentials were found either.`, }, { level: "info", text: "Authenticate with whichever you use:" }, { level: "info", text: ` mesh login ${context} (Zitadel SSO)` }, { level: "info", text: " aws sso login --profile … (AWS SSO profile)", }, { level: "info", text: " export AWS_PROFILE=… (profile with a key/secret pair)", }, ], exitCode: 1, }; } const lines = [ { level: "success", text: `AWS credentials are working (${identity.source})`, }, { level: "info", text: `Identity: ${identity.arn}` }, { level: "info", text: `Account: ${identity.accountId}` }, ]; if (expectedAccountId && identity.accountId !== expectedAccountId) { lines.push({ level: "warn", text: `This account does not match ${context}'s configured role account ` + `(${expectedAccountId}) — commands against ${context} will likely ` + `fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`, }); } else if (expectedAccountId) { lines.push({ level: "info", text: `Account matches ${context}'s configured role account.`, }); } else { lines.push({ level: "info", text: `This is a working AWS identity, not a verified connection to ` + `${context} — nothing here checked that this account is ${context}'s.`, }); } lines.push({ level: "info", text: `${reason} — that only matters for the commands that need a *user* ` + `identity (Hub SSO, \`mesh dev\` test users, zero-touch VPN join). ` + `Deploys, registry auth and secrets work off these credentials.`, }); return { lines, exitCode: 0 }; } async function showAwsCredentialStatus(context, reason, config) { const identity = await probeAwsIdentity(); const { lines, exitCode } = renderAwsFallbackStatus(context, reason, identity, accountIdFromRoleArn(config.defaultRole ?? config.adminRole)); for (const line of lines) { if (line.level === "success") logSuccess(line.text); else if (line.level === "warn") logWarn(line.text); else logInfo(line.text); } if (exitCode !== 0) process.exit(exitCode); } async function showStatus(context, config) { const creds = readCredentials(context); if (!creds) { await showAwsCredentialStatus(context, "No cached Zitadel session", config); return; } const expired = new Date(creds.expiresAt) < new Date(); if (expired && creds.refreshToken) { logInfo("Token expired, attempting refresh..."); try { const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken); const idPayload = decodeJwtPayload(tokens.id_token); writeCredentials(context, { ...creds, idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? creds.refreshToken, expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), email: idPayload.email ?? creds.email, }); logSuccess(`Token refreshed for ${creds.email ?? "unknown"}`); return; } catch { await showAwsCredentialStatus(context, "Zitadel token expired and refresh failed", config); return; } } if (expired) { await showAwsCredentialStatus(context, `Zitadel token expired at ${creds.expiresAt}`, config); return; } let tenants = []; try { const accessPayload = decodeJwtPayload(creds.accessToken); tenants = accessPayload["urn:mesh:tenants"] ?? []; } catch { } logSuccess(`Logged in as ${creds.email ?? "unknown"} (${context})`); logInfo(`Subject: ${creds.sub}`); logInfo(`Expires: ${creds.expiresAt}`); if (tenants.length > 0) { logInfo(`Deployable tenants: ${tenants.join(", ")}`); } } export function registerLoginCommand(program) { program .command("login") .description("Authenticate with Zitadel (OIDC PKCE or Device Code)") .argument("<context>", 'Platform context (e.g., "mesh.dev")') .option("--status", "Show current authentication status — the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.") .option("--device", "Force device code flow (no callback server needed)") .option("--export", "After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.") .option("--static", "With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile") .option("--credential-process", "Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.") .option("--role <arn>", "IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.") .option("--region <region>", "AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.") .option("--no-registry", "Deprecated and ignored — mesh login no longer touches the package registry (that is `mesh registry login`)") .action(async (context, opts) => { refuseReservedRegistryContext(context, "login"); let config = getContextConfig(context); if (!config) { config = await discoverConfigGuarded(context); if (!config) { logError(renderNoConfigHelp(context)); process.exit(1); } } if (opts.status) { await showStatus(context, config); return; } if (opts.export) { await exportAwsCredentials(context, config, opts); return; } if (opts.credentialProcess) { await credentialProcessAwsCredentials(context, config, opts); return; } if (opts.registry === false) { logWarn(NO_REGISTRY_DEPRECATED); } try { await runLoginFlow(context, config, opts); } catch (err) { logError(`Login failed: ${err.message}`); process.exit(1); } await hintRegistryIfMissing(); }); program .command("logout") .description("Clear cached Zitadel credentials") .argument("<context>", 'Platform context (e.g., "mesh.dev")') .action((context) => { refuseReservedRegistryContext(context, "logout"); clearCredentials(context); logSuccess(`Logged out of ${context}. Credentials cleared.`); }); } export const RESERVED_REGISTRY_CONTEXT = "registry"; export const NO_REGISTRY_DEPRECATED = "--no-registry is deprecated and ignored: mesh login no longer touches the registry."; export const REGISTRY_SEPARATE_HINT = "Package registry access is separate from platform sign-in — run: mesh registry login"; function refuseReservedRegistryContext(context, verb) { if (context !== RESERVED_REGISTRY_CONTEXT) return; throw new MeshCliError(`"registry" is the package registry's own session, not a platform context.`, { remediation: { command: `mesh registry ${verb}` } }); } async function hintRegistryIfMissing() { try { const { readRegistrySession } = await import("../utils/registry-identity.js"); if (readRegistrySession()) return; const { probeRegistryToken } = await import("../utils/auth-preflight.js"); const probe = await probeRegistryToken(); if (probe.state === "missing" || probe.state === "expired") { logInfo(REGISTRY_SEPARATE_HINT); } } catch { } } export function renderNoConfigHelp(context) { const pinned = firstPartyDomainFor(context); return (`No configuration found for "${context}".\n\n` + `Tried:\n` + ` 1. SSM: /mesh-platform/${context.split(".")[0]}/${context.split(".")[1]}/platform/zitadel\n` + ` (requires AWS SSO login + read access)\n` + (pinned ? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\n\n` : ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\n` + ` (only attempted for a full domain, or a known first-party context)\n\n`) + `To fix:\n` + ` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\n` + ` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\n` + ` - Or add config manually to ${CONFIG_FILE}:\n\n` + ` {\n` + ` "${context}": {\n` + ` "issuer": "https://identity.<env>.<your-platform-domain>",\n` + ` "clientId": "<cli-oidc-client-id>"\n` + ` }\n` + ` }\n`); } export function tokenStillValid(expiresAt, marginMs = 0, now = Date.now()) { return new Date(expiresAt).getTime() - marginMs > now; } async function getValidToken(context, opts = {}) { const config = getContextConfig(context); if (!config) return null; const creds = readCredentials(context); if (!creds) return null; if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) { return creds.idToken; } return remintToken(context, config, creds); } async function remintToken(context, config, creds) { if (!creds.refreshToken) return null; let tokens; try { tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken); } catch { return null; } let email = creds.email; try { email = decodeJwtPayload(tokens.id_token).email ?? creds.email; } catch { } try { writeCredentials(context, { ...creds, idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? creds.refreshToken, expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), email, }); } catch (err) { logWarn(`The refreshed ${context} session could not be cached (${err instanceof Error ? err.message : String(err)}) — ` + `this run continues on the new token, but the next one may need a fresh sign-in.`); } return tokens.id_token; } export async function forceRefreshToken(context) { const config = getContextConfig(context); if (!config) return null; const creds = readCredentials(context); if (!creds) return null; return remintToken(context, config, creds); } export async function probeCredentials(context, roleArn) { const cached = readCredentials(context); if (!cached) return { state: "no-session" }; if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) { return { state: "stale-env-override" }; } const token = await getValidToken(context); if (!token) return { state: "expired-session" }; const sessionName = (cached.email ?? "mesh-cli-doctor") .replace(/[^a-zA-Z0-9=,.@-]/g, "_") .slice(0, 64); const sts = await assumeRoleCredentials(roleArn, token, sessionName); if (!sts) { return { state: "assume-denied", detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, "inline"), }; } const ttlSeconds = Math.max(0, Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000)); return { state: "ok", ttlSeconds, expiresAt: sts.Expiration, email: cached.email }; } function shellSingleQuote(value) { return `'${value.replace(/'/g, "'\\''")}'`; } export function renderExportStaticLines(args) { return (`export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\n` + `export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\n` + `export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\n` + `export AWS_REGION=${shellSingleQuote(args.region)}\n`); } export function renderExportProfileLines(args) { return (`unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\n` + `export AWS_PROFILE=${shellSingleQuote(args.profileName)}\n` + `export AWS_REGION=${shellSingleQuote(args.region)}\n`); } export function resolveUserAwsConfigPath(env = process.env) { return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? "~", ".aws", "config"); } function canWriteDir(dir) { try { fs.mkdirSync(dir, { recursive: true }); fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; } } export function resolveAwsConfigTarget(env = process.env) { if (env.AWS_CONFIG_FILE) return { configPath: env.AWS_CONFIG_FILE, redirected: false }; const real = path.join(env.HOME ?? "~", ".aws", "config"); if (canWriteDir(path.dirname(real))) return { configPath: real, redirected: false }; return { configPath: path.join(CONFIG_DIR, "aws-config"), redirected: true }; } export function resolveRoleOrExplain(opts, config) { const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole; if (!role) { return { error: "No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run " + "`mesh login <context> --export --role <arn>` once to cache a default role for this context.", }; } return { role }; } export async function runLoginFlow(context, config, opts = {}) { const useDevice = opts.device || isRemoteEnvironment(); if (useDevice) { logInfo("Using device code flow"); await deviceCodeLogin(context, config); } else { await login(context, config); } } async function ensureValidToken(context, config, opts) { let token = await getValidToken(context); if (token) return token; logInfo(`No valid Zitadel session for ${context} — running login flow`); try { await runLoginFlow(context, config, opts); } catch (err) { logError(`Login failed: ${err.message}`); return null; } return await getValidToken(context); } export async function ensureLogin(context, opts = {}) { const existing = readCredentials(context); if (existing && new Date(existing.expiresAt) > new Date()) return existing; if (opts.interactive === false) return null; const config = getContextConfig(context) ?? (await discoverConfigGuarded(context)); if (!config) { logWarn(`No login config for "${context}". Run: mesh login ${context}`); return null; } const token = await ensureValidToken(context, config, opts); if (!token) return null; return readCredentials(context); } async function discoverConfigGuarded(context) { let config = null; if (parseTenantEnv(context)) { config = await discoverConfigFromSsm(context); } if (!config) { const pinned = firstPartyDomainFor(context); if (pinned) { logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`); config = await discoverConfig(pinned, context); } } if (!config && context.split(".").length - 1 >= 2) { config = await discoverConfig(context); } return config; } async function exportAwsCredentials(context, config, opts) { const token = await ensureValidToken(context, config, opts); if (!token) { process.exit(1); } const resolvedRole = resolveRoleOrExplain(opts, config); if ("error" in resolvedRole) { logError("--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\n" + " Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\n" + " After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted."); process.exit(1); } let roleArn = resolvedRole.role; if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) { roleArn = selectRoleForCaller(token, { defaultRole: config.defaultRole, adminRole: config.adminRole, adminClaimRoles: config.adminClaimRoles, }); } if (config.adminRole && roleArn === config.adminRole && !opts.role) { logInfo(`Caller has admin Zitadel role — assuming ${roleArn.split("/").pop()} (admin variant)`); } if (opts.role && opts.role !== config.defaultRole) { writeContextConfig(context, { ...config, defaultRole: opts.role }); logInfo(`Saved default role for ${context}`); } const region = opts.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; const roleName = roleArn.split("/").pop() ?? roleArn; if (opts.static) { const creds = readCredentials(context); const sessionName = (creds?.email ?? "mesh-cli-export") .replace(/[^a-zA-Z0-9=,.@-]/g, "_") .slice(0, 64); const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName); if (!env) { logError(describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) })); process.exit(1); } const accessKey = env.AWS_ACCESS_KEY_ID; const secretKey = env.AWS_SECRET_ACCESS_KEY; const sessionToken = env.AWS_SESSION_TOKEN; if (!accessKey || !secretKey || !sessionToken) { logError("AWS STS returned incomplete credentials"); process.exit(1); } process.stdout.write(renderExportStaticLines({ accessKey, secretKey, sessionToken, region })); logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`); return; } const meshBin = resolveStableMeshBin(process.argv[1]); const sanitized = context.replace(/[^A-Za-z0-9_-]/g, "-"); const profileName = `mesh-${sanitized}`; const { configPath, redirected } = resolveAwsConfigTarget(); fs.mkdirSync(path.dirname(configPath), { recursive: true }); const existedBefore = fs.existsSync(configPath); const existing = existedBefore ? fs.readFileSync(configPath, "utf-8") : ""; const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600; atomicWriteFileSync(configPath, upsertManagedAwsConfigSection(existing, context, renderCredentialProcessProfile({ profileName, context, roleArn, region, meshBin, })), mode); process.stdout.write(renderExportProfileLines({ profileName, region })); if (redirected) { process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\n`); } logSuccess(`Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` + `(managed section in ${configPath})`); } async function credentialProcessAwsCredentials(context, config, opts) { const token = await getValidToken(context); if (!token) { logError(`No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`); process.exit(1); } const resolvedRole = resolveRoleOrExplain(opts, config); if ("error" in resolvedRole) { logError(resolvedRole.error); process.exit(1); } const creds = readCredentials(context); const sessionName = (creds?.email ?? "mesh-cli-credential-process") .replace(/[^a-zA-Z0-9=,.@-]/g, "_") .slice(0, 64); const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName); if (!stsCreds) { logError(describeAssumeFailure({ roleArn: resolvedRole.role, context, issuer: tokenIssuer(token), })); process.exit(1); } process.stdout.write(toCredentialProcessJson(stsCreds) + "\n"); } export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE }; export function readAllContextConfigs() { return readConfig(); } export async function discoverConfigForContext(context) { return getContextConfig(context) ?? (await discoverConfigGuarded(context)); } export { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken, clearCredentials };