Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.tgz
.env
.DS_Store
.claude/
155 changes: 154 additions & 1 deletion src/auth/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down
60 changes: 48 additions & 12 deletions src/auth/commands.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -10,22 +11,45 @@ export function registerAuthCommands(program: Command): void {
.option("--token <token>", "Bearer token")
.option("--api-key <key>", "API key")
.option("--header-name <name>", "Custom header name for API key", "X-API-Key")
.option(
"-H, --header <header>",
'Custom header "Name: Value" (repeatable, for multi-header auth like VTEX)',
collect,
[] as string[]
)
.option("--profile <name>", "Profile name", "default")
.action(async (opts: Record<string, string>) => {
const profileName = opts["profile"] ?? "default";
.action(async (opts: Record<string, unknown>) => {
const profileName = (opts["profile"] as string) ?? "default";
const headerArgs = (opts["header"] as string[]) ?? [];

if (headerArgs.length > 0) {
const headers: Record<string, string> = {};
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);
}
});
Expand Down Expand Up @@ -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<string, string> }
): 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}`);
}
}
}
54 changes: 46 additions & 8 deletions src/auth/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface AuthFlags {
token?: string;
apiKey?: string;
authHeader?: string;
headers?: Record<string, string>;
profile?: string;
rcAuthType?: string;
rcAuthToken?: string;
Expand All @@ -18,21 +19,28 @@ export async function resolveAuth(
env: NodeJS.ProcessEnv = process.env
): Promise<AuthConfig> {
// Priority 1: Inline flags
if (flags.headers && Object.keys(flags.headers).length > 0) {
const resolved: Record<string, string> = {};
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];
Expand All @@ -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<string, string> = {};
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,
};
}
Expand Down Expand Up @@ -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;
}
22 changes: 22 additions & 0 deletions src/auth/headers.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
4 changes: 3 additions & 1 deletion src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
export interface AuthConfig {
type: "bearer" | "apiKey" | "basic" | "none";
type: "bearer" | "apiKey" | "basic" | "headers" | "none";
value: string;
headerName?: string;
headers?: Record<string, string>;
}

export interface AuthProfile {
type: AuthConfig["type"];
value: string;
headerName?: string;
headers?: Record<string, string>;
}

export interface AuthStore {
Expand Down
Loading
Loading