{"version":3,"file":"cf-access.mjs","names":[],"sources":["../../src/client/cf-access.ts"],"sourcesContent":["/**\n * Custom headers + Cloudflare Access support for the EmDash client.\n *\n * Two concerns live here:\n *\n * 1. **Custom headers** — injects user-provided headers on every request.\n *    Used for reverse proxy auth (CF Access service tokens, Tailscale, etc.)\n *    Headers come from three sources (all merged, later wins):\n *    - EMDASH_HEADERS env var (newline-separated \"Name: Value\" pairs)\n *    - --header CLI flags (repeatable, \"Name: Value\" format)\n *    - Stored credentials (persisted during `emdash login --header`)\n *\n * 2. **Cloudflare Access detection** — when the login command hits an Access\n *    redirect, it tries `cloudflared access token` for a cached JWT, or\n *    prompts the user to run `cloudflared access login <url>`.\n *\n * @example CF Access service token:\n *   emdash login --url https://cms.example.com \\\n *     --header \"CF-Access-Client-Id: xxx\" \\\n *     --header \"CF-Access-Client-Secret: yyy\"\n *\n * @example Via env var:\n *   EMDASH_HEADERS=\"CF-Access-Client-Id: xxx\\nCF-Access-Client-Secret: yyy\"\n */\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\n\nimport type { Interceptor } from \"./transport.js\";\n\nconst execFileAsync = promisify(execFile);\n\n/**\n * Parse a single \"Name: Value\" header string. Returns null if malformed.\n */\nfunction parseHeaderLine(line: string): [string, string] | null {\n\tconst idx = line.indexOf(\":\");\n\tif (idx === -1) return null;\n\tconst name = line.slice(0, idx).trim();\n\tconst value = line.slice(idx + 1).trim();\n\tif (!name) return null;\n\treturn [name, value];\n}\n\n/**\n * Parse headers from the EMDASH_HEADERS env var.\n * Format: newline-separated \"Name: Value\" pairs.\n * Blank lines and malformed entries are silently skipped.\n */\nexport function parseHeadersFromEnv(): Record<string, string> {\n\tconst raw = process.env[\"EMDASH_HEADERS\"];\n\tif (!raw) return {};\n\treturn parseHeaderStrings(raw.split(\"\\n\"));\n}\n\n/**\n * Parse an array of \"Name: Value\" strings into a headers record.\n * Malformed entries are silently skipped. Later values override earlier ones.\n */\nexport function parseHeaderStrings(headers: string[]): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\tfor (const h of headers) {\n\t\tconst parsed = parseHeaderLine(h);\n\t\tif (parsed) {\n\t\t\tresult[parsed[0]] = parsed[1];\n\t\t}\n\t}\n\treturn result;\n}\n\n/**\n * Collect all --header flag values from process.argv.\n *\n * citty doesn't support repeatable string args, so we parse argv directly.\n * Handles both `--header \"Name: Value\"` and `--header=\"Name: Value\"`.\n */\nexport function parseHeadersFromArgv(): string[] {\n\tconst headers: string[] = [];\n\tconst argv = process.argv;\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--header\" || arg === \"-H\") {\n\t\t\tconst next = argv[i + 1];\n\t\t\tif (next && !next.startsWith(\"-\")) {\n\t\t\t\theaders.push(next);\n\t\t\t\ti++; // skip value\n\t\t\t}\n\t\t} else if (arg.startsWith(\"--header=\")) {\n\t\t\theaders.push(arg.slice(\"--header=\".length));\n\t\t} else if (arg.startsWith(\"-H=\")) {\n\t\t\theaders.push(arg.slice(\"-H=\".length));\n\t\t}\n\t}\n\treturn headers;\n}\n\n/**\n * Resolve custom headers from all sources.\n * Priority: env var < CLI flags (later wins).\n */\nexport function resolveCustomHeaders(): Record<string, string> {\n\tconst envHeaders = parseHeadersFromEnv();\n\tconst cliHeaders = parseHeaderStrings(parseHeadersFromArgv());\n\treturn { ...envHeaders, ...cliHeaders };\n}\n\n/**\n * Creates a transport interceptor that injects custom headers on every request.\n */\nexport function customHeadersInterceptor(headers: Record<string, string>): Interceptor {\n\tconst entries = Object.entries(headers);\n\tif (entries.length === 0) {\n\t\t// No-op interceptor\n\t\treturn (request, next) => next(request);\n\t}\n\n\treturn (request, next) => {\n\t\tconst h = new Headers(request.headers);\n\t\tfor (const [name, value] of entries) {\n\t\t\th.set(name, value);\n\t\t}\n\t\treturn next(new Request(request, { headers: h }));\n\t};\n}\n\n/**\n * Creates a fetch wrapper that injects custom headers.\n * Used by the login command for raw fetch calls before the client is created.\n */\nexport function createHeaderAwareFetch(headers: Record<string, string>): typeof fetch {\n\tif (Object.keys(headers).length === 0) {\n\t\treturn globalThis.fetch.bind(globalThis);\n\t}\n\treturn (input: RequestInfo | URL, init?: RequestInit) => {\n\t\tconst h = new Headers(init?.headers);\n\t\tfor (const [name, value] of Object.entries(headers)) {\n\t\t\th.set(name, value);\n\t\t}\n\t\treturn globalThis.fetch(input, { ...init, headers: h });\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Cloudflare Access detection\n// ---------------------------------------------------------------------------\n\nconst ACCESS_LOGIN_PATTERN = /cloudflareaccess\\.com\\/cdn-cgi\\/access\\/login/;\n\n/**\n * Check whether a response (fetched with `redirect: \"manual\"`) is a\n * Cloudflare Access redirect.\n */\nexport function isAccessRedirect(response: Response): boolean {\n\tif (response.status !== 301 && response.status !== 302) return false;\n\tconst location = response.headers.get(\"location\") ?? \"\";\n\treturn ACCESS_LOGIN_PATTERN.test(location);\n}\n\n/**\n * Try to get a cached Cloudflare Access JWT via `cloudflared access token`.\n *\n * Returns the JWT string if cloudflared is installed and has a cached token.\n * Returns null if cloudflared is not installed or has no cached token.\n */\nexport async function getCachedAccessToken(appUrl: string): Promise<string | null> {\n\tconst origin = new URL(appUrl).origin;\n\ttry {\n\t\tconst { stdout } = await execFileAsync(\"cloudflared\", [\"access\", \"token\", \"-app\", origin]);\n\t\tconst token = stdout.trim();\n\t\treturn token || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Launch `cloudflared access login` for interactive browser-based auth.\n *\n * This opens a browser window for the user to authenticate with their IdP.\n * On success, cloudflared caches the JWT locally. Call `getCachedAccessToken`\n * afterwards to retrieve it.\n *\n * Returns true if the command succeeded, false otherwise.\n */\nexport async function runCloudflaredLogin(appUrl: string): Promise<boolean> {\n\tconst origin = new URL(appUrl).origin;\n\ttry {\n\t\tawait execFileAsync(\"cloudflared\", [\"access\", \"login\", origin]);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n"],"mappings":"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"}