diff --git a/.gitignore b/.gitignore index 6a2025e..a2c414b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.tgz .env .DS_Store +.claude/ diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 5d1fc9b..861bfbf 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { resolveAuth } from "./flags.js"; +import { resolveAuth, detectApiKeyHeaders } from "./flags.js"; import { saveProfile, loadAuthStore, removeProfile, maskToken } from "./config.js"; +import { parseHeaderFlag } from "./headers.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -158,6 +159,158 @@ describe("auth config (profile persistence)", () => { }); }); +const specDualHeader: OpenAPISpec = { + ...minimalSpec, + components: { + securitySchemes: { + appKey: { type: "apiKey", in: "header", name: "X-VTEX-API-AppKey" }, + appToken: { type: "apiKey", in: "header", name: "X-VTEX-API-AppToken" }, + }, + }, +}; + +describe("parseHeaderFlag", () => { + it("parses Name: Value", () => { + expect(parseHeaderFlag("X-Api-Key: abc123")).toEqual({ name: "X-Api-Key", value: "abc123" }); + }); + + it("trims whitespace", () => { + expect(parseHeaderFlag(" X-Key : val ")).toEqual({ name: "X-Key", value: "val" }); + }); + + it("preserves colons inside value (e.g. URLs)", () => { + expect(parseHeaderFlag("Referer: https://example.com:8080/x")).toEqual({ + name: "Referer", + value: "https://example.com:8080/x", + }); + }); + + it("returns null for missing colon", () => { + expect(parseHeaderFlag("NoColonHere")).toBeNull(); + }); + + it("returns null for empty name", () => { + expect(parseHeaderFlag(": value")).toBeNull(); + }); + + it("rejects CR/LF in value (header injection)", () => { + expect(parseHeaderFlag("X-Key: bad\r\nEvil: yes")).toBeNull(); + expect(parseHeaderFlag("X-Key: bad\nEvil: yes")).toBeNull(); + expect(parseHeaderFlag("X-Key: bad\rEvil: yes")).toBeNull(); + }); + + it("rejects invalid characters in header name", () => { + expect(parseHeaderFlag("X Key: v")).toBeNull(); + expect(parseHeaderFlag("X\tKey: v")).toBeNull(); + expect(parseHeaderFlag("X:Key: v")).toEqual({ name: "X", value: "Key: v" }); + }); +}); + +describe("detectApiKeyHeaders", () => { + it("returns all apiKey header names", () => { + const names = detectApiKeyHeaders(specDualHeader); + expect(names).toEqual(["X-VTEX-API-AppKey", "X-VTEX-API-AppToken"]); + }); + + it("returns empty array when no schemes", () => { + expect(detectApiKeyHeaders(minimalSpec)).toEqual([]); + }); +}); + +describe("resolveAuth with multi-header", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "tocli-auth-multiheader-")); + vi.stubEnv("XDG_CONFIG_HOME", tmpDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns headers auth when --header flags are provided", async () => { + const auth = await resolveAuth( + { headers: { "X-VTEX-API-AppKey": "key1", "X-VTEX-API-AppToken": "tok1" } }, + specDualHeader, + {} + ); + expect(auth.type).toBe("headers"); + expect(auth.headers).toEqual({ + "X-VTEX-API-AppKey": "key1", + "X-VTEX-API-AppToken": "tok1", + }); + }); + + it("resolves env vars inside header values", async () => { + const auth = await resolveAuth( + { headers: { "X-VTEX-API-AppKey": "$VTEX_KEY", "X-VTEX-API-AppToken": "${VTEX_TOKEN}" } }, + specDualHeader, + { VTEX_KEY: "resolved-key", VTEX_TOKEN: "resolved-tok" } + ); + expect(auth.headers).toEqual({ + "X-VTEX-API-AppKey": "resolved-key", + "X-VTEX-API-AppToken": "resolved-tok", + }); + }); + + it("--header takes priority over --token", async () => { + const auth = await resolveAuth( + { headers: { "X-Custom": "v" }, token: "sk-1" }, + minimalSpec, + {} + ); + expect(auth.type).toBe("headers"); + }); + + it("loads headers profile from disk", async () => { + await saveProfile("vtex", { + type: "headers", + value: "", + headers: { "X-VTEX-API-AppKey": "stored-key", "X-VTEX-API-AppToken": "stored-tok" }, + }); + const auth = await resolveAuth({ profile: "vtex" }, specDualHeader, {}); + expect(auth.type).toBe("headers"); + expect(auth.headers).toEqual({ + "X-VTEX-API-AppKey": "stored-key", + "X-VTEX-API-AppToken": "stored-tok", + }); + }); + + it("empty headers object falls through to other resolution", async () => { + const auth = await resolveAuth({ headers: {}, token: "sk-1" }, minimalSpec, {}); + expect(auth.type).toBe("bearer"); + expect(auth.value).toBe("sk-1"); + }); + + it("warns to stderr when $VAR in header resolves to empty", async () => { + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + const auth = await resolveAuth( + { headers: { "X-VTEX-API-AppKey": "$MISSING_VAR" } }, + specDualHeader, + {} + ); + expect(auth.headers?.["X-VTEX-API-AppKey"]).toBe(""); + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); + expect(logged).toMatch(/Warning.*MISSING_VAR.*unset/); + expect(logged).toContain("X-VTEX-API-AppKey"); + stderr.mockRestore(); + }); + + it("warns when $VAR resolves to empty string", async () => { + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + await resolveAuth( + { headers: { "X-Key": "$EMPTY_VAR" } }, + specDualHeader, + { EMPTY_VAR: "" } + ); + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); + expect(logged).toMatch(/Warning.*EMPTY_VAR.*empty/); + stderr.mockRestore(); + }); +}); + describe("maskToken", () => { it("masks long tokens", () => { expect(maskToken("sk-1234567890abcdef")).toBe("sk-1...cdef"); diff --git a/src/auth/commands.ts b/src/auth/commands.ts index c58edcd..0a60b7b 100644 --- a/src/auth/commands.ts +++ b/src/auth/commands.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; -import { saveProfile, removeProfile, getProfile, loadAuthStore, maskToken } from "./config.js"; +import { saveProfile, removeProfile, loadAuthStore, maskToken } from "./config.js"; +import { parseHeaderFlag } from "./headers.js"; export function registerAuthCommands(program: Command): void { const auth = program.command("auth").description("Manage authentication"); @@ -10,22 +11,45 @@ export function registerAuthCommands(program: Command): void { .option("--token ", "Bearer token") .option("--api-key ", "API key") .option("--header-name ", "Custom header name for API key", "X-API-Key") + .option( + "-H, --header
", + 'Custom header "Name: Value" (repeatable, for multi-header auth like VTEX)', + collect, + [] as string[] + ) .option("--profile ", "Profile name", "default") - .action(async (opts: Record) => { - const profileName = opts["profile"] ?? "default"; + .action(async (opts: Record) => { + const profileName = (opts["profile"] as string) ?? "default"; + const headerArgs = (opts["header"] as string[]) ?? []; + + if (headerArgs.length > 0) { + const headers: Record = {}; + for (const raw of headerArgs) { + const parsed = parseHeaderFlag(raw); + if (!parsed) { + console.error(`Error: invalid --header '${raw}'. Expected "Name: Value".`); + process.exit(1); + } + headers[parsed.name] = parsed.value; + } + await saveProfile(profileName, { type: "headers", value: "", headers }); + const names = Object.keys(headers).join(", "); + console.log(`Saved ${Object.keys(headers).length} header(s) [${names}] to profile '${profileName}'.`); + return; + } if (opts["token"]) { - await saveProfile(profileName, { type: "bearer", value: opts["token"] }); + await saveProfile(profileName, { type: "bearer", value: opts["token"] as string }); console.log(`Saved bearer token to profile '${profileName}'.`); } else if (opts["apiKey"]) { await saveProfile(profileName, { type: "apiKey", - value: opts["apiKey"], - headerName: opts["headerName"] ?? "X-API-Key", + value: opts["apiKey"] as string, + headerName: (opts["headerName"] as string) ?? "X-API-Key", }); console.log(`Saved API key to profile '${profileName}'.`); } else { - console.error("Error: provide --token or --api-key"); + console.error("Error: provide --token, --api-key, or one or more --header flags"); process.exit(1); } }); @@ -72,12 +96,24 @@ export function registerAuthCommands(program: Command): void { }); } -function printProfile(name: string, profile: { type: string; value: string; headerName?: string }): void { - const masked = maskToken(profile.value); +function collect(value: string, previous: string[]): string[] { + return previous.concat([value]); +} + +function printProfile( + name: string, + profile: { type: string; value: string; headerName?: string; headers?: Record } +): void { console.log(`Profile: ${name}`); console.log(` Type: ${profile.type}`); - console.log(` Value: ${masked}`); - if (profile.headerName) { - console.log(` Header: ${profile.headerName}`); + if (profile.type === "headers" && profile.headers) { + for (const [k, v] of Object.entries(profile.headers)) { + console.log(` ${k}: ${maskToken(v)}`); + } + } else { + console.log(` Value: ${maskToken(profile.value)}`); + if (profile.headerName) { + console.log(` Header: ${profile.headerName}`); + } } } diff --git a/src/auth/flags.ts b/src/auth/flags.ts index 740d996..c41835e 100644 --- a/src/auth/flags.ts +++ b/src/auth/flags.ts @@ -6,6 +6,7 @@ export interface AuthFlags { token?: string; apiKey?: string; authHeader?: string; + headers?: Record; profile?: string; rcAuthType?: string; rcAuthToken?: string; @@ -18,21 +19,28 @@ export async function resolveAuth( env: NodeJS.ProcessEnv = process.env ): Promise { // Priority 1: Inline flags + if (flags.headers && Object.keys(flags.headers).length > 0) { + const resolved: Record = {}; + for (const [k, v] of Object.entries(flags.headers)) { + resolved[k] = resolveEnvVar(v, env, `--header "${k}"`); + } + return { type: "headers", value: "", headers: resolved }; + } if (flags.token) { - return { type: "bearer", value: resolveEnvVar(flags.token, env) }; + return { type: "bearer", value: resolveEnvVar(flags.token, env, "--token") }; } if (flags.apiKey) { const headerName = detectApiKeyHeader(spec) ?? "X-API-Key"; - return { type: "apiKey", value: resolveEnvVar(flags.apiKey, env), headerName }; + return { type: "apiKey", value: resolveEnvVar(flags.apiKey, env, "--api-key"), headerName }; } if (flags.authHeader) { - return { type: "bearer", value: resolveEnvVar(flags.authHeader, env) }; + return { type: "bearer", value: resolveEnvVar(flags.authHeader, env, "--auth-header") }; } // Priority 2: .toclirc auth config if (flags.rcAuthToken) { const type = (flags.rcAuthType as AuthConfig["type"]) ?? "bearer"; - return { type, value: resolveEnvVar(flags.rcAuthToken, env) }; + return { type, value: resolveEnvVar(flags.rcAuthToken, env, ".toclirc auth.token") }; } if (flags.rcAuthEnvVar) { const envVal = env[flags.rcAuthEnvVar]; @@ -58,9 +66,16 @@ export async function resolveAuth( const profileName = flags.profile ?? "default"; const profile = await getProfile(profileName); if (profile) { + if (profile.type === "headers" && profile.headers) { + const resolved: Record = {}; + for (const [k, v] of Object.entries(profile.headers)) { + resolved[k] = resolveEnvVar(v, env, `profile '${profileName}' header "${k}"`); + } + return { type: "headers", value: "", headers: resolved }; + } return { type: profile.type, - value: resolveEnvVar(profile.value, env), + value: resolveEnvVar(profile.value, env, `profile '${profileName}'`), headerName: profile.headerName, }; } @@ -100,10 +115,33 @@ function detectApiKeyHeader(spec: OpenAPISpec): string | undefined { return undefined; } -function resolveEnvVar(value: string, env: NodeJS.ProcessEnv): string { - // Replace $VAR or ${VAR} with env values +function resolveEnvVar(value: string, env: NodeJS.ProcessEnv, context?: string): string { + // Replace $VAR or ${VAR} with env values. Warn when a reference resolves to empty — + // silent empty headers confuse downstream 401s ("auth wrong" when it's "env unset"). return value.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (_, braced, plain) => { const name = braced ?? plain; - return env[name] ?? ""; + const resolved = env[name]; + if (resolved === undefined || resolved === "") { + console.error( + `Warning: env var $${name} is ${resolved === undefined ? "unset" : "empty"}${context ? ` (used in ${context})` : ""}.` + ); + return ""; + } + return resolved; }); } + +/** + * Detect all apiKey header schemes. Used for multi-header auth detection (e.g. VTEX). + */ +export function detectApiKeyHeaders(spec: OpenAPISpec): string[] { + const schemes = spec.components?.securitySchemes; + if (!schemes) return []; + const names: string[] = []; + for (const scheme of Object.values(schemes)) { + if (scheme.type === "apiKey" && scheme.in === "header" && scheme.name) { + names.push(scheme.name); + } + } + return names; +} diff --git a/src/auth/headers.ts b/src/auth/headers.ts new file mode 100644 index 0000000..f127383 --- /dev/null +++ b/src/auth/headers.ts @@ -0,0 +1,22 @@ +export interface ParsedHeader { + name: string; + value: string; +} + +/** + * Parse a header flag in the form "Name: Value". + * Returns null for invalid input (empty name, missing colon). + * The value may contain additional colons (e.g. URLs) — only the first ":" splits. + */ +export function parseHeaderFlag(raw: string): ParsedHeader | null { + const idx = raw.indexOf(":"); + if (idx === -1) return null; + const name = raw.slice(0, idx).trim(); + const value = raw.slice(idx + 1).trim(); + if (!name) return null; + // Reject CR/LF — header injection (RFC 7230 §3.2.4 forbids them in values/names) + if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) return null; + // Header names per RFC 7230: token chars only (no whitespace, no control chars) + if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) return null; + return { name, value }; +} diff --git a/src/auth/types.ts b/src/auth/types.ts index 1fd70ee..e4617ba 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -1,13 +1,15 @@ export interface AuthConfig { - type: "bearer" | "apiKey" | "basic" | "none"; + type: "bearer" | "apiKey" | "basic" | "headers" | "none"; value: string; headerName?: string; + headers?: Record; } export interface AuthProfile { type: AuthConfig["type"]; value: string; headerName?: string; + headers?: Record; } export interface AuthStore { diff --git a/src/executor/http.test.ts b/src/executor/http.test.ts index 2906dd7..4a0a2d2 100644 --- a/src/executor/http.test.ts +++ b/src/executor/http.test.ts @@ -138,6 +138,81 @@ describe("executeRequest", () => { vi.unstubAllGlobals(); }); + it("sets multiple headers for multi-header auth (VTEX-style)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve(JSON.stringify({})), + })); + + const auth: AuthConfig = { + type: "headers", + value: "", + headers: { + "X-VTEX-API-AppKey": "my-key", + "X-VTEX-API-AppToken": "my-token", + }, + }; + await executeRequest(makeOp(), {}, auth, BASE_URL); + + const callArgs = (fetch as ReturnType).mock.calls[0]; + const headers = (callArgs[1] as RequestInit).headers as Record; + expect(headers["X-VTEX-API-AppKey"]).toBe("my-key"); + expect(headers["X-VTEX-API-AppToken"]).toBe("my-token"); + + vi.unstubAllGlobals(); + }); + + it("masks auth header values in verbose output (multi-header)", async () => { + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve("{}"), + })); + + const auth: AuthConfig = { + type: "headers", + value: "", + headers: { + "X-VTEX-API-AppKey": "vtexappkey-sanavita-SECRET", + "X-VTEX-API-AppToken": "FJRVTFCDCJWZQWDYPQELDCWWGEONETPXMVPDVMBQSBBE", + }, + }; + await executeRequest(makeOp(), {}, auth, BASE_URL, true); + + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); + expect(logged).not.toContain("vtexappkey-sanavita-SECRET"); + expect(logged).not.toContain("FJRVTFCDCJWZQWDYPQELDCWWGEONETPXMVPDVMBQSBBE"); + expect(logged).toMatch(/X-VTEX-API-AppKey: vtex\.\.\.CRET/); + expect(logged).toMatch(/X-VTEX-API-AppToken: FJRV\.\.\.SBBE/); + + stderr.mockRestore(); + vi.unstubAllGlobals(); + }); + + it("masks apiKey header value in verbose output", async () => { + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve("{}"), + })); + + const auth: AuthConfig = { type: "apiKey", value: "super-secret-key-value", headerName: "X-Custom-Key" }; + await executeRequest(makeOp(), {}, auth, BASE_URL, true); + + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); + expect(logged).not.toContain("super-secret-key-value"); + expect(logged).toMatch(/X-Custom-Key: supe\.\.\.alue/); + + stderr.mockRestore(); + vi.unstubAllGlobals(); + }); + it("returns status, headers, and parsed data", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 200, diff --git a/src/executor/http.ts b/src/executor/http.ts index 336f90a..4799dc9 100644 --- a/src/executor/http.ts +++ b/src/executor/http.ts @@ -1,5 +1,6 @@ import type { Operation } from "../parser/types.js"; import type { AuthConfig, HttpResponse } from "./types.js"; +import { maskToken } from "../auth/config.js"; export async function executeRequest( op: Operation, @@ -14,9 +15,10 @@ export async function executeRequest( const method = op.method; if (verbose) { + const authNames = authHeaderNames(auth); console.error(`→ ${method} ${url}`); for (const [k, v] of Object.entries(headers)) { - const display = k.toLowerCase() === "authorization" ? `${v.slice(0, 15)}...` : v; + const display = authNames.has(k.toLowerCase()) ? maskToken(v) : v; console.error(` ${k}: ${display}`); } if (body) { @@ -119,11 +121,37 @@ function buildHeaders( case "basic": headers["Authorization"] = `Basic ${Buffer.from(auth.value).toString("base64")}`; break; + case "headers": + if (auth.headers) { + for (const [k, v] of Object.entries(auth.headers)) { + headers[k] = v; + } + } + break; } return headers; } +function authHeaderNames(auth: AuthConfig): Set { + const names = new Set(); + switch (auth.type) { + case "bearer": + case "basic": + names.add("authorization"); + break; + case "apiKey": + names.add((auth.headerName ?? "X-API-Key").toLowerCase()); + break; + case "headers": + if (auth.headers) { + for (const name of Object.keys(auth.headers)) names.add(name.toLowerCase()); + } + break; + } + return names; +} + function buildBody( op: Operation, params: Record diff --git a/src/executor/types.ts b/src/executor/types.ts index d886e56..2ec1664 100644 --- a/src/executor/types.ts +++ b/src/executor/types.ts @@ -1,7 +1,8 @@ export interface AuthConfig { - type: "bearer" | "apiKey" | "basic" | "none"; + type: "bearer" | "apiKey" | "basic" | "headers" | "none"; value: string; headerName?: string; + headers?: Record; } export interface HttpResponse { diff --git a/src/index.ts b/src/index.ts index 517f7f3..de2ea13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { executeRequest } from "./executor/http.js"; import { formatOutput } from "./output/formatters.js"; import { registerAuthCommands } from "./auth/commands.js"; import { resolveAuth as resolveAuthFromFlags } from "./auth/flags.js"; +import { parseHeaderFlag } from "./auth/headers.js"; import { registerInitCommand } from "./config/init.js"; import { registerUseCommand } from "./templates/commands.js"; import { loadConfig, resolveConfig } from "./config/rc.js"; @@ -33,6 +34,7 @@ Flags: --dry-run Preview the HTTP request without executing --validate Validate response against the OpenAPI schema --agent-help Compact YAML with all commands, params, and auth + --header "Name: Value" Send a custom auth header (repeatable, for APIs like VTEX) Examples: spec2cli --spec ./api.yaml pets list @@ -104,6 +106,7 @@ async function main() { { token: getFlagValue(rawArgs, "--token"), apiKey: getFlagValue(rawArgs, "--api-key"), + headers: parseHeaderArgs(rawArgs), profile: getFlagValue(rawArgs, "--profile"), rcAuthType, rcAuthToken, @@ -249,6 +252,10 @@ function printDryRun(op: import("./parser/types.js").Operation, params: Record 1) { + const parts = apiKeyHeaders.map((h) => `--header "${h}: "`).join(" "); + return `multi-header ${parts}`; + } + for (const scheme of Object.values(schemes)) { if (scheme.type === "http" && scheme.scheme === "bearer") return "bearer --token "; if (scheme.type === "apiKey") return `apiKey --api-key (header: ${scheme.name})`; @@ -385,13 +405,38 @@ function simplifyName(operationId: string, tag: string): string { return operationId.toLowerCase(); } +function parseHeaderArgs(args: string[]): Record | undefined { + const raws = getFlagValues(args, "--header").concat(getFlagValues(args, "-H")); + if (raws.length === 0) return undefined; + const headers: Record = {}; + for (const raw of raws) { + const parsed = parseHeaderFlag(raw); + if (!parsed) { + console.error(`Error: invalid --header '${raw}'. Expected "Name: Value" with RFC-valid name and no CR/LF.`); + process.exit(1); + } + headers[parsed.name] = parsed.value; + } + return headers; +} + function getFlagValue(args: string[], flag: string): string | undefined { const idx = args.indexOf(flag); return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined; } +function getFlagValues(args: string[], flag: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === flag && i + 1 < args.length) { + values.push(args[i + 1]); + } + } + return values; +} + function filterTocliFlags(argv: string[]): string[] { - const valueFlags = new Set(["--spec", "--output", "--max-items", "--token", "--api-key", "--base-url", "--profile", "--env"]); + const valueFlags = new Set(["--spec", "--output", "--max-items", "--token", "--api-key", "--base-url", "--profile", "--env", "--header", "-H"]); const boolFlags = new Set(["--verbose", "--quiet", "--dry-run", "--validate", "--agent-help"]); const result: string[] = []; let i = 0;