diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ad670..6704ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ ### Added +- **`brew-cli login` signs in through the browser.** On an interactive + terminal it starts a sign-in on brew.new, prints the link and a code + ("Check the code in your browser matches: WDJB-MJHT"), opens the page, + and polls until the person approves; then it stores a new brand key + (`CLI · `) in the config at mode 0600 and prints + `Signed in as to `. The key is encrypted to a one-time + P-256 key pair the CLI makes for the sign-in and travels only through + the poll, so it also works from Codespaces, Replit, Gitpod, SSH and CI, + where the CLI prints the link instead of opening a browser. New flags: + `--browser`, `--paste` (the old prompt), `--no-open`, `--wait + `, `--no-wait` (exit 3 at once with `{ status: "pending", + verificationUrl, userCode, expiresAt, resumeCommand }`, keeping the + pending sign-in in `pending-auth.json`, mode 0600) and `--resume`. + Denied, expired and unknown sign-ins exit 3 with `CLI_LOGIN_DENIED`, + `CLI_LOGIN_EXPIRED` or `CLI_LOGIN_INVALID_GRANT`; a server without + browser sign-in exits 3 with `CLI_LOGIN_UNAVAILABLE`. `--api-key`, a + piped key and `BREW_API_KEY` work as before, and `login` off a terminal + with no key still exits 2. JSON output gains `method` (`browser`, + `api-key`, `stdin`, `paste`) and, after a browser sign-in, `brandId`, + `brandName`, `userEmail` and `keyId`. +- `whoami` shows `userEmail` and `brandName` for a key stored by a browser + sign-in (`null` otherwise); `config get|list` read them, and `logout` + clears them and any pending sign-in. +- The brand id a browser sign-in stores belongs to that key: a key from + `--api-key` or `BREW_API_KEY` never sends it, and `logout` or a pasted + key drops it. A sign-in against a non-default `--api-url` stores that + URL with the key. The `Signed in as …` line shows even under `--quiet`. + - **`brew-cli mcp install`** adds the Brew MCP server to Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, diff --git a/README.md b/README.md index 8ada896..a94795f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ are attached to every [GitHub Release](https://github.com/GetBrew/brew-cli/relea ## Quick start ```bash -brew-cli login # paste an API key from https://brew.new/settings/api +brew-cli login # sign in through the browser (or --paste a key) brew-cli doctor # trust check: auth, reachability, CLI-vs-API drift (exit-code gated) brew-cli whoami brew-cli contacts search --filter email:equals:jane@example.com @@ -89,6 +89,49 @@ VS Code results carry a one-click install `deeplink`. ## Authentication +### Sign in through the browser + +```bash +brew-cli login # opens brew.new, then waits for approval +brew-cli login --no-open # print the link instead of opening a browser +``` + +On an interactive terminal, `login` starts a sign-in on brew.new, prints +the link and a code, and opens the page in your browser. Check that the +code on the page matches the one in your terminal, pick a brand, and +approve. The CLI then prints `Signed in as to ` and stores a +new brand key, named `CLI · `, in `~/.config/brew-cli/config.json` +(mode 0600). It is an ordinary brand key: it is listed, and can be revoked, +under Settings, API keys. + +The key never passes through the browser. The CLI makes a one-time key +pair, Brew encrypts the new key to it on approval, and the CLI collects it +by polling, so sign-in also works from GitHub Codespaces, Replit, Gitpod, +an SSH session or CI: there the CLI does not try to open a browser, and you +open the link on any device. + +For agents and scripts, which cannot wait at a prompt: + +```bash +brew-cli login --no-wait --json # exit 3: {"status":"pending","verificationUrl":…,"userCode":…,"expiresAt":…,"resumeCommand":…} +brew-cli login --resume # after the person approved: collects and stores the key +brew-cli login --resume --wait 2m # wait at most two minutes, else exit 3 pending again +``` + +`--no-wait` keeps the pending sign-in (its poll secret and private key) in +`~/.config/brew-cli/pending-auth.json` (mode 0600) until it is approved, +denied or expired. A denied or expired sign-in exits 3 with +`CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED`; run `login` again. A sign-in +lasts ten minutes. + +### API keys + +```bash +brew-cli login --paste # paste a key at the prompt +brew-cli login --api-key brew_xxxxxxxx # or pass it +echo "$BREW_API_KEY" | brew-cli login # or pipe it +``` + | Precedence | Source | | --- | --- | | 1 | `--api-key ` flag | @@ -133,10 +176,12 @@ Designed for AI agents as first-class users: trigger fires) never hang waiting for input: non-interactive callers get exit `4` plus a JSON envelope containing a ready-to-run `confirmCommand`; pass `--yes` to proceed. Humans on a TTY get a y/N - prompt. Test sends (`emails send --test`) skip the gate. Printed - commands (`confirmCommand`, `retryCommand`, "run … login") start the - way the CLI was invoked: a wrapper that runs it through a package - runner sets `BREW_CLI_INVOKED_AS` (for example + prompt. Test sends (`emails send --test`) skip the gate. Sign-in works + the same way: `login --no-wait --json` exits `3` with the link and code + to show a person, and a `resumeCommand` to run once they approved. + Printed commands (`confirmCommand`, `retryCommand`, `resumeCommand`, + "run … login") start the way the CLI was invoked: a wrapper that runs it + through a package runner sets `BREW_CLI_INVOKED_AS` (for example `npx -y @brew.new/cli@`), so they re-run through it. - **Saved email recovery** — `409 EMAIL_RESULT_UNAVAILABLE` means the generation or edit committed. Do not repeat the write. Read the exact diff --git a/docs/commands/README.md b/docs/commands/README.md index 572c5ba..d77b862 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -22,9 +22,9 @@ a `confirmCommand` otherwise, `--yes` to proceed). | Command | Class | API route | Summary | | --- | --- | --- | --- | -| `brew-cli login` | write | — | Store an API key for this machine (validated against the API) | +| `brew-cli login` | write | — | Sign in through the browser, or store an API key, for this machine | | `brew-cli logout` | write | — | Remove the stored API key from this machine | -| `brew-cli whoami` | read | — | Show the resolved credential, brand, and API target | +| `brew-cli whoami` | read | — | Show the resolved credential, brand, API target, and who approved a browser sign-in | | `brew-cli config list` | read | — | Show the stored configuration (API key masked) | | `brew-cli config get` | read | — | Read one stored configuration value | | `brew-cli config set` | write | — | Store a configuration value (brandId or apiUrl) | @@ -177,12 +177,21 @@ a `confirmCommand` otherwise, `--yes` to proceed). ### brew-cli login -Store an API key for this machine (validated against the API) +Sign in through the browser, or store an API key, for this machine - Class: write +- `--browser` — Sign in through the browser (the default on an interactive terminal) +- `--paste` — Paste an API key instead (or pipe one on stdin) +- `--no-open` — Print the sign-in link and code without opening a browser +- `--wait ` — Wait at most this long for approval (90s, 5m), then exit 3 with a pending result to --resume +- `--no-wait` — Print the sign-in link and code, and exit 3 at once with a pending result (agents: with --json, then --resume) +- `--resume` — Collect the pending browser sign-in started with --no-wait ```bash brew-cli login +brew-cli login --no-wait --json +brew-cli login --resume +brew-cli login --no-open brew-cli login --api-key brew_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx echo "$BREW_API_KEY" | brew-cli login ``` @@ -199,7 +208,7 @@ brew-cli logout ### brew-cli whoami -Show the resolved credential, brand, and API target +Show the resolved credential, brand, API target, and who approved a browser sign-in - Class: read @@ -223,7 +232,7 @@ brew-cli config list Read one stored configuration value - Class: read -- Argument `key` — brandId | apiUrl | apiKey +- Argument `key` — brandId | apiUrl | apiKey | userEmail | brandName ```bash brew-cli config get brandId diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 267559b..b34a0d0 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -22,6 +22,21 @@ this build lacks (update the CLI); auth/reachability failures name the fix. ## Auth and targeting - `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`). +- No key and a person to ask? Sign in through their browser without + blocking on it: + + ```bash + brew-cli login --no-wait --json # exit 3: {status: "pending", verificationUrl, userCode, expiresAt, resumeCommand} + ``` + + Show the person `verificationUrl` and `userCode`, and ask them to approve + on brew.new only if the code on the page matches. Then run the + `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and + reports `userEmail` and `brandName`; exit 3 with `status: "pending"` + means not approved yet (add `--wait 2m` to wait for it); exit 3 with + `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for + or print the key itself. A plain `brew-cli login` off a terminal exits 2 + instead of waiting. - Default target is production. For a local dev server: `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in `.env.test.local` as `TEST_API_KEY`). diff --git a/src/commands/config.ts b/src/commands/config.ts index 64cea6f..4314f65 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,5 +1,6 @@ import { maskApiKey } from '../lib/client' import { + type ConfigKey, configPath, isConfigKey, readConfig, @@ -27,7 +28,11 @@ export const configGetCommand = defineCommand({ sdkMethod: null, commandClass: 'read', args: [ - { name: 'key', summary: 'brandId | apiUrl | apiKey', isRequired: true }, + { + name: 'key', + summary: 'brandId | apiUrl | apiKey | userEmail | brandName', + isRequired: true, + }, ], examples: ['brew-cli config get brandId'], run: async ({ ctx, args }) => { @@ -83,23 +88,23 @@ function maskedConfig(ctx: CliContext): Record { } } -function requireKnownKey( - value: string | undefined -): 'apiKey' | 'apiUrl' | 'brandId' { +function requireKnownKey(value: string | undefined): ConfigKey { if (value !== undefined && isConfigKey(value)) { return value } - throw new CliUsageError('Unknown key. Expected brandId, apiUrl, or apiKey.') + throw new CliUsageError( + 'Unknown key. Expected brandId, apiUrl, apiKey, userEmail, or brandName.' + ) } function requireSettableKey( value: string | undefined, ctx: CliContext ): 'apiUrl' | 'brandId' { - if (value === 'apiKey') { + if (value === 'apiKey' || value === 'userEmail' || value === 'brandName') { const prefix = getInvocationPrefix(ctx.io.env) throw new CliUsageError( - `The API key is managed by \`${prefix} login\` / \`${prefix} logout\`.` + `${value === 'apiKey' ? 'The API key is' : `${value} is`} managed by \`${prefix} login\` / \`${prefix} logout\`.` ) } const key = requireKnownKey(value) diff --git a/src/commands/login.ts b/src/commands/login.ts index 4c7d197..249614a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,66 +1,282 @@ +import { + type BrowserLoginResult, + type PendingLoginView, + resumeBrowserLogin, + startBrowserLogin, +} from '../lib/auth-flow/login' +import { printable } from '../lib/auth-flow/session' import { buildSdkClient, + DEFAULT_API_URL, LOGIN_KEYS_URL, maskApiKey, resolveAuth, } from '../lib/client' -import { updateConfig } from '../lib/config-store' +import { readConfig, updateConfig } from '../lib/config-store' import { sdkTransportFor } from '../lib/context' -import { defineCommand } from '../lib/define-command' -import { CliUsageError } from '../lib/errors' +import { type CommandOutcome, defineCommand } from '../lib/define-command' +import { CliApiError, CliUsageError, EXIT_AUTH } from '../lib/errors' +import { getInvocationPrefix } from '../lib/invocation' import { progress } from '../lib/output' +import { parseDurationFlag } from '../lib/transport' import type { CliContext } from '../lib/types' const KEY_SHAPE = /^brew_[a-z0-9]{16,64}$/ +/** How the key reached the CLI, reported as `method`. */ +type KeyMethod = 'api-key' | 'stdin' | 'paste' + +type LoginPlan = + | { readonly kind: 'key'; readonly isPaste: boolean } + | { + readonly kind: 'browser' + readonly isResume: boolean + readonly openBrowser: boolean + /** Undefined: until the session expires; 0: `--no-wait`. */ + readonly waitMs: number | undefined + } + export const loginCommand = defineCommand({ path: ['login'], - summary: 'Store an API key for this machine (validated against the API)', + summary: 'Sign in through the browser, or store an API key, for this machine', sdkMethod: null, commandClass: 'write', + flags: [ + { + flag: '--browser', + summary: + 'Sign in through the browser (the default on an interactive terminal)', + }, + { + flag: '--paste', + summary: 'Paste an API key instead (or pipe one on stdin)', + }, + { + flag: '--no-open', + summary: 'Print the sign-in link and code without opening a browser', + }, + { + flag: '--wait ', + summary: + 'Wait at most this long for approval (90s, 5m), then exit 3 with a pending result to --resume', + }, + { + flag: '--no-wait', + summary: + 'Print the sign-in link and code, and exit 3 at once with a pending result (agents: with --json, then --resume)', + }, + { + flag: '--resume', + summary: 'Collect the pending browser sign-in started with --no-wait', + }, + ], examples: [ 'brew-cli login', + 'brew-cli login --no-wait --json', + 'brew-cli login --resume', + 'brew-cli login --no-open', 'brew-cli login --api-key brew_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'echo "$BREW_API_KEY" | brew-cli login', ], - run: async ({ ctx }) => { - const candidate = await readCandidateKey(ctx) - if (!KEY_SHAPE.test(candidate)) { - progress( - ctx, - 'Warning: key does not look like a Brew API key (brew_…); validating anyway.' - ) - } - const auth = resolveAuth({ - globals: { ...ctx.globals, apiKey: candidate }, - env: ctx.io.env, - }) - // Throws a 401 BrewApiError (exit 3) when the key is invalid. - await buildSdkClient(auth, sdkTransportFor(ctx)).usage.get() - const path = updateConfig(ctx.io.env, { apiKey: candidate }) - progress(ctx, `Logged in. Key stored in ${path} (0600).`) - return { - data: { - loggedIn: true, - apiKey: maskApiKey(candidate), - apiUrl: auth.apiUrl, - configPath: path, - }, + run: async ({ ctx, flags }) => { + const plan = planLogin(ctx, flags) + if (plan.kind === 'browser') { + return await browserLogin(ctx, plan) } + return await keyLogin(ctx, plan.isPaste) }, }) -async function readCandidateKey(ctx: CliContext): Promise { +/** + * Picks the sign-in method from the flags. A key from `--api-key` or stdin + * keeps the old path; an interactive terminal with neither signs in through + * the browser; contradictory flags are a usage error (exit 2). + */ +function planLogin( + ctx: CliContext, + flags: Readonly> +): LoginPlan { + // `--wait ` and `--no-wait` share one commander value (the last one + // wins), so the pair is told apart by what was typed. + const saysWait = ctx.rawArgv.some( + (token) => token === '--wait' || token.startsWith('--wait=') + ) + const saysNoWait = ctx.rawArgv.includes('--no-wait') + if (saysWait && saysNoWait) { + throw new CliUsageError('--wait and --no-wait cannot be combined.') + } + const browserFlags = [ + ...(flags.browser === true ? ['--browser'] : []), + ...(flags.open === false ? ['--no-open'] : []), + ...(saysWait ? ['--wait'] : []), + ...(saysNoWait ? ['--no-wait'] : []), + ...(flags.resume === true ? ['--resume'] : []), + ] + const isPaste = flags.paste === true + if (ctx.globals.apiKey !== undefined) { + const others = [...(isPaste ? ['--paste'] : []), ...browserFlags] + if (others.length > 0) { + throw new CliUsageError( + `--api-key cannot be combined with ${others.join(', ')}.` + ) + } + return { kind: 'key', isPaste: false } + } + if (isPaste) { + if (browserFlags.length > 0) { + throw new CliUsageError( + `--paste cannot be combined with ${browserFlags.join(', ')}.` + ) + } + return { kind: 'key', isPaste: true } + } + if (browserFlags.length === 0 && !ctx.io.isTtyIn) { + return { kind: 'key', isPaste: false } + } + return { + kind: 'browser', + isResume: flags.resume === true, + openBrowser: flags.open !== false, + waitMs: saysNoWait + ? 0 + : typeof flags.wait === 'string' + ? parseDurationFlag(flags.wait, '--wait') + : undefined, + } +} + +async function browserLogin( + ctx: CliContext, + plan: Extract +): Promise { + const prefix = getInvocationPrefix(ctx.io.env) + const commands = { + resumeCommand: `${prefix} login --resume${ctx.mode === 'json' ? ' --json' : ''}`, + restartCommand: `${prefix} login`, + } + const result: BrowserLoginResult = plan.isResume + ? await resumeBrowserLogin(ctx, { waitMs: plan.waitMs, ...commands }) + : await startBrowserLogin(ctx, { + keys: ['cli'], + openBrowser: plan.openBrowser, + waitMs: plan.waitMs, + ...commands, + }) + if (result.status === 'pending') { + return pendingOutcome(result.pending) + } + const { approved, apiUrl } = result + const cliKey = approved.keys.cli + if (cliKey === undefined) { + throw new CliApiError({ + status: 200, + code: 'CLI_LOGIN_SEAL_INVALID', + type: 'internal_error', + message: 'The approved sign-in carried no CLI key.', + suggestion: `Run \`${commands.restartCommand}\` again.`, + }) + } + const path = updateConfig(ctx.io.env, { + apiKey: cliKey.key, + // The key is bound to this brand; a stale brand id would make every + // request a 403 BRAND_SCOPE_MISMATCH. + brandId: approved.brand.brandId, + userEmail: approved.approver.email, + brandName: approved.brand.name, + // The key lives on the server that minted it: a sign-in against + // staging or a local dev server keeps sending there. + apiUrl: apiUrl === DEFAULT_API_URL ? undefined : apiUrl, + }) + // Who approved is the check against a phished approval: shown even + // under --quiet (JSON output carries it as `userEmail`). + const signedIn = `Signed in as ${printable(approved.approver.email)} to ${printable(approved.brand.name)}. Key stored in ${path} (0600).` + if (ctx.mode === 'json') { + progress(ctx, signedIn) + } else { + ctx.io.stderr.write(`${signedIn}\n`) + } + warnIfEnvKeyWins(ctx) + return { + data: { + loggedIn: true, + method: 'browser', + apiKey: maskApiKey(cliKey.key), + apiUrl, + configPath: path, + brandId: approved.brand.brandId, + brandName: approved.brand.name, + userEmail: approved.approver.email, + keyId: cliKey.keyId, + }, + } +} + +function pendingOutcome(pending: PendingLoginView): CommandOutcome { + return { + data: pending, + human: [ + `Sign-in pending. Approve it at ${printable(pending.verificationUrl)} (code ${printable(pending.userCode)}),`, + `then run: ${pending.resumeCommand}`, + ].join('\n'), + exitCode: EXIT_AUTH, + } +} + +async function keyLogin( + ctx: CliContext, + isPaste: boolean +): Promise { + const { key: candidate, method } = await readCandidateKey(ctx, isPaste) + if (!KEY_SHAPE.test(candidate)) { + progress( + ctx, + 'Warning: key does not look like a Brew API key (brew_…); validating anyway.' + ) + } + const auth = resolveAuth({ + globals: { ...ctx.globals, apiKey: candidate }, + env: ctx.io.env, + }) + // Throws a 401 BrewApiError (exit 3) when the key is invalid. + await buildSdkClient(auth, sdkTransportFor(ctx)).usage.get() + // A pasted key says nothing about who approved it: forget the identity + // a browser sign-in stored, and the brand that sign-in's key was bound to. + const browserBrand = readConfig(ctx.io.env).brandName !== undefined + const path = updateConfig(ctx.io.env, { + apiKey: candidate, + userEmail: undefined, + brandName: undefined, + ...(browserBrand ? { brandId: undefined } : {}), + }) + progress(ctx, `Logged in. Key stored in ${path} (0600).`) + return { + data: { + loggedIn: true, + method, + apiKey: maskApiKey(candidate), + apiUrl: auth.apiUrl, + configPath: path, + }, + } +} + +async function readCandidateKey( + ctx: CliContext, + isPaste: boolean +): Promise<{ readonly key: string; readonly method: KeyMethod }> { if (ctx.globals.apiKey !== undefined) { - return ctx.globals.apiKey + return { key: ctx.globals.apiKey, method: 'api-key' } } if (!ctx.io.isTtyIn) { const piped = (await ctx.io.readStdin(ctx.signal)).trim() if (piped !== '') { - return piped + return { key: piped, method: 'stdin' } } + const prefix = getInvocationPrefix(ctx.io.env) throw new CliUsageError( - `No key provided. Pass --api-key or pipe the key on stdin. Create keys at ${LOGIN_KEYS_URL}` + isPaste + ? `No key provided. Pipe the key on stdin or pass --api-key. Create keys at ${LOGIN_KEYS_URL}` + : `No key provided. Pass --api-key, pipe the key on stdin, or sign in through the browser with \`${prefix} login --browser\` (agents: \`${prefix} login --no-wait --json\`, then \`${prefix} login --resume\`). Create keys at ${LOGIN_KEYS_URL}` ) } progress(ctx, `Create an API key at ${LOGIN_KEYS_URL}`) @@ -68,5 +284,15 @@ async function readCandidateKey(ctx: CliContext): Promise { if (answer === '') { throw new CliUsageError('No key entered.') } - return answer + return { key: answer, method: 'paste' } +} + +function warnIfEnvKeyWins(ctx: CliContext): void { + const fromEnv = ctx.io.env.BREW_API_KEY + if (fromEnv !== undefined && fromEnv !== '') { + progress( + ctx, + 'Note: BREW_API_KEY is set in this environment and takes precedence over the stored key.' + ) + } } diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 7ab9480..d0adc54 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,3 +1,4 @@ +import { deletePendingLogin } from '../lib/auth-flow/pending' import { readConfig, updateConfig } from '../lib/config-store' import { defineCommand } from '../lib/define-command' import { progress } from '../lib/output' @@ -9,8 +10,18 @@ export const logoutCommand = defineCommand({ commandClass: 'write', examples: ['brew-cli logout'], run: async ({ ctx }) => { - const hadKey = readConfig(ctx.io.env).apiKey !== undefined - const path = updateConfig(ctx.io.env, { apiKey: undefined }) + const stored = readConfig(ctx.io.env) + const hadKey = stored.apiKey !== undefined + const path = updateConfig(ctx.io.env, { + apiKey: undefined, + userEmail: undefined, + brandName: undefined, + // A brand id a browser sign-in stored belongs to the removed key; one + // set by hand (`config set brandId`) stays. + ...(stored.brandName !== undefined ? { brandId: undefined } : {}), + }) + // A browser sign-in still waiting to be resumed would sign back in. + deletePendingLogin(ctx.io.env) progress(ctx, hadKey ? 'Logged out.' : 'No stored key found.') return { data: { loggedOut: true, hadKey, configPath: path } } }, diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 743eba6..b42b179 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,11 +1,13 @@ import { BrewApiError } from '@brew.new/sdk' import { maskApiKey, resolveAuth } from '../lib/client' +import { readConfig } from '../lib/config-store' import { defineCommand } from '../lib/define-command' import { progress } from '../lib/output' export const whoamiCommand = defineCommand({ path: ['whoami'], - summary: 'Show the resolved credential, brand, and API target', + summary: + 'Show the resolved credential, brand, API target, and who approved a browser sign-in', sdkMethod: null, commandClass: 'read', examples: ['brew-cli whoami', 'brew-cli whoami --json'], @@ -31,12 +33,18 @@ export const whoamiCommand = defineCommand({ 'Warning: could not reach /v1/usage — showing local credential state only.' ) } + // Who approved the stored key's browser sign-in: it describes that key + // only, never one from --api-key or BREW_API_KEY. + const stored = + auth.apiKeySource === 'config' ? readConfig(ctx.io.env) : undefined return { data: { apiUrl: auth.apiUrl, apiKey: maskApiKey(auth.apiKey), apiKeySource: auth.apiKeySource, brandId: auth.brandId ?? null, + userEmail: stored?.userEmail ?? null, + brandName: stored?.brandName ?? null, usage, }, } diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 4fd3fcd..e89ef1f 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/auth-flow/login.ts b/src/lib/auth-flow/login.ts new file mode 100644 index 0000000..637eb46 --- /dev/null +++ b/src/lib/auth-flow/login.ts @@ -0,0 +1,266 @@ +import { resolveAuth } from '../client' +import { CliApiError, CliAuthError } from '../errors' +import { progress } from '../output' +import type { CliContext } from '../types' +import { type OpenOutcome, openVerificationPage } from './open-browser' +import { + deletePendingLogin, + type PendingLogin, + readPendingLogin, + writePendingLogin, +} from './pending' +import { + type ActiveLogin, + type ApprovedLogin, + FINAL_LOGIN_CODES, + type WaitOutcome, + waitForApproval, +} from './poll' +import { + exportPrivateJwk, + generateLoginKeyPair, + generatePollSecret, + hashPollSecret, + importPrivateJwk, + type LoginKeyKind, +} from './seal' +import { + type CreatedSession, + cleanClientInfo, + createSession, + defaultClientInfo, + type LoginClientInfo, + printable, + siteUrlFromApiUrl, +} from './session' + +/** + * Browser sign-in (an RFC 8628-shaped device flow): start a session, show + * its link and code, open the approval page where there is a browser, poll + * for the sealed keys and open them. Shared by `login` and, later, `init`, + * which asks for an app key too and keeps it in memory only. + */ + +export type BrowserLoginOptions = { + readonly keys: readonly LoginKeyKind[] + /** Merged over this machine's own facts (`project`, `appUrl` for init). */ + readonly client?: LoginClientInfo + readonly openBrowser: boolean + /** Undefined: until the session expires. `0`: return pending at once. */ + readonly waitMs: number | undefined + /** Collects a pending sign-in (`brew-cli login --resume`). */ + readonly resumeCommand: string + /** Starts over (`brew-cli login`). */ + readonly restartCommand: string +} + +/** The machine-readable "approve it, then resume" result (exit 3). */ +export type PendingLoginView = { + readonly status: 'pending' + readonly verificationUrl: string + readonly userCode: string + readonly expiresAt: string + readonly resumeCommand: string +} + +export type BrowserLoginResult = + | { + readonly status: 'approved' + readonly approved: ApprovedLogin + /** The API URL the keys belong to. */ + readonly apiUrl: string + } + | { readonly status: 'pending'; readonly pending: PendingLoginView } + +export async function startBrowserLogin( + ctx: CliContext, + options: BrowserLoginOptions +): Promise { + const { apiUrl } = resolveAuth({ + globals: ctx.globals, + env: ctx.io.env, + allowAnonymous: true, + }) + const siteUrl = siteUrlFromApiUrl(apiUrl) + const keyPair = await generateLoginKeyPair() + const pollSecret = generatePollSecret() + const session = await createSession(ctx, { + siteUrl, + publicKey: keyPair.publicJwk, + pollSecretHash: hashPollSecret(pollSecret), + keys: options.keys, + client: cleanClientInfo({ ...defaultClientInfo(), ...options.client }), + }) + const isNoWait = options.waitMs === 0 + // Without --no-wait the link and code are the only way to finish, so + // they print even under --quiet; with it, the result carries them. + const isSilent = isNoWait && ctx.globals.quiet + if (!isSilent) { + ctx.io.stderr.write(`${instructionLines(session).join('\n')}\n`) + } + const opened = await openVerificationPage(ctx.io, session.verificationUrl, { + isDisabled: !options.openBrowser, + }) + if (!isSilent) { + ctx.io.stderr.write( + `${openedLine(opened)} ${ + isNoWait + ? `After approving, run: ${options.resumeCommand}` + : 'Waiting for approval… (Ctrl-C to cancel)' + }\n` + ) + } + const active: ActiveLogin = { + sessionId: session.sessionId, + pollSecret, + privateKey: keyPair.privateKey, + expiresAt: session.expiresAt, + interval: session.interval, + siteUrl, + } + const outcome = await waitOrForget(ctx, active, { + waitMs: options.waitMs, + pollFirst: false, + restartCommand: options.restartCommand, + }) + if (outcome.status === 'approved') { + return { status: 'approved', approved: outcome.approved, apiUrl } + } + writePendingLogin(ctx.io.env, { + v: 1, + sessionId: session.sessionId, + pollSecret, + privateKey: await exportPrivateJwk(keyPair.privateKey), + userCode: session.userCode, + verificationUrl: session.verificationUrl, + expiresAt: session.expiresAt, + interval: session.interval, + siteUrl, + apiUrl, + keys: options.keys, + }) + return { + status: 'pending', + pending: pendingView(session, options.resumeCommand), + } +} + +/** Collects the sign-in `--no-wait` left pending. */ +export async function resumeBrowserLogin( + ctx: CliContext, + options: Pick< + BrowserLoginOptions, + 'waitMs' | 'resumeCommand' | 'restartCommand' + > +): Promise { + const pending = readPendingLogin(ctx.io.env) + const privateKey = + pending === undefined ? undefined : await importKey(pending) + if (pending === undefined || privateKey === undefined) { + deletePendingLogin(ctx.io.env) + throw new CliAuthError( + 'No browser sign-in is waiting to be resumed.', + `Start one with \`${options.restartCommand} --no-wait --json\`, or run \`${options.restartCommand}\` and approve it in the browser.`, + 'CLI_LOGIN_NOT_PENDING' + ) + } + progress( + ctx, + `Checking the sign-in with code ${printable(pending.userCode)} (${printable(pending.verificationUrl)})…` + ) + const outcome = await waitOrForget( + ctx, + { + sessionId: pending.sessionId, + pollSecret: pending.pollSecret, + privateKey, + expiresAt: pending.expiresAt, + interval: pending.interval, + siteUrl: pending.siteUrl, + }, + { + waitMs: options.waitMs, + pollFirst: true, + restartCommand: options.restartCommand, + } + ) + if (outcome.status === 'pending') { + return { + status: 'pending', + pending: pendingView(pending, options.resumeCommand), + } + } + deletePendingLogin(ctx.io.env, pending.sessionId) + return { + status: 'approved', + approved: outcome.approved, + apiUrl: pending.apiUrl, + } +} + +/** + * Polls; a sign-in that ended for good (denied, expired, unknown, or a seal + * that would not open) takes its pending file with it. + */ +async function waitOrForget( + ctx: CliContext, + login: ActiveLogin, + options: Parameters[2] +): Promise { + try { + return await waitForApproval(ctx, login, options) + } catch (error) { + if ( + (error instanceof CliAuthError && FINAL_LOGIN_CODES.has(error.code)) || + (error instanceof CliApiError && error.code === 'CLI_LOGIN_SEAL_INVALID') + ) { + deletePendingLogin(ctx.io.env, login.sessionId) + } + throw error + } +} + +async function importKey( + pending: PendingLogin +): Promise> | undefined> { + try { + return await importPrivateJwk(pending.privateKey) + } catch { + return + } +} + +function pendingView( + session: Pick, + resumeCommand: string +): PendingLoginView { + return { + status: 'pending', + verificationUrl: session.verificationUrl, + userCode: session.userCode, + expiresAt: session.expiresAt, + resumeCommand, + } +} + +function instructionLines(session: CreatedSession): string[] { + const url = printable(session.verificationUrl) + return [ + `Sign in to Brew in your browser. The page is on ${new URL(session.verificationUrl).host}:`, + ` ${url}`, + `Check the code in your browser matches: ${printable(session.userCode)}`, + ] +} + +function openedLine(outcome: OpenOutcome): string { + switch (outcome.status) { + case 'opened': + return 'Opened it in your browser.' + case 'remote': + return `This looks like ${outcome.remote.name}: open the link in a browser on any device.` + case 'skipped': + return 'Open the link in a browser on any device.' + case 'failed': + return 'Could not open a browser here: open the link in a browser on any device.' + } +} diff --git a/src/lib/auth-flow/open-browser.ts b/src/lib/auth-flow/open-browser.ts new file mode 100644 index 0000000..380c1b9 --- /dev/null +++ b/src/lib/auth-flow/open-browser.ts @@ -0,0 +1,38 @@ +import { defaultOpenUrl } from '../project/process' +import type { CliIo } from '../types' +import { detectRemoteEnv, type RemoteEnv } from './remote-env' + +export type OpenOutcome = + | { readonly status: 'opened' } + /** A cloud shell, SSH or CI: this machine's browser is not the person's. */ + | { readonly status: 'remote'; readonly remote: RemoteEnv } + /** `--no-open`. */ + | { readonly status: 'skipped' } + /** No opener on this machine, or it refused. */ + | { readonly status: 'failed' } + +/** + * Opens the approval page in the person's browser when there is one to + * open. Never throws: the link and code are already on the terminal, so a + * missing opener only changes what the next line says. + */ +export async function openVerificationPage( + io: Pick, + url: string, + options: { readonly isDisabled: boolean } +): Promise { + if (options.isDisabled) { + return { status: 'skipped' } + } + const remote = detectRemoteEnv(io.env) + if (remote !== undefined) { + return { status: 'remote', remote } + } + const open = io.openUrl ?? ((target) => defaultOpenUrl(target, io.env)) + try { + await open(url) + return { status: 'opened' } + } catch { + return { status: 'failed' } + } +} diff --git a/src/lib/auth-flow/pending.ts b/src/lib/auth-flow/pending.ts new file mode 100644 index 0000000..8b43396 --- /dev/null +++ b/src/lib/auth-flow/pending.ts @@ -0,0 +1,117 @@ +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { configDir } from '../config-store' +import type { LoginKeyKind, PrivateKeyJwk } from './seal' + +/** + * A browser sign-in started with `--no-wait` (or whose `--wait` ran out), + * kept so `login --resume` can collect it from another process. It holds + * the poll secret and the private key, so it lives next to the config at + * mode 0600 and is deleted as soon as the sign-in ends (approved, denied, + * expired). Only one is kept: a new one replaces it. + */ +export type PendingLogin = { + readonly v: 1 + readonly sessionId: string + readonly pollSecret: string + readonly privateKey: PrivateKeyJwk + readonly userCode: string + readonly verificationUrl: string + /** ISO 8601, from the server. */ + readonly expiresAt: string + /** Seconds between polls, from the server. */ + readonly interval: number + /** Where the sign-in routes live (`https://brew.new`). */ + readonly siteUrl: string + /** The API URL the key is for. */ + readonly apiUrl: string + readonly keys: readonly LoginKeyKind[] +} + +export function pendingLoginPath( + env: Readonly> +): string { + return join(configDir(env), 'pending-auth.json') +} + +export function readPendingLogin( + env: Readonly> +): PendingLogin | undefined { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(pendingLoginPath(env), 'utf8')) + } catch { + return + } + return isPendingLogin(parsed) ? parsed : undefined +} + +export function writePendingLogin( + env: Readonly>, + pending: PendingLogin +): string { + mkdirSync(configDir(env), { recursive: true, mode: 0o700 }) + const path = pendingLoginPath(env) + // A fresh 0600 file renamed into place: the secret is never readable + // through an older, wider file, and a crash never leaves half a file. + const temporary = `${path}.${process.pid}.tmp` + rmSync(temporary, { force: true }) + writeFileSync(temporary, `${JSON.stringify(pending, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }) + chmodSync(temporary, 0o600) + renameSync(temporary, path) + return path +} + +/** + * Deletes the pending sign-in — only the one for `sessionId` when given, so + * ending one sign-in never discards another that is still waiting. + */ +export function deletePendingLogin( + env: Readonly>, + sessionId?: string +): void { + if (sessionId !== undefined) { + const current = readPendingLogin(env) + if (current !== undefined && current.sessionId !== sessionId) { + return + } + } + rmSync(pendingLoginPath(env), { force: true }) +} + +function isPendingLogin(value: unknown): value is PendingLogin { + if (typeof value !== 'object' || value === null) { + return false + } + const record = value as Record + const key = record.privateKey as Record | null | undefined + return ( + record.v === 1 && + typeof record.sessionId === 'string' && + typeof record.pollSecret === 'string' && + typeof record.userCode === 'string' && + typeof record.verificationUrl === 'string' && + typeof record.expiresAt === 'string' && + typeof record.interval === 'number' && + typeof record.siteUrl === 'string' && + typeof record.apiUrl === 'string' && + Array.isArray(record.keys) && + typeof key === 'object' && + key !== null && + key.kty === 'EC' && + key.crv === 'P-256' && + typeof key.x === 'string' && + typeof key.y === 'string' && + typeof key.d === 'string' + ) +} diff --git a/src/lib/auth-flow/poll.ts b/src/lib/auth-flow/poll.ts new file mode 100644 index 0000000..2e3ebf0 --- /dev/null +++ b/src/lib/auth-flow/poll.ts @@ -0,0 +1,209 @@ +import type { webcrypto } from 'node:crypto' +import { systemClock } from '../clock' +import { CliApiError, CliAuthError } from '../errors' +import type { CliContext } from '../types' +import { type LoginKeyKind, parseSealedPayload, unseal } from './seal' +import { pollToken, type TokenSuccess } from './session' + +/** + * An approval extends the session to at least a minute after it, so the + * CLI keeps polling until a minute past `expiresAt` (runbook: a person who + * approves in the last seconds must not see "signed in" while expiry + * revokes the keys). + */ +export const EXPIRY_GRACE_MS = 60_000 +/** RFC 8628: each `slow_down` adds five seconds to the interval. */ +export const SLOW_DOWN_STEP_MS = 5000 +const MIN_INTERVAL_MS = 1000 +const MAX_INTERVAL_MS = 60_000 +/** Transient failures in a row before the sign-in gives up on them. */ +const MAX_CONSECUTIVE_RETRIES = 10 + +/** Error codes that end a sign-in for good: start a new one. */ +export const FINAL_LOGIN_CODES: ReadonlySet = new Set([ + 'CLI_LOGIN_DENIED', + 'CLI_LOGIN_EXPIRED', + 'CLI_LOGIN_INVALID_GRANT', +]) + +/** A sign-in being polled, with its secrets in memory. */ +export type ActiveLogin = { + readonly sessionId: string + readonly pollSecret: string + readonly privateKey: webcrypto.CryptoKey + readonly expiresAt: string + /** Seconds, from the server. */ + readonly interval: number + readonly siteUrl: string +} + +/** What an approval handed over. The keys stay in memory. */ +export type ApprovedLogin = { + readonly keys: Partial< + Record + > + readonly brand: TokenSuccess['brand'] + readonly approver: TokenSuccess['approver'] + readonly orgId: string + readonly apiBaseUrl: string +} + +export type WaitOutcome = + | { readonly status: 'approved'; readonly approved: ApprovedLogin } + /** `waitMs` ran out first; the session can still be resumed. */ + | { readonly status: 'pending' } + +/** + * Polls until the person approves or denies, the session expires, or + * `waitMs` runs out. `pollFirst` polls before the first wait (a resumed + * sign-in may already be approved); a fresh one waits an interval first. + */ +export async function waitForApproval( + ctx: CliContext, + login: ActiveLogin, + options: { + /** Undefined: until the session expires. `0`: one poll at most. */ + readonly waitMs: number | undefined + readonly pollFirst: boolean + /** The command that starts over, for error suggestions. */ + readonly restartCommand: string + } +): Promise { + const clock = ctx.io.clock ?? systemClock + const sessionDeadline = Date.parse(login.expiresAt) + EXPIRY_GRACE_MS + const waitDeadline = + options.waitMs === undefined + ? Number.POSITIVE_INFINITY + : clock.now() + options.waitMs + let intervalMs = clamp(login.interval * 1000) + let delayMs = options.pollFirst ? 0 : intervalMs + let retries = 0 + for (;;) { + const now = clock.now() + if (now >= sessionDeadline) { + throw expiredError(options.restartCommand, false) + } + if (delayMs > 0) { + const remainingWait = waitDeadline - now + // Too little left for another poll the server would accept. + if (remainingWait < MIN_INTERVAL_MS) { + return { status: 'pending' } + } + await clock.sleep( + Math.min(delayMs, remainingWait, sessionDeadline - now), + ctx.signal + ) + } + const result = await pollToken( + ctx, + { + siteUrl: login.siteUrl, + sessionId: login.sessionId, + pollSecret: login.pollSecret, + }, + clock.now() + ) + switch (result.kind) { + case 'approved': + return { + status: 'approved', + approved: await openSeal(login, result.token, options), + } + case 'pending': + retries = 0 + delayMs = intervalMs + break + case 'slow_down': + retries = 0 + intervalMs = clamp(intervalMs + SLOW_DOWN_STEP_MS) + delayMs = intervalMs + break + case 'retry': + retries += 1 + if (retries > MAX_CONSECUTIVE_RETRIES) { + throw result.error + } + delayMs = Math.max(intervalMs, result.retryAfterMs ?? 0) + break + case 'denied': + throw new CliAuthError( + 'The sign-in was denied in the browser.', + `Run \`${options.restartCommand}\` to start a new one.`, + 'CLI_LOGIN_DENIED' + ) + case 'expired': + throw expiredError(options.restartCommand, true) + case 'invalid_grant': + throw new CliAuthError( + 'Brew no longer has this sign-in: it expired and was cleaned up, or the poll secret did not match.', + `Run \`${options.restartCommand}\` to start a new one.`, + 'CLI_LOGIN_INVALID_GRANT' + ) + } + } +} + +function expiredError( + restartCommand: string, + fromServer: boolean +): CliAuthError { + return new CliAuthError( + fromServer + ? 'The sign-in expired, or its keys were already collected.' + : 'The sign-in expired before it was approved.', + fromServer + ? `Run \`${restartCommand}\` to start a new one. Keys whose response never arrived stay listed as "CLI · …" under Settings, API keys, where they can be revoked.` + : `Run \`${restartCommand}\` to start a new one.`, + 'CLI_LOGIN_EXPIRED' + ) +} + +async function openSeal( + login: ActiveLogin, + token: TokenSuccess, + options: { readonly restartCommand: string } +): Promise { + let plaintext: string + try { + plaintext = await unseal({ + privateKey: login.privateKey, + sessionId: login.sessionId, + sealed: token.sealed, + }) + } catch { + throw sealError(options.restartCommand) + } + const payload = parseSealedPayload(plaintext) + if (payload === undefined) { + throw sealError(options.restartCommand) + } + const keys: Partial> = {} + for (const entry of payload.keys) { + keys[entry.kind] = { key: entry.key, keyId: entry.keyId } + } + return { + keys, + brand: token.brand, + approver: token.approver, + orgId: token.orgId, + apiBaseUrl: token.apiBaseUrl, + } +} + +function sealError(restartCommand: string): CliApiError { + return new CliApiError({ + status: 200, + code: 'CLI_LOGIN_SEAL_INVALID', + type: 'internal_error', + message: + 'The keys Brew sent could not be opened with this sign-in’s private key.', + suggestion: `Revoke the "CLI · …" key created just now under Settings, API keys, then run \`${restartCommand}\` again.`, + }) +} + +function clamp(intervalMs: number): number { + if (!Number.isFinite(intervalMs)) { + return MIN_INTERVAL_MS + } + return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, intervalMs)) +} diff --git a/src/lib/auth-flow/remote-env.ts b/src/lib/auth-flow/remote-env.ts new file mode 100644 index 0000000..1f20163 --- /dev/null +++ b/src/lib/auth-flow/remote-env.ts @@ -0,0 +1,49 @@ +/** + * Where a browser on this machine is not the person's browser: a cloud + * shell, a remote SSH session, an in-browser WebContainer, CI. Sign-in there + * never tries to open one; it prints the link and code for any device, and + * the poll works the same (nothing travels back through the browser). + */ +export type RemoteEnv = { + /** Display name for the message, e.g. `GitHub Codespaces`. */ + readonly name: string +} + +export function detectRemoteEnv( + env: Readonly>, + versions: Readonly> = process.versions +): RemoteEnv | undefined { + if (isSet(env.CODESPACES)) { + return { name: 'GitHub Codespaces' } + } + if (isSet(env.REPL_ID)) { + return { name: 'Replit' } + } + if (isSet(env.GITPOD_WORKSPACE_ID)) { + return { name: 'Gitpod' } + } + if (isSet(versions.webcontainer)) { + return { name: 'a WebContainer' } + } + if ( + (isSet(env.SSH_CONNECTION) || + isSet(env.SSH_CLIENT) || + isSet(env.SSH_TTY)) && + !isSet(env.DISPLAY) && + !isSet(env.WAYLAND_DISPLAY) + ) { + return { name: 'an SSH session' } + } + if (isSet(env.CI)) { + return { name: 'CI' } + } +} + +/** Present, and not one of the usual "off" spellings. */ +function isSet(value: string | undefined): boolean { + if (value === undefined) { + return false + } + const normalized = value.trim().toLowerCase() + return normalized !== '' && normalized !== '0' && normalized !== 'false' +} diff --git a/src/lib/auth-flow/seal.ts b/src/lib/auth-flow/seal.ts new file mode 100644 index 0000000..52c3ccd --- /dev/null +++ b/src/lib/auth-flow/seal.ts @@ -0,0 +1,209 @@ +import { createHash, randomBytes, webcrypto } from 'node:crypto' + +/** + * The client half of the browser sign-in seal (brew-v2 + * `lib/cli-login/seal.ts`, whose `unsealForCli` this mirrors). Brew encrypts + * the minted keys to a P-256 key pair that exists only for one sign-in: + * + * 1. Z = ECDH(cliPrivate, serverEphemeralPublic): the 32-byte x-coordinate. + * 2. K = HKDF-SHA256(Z, salt = empty, info = "brew-cli-login-v1"), 32 bytes. + * 3. AES-256-GCM with K, a 12-byte IV, the 128-bit tag appended to the + * ciphertext, and the UTF-8 session id as associated data, so a sealed + * blob cannot be moved to another session. + * + * Every binary field is base64url without padding. `node:crypto` only. + */ + +export const SEAL_INFO = 'brew-cli-login-v1' + +const EC_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const +const TAG_BITS = 128 +const POLL_SECRET_BYTES = 32 + +export type SealedKeys = { + readonly serverPublicKey: string + readonly iv: string + readonly ciphertext: string +} + +export type LoginKeyKind = 'cli' | 'app' + +/** The plaintext inside the seal. */ +export type SealedPayload = { + readonly v: 1 + readonly keys: ReadonlyArray<{ + readonly kind: LoginKeyKind + readonly keyId: string + readonly key: string + }> +} + +/** A P-256 public key as the session route takes it. */ +export type PublicKeyJwk = { + readonly kty: 'EC' + readonly crv: 'P-256' + readonly x: string + readonly y: string +} + +/** The private half, persisted only for a `--no-wait` sign-in (mode 0600). */ +export type PrivateKeyJwk = PublicKeyJwk & { readonly d: string } + +export type LoginKeyPair = { + readonly privateKey: webcrypto.CryptoKey + readonly publicJwk: PublicKeyJwk +} + +const BASE64URL = /^[A-Za-z0-9_-]*$/ + +export function base64UrlEncode(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url') +} + +/** Throws on anything that is not base64url. */ +export function base64UrlDecode(value: string): Uint8Array { + if (!BASE64URL.test(value)) { + throw new Error('Not base64url') + } + const decoded = Buffer.from(value, 'base64url') + const bytes = new Uint8Array(decoded.byteLength) + bytes.set(decoded) + return bytes +} + +/** A fresh, extractable P-256 key pair for one sign-in. */ +export async function generateLoginKeyPair(): Promise { + const pair = (await webcrypto.subtle.generateKey(EC_PARAMS, true, [ + 'deriveBits', + ])) as webcrypto.CryptoKeyPair + const jwk = await webcrypto.subtle.exportKey('jwk', pair.publicKey) + if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { + throw new Error('Could not export the sign-in public key.') + } + return { + privateKey: pair.privateKey, + publicJwk: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, + } +} + +export async function exportPrivateJwk( + privateKey: webcrypto.CryptoKey +): Promise { + const jwk = await webcrypto.subtle.exportKey('jwk', privateKey) + if ( + typeof jwk.x !== 'string' || + typeof jwk.y !== 'string' || + typeof jwk.d !== 'string' + ) { + throw new Error('Could not export the sign-in private key.') + } + return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y, d: jwk.d } +} + +export async function importPrivateJwk( + jwk: PrivateKeyJwk +): Promise { + return await webcrypto.subtle.importKey( + 'jwk', + { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y, d: jwk.d, ext: true }, + EC_PARAMS, + false, + ['deriveBits'] + ) +} + +/** 32 random bytes, base64url: 43 printable characters, no spaces. */ +export function generatePollSecret(): string { + return randomBytes(POLL_SECRET_BYTES).toString('base64url') +} + +/** base64url(SHA-256(UTF-8 bytes of the poll secret)): what Brew stores. */ +export function hashPollSecret(pollSecret: string): string { + return createHash('sha256').update(pollSecret, 'utf8').digest('base64url') +} + +/** + * Opens a seal with the session's private key. Throws when the blob was + * altered, belongs to another session, or was sealed to another key. + */ +export async function unseal(input: { + readonly privateKey: webcrypto.CryptoKey + readonly sessionId: string + readonly sealed: SealedKeys +}): Promise { + const serverKey = await webcrypto.subtle.importKey( + 'raw', + base64UrlDecode(input.sealed.serverPublicKey), + EC_PARAMS, + false, + [] + ) + const shared = await webcrypto.subtle.deriveBits( + { name: 'ECDH', public: serverKey }, + input.privateKey, + 256 + ) + const ikm = await webcrypto.subtle.importKey('raw', shared, 'HKDF', false, [ + 'deriveKey', + ]) + const aesKey = await webcrypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new TextEncoder().encode(SEAL_INFO), + }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ) + const plaintext = await webcrypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: base64UrlDecode(input.sealed.iv), + additionalData: new TextEncoder().encode(input.sessionId), + tagLength: TAG_BITS, + }, + aesKey, + base64UrlDecode(input.sealed.ciphertext) + ) + return new TextDecoder().decode(plaintext) +} + +/** The sealed plaintext, checked; `undefined` for anything else. */ +export function parseSealedPayload(text: string): SealedPayload | undefined { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return + } + if (!isRecord(parsed) || parsed.v !== 1 || !Array.isArray(parsed.keys)) { + return + } + const keys: Array = [] + for (const entry of parsed.keys) { + // A kind this CLI does not know (a newer server) is skipped, not fatal: + // the session is already consumed, so failing here would strand the + // keys this CLI does know. + if (isRecord(entry) && entry.kind !== 'cli' && entry.kind !== 'app') { + continue + } + if ( + !isRecord(entry) || + (entry.kind !== 'cli' && entry.kind !== 'app') || + typeof entry.keyId !== 'string' || + typeof entry.key !== 'string' || + entry.key === '' + ) { + return + } + keys.push({ kind: entry.kind, keyId: entry.keyId, key: entry.key }) + } + return { v: 1, keys } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/lib/auth-flow/session.ts b/src/lib/auth-flow/session.ts new file mode 100644 index 0000000..ac175c8 --- /dev/null +++ b/src/lib/auth-flow/session.ts @@ -0,0 +1,430 @@ +import { hostname as osHostname } from 'node:os' +import { CLI_NAME, CLI_VERSION } from '../../version' +import { LOGIN_KEYS_URL } from '../client' +import { + CliApiError, + CliAuthError, + CliConnectionError, + CliUsageError, + errorTypeForStatus, + isTimeoutError, + suggestionForStatus, +} from '../errors' +import { getInvocationPrefix } from '../invocation' +import { + DEFAULT_ATTEMPT_TIMEOUT_MS, + rawFetch, + tryParseJson, +} from '../raw-request' +import type { CliContext } from '../types' +import type { LoginKeyKind, PublicKeyJwk, SealedKeys } from './seal' + +/** + * The two browser sign-in routes (brew-v2 `docs/runbooks/cli-login.md`). + * They live off `/v1` on the site origin, take no credential, and answer + * OAuth-device-flow style `{ error }` bodies instead of API envelopes. + */ + +/** Display-only facts the approval page shows under "Reported by the CLI". */ +export type LoginClientInfo = { + readonly hostname?: string + readonly os?: string + readonly cliVersion?: string + readonly project?: string + readonly appUrl?: string +} + +/** `201` of `POST /api/cli/sessions`. */ +export type CreatedSession = { + readonly sessionId: string + /** `XXXX-XXXX`, shown in the terminal and on the approval page. */ + readonly userCode: string + readonly verificationUrl: string + /** ISO 8601. */ + readonly expiresAt: string + /** Seconds between polls. */ + readonly interval: number +} + +/** `200` of `POST /api/cli/sessions/{sessionId}/token`, returned once. */ +export type TokenSuccess = { + readonly sealed: SealedKeys + readonly keys: ReadonlyArray<{ + readonly kind: LoginKeyKind + readonly keyId: string + }> + readonly orgId: string + readonly brand: { readonly brandId: string; readonly name: string } + readonly approver: { readonly email: string } + readonly apiBaseUrl: string +} + +export type PollResult = + | { readonly kind: 'approved'; readonly token: TokenSuccess } + | { readonly kind: 'pending' } + | { readonly kind: 'slow_down' } + /** Transient (429, 5xx, a dropped or timed-out attempt): poll again. */ + | { + readonly kind: 'retry' + readonly retryAfterMs: number | undefined + readonly error: Error + } + | { readonly kind: 'denied' } + /** 410: expired, or its keys were already collected. */ + | { readonly kind: 'expired' } + /** Unknown session or wrong poll secret, or deleted after expiry. */ + | { readonly kind: 'invalid_grant' } + +const MAX_RETRY_AFTER_MS = 60_000 + +/** + * The site the sign-in routes live on: the API URL without its `/api` + * suffix (`https://brew.new/api` → `https://brew.new`). + */ +export function siteUrlFromApiUrl(apiUrl: string): string { + const site = apiUrl.replace(/\/+$/, '').replace(/\/api$/, '') + let parsed: URL + try { + parsed = new URL(site) + } catch { + throw new CliUsageError( + `The API URL must be an absolute http(s) URL; got "${apiUrl}".` + ) + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new CliUsageError( + `The API URL must be an absolute http(s) URL; got "${apiUrl}".` + ) + } + return site +} + +/** What this machine reports about itself; every member optional. */ +export function defaultClientInfo(): LoginClientInfo { + const hostname = displayValue(safeHostname(), 253) + return { + ...(hostname === undefined ? {} : { hostname }), + os: process.platform, + cliVersion: CLI_VERSION.slice(0, 32), + } +} + +/** Drops empty and over-long values the route would refuse. */ +export function cleanClientInfo(info: LoginClientInfo): LoginClientInfo { + const limits: Record = { + hostname: 253, + os: 64, + cliVersion: 32, + project: 100, + appUrl: 512, + } + const cleaned: Record = {} + for (const [key, limit] of Object.entries(limits)) { + const value = displayValue(info[key as keyof LoginClientInfo], limit) + if (value !== undefined) { + cleaned[key] = value + } + } + return cleaned +} + +export async function createSession( + ctx: CliContext, + input: { + readonly siteUrl: string + readonly publicKey: PublicKeyJwk + readonly pollSecretHash: string + readonly keys: readonly LoginKeyKind[] + readonly client: LoginClientInfo + } +): Promise { + const { response, text } = await rawFetch(ctx, { + method: 'POST', + url: `${input.siteUrl}/api/cli/sessions`, + headers: jsonHeaders(), + body: JSON.stringify({ + publicKey: input.publicKey, + pollSecretHash: input.pollSecretHash, + keys: input.keys, + client: input.client, + }), + timeoutMs: attemptTimeoutMs(ctx), + }) + const parsed = tryParseJson(text) + if (response.status === 201) { + const session = readCreatedSession(parsed) + if (session === undefined) { + throw unexpectedResponse(201, 'POST /api/cli/sessions') + } + return session + } + const code = errorCodeOf(parsed) + // A server without the routes answers 404 (or its sign-in gate a 401, + // 403 or an HTML page): browser sign-in is not deployed there. + if ( + [401, 403, 404, 405].includes(response.status) || + (code === undefined && + response.status < 500 && + response.status !== 408 && + response.status !== 429) + ) { + const prefix = getInvocationPrefix(ctx.io.env) + throw new CliAuthError( + `${input.siteUrl} does not offer browser sign-in (POST /api/cli/sessions answered ${response.status}).`, + `Run \`${prefix} login --paste\` with a key from ${LOGIN_KEYS_URL}, or set BREW_API_KEY.`, + 'CLI_LOGIN_UNAVAILABLE' + ) + } + const retryAfterMs = retryAfterOf(response, Date.now()) + throw new CliApiError({ + status: response.status, + code: (code ?? 'server_error').toUpperCase(), + type: errorTypeForStatus(response.status), + message: + messageOf(parsed) ?? + `Brew could not start a sign-in (${response.status}).`, + suggestion: + retryAfterMs === undefined + ? suggestionForStatus(response.status) + : `Run the command again in ${Math.ceil(retryAfterMs / 1000)} s.`, + body: parsed, + }) +} + +/** + * One token poll, classified. Throws only for a stop (interrupt, the + * command's deadline), a malformed request, or an answer outside the + * contract. + */ +export async function pollToken( + ctx: CliContext, + input: { + readonly siteUrl: string + readonly sessionId: string + readonly pollSecret: string + }, + now: number +): Promise { + let response: Response + let text: string + try { + ;({ response, text } = await rawFetch(ctx, { + method: 'POST', + url: `${input.siteUrl}/api/cli/sessions/${encodeURIComponent(input.sessionId)}/token`, + headers: jsonHeaders(), + body: JSON.stringify({ pollSecret: input.pollSecret }), + timeoutMs: attemptTimeoutMs(ctx), + })) + } catch (error) { + // Ctrl-C, SIGTERM and --timeout end the sign-in; one dropped or slow + // attempt does not. + if (ctx.signal.aborted) { + throw error + } + if ( + error instanceof Error && + (error instanceof CliConnectionError || isTimeoutError(error)) + ) { + return { kind: 'retry', retryAfterMs: undefined, error } + } + throw error + } + const parsed = tryParseJson(text) + if (response.status === 200) { + const token = readTokenSuccess(parsed) + if (token === undefined) { + throw unexpectedResponse(200, 'POST /api/cli/sessions/{id}/token') + } + return { kind: 'approved', token } + } + const code = errorCodeOf(parsed) + switch (code) { + case 'authorization_pending': + return { kind: 'pending' } + case 'slow_down': + return { kind: 'slow_down' } + case 'access_denied': + return { kind: 'denied' } + case 'expired_token': + return { kind: 'expired' } + case 'invalid_grant': + return { kind: 'invalid_grant' } + default: + break + } + const error = new CliApiError({ + status: response.status, + code: (code ?? 'server_error').toUpperCase(), + type: errorTypeForStatus(response.status), + message: + messageOf(parsed) ?? + `Brew could not answer the sign-in poll (${response.status}).`, + suggestion: suggestionForStatus(response.status), + body: parsed, + }) + if (response.status === 429 || response.status >= 500) { + return { kind: 'retry', retryAfterMs: retryAfterOf(response, now), error } + } + throw error +} + +/** + * Text from the server (brand names, emails, codes) with control and + * invisible characters removed, so it cannot rewrite the terminal. + */ +export function printable(text: string): string { + return text.replace(/\p{C}/gu, '') +} + +function displayValue( + value: string | undefined, + limit: number +): string | undefined { + if (value === undefined) { + return + } + // By code point: slicing UTF-16 could leave half an emoji, which the + // server's printable check refuses. + const cleaned = Array.from(printable(value).trim()) + .slice(0, limit) + .join('') + .trim() + return cleaned === '' ? undefined : cleaned +} + +function safeHostname(): string | undefined { + try { + return osHostname() + } catch { + return + } +} + +function jsonHeaders(): Headers { + return new Headers({ + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': `${CLI_NAME}/${CLI_VERSION}`, + }) +} + +function attemptTimeoutMs(ctx: CliContext): number { + return ctx.budget.attemptMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS +} + +function unexpectedResponse(status: number, route: string): CliApiError { + return new CliApiError({ + status, + code: 'UNEXPECTED_RESPONSE', + type: 'internal_error', + message: `${route} answered ${status} with a body outside the sign-in contract.`, + suggestion: 'Update the CLI, then sign in again.', + }) +} + +function readCreatedSession(value: unknown): CreatedSession | undefined { + if (!isRecord(value)) { + return + } + const { sessionId, userCode, verificationUrl, expiresAt, interval } = value + if ( + typeof sessionId !== 'string' || + !/^[A-Za-z0-9_-]{16,128}$/.test(sessionId) || + typeof userCode !== 'string' || + typeof verificationUrl !== 'string' || + !isHttpUrl(verificationUrl) || + typeof expiresAt !== 'string' || + Number.isNaN(Date.parse(expiresAt)) || + typeof interval !== 'number' || + !Number.isFinite(interval) + ) { + return + } + return { sessionId, userCode, verificationUrl, expiresAt, interval } +} + +function readTokenSuccess(value: unknown): TokenSuccess | undefined { + if (!isRecord(value)) { + return + } + const { sealed, keys, orgId, brand, approver, apiBaseUrl } = value + if ( + !isRecord(sealed) || + typeof sealed.serverPublicKey !== 'string' || + typeof sealed.iv !== 'string' || + typeof sealed.ciphertext !== 'string' || + !isRecord(brand) || + typeof brand.brandId !== 'string' || + typeof brand.name !== 'string' || + !isRecord(approver) || + typeof approver.email !== 'string' || + !Array.isArray(keys) + ) { + return + } + const keyRows: Array = [] + for (const row of keys) { + if ( + isRecord(row) && + (row.kind === 'cli' || row.kind === 'app') && + typeof row.keyId === 'string' + ) { + keyRows.push({ kind: row.kind, keyId: row.keyId }) + } + } + return { + sealed: { + serverPublicKey: sealed.serverPublicKey, + iv: sealed.iv, + ciphertext: sealed.ciphertext, + }, + keys: keyRows, + orgId: typeof orgId === 'string' ? orgId : '', + brand: { brandId: brand.brandId, name: brand.name }, + approver: { email: approver.email }, + apiBaseUrl: typeof apiBaseUrl === 'string' ? apiBaseUrl : '', + } +} + +function errorCodeOf(value: unknown): string | undefined { + return isRecord(value) && typeof value.error === 'string' + ? value.error + : undefined +} + +function messageOf(value: unknown): string | undefined { + return isRecord(value) && + typeof value.message === 'string' && + value.message !== '' + ? printable(value.message) + : undefined +} + +/** `Retry-After` in seconds or as an HTTP date, capped at a minute. */ +function retryAfterOf(response: Response, now: number): number | undefined { + const header = response.headers.get('retry-after')?.trim() + if (header === undefined || header === '') { + return + } + const seconds = Number(header) + const ms = Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(header) - now + if (!Number.isFinite(ms) || ms < 0) { + return + } + return Math.min(ms, MAX_RETRY_AFTER_MS) +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' + } catch { + return false + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 63b082f..942291d 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -58,8 +58,16 @@ export function resolveAuth(input: { readEnvValue(env.BREW_API_URL) ?? stored.apiUrl ?? DEFAULT_API_URL + // A browser sign-in stores the brand its key is bound to (with + // `brandName`). That brand belongs to the stored key: a key from + // --api-key or BREW_API_KEY may be bound to another brand, and sending + // the stored id with it would make every call a 403 BRAND_SCOPE_MISMATCH. + const storedBrandId = + stored.brandName !== undefined && apiKey !== keyFromConfig + ? undefined + : stored.brandId const brandId = - globals.brand ?? readEnvValue(env.BREW_BRAND_ID) ?? stored.brandId + globals.brand ?? readEnvValue(env.BREW_BRAND_ID) ?? storedBrandId if (!apiKey) { if (input.allowAnonymous === true) { return { diff --git a/src/lib/clock.ts b/src/lib/clock.ts new file mode 100644 index 0000000..c56136c --- /dev/null +++ b/src/lib/clock.ts @@ -0,0 +1,35 @@ +/** + * Wall-clock time and waiting, behind a seam so a test can run a ten-minute + * browser sign-in in milliseconds (`CliIo.clock`). + */ +export type Clock = { + readonly now: () => number + /** + * Resolves after `ms`; rejects with `signal.reason` the moment `signal` + * aborts (Ctrl-C, SIGTERM, the command's `--timeout`). + */ + readonly sleep: (ms: number, signal: AbortSignal) => Promise +} + +export const systemClock: Clock = { + now: () => Date.now(), + sleep: (ms, signal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + const onAbort = (): void => { + clearTimeout(timer) + reject(signal.reason) + } + const timer = setTimeout( + () => { + signal.removeEventListener('abort', onAbort) + resolve() + }, + Math.max(0, ms) + ) + signal.addEventListener('abort', onAbort, { once: true }) + }), +} diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 0ea7f60..d9790cb 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -6,9 +6,21 @@ export type StoredConfig = { readonly apiKey?: string readonly brandId?: string readonly apiUrl?: string + /** + * Who approved the stored key's browser sign-in, and the brand it is bound + * to: display only (`whoami`), never sent. A pasted key clears both. + */ + readonly userEmail?: string + readonly brandName?: string } -const CONFIG_KEYS = ['apiKey', 'brandId', 'apiUrl'] as const +const CONFIG_KEYS = [ + 'apiKey', + 'brandId', + 'apiUrl', + 'userEmail', + 'brandName', +] as const export type ConfigKey = (typeof CONFIG_KEYS)[number] diff --git a/src/lib/errors.ts b/src/lib/errors.ts index bda7e30..574a6b1 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -75,13 +75,19 @@ export class CliConnectionError extends Error { /** Bad invocation: malformed input, unusable flag combinations. */ export class CliUsageError extends Error {} -/** Missing or unusable credentials, detected before any request is sent. */ +/** + * Missing or unusable credentials (exit 3): none was found before any + * request was sent, or a browser sign-in ended without one (denied, + * expired). `code` names which, for agents; the default is `CLI_AUTH`. + */ export class CliAuthError extends Error { readonly suggestion: string + readonly code: string - constructor(message: string, suggestion: string) { + constructor(message: string, suggestion: string, code = 'CLI_AUTH') { super(message) this.suggestion = suggestion + this.code = code } } @@ -386,7 +392,7 @@ function baseEnvelope(error: unknown, context: ErrorContext): CliErrorEnvelope { } if (error instanceof CliAuthError) { return { - code: 'CLI_AUTH', + code: error.code, type: 'authentication_error', message: error.message, suggestion: error.suggestion, @@ -445,6 +451,17 @@ function withReplayAdvice( ? { ...envelope, suggestion: 'Nothing was sent.' } : envelope } + if (isSignInPath(context.request.path)) { + // `login`'s session routes: a re-run starts a fresh sign-in, and + // nothing is stored until one is approved and collected. + return { + ...envelope, + suggestion: + envelope.code === 'CLI_INTERRUPTED' + ? 'Nothing was stored on this machine: run the command again.' + : 'Check the connection, then run the command again.', + } + } if (context.isRead === true || context.request.method === 'GET') { return envelope.code === 'CLI_INTERRUPTED' ? { ...envelope, suggestion: 'It only reads; nothing was changed.' } @@ -466,6 +483,11 @@ function withReplayAdvice( } } +/** The browser sign-in routes (`/api/cli/sessions…`), off the public API. */ +function isSignInPath(path: string): boolean { + return /\/api\/cli\/sessions(?:\/|$|\?)/.test(path) +} + function isUnknownOutcome(envelope: CliErrorEnvelope): boolean { if ( envelope.code === 'CLI_INTERRUPTED' || diff --git a/src/lib/project/process.ts b/src/lib/project/process.ts index 32e6f33..cf2e92d 100644 --- a/src/lib/project/process.ts +++ b/src/lib/project/process.ts @@ -61,22 +61,64 @@ export const defaultSpawn: SpawnFn = (command, args, options = {}) => child.stdin?.end(options.input ?? '') }) +/** + * The program that opens `url` in the default browser, and its argv (the URL + * is always one entry, never shell-interpreted). WSL runs Linux with the + * browser on the Windows side, so `wslview` (wslu) hands the URL across. + * Windows uses `rundll32 url.dll,FileProtocolHandler`, which takes the URL + * as a single argument; `cmd /c start` would parse it again and split it at + * `&`. + */ +export function browserOpener( + url: string, + platform: NodeJS.Platform, + env: Readonly> +): { readonly command: string; readonly args: readonly string[] } { + if (platform === 'darwin') { + return { command: 'open', args: [url] } + } + if (platform === 'win32') { + return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] } + } + if (platform === 'linux' && isWsl(env)) { + return { command: 'wslview', args: [url] } + } + return { command: 'xdg-open', args: [url] } +} + +/** Windows Subsystem for Linux sets these in every shell it starts. */ +export function isWsl( + env: Readonly> +): boolean { + return Boolean(env.WSL_DISTRO_NAME) || Boolean(env.WSL_INTEROP) +} + /** * Hands a URL to the platform opener. Only absolute URLs with a scheme are - * accepted, and the URL is one argv entry (never shell-interpreted). + * accepted. The opener runs detached: `xdg-open` can stay in the foreground + * as long as the browser it started, and the CLI must neither wait for it + * nor be kept alive by it. Resolves once the opener started; rejects when + * it could not be (ENOENT). */ export async function defaultOpenUrl( url: string, - spawn: SpawnFn = defaultSpawn + env: Readonly> = process.env ): Promise { if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) { throw new Error('Refusing to open a URL without a scheme.') } - const [command, args]: readonly [string, readonly string[]] = - process.platform === 'darwin' - ? ['open', [url]] - : process.platform === 'win32' - ? ['rundll32', ['url.dll,FileProtocolHandler', url]] - : ['xdg-open', [url]] - await spawn(command, args) + const { command, args } = browserOpener(url, process.platform, env) + await new Promise((resolve, reject) => { + const child = nodeSpawn(command, [...args], { + detached: true, + stdio: 'ignore', + shell: false, + windowsHide: true, + }) + child.once('error', reject) + child.once('spawn', () => { + child.unref() + resolve() + }) + }) } diff --git a/src/lib/transport.ts b/src/lib/transport.ts index c2e175c..59a5494 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -142,6 +142,14 @@ const MAX_TIMEOUT_MS = 24 * 3_600_000 * Anything else, zero, or over a day is a usage error (exit 2). */ export function parseTimeoutFlag(value: unknown): number | undefined { + return parseDurationFlag(value, '--timeout') +} + +/** A duration flag in the `--timeout` grammar, in ms (`login --wait`). */ +export function parseDurationFlag( + value: unknown, + flag: string +): number | undefined { if (value === undefined) { return } @@ -151,7 +159,7 @@ export function parseTimeoutFlag(value: unknown): number | undefined { match === null ? Number.NaN : Math.round(Number(match[1]) * UNIT_MS[unit]) if (!(ms >= 1 && ms <= MAX_TIMEOUT_MS)) { throw new CliUsageError( - `--timeout takes a positive duration up to 24h: 90, 90s, 1500ms or 5m (a bare number is seconds); got "${String(value)}".` + `${flag} takes a positive duration up to 24h: 90, 90s, 1500ms or 5m (a bare number is seconds); got "${String(value)}".` ) } return ms diff --git a/src/lib/types.ts b/src/lib/types.ts index 117dc59..e07cb0a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ import type { BrewClient } from './client' +import type { Clock } from './clock' import type { CommandMetadata } from './define-command' import type { Budget, TransportState } from './transport' @@ -50,11 +51,17 @@ export type CliIo = { readonly spawn?: SpawnFn /** * Opens a URL in the user's browser or editor. Absent: the platform - * opener (`open`, `xdg-open`, `start`). + * opener (`open`, `xdg-open`, `wslview` under WSL, `rundll32` on Windows; + * see `lib/project/process.ts`). */ readonly openUrl?: (url: string) => Promise /** The working directory project commands act on. Absent: `process.cwd()`. */ readonly cwd?: string + /** + * Time and waiting for commands that poll (`login`). Absent: the system + * clock and `setTimeout`. + */ + readonly clock?: Clock } export type OutputMode = 'human' | 'json' diff --git a/tests/commands/login-browser.test.ts b/tests/commands/login-browser.test.ts new file mode 100644 index 0000000..a000ef1 --- /dev/null +++ b/tests/commands/login-browser.test.ts @@ -0,0 +1,565 @@ +import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { pendingLoginPath } from '../../src/lib/auth-flow/pending' +import { configPath, readConfig } from '../../src/lib/config-store' +import { CliInterruptError } from '../../src/lib/errors' +import { + APPROVER_EMAIL, + BRAND, + CLI_KEY, + CLI_KEY_ID, + fakeClock, + SESSION_ID, + sha256, + T0, + USER_CODE, + useLoginServer, +} from '../helpers/login-server' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const VERIFICATION_URL = `https://brew.new/cli/authorize?session=${SESSION_ID}` +const USAGE = { plan: 'growth', credits: { balance: 420 } } + +function tempEnv( + extra: Record = {} +): Record { + return { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-login-')), + ...extra, + } +} + +function recordingOpener(): { + readonly urls: string[] + readonly openUrl: (url: string) => Promise +} { + const urls: string[] = [] + return { + urls, + openUrl: async (url) => { + urls.push(url) + }, + } +} + +type ErrorEnvelope = { error: { code: string; message: string } } + +describe('login through the browser', () => { + it('signs in on an interactive terminal: opens the page, polls, unseals and stores the CLI key', async () => { + const login = useLoginServer({ steps: ['pending', 'approved'] }) + const env = tempEnv() + const opener = recordingOpener() + const clock = fakeClock() + const result = await runCli(['login', '--json'], { + env, + ttyIn: true, + clock, + openUrl: opener.openUrl, + }) + expect(result.code).toBe(0) + expect(result.json).toEqual({ + loggedIn: true, + method: 'browser', + apiKey: 'brew_cli…nop', + apiUrl: 'https://brew.new/api', + configPath: configPath(env), + brandId: BRAND.brandId, + brandName: BRAND.name, + userEmail: APPROVER_EMAIL, + keyId: CLI_KEY_ID, + }) + expect(readConfig(env)).toEqual({ + apiKey: CLI_KEY, + brandId: BRAND.brandId, + userEmail: APPROVER_EMAIL, + brandName: BRAND.name, + }) + expect(statSync(configPath(env)).mode & 0o777).toBe(0o600) + expect(opener.urls).toEqual([VERIFICATION_URL]) + expect(result.stderr).toContain(VERIFICATION_URL) + expect(result.stderr).toContain('The page is on brew.new') + expect(result.stderr).toContain( + `Check the code in your browser matches: ${USER_CODE}` + ) + expect(result.stderr).toContain( + `Signed in as ${APPROVER_EMAIL} to ${BRAND.name}` + ) + // Waited one interval before each poll. + expect(clock.sleeps).toEqual([2000, 2000]) + expect(login.polls()).toBe(2) + + const [created] = login.createBodies + expect(created?.keys).toEqual(['cli']) + expect(created?.publicKey).toMatchObject({ kty: 'EC', crv: 'P-256' }) + expect(created?.publicKey.x).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(created?.publicKey.y).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(created?.client).toMatchObject({ os: process.platform }) + const [secret] = login.pollSecrets + expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(sha256(secret ?? '')).toBe(created?.pollSecretHash) + // The same secret on every poll. + expect(new Set(login.pollSecrets).size).toBe(1) + }) + + it('backs off five seconds on slow_down and keeps polling until approved', async () => { + const login = useLoginServer({ + steps: ['pending', 'slow_down', 'approved'], + }) + const env = tempEnv() + const clock = fakeClock() + const result = await runCli(['login', '--browser'], { env, clock }) + expect(result.code).toBe(0) + expect(clock.sleeps).toEqual([2000, 2000, 7000]) + expect(login.polls()).toBe(3) + expect(readConfig(env).apiKey).toBe(CLI_KEY) + }) + + it('rides out a 429 (honouring Retry-After) and a 500', async () => { + useLoginServer({ steps: ['rate_limited', 'server_error', 'approved'] }) + const env = tempEnv() + const clock = fakeClock() + const result = await runCli(['login', '--browser'], { env, clock }) + expect(result.code).toBe(0) + expect(clock.sleeps).toEqual([2000, 7000, 2000]) + }) + + it('Ctrl-C while waiting ends with 130 and stores nothing', async () => { + useLoginServer({ steps: ['pending'] }) + const env = tempEnv() + const interrupt = new AbortController() + const clock = fakeClock() + const result = await runCli(['login', '--browser'], { + env, + signal: interrupt.signal, + clock: { + now: clock.now, + sleep: async (ms, signal) => { + if (clock.sleeps.length === 1) { + interrupt.abort(new CliInterruptError('SIGINT')) + } + await clock.sleep(ms, signal) + }, + }, + }) + expect(result.code).toBe(130) + expect(lastError(result.stderr)).toMatchObject({ + code: 'CLI_INTERRUPTED', + suggestion: 'Nothing was stored on this machine: run the command again.', + }) + expect(readConfig(env).apiKey).toBeUndefined() + }) + + it('exits 3 when the person denies the request, storing nothing', async () => { + useLoginServer({ steps: ['pending', 'denied'] }) + const env = tempEnv() + const result = await runCli(['login', '--browser'], { + env, + clock: fakeClock(), + }) + expect(result.code).toBe(3) + expect(result.stdout).toBe('') + const parsed = JSON.parse(result.stderr.trim().split('\n').at(-1) ?? '') + expect((parsed as ErrorEnvelope).error.code).toBe('CLI_LOGIN_DENIED') + expect((parsed as ErrorEnvelope).error.message).toContain('denied') + expect(readConfig(env).apiKey).toBeUndefined() + }) + + it('exits 3 when the server says the session expired', async () => { + useLoginServer({ steps: ['expired'] }) + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock: fakeClock(), + }) + expect(result.code).toBe(3) + expect(lastError(result.stderr).code).toBe('CLI_LOGIN_EXPIRED') + }) + + it('stops at expiresAt plus a minute when nobody decides', async () => { + const login = useLoginServer({ steps: ['pending'] }) + const clock = fakeClock() + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock, + }) + expect(result.code).toBe(3) + expect(lastError(result.stderr).code).toBe('CLI_LOGIN_EXPIRED') + expect(clock.now()).toBe(T0 + 11 * 60_000) + // One poll per 2 s across the 11 minutes, no more. + expect(login.polls()).toBe(330) + }) + + it('treats invalid_grant as final after one poll', async () => { + const login = useLoginServer({ steps: ['invalid_grant'] }) + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock: fakeClock(), + }) + expect(result.code).toBe(3) + expect(lastError(result.stderr).code).toBe('CLI_LOGIN_INVALID_GRANT') + expect(login.polls()).toBe(1) + }) + + it('refuses keys sealed for another session', async () => { + useLoginServer({ + steps: ['approved'], + sealSessionId: 'AnotherSessionAnotherSession0123', + }) + const env = tempEnv() + const result = await runCli(['login', '--browser'], { + env, + clock: fakeClock(), + }) + expect(result.code).toBe(1) + expect(lastError(result.stderr).code).toBe('CLI_LOGIN_SEAL_INVALID') + expect(readConfig(env).apiKey).toBeUndefined() + }) + + it('never opens a browser in a cloud shell, but prints the link and still polls', async () => { + useLoginServer({ steps: ['approved'] }) + const opener = recordingOpener() + const env = tempEnv({ CODESPACES: 'true' }) + const result = await runCli(['login', '--browser'], { + env, + clock: fakeClock(), + openUrl: opener.openUrl, + }) + expect(result.code).toBe(0) + expect(opener.urls).toEqual([]) + expect(result.stderr).toContain('GitHub Codespaces') + expect(result.stderr).toContain(VERIFICATION_URL) + expect(readConfig(env).apiKey).toBe(CLI_KEY) + }) + + it('--no-open prints the link without opening it', async () => { + useLoginServer({ steps: ['approved'] }) + const opener = recordingOpener() + const result = await runCli(['login', '--no-open'], { + env: tempEnv(), + clock: fakeClock(), + openUrl: opener.openUrl, + }) + expect(result.code).toBe(0) + expect(opener.urls).toEqual([]) + expect(result.stderr).toContain(VERIFICATION_URL) + }) + + it('carries on when no browser can be opened', async () => { + useLoginServer({ steps: ['approved'] }) + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock: fakeClock(), + }) + expect(result.code).toBe(0) + expect(result.stderr).toContain('Could not open a browser here') + }) + + it('starts the session on the --api-url site', async () => { + const login = useLoginServer({ + origin: 'http://localhost:3000', + steps: ['approved'], + }) + const result = await runCli( + ['login', '--browser', '--api-url', 'http://localhost:3000/api'], + { env: tempEnv(), clock: fakeClock() } + ) + expect(result.code).toBe(0) + expect(login.createBodies).toHaveLength(1) + expect((result.json as { apiUrl: string }).apiUrl).toBe( + 'http://localhost:3000/api' + ) + }) + + it('a dropped connection says to run it again, not to check for a write', async () => { + server.use( + http.post('https://brew.new/api/cli/sessions', () => HttpResponse.error()) + ) + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock: fakeClock(), + }) + expect(result.code).toBe(1) + const error = lastError(result.stderr) + expect(error.code).toBe('CLI_CONNECTION') + expect(error.suggestion).toBe( + 'Check the connection, then run the command again.' + ) + }) + + it('exits 3 with a --paste hint when the server has no browser sign-in', async () => { + useLoginServer({ createStatus: 404 }) + const result = await runCli(['login', '--browser'], { + env: tempEnv(), + clock: fakeClock(), + }) + expect(result.code).toBe(3) + const error = lastError(result.stderr) as { + code: string + suggestion: string + } + expect(error.code).toBe('CLI_LOGIN_UNAVAILABLE') + expect(error.suggestion).toContain('brew-cli login --paste') + }) + + it('whoami shows who approved the stored key', async () => { + useLoginServer({ steps: ['approved'] }) + server.use( + http.get('https://brew.new/api/v1/usage', () => HttpResponse.json(USAGE)) + ) + const env = tempEnv() + await runCli(['login', '--browser'], { env, clock: fakeClock() }) + const whoami = await runCli(['whoami'], { env }) + expect(whoami.code).toBe(0) + expect(whoami.json).toMatchObject({ + apiKeySource: 'config', + brandId: BRAND.brandId, + userEmail: APPROVER_EMAIL, + brandName: BRAND.name, + }) + + const fromEnv = await runCli(['whoami'], { + env: { ...env, BREW_API_KEY: 'brew_otherkey0123456789abcdefghijkl' }, + }) + // The stored brand belongs to the stored key: another key never + // inherits it (it would 403 BRAND_SCOPE_MISMATCH on brand routes). + expect(fromEnv.json).toMatchObject({ + apiKeySource: 'env', + userEmail: null, + brandName: null, + }) + expect((fromEnv.json as { brandId?: unknown }).brandId ?? null).toBeNull() + + const logout = await runCli(['logout'], { env }) + expect(logout.code).toBe(0) + expect(readConfig(env)).toEqual({}) + }) + + it('a pasted key after a browser sign-in forgets the brand of that sign-in', async () => { + useLoginServer({ steps: ['approved'] }) + server.use( + http.get('https://brew.new/api/v1/usage', () => HttpResponse.json(USAGE)) + ) + const env = tempEnv() + await runCli(['login', '--browser'], { env, clock: fakeClock() }) + expect(readConfig(env).brandId).toBe(BRAND.brandId) + const pasted = await runCli( + ['login', '--api-key', 'brew_otherkey0123456789abcdefghijkl'], + { env } + ) + expect(pasted.code).toBe(0) + expect(readConfig(env)).toEqual({ + apiKey: 'brew_otherkey0123456789abcdefghijkl', + }) + }) + + it('shows who approved even under --quiet, and remembers a non-default API URL', async () => { + useLoginServer({ steps: ['approved'], origin: 'http://localhost:3000' }) + const env = tempEnv() + const result = await runCli( + [ + 'login', + '--browser', + '--quiet', + '--api-url', + 'http://localhost:3000/api', + ], + { env, clock: fakeClock(), ttyOut: true } + ) + expect(result.code).toBe(0) + expect(result.stderr).toContain( + `Signed in as ${APPROVER_EMAIL} to ${BRAND.name}` + ) + expect(readConfig(env).apiUrl).toBe('http://localhost:3000/api') + }) +}) + +describe('login --no-wait / --resume (agents)', () => { + it('prints the pending result and exits 3 at once, then --resume collects the keys', async () => { + const login = useLoginServer({ steps: ['approved'] }) + const env = tempEnv() + const opener = recordingOpener() + const started = await runCli(['login', '--no-wait', '--json'], { + env, + clock: fakeClock(), + openUrl: opener.openUrl, + }) + expect(started.code).toBe(3) + expect(started.json).toEqual({ + status: 'pending', + verificationUrl: VERIFICATION_URL, + userCode: USER_CODE, + expiresAt: new Date(T0 + 10 * 60_000).toISOString(), + resumeCommand: 'brew-cli login --resume --json', + }) + expect(login.polls()).toBe(0) + expect(opener.urls).toEqual([VERIFICATION_URL]) + const pendingPath = pendingLoginPath(env) + expect(statSync(pendingPath).mode & 0o777).toBe(0o600) + expect(readConfig(env).apiKey).toBeUndefined() + + const resumed = await runCli(['login', '--resume', '--json'], { + env, + clock: fakeClock(), + }) + expect(resumed.code).toBe(0) + expect(resumed.json).toMatchObject({ + method: 'browser', + brandId: BRAND.brandId, + userEmail: APPROVER_EMAIL, + keyId: CLI_KEY_ID, + }) + expect(readConfig(env).apiKey).toBe(CLI_KEY) + expect(existsSync(pendingPath)).toBe(false) + expect(login.createBodies).toHaveLength(1) + }) + + it('names the launcher in resumeCommand', async () => { + useLoginServer() + const result = await runCli(['login', '--no-wait', '--json'], { + env: tempEnv({ BREW_CLI_INVOKED_AS: 'npx -y brew.new@0.18.0' }), + clock: fakeClock(), + }) + expect(result.code).toBe(3) + expect((result.json as { resumeCommand: string }).resumeCommand).toBe( + 'npx -y brew.new@0.18.0 login --resume --json' + ) + }) + + it('--resume --no-wait polls once and stays pending', async () => { + const login = useLoginServer({ steps: ['pending'] }) + const env = tempEnv() + await runCli(['login', '--no-wait'], { env, clock: fakeClock() }) + const check = await runCli(['login', '--resume', '--no-wait'], { + env, + clock: fakeClock(), + }) + expect(check.code).toBe(3) + expect(check.json).toMatchObject({ status: 'pending' }) + expect(login.polls()).toBe(1) + expect(existsSync(pendingLoginPath(env))).toBe(true) + }) + + it('--wait returns the pending result when it runs out first', async () => { + const login = useLoginServer({ steps: ['pending'] }) + const env = tempEnv() + const clock = fakeClock() + const result = await runCli(['login', '--browser', '--wait', '5s'], { + env, + clock, + }) + expect(result.code).toBe(3) + expect(result.json).toMatchObject({ status: 'pending' }) + expect(clock.sleeps).toEqual([2000, 2000, 1000]) + expect(login.polls()).toBe(3) + expect(existsSync(pendingLoginPath(env))).toBe(true) + }) + + it('a denied resume deletes the pending sign-in', async () => { + useLoginServer({ steps: ['denied'] }) + const env = tempEnv() + await runCli(['login', '--no-wait'], { env, clock: fakeClock() }) + const resumed = await runCli(['login', '--resume'], { + env, + clock: fakeClock(), + }) + expect(resumed.code).toBe(3) + expect(lastError(resumed.stderr).code).toBe('CLI_LOGIN_DENIED') + expect(existsSync(pendingLoginPath(env))).toBe(false) + }) + + it('--resume with nothing pending exits 3', async () => { + const result = await runCli(['login', '--resume'], { env: tempEnv() }) + expect(result.code).toBe(3) + expect(lastError(result.stderr).code).toBe('CLI_LOGIN_NOT_PENDING') + }) +}) + +describe('login flag rules', () => { + it.each([ + [['--paste', '--browser']], + [['--paste', '--no-wait']], + [['--paste', '--resume']], + [['--api-key', 'brew_abcdefghijklmnopqrstuvwxyz012345', '--browser']], + [['--api-key', 'brew_abcdefghijklmnopqrstuvwxyz012345', '--paste']], + [['--wait', '1m', '--no-wait']], + [['--no-wait', '--wait', '1m']], + [['--wait', 'soon']], + ])('exits 2 for login %j', async (flags) => { + const result = await runCli(['login', ...flags], { env: tempEnv() }) + expect(result.code).toBe(2) + expect(lastError(result.stderr).code).toBe('CLI_USAGE') + }) + + it('off a terminal with no key, points agents at --no-wait --json', async () => { + const result = await runCli(['login'], { env: tempEnv() }) + expect(result.code).toBe(2) + expect(lastError(result.stderr).message).toContain( + 'brew-cli login --no-wait --json' + ) + }) + + it('--paste keeps the old prompt on a terminal', async () => { + server.use( + http.get('https://brew.new/api/v1/usage', () => HttpResponse.json(USAGE)) + ) + const env = tempEnv() + const result = await runCli(['login', '--paste'], { + env, + ttyIn: true, + promptAnswer: 'brew_abcdefghijklmnopqrstuvwxyz012345', + }) + expect(result.code).toBe(0) + expect(result.json).toMatchObject({ method: 'paste' }) + expect(readConfig(env).apiKey).toBe('brew_abcdefghijklmnopqrstuvwxyz012345') + }) +}) + +describe('secrets never reach the terminal', () => { + it('keeps the key, poll secret and private key off stdout and stderr', async () => { + const login = useLoginServer({ steps: ['pending', 'approved'] }) + const env = tempEnv() + const started = await runCli(['login', '--no-wait', '--json'], { + env, + clock: fakeClock(), + }) + const pending = JSON.parse(readFileSync(pendingLoginPath(env), 'utf8')) as { + pollSecret: string + privateKey: { d: string } + } + const human = await runCli(['login', '--resume', '--no-wait'], { + env, + ttyOut: true, + clock: fakeClock(), + }) + const resumed = await runCli(['login', '--resume'], { + env, + clock: fakeClock(), + }) + expect(resumed.code).toBe(0) + const whoami = await runCli(['config', 'list'], { env }) + const secrets = [ + CLI_KEY, + pending.pollSecret, + pending.privateKey.d, + ...login.pollSecrets, + ] + for (const run of [started, human, resumed, whoami]) { + for (const secret of secrets) { + expect(run.stdout).not.toContain(secret) + expect(run.stderr).not.toContain(secret) + } + } + }) +}) + +function lastError(stderr: string): { + code: string + message: string + suggestion?: string +} { + const line = stderr.trim().split('\n').at(-1) ?? '' + return (JSON.parse(line) as ErrorEnvelope).error +} diff --git a/tests/helpers/login-server.ts b/tests/helpers/login-server.ts new file mode 100644 index 0000000..2493b25 --- /dev/null +++ b/tests/helpers/login-server.ts @@ -0,0 +1,234 @@ +import { createHash, webcrypto } from 'node:crypto' +import { HttpResponse, http } from 'msw' +import type { Clock } from '../../src/lib/clock' +import { server } from './msw-server' + +/** + * A stand-in for brew-v2's browser sign-in routes (`POST /api/cli/sessions`, + * `POST /api/cli/sessions/{id}/token`), sealing keys exactly as + * `lib/cli-login/seal.ts` `sealForCli` does: a fresh server P-256 key, ECDH + * with the CLI's public key, HKDF-SHA256 (empty salt, info + * `brew-cli-login-v1`), AES-256-GCM with the session id as associated data. + */ + +export const T0 = Date.parse('2026-10-13T12:00:00.000Z') +export const SESSION_ID = 'Qm9ndXNTZXNzaW9uSWRGb3JUZXN0czAx' +export const USER_CODE = 'WDJB-MJHT' +export const CLI_KEY = 'brew_clikey0123456789abcdefghijklmnop' +export const APP_KEY = 'brew_appkey0123456789abcdefghijklmnop' +export const CLI_KEY_ID = 'key_cli_1' +export const APP_KEY_ID = 'key_app_1' +export const APPROVER_EMAIL = 'ada@acme.com' +export const BRAND = { brandId: 'bd_acme', name: 'Acme' } as const + +export type FakeClock = Clock & { + /** Every wait the CLI asked for, in ms. */ + readonly sleeps: number[] +} + +/** Time that moves only when the CLI waits: a 10-minute poll in 0 ms. */ +export function fakeClock(start = T0): FakeClock { + let now = start + const sleeps: number[] = [] + return { + sleeps, + now: () => now, + sleep: async (ms, signal) => { + signal.throwIfAborted() + sleeps.push(ms) + now += ms + }, + } +} + +export type TokenStep = + | 'pending' + | 'slow_down' + | 'approved' + | 'denied' + | 'expired' + | 'invalid_grant' + | 'rate_limited' + | 'server_error' + +type CreateBody = { + readonly publicKey: { kty: string; crv: string; x: string; y: string } + readonly pollSecretHash: string + readonly keys: readonly string[] + readonly client: Record +} + +export type LoginServer = { + readonly createBodies: CreateBody[] + /** Poll secrets the CLI sent, one per poll. */ + readonly pollSecrets: string[] + readonly polls: () => number +} + +export function useLoginServer( + options: { + readonly origin?: string + /** One answer per poll; the last one repeats. */ + readonly steps?: readonly TokenStep[] + readonly expiresAt?: string + readonly interval?: number + /** Seal to this session id instead (a blob moved between sessions). */ + readonly sealSessionId?: string + readonly createStatus?: number + } = {} +): LoginServer { + const origin = options.origin ?? 'https://brew.new' + const steps = options.steps ?? ['approved'] + const createBodies: CreateBody[] = [] + const pollSecrets: string[] = [] + server.use( + http.post(`${origin}/api/cli/sessions`, async ({ request }) => { + if (options.createStatus !== undefined) { + return HttpResponse.text('Not Found', { status: options.createStatus }) + } + createBodies.push((await request.json()) as CreateBody) + return HttpResponse.json( + { + sessionId: SESSION_ID, + userCode: USER_CODE, + verificationUrl: `${origin}/cli/authorize?session=${SESSION_ID}`, + expiresAt: + options.expiresAt ?? new Date(T0 + 10 * 60_000).toISOString(), + interval: options.interval ?? 2, + }, + { status: 201 } + ) + }), + http.post( + `${origin}/api/cli/sessions/:sessionId/token`, + async ({ request, params }) => { + const { pollSecret } = (await request.json()) as { pollSecret: string } + pollSecrets.push(pollSecret) + const created = createBodies.at(-1) + if ( + params.sessionId !== SESSION_ID || + created === undefined || + sha256(pollSecret) !== created.pollSecretHash + ) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } + const step = + steps[Math.min(pollSecrets.length - 1, steps.length - 1)] ?? 'pending' + return await answer(step, created, options.sealSessionId ?? SESSION_ID) + } + ) + ) + return { createBodies, pollSecrets, polls: () => pollSecrets.length } +} + +async function answer( + step: TokenStep, + created: CreateBody, + sealSessionId: string +): Promise { + switch (step) { + case 'pending': + return HttpResponse.json( + { error: 'authorization_pending' }, + { status: 400 } + ) + case 'slow_down': + return HttpResponse.json({ error: 'slow_down' }, { status: 400 }) + case 'denied': + return HttpResponse.json({ error: 'access_denied' }, { status: 403 }) + case 'expired': + return HttpResponse.json({ error: 'expired_token' }, { status: 410 }) + case 'invalid_grant': + return HttpResponse.json({ error: 'invalid_grant' }, { status: 400 }) + case 'rate_limited': + return HttpResponse.json( + { error: 'rate_limited', message: 'Too many polls.' }, + { status: 429, headers: { 'retry-after': '7' } } + ) + case 'server_error': + return HttpResponse.json({ error: 'server_error' }, { status: 500 }) + case 'approved': { + const keys = [ + { kind: 'cli', keyId: CLI_KEY_ID, key: CLI_KEY }, + ...(created.keys.includes('app') + ? [{ kind: 'app', keyId: APP_KEY_ID, key: APP_KEY }] + : []), + ] + const sealed = await sealForCli( + created.publicKey, + sealSessionId, + JSON.stringify({ v: 1, keys }) + ) + return HttpResponse.json({ + sealed, + keys: keys.map(({ kind, keyId }) => ({ kind, keyId })), + orgId: 'org_1', + brand: BRAND, + approver: { email: APPROVER_EMAIL }, + apiBaseUrl: 'https://brew.new/api', + }) + } + } +} + +export function sha256(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('base64url') +} + +const EC = { name: 'ECDH', namedCurve: 'P-256' } as const + +/** brew-v2 `sealForCli`, from the client's JWK. */ +export async function sealForCli( + clientJwk: { x: string; y: string }, + sessionId: string, + plaintext: string +): Promise<{ serverPublicKey: string; iv: string; ciphertext: string }> { + const subtle = webcrypto.subtle + const clientKey = await subtle.importKey( + 'jwk', + { kty: 'EC', crv: 'P-256', x: clientJwk.x, y: clientJwk.y, ext: true }, + EC, + false, + [] + ) + const ephemeral = (await subtle.generateKey(EC, true, [ + 'deriveBits', + ])) as webcrypto.CryptoKeyPair + const shared = await subtle.deriveBits( + { name: 'ECDH', public: clientKey }, + ephemeral.privateKey, + 256 + ) + const ikm = await subtle.importKey('raw', shared, 'HKDF', false, [ + 'deriveKey', + ]) + const aesKey = await subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new TextEncoder().encode('brew-cli-login-v1'), + }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ) + const iv = webcrypto.getRandomValues(new Uint8Array(12)) + const ciphertext = await subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData: new TextEncoder().encode(sessionId), + tagLength: 128, + }, + aesKey, + new TextEncoder().encode(plaintext) + ) + const serverPublicKey = await subtle.exportKey('raw', ephemeral.publicKey) + return { + serverPublicKey: Buffer.from(serverPublicKey).toString('base64url'), + iv: Buffer.from(iv).toString('base64url'), + ciphertext: Buffer.from(ciphertext).toString('base64url'), + } +} diff --git a/tests/helpers/run-cli.ts b/tests/helpers/run-cli.ts index 00ea5cd..c60dc51 100644 --- a/tests/helpers/run-cli.ts +++ b/tests/helpers/run-cli.ts @@ -1,5 +1,6 @@ import { PassThrough } from 'node:stream' import { run } from '../../src/cli' +import type { Clock } from '../../src/lib/clock' import type { CommandSpec } from '../../src/lib/define-command' import type { CliIo, SpawnFn } from '../../src/lib/types' import { ALL_COMMANDS } from '../../src/registry' @@ -33,8 +34,18 @@ export type RunCliOptions = { * a host binary. */ readonly spawn?: SpawnFn - /** Records URLs a command asks to open. */ + /** + * Records URLs a command asks to open. Absent: every open fails as if no + * opener were installed, so a test never launches the host browser. + */ readonly openUrl?: (url: string) => Promise + /** Stands in for the wall clock and waits (`login` polling). */ + readonly clock?: Clock +} + +/** An opener that is not installed, as on a host without a browser. */ +const noHostOpenUrl = async (): Promise => { + throw new Error('No browser opener in tests.') } /** A spawn that finds no program, as on a host without it. */ @@ -77,7 +88,8 @@ export async function runCli( ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.cwd === undefined ? {} : { cwd: options.cwd }), spawn: options.spawn ?? noHostSpawn, - ...(options.openUrl === undefined ? {} : { openUrl: options.openUrl }), + openUrl: options.openUrl ?? noHostOpenUrl, + ...(options.clock === undefined ? {} : { clock: options.clock }), } const code = await run( argv, diff --git a/tests/lib/auth-flow.test.ts b/tests/lib/auth-flow.test.ts new file mode 100644 index 0000000..4cc4d35 --- /dev/null +++ b/tests/lib/auth-flow.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { detectRemoteEnv } from '../../src/lib/auth-flow/remote-env' +import { + exportPrivateJwk, + generateLoginKeyPair, + generatePollSecret, + hashPollSecret, + importPrivateJwk, + parseSealedPayload, + unseal, +} from '../../src/lib/auth-flow/seal' +import { siteUrlFromApiUrl } from '../../src/lib/auth-flow/session' +import { browserOpener } from '../../src/lib/project/process' +import { sealForCli, sha256 } from '../helpers/login-server' + +describe('detectRemoteEnv', () => { + it.each([ + [{ CODESPACES: 'true' }, 'GitHub Codespaces'], + [{ REPL_ID: 'abc' }, 'Replit'], + [{ GITPOD_WORKSPACE_ID: 'ws-1' }, 'Gitpod'], + [{ SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' }, 'an SSH session'], + [{ CI: 'true' }, 'CI'], + ])('%j is %s', (env, name) => { + expect(detectRemoteEnv(env, {})?.name).toBe(name) + }) + + it('treats SSH with a display as local, and off spellings as unset', () => { + expect( + detectRemoteEnv({ SSH_CONNECTION: '1 2 3 4', DISPLAY: ':0' }, {}) + ).toBeUndefined() + expect(detectRemoteEnv({ CI: 'false', CODESPACES: '0' }, {})).toBe( + undefined + ) + expect(detectRemoteEnv({}, {})).toBeUndefined() + }) + + it('spots a WebContainer from process.versions', () => { + expect(detectRemoteEnv({}, { webcontainer: '1.0' })?.name).toBe( + 'a WebContainer' + ) + }) +}) + +describe('browserOpener', () => { + const url = 'https://brew.new/cli/authorize?session=abc' + + it('picks the platform opener, wslview under WSL', () => { + expect(browserOpener(url, 'darwin', {})).toEqual({ + command: 'open', + args: [url], + }) + expect(browserOpener(url, 'linux', {})).toEqual({ + command: 'xdg-open', + args: [url], + }) + expect(browserOpener(url, 'linux', { WSL_DISTRO_NAME: 'Ubuntu' })).toEqual({ + command: 'wslview', + args: [url], + }) + expect(browserOpener(url, 'win32', {})).toEqual({ + command: 'rundll32', + args: ['url.dll,FileProtocolHandler', url], + }) + }) +}) + +describe('siteUrlFromApiUrl', () => { + it('strips the /api suffix', () => { + expect(siteUrlFromApiUrl('https://brew.new/api')).toBe('https://brew.new') + expect(siteUrlFromApiUrl('http://localhost:3000/api/')).toBe( + 'http://localhost:3000' + ) + expect(siteUrlFromApiUrl('https://tunnel.example/brew/api')).toBe( + 'https://tunnel.example/brew' + ) + }) + + it('refuses a URL that is not http(s)', () => { + expect(() => siteUrlFromApiUrl('ftp://brew.new/api')).toThrow(/http\(s\)/) + expect(() => siteUrlFromApiUrl('not a url')).toThrow(/http\(s\)/) + }) +}) + +describe('seal', () => { + it('opens what brew-v2 sealForCli seals, bound to the session id', async () => { + const pair = await generateLoginKeyPair() + const plaintext = JSON.stringify({ + v: 1, + keys: [{ kind: 'cli', keyId: 'k1', key: 'brew_secret' }], + }) + const sealed = await sealForCli(pair.publicJwk, 'session-a', plaintext) + expect(sealed.serverPublicKey).toMatch(/^[A-Za-z0-9_-]{87}$/) + expect(sealed.iv).toMatch(/^[A-Za-z0-9_-]{16}$/) + + const opened = await unseal({ + privateKey: pair.privateKey, + sessionId: 'session-a', + sealed, + }) + expect(parseSealedPayload(opened)).toEqual({ + v: 1, + keys: [{ kind: 'cli', keyId: 'k1', key: 'brew_secret' }], + }) + await expect( + unseal({ privateKey: pair.privateKey, sessionId: 'session-b', sealed }) + ).rejects.toThrow() + }) + + it('survives a round trip through the persisted private JWK', async () => { + const pair = await generateLoginKeyPair() + const restored = await importPrivateJwk( + await exportPrivateJwk(pair.privateKey) + ) + const sealed = await sealForCli(pair.publicJwk, 's', '{"v":1,"keys":[]}') + expect(await unseal({ privateKey: restored, sessionId: 's', sealed })).toBe( + '{"v":1,"keys":[]}' + ) + }) + + it('refuses another key pair', async () => { + const pair = await generateLoginKeyPair() + const other = await generateLoginKeyPair() + const sealed = await sealForCli(pair.publicJwk, 's', '{}') + await expect( + unseal({ privateKey: other.privateKey, sessionId: 's', sealed }) + ).rejects.toThrow() + }) + + it('makes a 43-character poll secret and hashes it as the server does', () => { + const secret = generatePollSecret() + expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(generatePollSecret()).not.toBe(secret) + expect(hashPollSecret(secret)).toBe(sha256(secret)) + expect(hashPollSecret(secret)).toHaveLength(43) + }) + + it('rejects payloads outside the contract', () => { + expect(parseSealedPayload('not json')).toBeUndefined() + expect(parseSealedPayload('{"v":2,"keys":[]}')).toBeUndefined() + expect( + parseSealedPayload('{"v":1,"keys":[{"kind":"cli","keyId":"k"}]}') + ).toBeUndefined() + }) + + it('skips a key kind it does not know instead of failing the sign-in', () => { + expect( + parseSealedPayload( + '{"v":1,"keys":[{"kind":"root","keyId":"r","key":"x"},{"kind":"cli","keyId":"k","key":"brew_secret"}]}' + ) + ).toEqual({ + v: 1, + keys: [{ kind: 'cli', keyId: 'k', key: 'brew_secret' }], + }) + }) +})