Skip to content

Commit 8115c56

Browse files
authored
Merge pull request #21 from lucianfialho/feat/20-dual-header-auth
feat: multi-header auth for VTEX-style APIs
2 parents 72b9628 + 772daba commit 8115c56

10 files changed

Lines changed: 426 additions & 25 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ dist/
33
*.tgz
44
.env
55
.DS_Store
6+
.claude/

src/auth/auth.test.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { resolveAuth } from "./flags.js";
2+
import { resolveAuth, detectApiKeyHeaders } from "./flags.js";
33
import { saveProfile, loadAuthStore, removeProfile, maskToken } from "./config.js";
4+
import { parseHeaderFlag } from "./headers.js";
45
import { mkdtemp, rm } from "node:fs/promises";
56
import { tmpdir } from "node:os";
67
import { join } from "node:path";
@@ -158,6 +159,158 @@ describe("auth config (profile persistence)", () => {
158159
});
159160
});
160161

162+
const specDualHeader: OpenAPISpec = {
163+
...minimalSpec,
164+
components: {
165+
securitySchemes: {
166+
appKey: { type: "apiKey", in: "header", name: "X-VTEX-API-AppKey" },
167+
appToken: { type: "apiKey", in: "header", name: "X-VTEX-API-AppToken" },
168+
},
169+
},
170+
};
171+
172+
describe("parseHeaderFlag", () => {
173+
it("parses Name: Value", () => {
174+
expect(parseHeaderFlag("X-Api-Key: abc123")).toEqual({ name: "X-Api-Key", value: "abc123" });
175+
});
176+
177+
it("trims whitespace", () => {
178+
expect(parseHeaderFlag(" X-Key : val ")).toEqual({ name: "X-Key", value: "val" });
179+
});
180+
181+
it("preserves colons inside value (e.g. URLs)", () => {
182+
expect(parseHeaderFlag("Referer: https://example.com:8080/x")).toEqual({
183+
name: "Referer",
184+
value: "https://example.com:8080/x",
185+
});
186+
});
187+
188+
it("returns null for missing colon", () => {
189+
expect(parseHeaderFlag("NoColonHere")).toBeNull();
190+
});
191+
192+
it("returns null for empty name", () => {
193+
expect(parseHeaderFlag(": value")).toBeNull();
194+
});
195+
196+
it("rejects CR/LF in value (header injection)", () => {
197+
expect(parseHeaderFlag("X-Key: bad\r\nEvil: yes")).toBeNull();
198+
expect(parseHeaderFlag("X-Key: bad\nEvil: yes")).toBeNull();
199+
expect(parseHeaderFlag("X-Key: bad\rEvil: yes")).toBeNull();
200+
});
201+
202+
it("rejects invalid characters in header name", () => {
203+
expect(parseHeaderFlag("X Key: v")).toBeNull();
204+
expect(parseHeaderFlag("X\tKey: v")).toBeNull();
205+
expect(parseHeaderFlag("X:Key: v")).toEqual({ name: "X", value: "Key: v" });
206+
});
207+
});
208+
209+
describe("detectApiKeyHeaders", () => {
210+
it("returns all apiKey header names", () => {
211+
const names = detectApiKeyHeaders(specDualHeader);
212+
expect(names).toEqual(["X-VTEX-API-AppKey", "X-VTEX-API-AppToken"]);
213+
});
214+
215+
it("returns empty array when no schemes", () => {
216+
expect(detectApiKeyHeaders(minimalSpec)).toEqual([]);
217+
});
218+
});
219+
220+
describe("resolveAuth with multi-header", () => {
221+
let tmpDir: string;
222+
223+
beforeEach(async () => {
224+
tmpDir = await mkdtemp(join(tmpdir(), "tocli-auth-multiheader-"));
225+
vi.stubEnv("XDG_CONFIG_HOME", tmpDir);
226+
});
227+
228+
afterEach(async () => {
229+
vi.unstubAllEnvs();
230+
await rm(tmpDir, { recursive: true, force: true });
231+
});
232+
233+
it("returns headers auth when --header flags are provided", async () => {
234+
const auth = await resolveAuth(
235+
{ headers: { "X-VTEX-API-AppKey": "key1", "X-VTEX-API-AppToken": "tok1" } },
236+
specDualHeader,
237+
{}
238+
);
239+
expect(auth.type).toBe("headers");
240+
expect(auth.headers).toEqual({
241+
"X-VTEX-API-AppKey": "key1",
242+
"X-VTEX-API-AppToken": "tok1",
243+
});
244+
});
245+
246+
it("resolves env vars inside header values", async () => {
247+
const auth = await resolveAuth(
248+
{ headers: { "X-VTEX-API-AppKey": "$VTEX_KEY", "X-VTEX-API-AppToken": "${VTEX_TOKEN}" } },
249+
specDualHeader,
250+
{ VTEX_KEY: "resolved-key", VTEX_TOKEN: "resolved-tok" }
251+
);
252+
expect(auth.headers).toEqual({
253+
"X-VTEX-API-AppKey": "resolved-key",
254+
"X-VTEX-API-AppToken": "resolved-tok",
255+
});
256+
});
257+
258+
it("--header takes priority over --token", async () => {
259+
const auth = await resolveAuth(
260+
{ headers: { "X-Custom": "v" }, token: "sk-1" },
261+
minimalSpec,
262+
{}
263+
);
264+
expect(auth.type).toBe("headers");
265+
});
266+
267+
it("loads headers profile from disk", async () => {
268+
await saveProfile("vtex", {
269+
type: "headers",
270+
value: "",
271+
headers: { "X-VTEX-API-AppKey": "stored-key", "X-VTEX-API-AppToken": "stored-tok" },
272+
});
273+
const auth = await resolveAuth({ profile: "vtex" }, specDualHeader, {});
274+
expect(auth.type).toBe("headers");
275+
expect(auth.headers).toEqual({
276+
"X-VTEX-API-AppKey": "stored-key",
277+
"X-VTEX-API-AppToken": "stored-tok",
278+
});
279+
});
280+
281+
it("empty headers object falls through to other resolution", async () => {
282+
const auth = await resolveAuth({ headers: {}, token: "sk-1" }, minimalSpec, {});
283+
expect(auth.type).toBe("bearer");
284+
expect(auth.value).toBe("sk-1");
285+
});
286+
287+
it("warns to stderr when $VAR in header resolves to empty", async () => {
288+
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
289+
const auth = await resolveAuth(
290+
{ headers: { "X-VTEX-API-AppKey": "$MISSING_VAR" } },
291+
specDualHeader,
292+
{}
293+
);
294+
expect(auth.headers?.["X-VTEX-API-AppKey"]).toBe("");
295+
const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n");
296+
expect(logged).toMatch(/Warning.*MISSING_VAR.*unset/);
297+
expect(logged).toContain("X-VTEX-API-AppKey");
298+
stderr.mockRestore();
299+
});
300+
301+
it("warns when $VAR resolves to empty string", async () => {
302+
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
303+
await resolveAuth(
304+
{ headers: { "X-Key": "$EMPTY_VAR" } },
305+
specDualHeader,
306+
{ EMPTY_VAR: "" }
307+
);
308+
const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n");
309+
expect(logged).toMatch(/Warning.*EMPTY_VAR.*empty/);
310+
stderr.mockRestore();
311+
});
312+
});
313+
161314
describe("maskToken", () => {
162315
it("masks long tokens", () => {
163316
expect(maskToken("sk-1234567890abcdef")).toBe("sk-1...cdef");

src/auth/commands.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Command } from "commander";
2-
import { saveProfile, removeProfile, getProfile, loadAuthStore, maskToken } from "./config.js";
2+
import { saveProfile, removeProfile, loadAuthStore, maskToken } from "./config.js";
3+
import { parseHeaderFlag } from "./headers.js";
34

45
export function registerAuthCommands(program: Command): void {
56
const auth = program.command("auth").description("Manage authentication");
@@ -10,22 +11,45 @@ export function registerAuthCommands(program: Command): void {
1011
.option("--token <token>", "Bearer token")
1112
.option("--api-key <key>", "API key")
1213
.option("--header-name <name>", "Custom header name for API key", "X-API-Key")
14+
.option(
15+
"-H, --header <header>",
16+
'Custom header "Name: Value" (repeatable, for multi-header auth like VTEX)',
17+
collect,
18+
[] as string[]
19+
)
1320
.option("--profile <name>", "Profile name", "default")
14-
.action(async (opts: Record<string, string>) => {
15-
const profileName = opts["profile"] ?? "default";
21+
.action(async (opts: Record<string, unknown>) => {
22+
const profileName = (opts["profile"] as string) ?? "default";
23+
const headerArgs = (opts["header"] as string[]) ?? [];
24+
25+
if (headerArgs.length > 0) {
26+
const headers: Record<string, string> = {};
27+
for (const raw of headerArgs) {
28+
const parsed = parseHeaderFlag(raw);
29+
if (!parsed) {
30+
console.error(`Error: invalid --header '${raw}'. Expected "Name: Value".`);
31+
process.exit(1);
32+
}
33+
headers[parsed.name] = parsed.value;
34+
}
35+
await saveProfile(profileName, { type: "headers", value: "", headers });
36+
const names = Object.keys(headers).join(", ");
37+
console.log(`Saved ${Object.keys(headers).length} header(s) [${names}] to profile '${profileName}'.`);
38+
return;
39+
}
1640

1741
if (opts["token"]) {
18-
await saveProfile(profileName, { type: "bearer", value: opts["token"] });
42+
await saveProfile(profileName, { type: "bearer", value: opts["token"] as string });
1943
console.log(`Saved bearer token to profile '${profileName}'.`);
2044
} else if (opts["apiKey"]) {
2145
await saveProfile(profileName, {
2246
type: "apiKey",
23-
value: opts["apiKey"],
24-
headerName: opts["headerName"] ?? "X-API-Key",
47+
value: opts["apiKey"] as string,
48+
headerName: (opts["headerName"] as string) ?? "X-API-Key",
2549
});
2650
console.log(`Saved API key to profile '${profileName}'.`);
2751
} else {
28-
console.error("Error: provide --token or --api-key");
52+
console.error("Error: provide --token, --api-key, or one or more --header flags");
2953
process.exit(1);
3054
}
3155
});
@@ -72,12 +96,24 @@ export function registerAuthCommands(program: Command): void {
7296
});
7397
}
7498

75-
function printProfile(name: string, profile: { type: string; value: string; headerName?: string }): void {
76-
const masked = maskToken(profile.value);
99+
function collect(value: string, previous: string[]): string[] {
100+
return previous.concat([value]);
101+
}
102+
103+
function printProfile(
104+
name: string,
105+
profile: { type: string; value: string; headerName?: string; headers?: Record<string, string> }
106+
): void {
77107
console.log(`Profile: ${name}`);
78108
console.log(` Type: ${profile.type}`);
79-
console.log(` Value: ${masked}`);
80-
if (profile.headerName) {
81-
console.log(` Header: ${profile.headerName}`);
109+
if (profile.type === "headers" && profile.headers) {
110+
for (const [k, v] of Object.entries(profile.headers)) {
111+
console.log(` ${k}: ${maskToken(v)}`);
112+
}
113+
} else {
114+
console.log(` Value: ${maskToken(profile.value)}`);
115+
if (profile.headerName) {
116+
console.log(` Header: ${profile.headerName}`);
117+
}
82118
}
83119
}

src/auth/flags.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface AuthFlags {
66
token?: string;
77
apiKey?: string;
88
authHeader?: string;
9+
headers?: Record<string, string>;
910
profile?: string;
1011
rcAuthType?: string;
1112
rcAuthToken?: string;
@@ -18,21 +19,28 @@ export async function resolveAuth(
1819
env: NodeJS.ProcessEnv = process.env
1920
): Promise<AuthConfig> {
2021
// Priority 1: Inline flags
22+
if (flags.headers && Object.keys(flags.headers).length > 0) {
23+
const resolved: Record<string, string> = {};
24+
for (const [k, v] of Object.entries(flags.headers)) {
25+
resolved[k] = resolveEnvVar(v, env, `--header "${k}"`);
26+
}
27+
return { type: "headers", value: "", headers: resolved };
28+
}
2129
if (flags.token) {
22-
return { type: "bearer", value: resolveEnvVar(flags.token, env) };
30+
return { type: "bearer", value: resolveEnvVar(flags.token, env, "--token") };
2331
}
2432
if (flags.apiKey) {
2533
const headerName = detectApiKeyHeader(spec) ?? "X-API-Key";
26-
return { type: "apiKey", value: resolveEnvVar(flags.apiKey, env), headerName };
34+
return { type: "apiKey", value: resolveEnvVar(flags.apiKey, env, "--api-key"), headerName };
2735
}
2836
if (flags.authHeader) {
29-
return { type: "bearer", value: resolveEnvVar(flags.authHeader, env) };
37+
return { type: "bearer", value: resolveEnvVar(flags.authHeader, env, "--auth-header") };
3038
}
3139

3240
// Priority 2: .toclirc auth config
3341
if (flags.rcAuthToken) {
3442
const type = (flags.rcAuthType as AuthConfig["type"]) ?? "bearer";
35-
return { type, value: resolveEnvVar(flags.rcAuthToken, env) };
43+
return { type, value: resolveEnvVar(flags.rcAuthToken, env, ".toclirc auth.token") };
3644
}
3745
if (flags.rcAuthEnvVar) {
3846
const envVal = env[flags.rcAuthEnvVar];
@@ -58,9 +66,16 @@ export async function resolveAuth(
5866
const profileName = flags.profile ?? "default";
5967
const profile = await getProfile(profileName);
6068
if (profile) {
69+
if (profile.type === "headers" && profile.headers) {
70+
const resolved: Record<string, string> = {};
71+
for (const [k, v] of Object.entries(profile.headers)) {
72+
resolved[k] = resolveEnvVar(v, env, `profile '${profileName}' header "${k}"`);
73+
}
74+
return { type: "headers", value: "", headers: resolved };
75+
}
6176
return {
6277
type: profile.type,
63-
value: resolveEnvVar(profile.value, env),
78+
value: resolveEnvVar(profile.value, env, `profile '${profileName}'`),
6479
headerName: profile.headerName,
6580
};
6681
}
@@ -100,10 +115,33 @@ function detectApiKeyHeader(spec: OpenAPISpec): string | undefined {
100115
return undefined;
101116
}
102117

103-
function resolveEnvVar(value: string, env: NodeJS.ProcessEnv): string {
104-
// Replace $VAR or ${VAR} with env values
118+
function resolveEnvVar(value: string, env: NodeJS.ProcessEnv, context?: string): string {
119+
// Replace $VAR or ${VAR} with env values. Warn when a reference resolves to empty —
120+
// silent empty headers confuse downstream 401s ("auth wrong" when it's "env unset").
105121
return value.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (_, braced, plain) => {
106122
const name = braced ?? plain;
107-
return env[name] ?? "";
123+
const resolved = env[name];
124+
if (resolved === undefined || resolved === "") {
125+
console.error(
126+
`Warning: env var $${name} is ${resolved === undefined ? "unset" : "empty"}${context ? ` (used in ${context})` : ""}.`
127+
);
128+
return "";
129+
}
130+
return resolved;
108131
});
109132
}
133+
134+
/**
135+
* Detect all apiKey header schemes. Used for multi-header auth detection (e.g. VTEX).
136+
*/
137+
export function detectApiKeyHeaders(spec: OpenAPISpec): string[] {
138+
const schemes = spec.components?.securitySchemes;
139+
if (!schemes) return [];
140+
const names: string[] = [];
141+
for (const scheme of Object.values(schemes)) {
142+
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.name) {
143+
names.push(scheme.name);
144+
}
145+
}
146+
return names;
147+
}

src/auth/headers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface ParsedHeader {
2+
name: string;
3+
value: string;
4+
}
5+
6+
/**
7+
* Parse a header flag in the form "Name: Value".
8+
* Returns null for invalid input (empty name, missing colon).
9+
* The value may contain additional colons (e.g. URLs) — only the first ":" splits.
10+
*/
11+
export function parseHeaderFlag(raw: string): ParsedHeader | null {
12+
const idx = raw.indexOf(":");
13+
if (idx === -1) return null;
14+
const name = raw.slice(0, idx).trim();
15+
const value = raw.slice(idx + 1).trim();
16+
if (!name) return null;
17+
// Reject CR/LF — header injection (RFC 7230 §3.2.4 forbids them in values/names)
18+
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) return null;
19+
// Header names per RFC 7230: token chars only (no whitespace, no control chars)
20+
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) return null;
21+
return { name, value };
22+
}

src/auth/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
export interface AuthConfig {
2-
type: "bearer" | "apiKey" | "basic" | "none";
2+
type: "bearer" | "apiKey" | "basic" | "headers" | "none";
33
value: string;
44
headerName?: string;
5+
headers?: Record<string, string>;
56
}
67

78
export interface AuthProfile {
89
type: AuthConfig["type"];
910
value: string;
1011
headerName?: string;
12+
headers?: Record<string, string>;
1113
}
1214

1315
export interface AuthStore {

0 commit comments

Comments
 (0)