From 72095c364261ecf0120344693598cd41740066af Mon Sep 17 00:00:00 2001 From: Jelani Woods Date: Wed, 30 Sep 2026 14:23:25 -0500 Subject: [PATCH] Add login/logout commands Workshop participants already sign in to GitHub, Render, Neon, and Revyl with a browser-based login command. First Draft instead made them copy a token from /api-tokens into an environment variable, which is an extra manual step and easy to get wrong. A saved login only authenticates the host it was created for, and an environment variable can still be set to override it, so existing scripts and CI setups still behave as they did before. --- CHANGELOG.md | 21 + README.md | 6 +- docs/commands.md | 140 ++++-- docs/errors.md | 48 +- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 2 +- scripts/check-pack.js | 4 + scripts/run-tests.js | 8 +- scripts/smoke-package.js | 22 + src/api-authentication.js | 68 ++- src/api-response.js | 12 + src/cli.js | 401 +++++++++++++++- src/commands/login.js | 314 +++++++++++++ src/commands/logout.js | 107 +++++ src/credentials.js | 343 ++++++++++++++ src/oauth.js | 565 +++++++++++++++++++++++ test/api-environments.test.js | 141 ++++++ test/cli.test.js | 2 + test/credentials.test.js | 263 +++++++++++ test/login.test.js | 842 ++++++++++++++++++++++++++++++++++ test/logout.test.js | 362 +++++++++++++++ test/plan-push.test.js | 4 +- test/plan-status.test.js | 4 +- 24 files changed, 3614 insertions(+), 71 deletions(-) create mode 100644 src/commands/login.js create mode 100644 src/commands/logout.js create mode 100644 src/credentials.js create mode 100644 src/oauth.js create mode 100644 test/credentials.test.js create mode 100644 test/login.test.js create mode 100644 test/logout.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b0869c5..81479ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,24 @@ carry no release status. The `v` tag and npm show whether a version is Entries start with the first version prepared after this file was added, and earlier versions have none. The frozen [release history](docs/release-history.md) records releases through 0.4.0. The repository's [tags](https://github.com/firstdraft/cli/tags) identify the source of each published version, including later ones. + +## 0.8.1 + +Adds `firstdraft login` and `firstdraft logout`, so a token no longer has to be copied from `/api-tokens` into the +environment. + +- `login` approves the CLI in a browser and saves a token for the selected origin. By default it uses the OAuth + authorization code flow with PKCE and a loopback redirect. `login --interactive` (also `-i` or `--device`) uses the + device flow for machines without a browser. `--staging` and `FIRSTDRAFT_API_URL` select the origin as they do for + other remote commands. +- Tokens are saved per exact origin in `$XDG_CONFIG_HOME/firstdraft/credentials.json`, or + `~/.config/firstdraft/credentials.json`, with mode `0600`. The CLI never prints a saved token. +- `logout` revokes the saved token for the selected origin and removes the local entry. +- Remote commands use the saved token for their origin when `FIRSTDRAFT_API_TOKEN` or + `FIRSTDRAFT_STAGING_API_TOKEN` is unset. A saved login never authenticates a different origin. +- New `error` values: `authorization_denied`, `authorization_expired`, `login_failed`, and `logout_failed`. The + `authentication_required` detail now points to `firstdraft login`. + +Callers that set token environment variables need no change, because those variables still take precedence over a +saved login. The release needs a Service that serves the `/oauth` endpoints. Those endpoints sit outside the +versioned API, so the accepted API-contract range is unchanged. diff --git a/README.md b/README.md index 39ed192..badfd65 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ To exercise the checkout directly: node bin/firstdraft.js --help ``` -Remote commands default to production and read `FIRSTDRAFT_API_TOKEN`. Use `firstdraft --staging plan compile` -and a separate `FIRSTDRAFT_STAGING_API_TOKEN` for staging. Existing Projects retain their saved origin. See +Remote commands default to production. Run `firstdraft login` (or `firstdraft --staging login`) once, or set +`FIRSTDRAFT_API_TOKEN` / `FIRSTDRAFT_STAGING_API_TOKEN`. Existing Projects retain their saved origin. See [environment selection](docs/commands.md#select-an-environment-and-authenticate) for custom URLs and credential isolation. Keep tokens out of arguments, shell history, fixtures, snapshots, and logs. @@ -98,7 +98,7 @@ The published package: - runs reviewed JavaScript source directly; - has no runtime dependencies or install scripts; - performs no telemetry, update check, or network request unless the caller invokes an API command; -- reads Bearer credentials only from the environment; and +- reads Bearer credentials from the environment or its private `firstdraft login` credentials file; and - carries npm provenance linking registry bytes to its GitHub workflow and commit. The package includes this README, the documentation map, the command and error references, and the security diff --git a/docs/commands.md b/docs/commands.md index 172b039..333401e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3,7 +3,7 @@ This page owns the detailed public semantics of the current command surface. Run `firstdraft --help` or a command group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation. -The current `0.8.x` source line contains the auditable command shell, local Foundation Plan initialization, local +The current `0.8.x` source line contains the auditable command shell, browser and device login, local Foundation Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.8.x` requires the service's `0.7.x` API contract. The @@ -11,55 +11,119 @@ requires the service's `0.7.x` API contract. The ## Command map -| Command | Network | Purpose | -| ------------------------------------- | ------- | ---------------------------------------------------------- | -| `firstdraft plan init` | No | Create an empty local Foundation Plan and Project identity | -| `firstdraft generate application-key` | No | Preview deterministic name-to-key derivation | -| `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities | -| `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan | -| `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis | -| `firstdraft plan compile` | Yes | Push and analyze, then materialize in the current folder | -| `firstdraft plan compile --github` | Yes | Push and analyze, then publish to private GitHub | -| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | -| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | +| Command | Network | Purpose | +| ------------------------------------- | ------- | ----------------------------------------------------------- | +| `firstdraft plan init` | No | Create an empty local Foundation Plan and Project identity | +| `firstdraft generate application-key` | No | Preview deterministic name-to-key derivation | +| `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities | +| `firstdraft login` | Yes | Approve the CLI in a browser and save a token for an origin | +| `firstdraft logout` | Yes | Revoke and remove the saved token for an origin | +| `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan | +| `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis | +| `firstdraft plan compile` | Yes | Push and analyze, then materialize in the current folder | +| `firstdraft plan compile --github` | Yes | Push and analyze, then publish to private GitHub | +| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | +| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | ## Select an environment and authenticate -Production at `https://firstdraft.com` is the default. Create a token at -[First Draft](https://firstdraft.com/api-tokens) and provide it through `FIRSTDRAFT_API_TOKEN` when running a network -command. Keep token values out of shell history and command arguments. +Production at `https://firstdraft.com` is the default. Log in once per environment: ```sh -firstdraft plan push +firstdraft login ``` -For staging, create a separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens), provide it -through `FIRSTDRAFT_STAGING_API_TOKEN`, and select staging on the first remote command: +The command prints an authorization URL on standard error. Open it in a browser on the same machine, sign in, and +approve **First Draft CLI**. The CLI then saves a token and prints `Logged in to ` on standard output. + +For staging, select it on the login and on the first remote command: ```sh +firstdraft --staging login firstdraft --staging plan push firstdraft plan compile --staging ``` -`--staging` may precede the command group or appear among a remote command's options. It selects -`https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a local command does not -save an environment selection. The first successful push, including the push within `plan compile`, pins the API -origin in `.firstdraft/state.json`. +### How login works + +The default login uses the OAuth 2.0 authorization code flow with PKCE (`S256`) and a loopback redirect +(RFC 8252). The CLI listens on `127.0.0.1` at an ephemeral port for one `GET /callback`, sends a random `state` +and code challenge, and waits up to five minutes. Only a callback with the exact `state` is accepted. Requests +for other paths, with a missing or different `state`, or with another `Host` get an error page and do not end +the wait, so a stray local request can neither inject a code nor cancel the login. The first matching callback +closes the listener and shows a page that says you can close the tab. The CLI then exchanges the single-use code +and its PKCE verifier at `POST /oauth/token`. The token never appears in a URL. + +On a machine without a browser, such as an SSH session or a container, use the device flow (RFC 8628): + +```sh +firstdraft login --interactive # also -i or --device +``` + +The CLI prints a verification URL, a short user code, and a URL that already includes the code. Open either URL on +any device, confirm the code, and approve. The CLI polls at the server's interval, adds five seconds when asked to +slow down, and stops when the code expires. + +Both flows send this machine's hostname as the device name shown on the approval page and in the token's name on +`/api-tokens`. A denied approval exits with `authorization_denied`. A login that is not approved in time exits with +`authorization_expired`. Other failures exit with `login_failed`. No credential is saved unless the login succeeds. +See [Errors and recovery](errors.md#login-and-logout-errors). + +### Saved credentials + +The token is saved in `$XDG_CONFIG_HOME/firstdraft/credentials.json`, or `~/.config/firstdraft/credentials.json` +when `XDG_CONFIG_HOME` is unset or not absolute. The directory is created with mode `0700`. The file is written +with mode `0600` through a temporary file and an atomic rename. Each update holds `credentials.json.lock` in the +same directory from its read to its rename, so concurrent commands cannot drop each other's entries. An update +waits up to 10 seconds for another command's lock, then fails with reason `credentials_locked`. The CLI never removes +a lock by itself: if no other `firstdraft` command is running, the lock was left by one that exited mid-update, and +you can delete it. The file stores one entry per exact origin, so production, staging, and local development logins +can exist side by side. Logging in again to the same origin replaces its +entry. Run `firstdraft logout` first if the old token should also be revoked. The CLI never prints a saved token or +writes it to `.firstdraft`. + +`firstdraft logout` (or `firstdraft --staging logout`) asks First Draft to revoke the saved token for the selected +origin through `POST /oauth/revoke`. It then removes the local entry even when revocation cannot be confirmed; in +that case it says so on standard error, and you can revoke the token on `/api-tokens`. With nothing saved, it +reports that and exits 0. If a newer login saved a different token for the origin while revocation was pending, +logout keeps that token and says so on standard error. If the local update fails after revocation, logout exits with `logout_failed` and reports +whether revocation was confirmed; see [Errors and recovery](errors.md#login-and-logout-errors). Logout never changes +token environment variables. + +### Environment tokens + +Tokens from the environment still work and take precedence over a saved login. Create one on +[First Draft](https://firstdraft.com/api-tokens) and provide it through `FIRSTDRAFT_API_TOKEN`. For staging, create a +separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens) and provide it through +`FIRSTDRAFT_STAGING_API_TOKEN`. Keep token values out of shell history and command arguments. `login` and `logout` +note on standard error when an environment token will override the saved login. + +### Origins and projects + +`--staging` may precede the command group or appear among a remote command's options, including `login` and +`logout`. It selects `https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a +local command does not save an environment selection. The first successful push, including the push within +`plan compile`, pins the API origin in `.firstdraft/state.json`. Existing Projects keep their pinned origin with or without the flag. A CLI upgrade does not migrate a Project or its credentials. A staging flag that disagrees with a Project's pin stops before any request. To work with another environment, initialize a separate project directory and submit the Plan there; do not edit the existing Project's private state to redirect it. -`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server. -`--staging` together with a different URL is an error; the equivalent normalized staging URL is allowed. Later -pushes and compilation reject an override that differs from the pin. Read-only status and retained download -commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit `--staging`. - -Every remote command selects credentials from its effective origin: the exact `https://staging.firstdraft.com` -origin requires `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use `FIRSTDRAFT_API_TOKEN`. Neither -token is a fallback for the other. This includes existing staging Projects and retained status or artifact reads, -even when no flag is supplied. +`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server, and +it also selects the origin for `login` and `logout`, for example `FIRSTDRAFT_API_URL=http://127.0.0.1:3000 +firstdraft login`. `--staging` together with a different URL is an error; the equivalent normalized staging URL is +allowed. Later pushes and compilation reject an override that differs from the pin. Read-only status and retained +download commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit +`--staging`. + +Every remote command selects credentials from its effective origin. First it uses the environment: the exact +`https://staging.firstdraft.com` origin uses `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use +`FIRSTDRAFT_API_TOKEN`. When that variable is unset or empty, it uses the token saved by `firstdraft login` for that +exact origin. No token is a fallback for a different origin: neither environment variable substitutes for the other, +and a saved login for one origin never authenticates another. This includes existing staging Projects and retained +status or artifact reads, even when no flag is supplied. An unreadable or malformed credentials file counts as no +saved login. The CLI sends the selected token as a Bearer credential on every API request. It does not save it in `.firstdraft`, print it, or require it for local commands. Revoke a token in the environment that issued it if it is exposed. A @@ -402,8 +466,10 @@ contents, and digests without claiming POSIX mode bits. The declared and streame ## Service endpoints -The CLI calls these Service API routes at the origin pinned for the Project. Every request sends the selected token -as a Bearer credential, refuses redirects, and has a bounded timeout. Each request takes its method and path from one +The CLI calls these Service API routes. The `/v1` routes go to the origin pinned for the Project and send the +selected token as a Bearer credential. The `/oauth` routes go to the origin selected for `login` or `logout` and +send form-encoded OAuth parameters without a Bearer credential. Every request refuses redirects and has a bounded +timeout. Each request takes its method and path from one `SERVICE_ROUTES` entry in `src/api-response.js`. `test/service-endpoints.test.js` fails when this table and `SERVICE_ROUTES` differ, or when no file in `src/` uses a declared route. @@ -416,15 +482,21 @@ as a Bearer credential, refuses redirects, and has a bounded timeout. Each reque | `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}/artifact` | Download that Compilation's artifact | | `PUT` | `/v1/projects/{project_id}/github-publication` | Start or rejoin the Publication | | `GET` | `/v1/projects/{project_id}/github-publication` | Poll or reconcile the Publication | +| `POST` | `/oauth/token` | Exchange a login grant for a token | +| `POST` | `/oauth/device_authorization` | Start a device-flow login | +| `POST` | `/oauth/revoke` | Revoke a saved token | `plan push` sends the Plan `PUT`, and `plan status` reads the analysis. `plan compile` does both. It then starts a Compilation, polls it, and downloads its artifact. With `--github`, it starts and polls the Publication instead. `compilation status` reads one retained Compilation. `compilation download` reads it and downloads its artifact. +`login` exchanges its authorization code at `/oauth/token`. With `--interactive`, it starts at +`/oauth/device_authorization` and then polls `/oauth/token`. `logout` calls `/oauth/revoke`. The browser, not the +CLI, opens `/oauth/authorize` and `/device`, so they have no rows. The first push sends `If-None-Match: *`. Later pushes, the Compilation `POST`, and the Publication `PUT` send the saved Head ETag in `If-Match`. The Service also has a Compilation cancel route, which the CLI does not call. -These routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the +The `/v1` routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the Service's `FirstDraft-API-Contract` response header. Instead, the Service's release compatibility check compares the declared ranges before a release. The Service documents the routes in its Foundation Plan machine reference, `docs/architecture/reference/README.md` in the private `firstdraft/firstdraft` repository. diff --git a/docs/errors.md b/docs/errors.md index 9f36fbc..0db1ed8 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -7,7 +7,8 @@ This page owns handled-error interpretation, retry safety, and recovery guidance Every handled subcommand failure ends with exactly one JSON object on standard error. `plan compile` may first write progress lines; machine consumers can remove only lines beginning with the exact `First Draft: ` prefix and parse the -remaining JSON document. Branch on the stable `error` value rather than the human-readable `detail`. +remaining JSON document. `login` may first write its human-readable authorization instructions; its JSON object +starts on the line after them. Branch on the stable `error` value rather than the human-readable `detail`. Handled output never includes command arguments, local Plan bytes, raw artifact bytes, raw filesystem or network errors, or unvalidated response bodies. `local_state_not_saved` is the sole exception to private-state redaction: @@ -21,10 +22,42 @@ saved origin. Push and Compile also reject any URL override that differs from th override or use a separate initialized project directory for the other environment; do not redirect existing private Project state. Status and retained download commands continue using their pin. -`authentication_required` means the selected environment's token is missing or rejected. Staging requires -`FIRSTDRAFT_STAGING_API_TOKEN`, including old staging Projects with no flag. Production and custom origins require -`FIRSTDRAFT_API_TOKEN`. The CLI never substitutes one for the other. Obtain or refresh the credential from the -same environment, then follow the command's recovery instructions below. +`authentication_required` means the selected environment's token is missing or rejected. Staging uses +`FIRSTDRAFT_STAGING_API_TOKEN`, including old staging Projects with no flag. Production and custom origins use +`FIRSTDRAFT_API_TOKEN`. When that variable is unset, the token saved by `firstdraft login` for the exact origin is +used. The CLI never substitutes one environment's credential for another's. Run `firstdraft login` for the same +environment (with `--staging` or the same `FIRSTDRAFT_API_URL`), or refresh the environment token, then follow the +command's recovery instructions below. If a saved login is rejected, run `firstdraft login` again. + +## Login and logout errors + +`login` and `logout` never print a token. A local credential failure can occur after the server has issued or revoked a token. + +- `authorization_denied`: the approval was denied in the browser. Run `firstdraft login` again to retry. +- `authorization_expired`: no loopback callback arrived within five minutes, or the device code expired before + approval. Run `firstdraft login` again. Use `--interactive` if no browser on this machine can reach the printed URL. +- `login_failed`: the login could not be completed. `reason`, when present, is the server's OAuth error code (for + example `invalid_grant` for a used, expired, or mismatched code), `loopback_unavailable` when the local listener + could not start, `credentials_invalid` / `credentials_unavailable` when the credentials file could not be read or + written, or `credentials_locked` when another command held its lock for 10 seconds. `status` is the HTTP status + when one was received. Credentials failures include `credentials_path` and `phase`. With `phase: "read"`, no + request was made; repair or remove the file and retry. With `phase: "write"`, First Draft issued a token but the + CLI could not save it. Revoke that new token on the selected origin's `/api-tokens` page, then repair the + credentials file and retry login. Any previously saved token remains unchanged when the replacement fails. +- `logout_failed`: the credentials file could not be read or updated. It carries `reason`, `credentials_path`, + and `phase`. With `phase: "read"`, no token was revoked or removed; repair or remove the file and retry. With + `phase: "write"`, local removal failed after the revocation attempt. `revoked: true` means First Draft confirmed + revocation; repair the file and retry logout to remove the stale entry. `revoked: false` means revocation was not + confirmed, so the token may still be active; revoke it on the selected origin's `/api-tokens` page, then repair + the file and retry logout. Preserve entries for other origins when repairing the file. +- With reason `credentials_locked`, from either command: wait for any other `firstdraft` command to finish and + retry. If none is running, the lock was left by a command that exited mid-update; delete `credentials.json.lock` + next to `credentials_path` instead of repairing the file, and retry. The CLI never removes the lock itself, + because it cannot tell an abandoned lock from one another command has just taken. + +`invalid_configuration` from `login` or `logout` means `--staging` conflicts with `FIRSTDRAFT_API_URL`, or the URL is +invalid. A logout whose server revocation is not confirmed still succeeds after removing the local entry. It +writes a note on standard error; revoke the token on `/api-tokens` if it may still be active. ## Ambiguous mutations @@ -123,7 +156,10 @@ stopped without following the replacement. | Any leaf command | `invalid_arguments` | 2 | Syntax was invalid; no request was made. | | `plan init` | `local_initialization_failed` | 1 | Initialization failed without overwriting an existing path. | | Network commands | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. | -| Network commands | `authentication_required` | 1 | The token is missing or First Draft returned a validated authentication problem. | +| `login` | `authorization_denied`, `authorization_expired` | 1 | The approval was denied, or not granted before the callback wait or device code expired. | +| `login` | `login_failed` | 1 | Nothing was saved; `phase: "write"` means a token was issued ([recovery](#login-and-logout-errors)). | +| `logout` | `logout_failed` | 1 | Check `phase` and `revoked`; revocation may have happened ([recovery](#login-and-logout-errors)). | +| Network commands | `authentication_required` | 1 | No environment or saved token exists for the origin, or First Draft rejected it. | | Plan commands, `compilation *` | `local_input_unreadable` | 1 | Required local Plan or private state could not be read. | | Status, Compile, Compilation commands | `project_not_pushed` | 1 | No API origin is pinned for the local Project. | | `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A mutation or its response could not be verified; `plan compile` identifies its mutation phase. | diff --git a/package-lock.json b/package-lock.json index 3109f09..913e882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.8.0", + "version": "0.8.1", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 8e1f04e..55166f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.8.0", + "version": "0.8.1", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 6d36bd4..87502c2 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,7 +1,7 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.8.0", + "version": "0.8.1", "requires": { "api_contract": [">= 0.7.0", "< 0.8.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.23"] diff --git a/scripts/check-pack.js b/scripts/check-pack.js index bed80a8..275d01d 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -41,13 +41,17 @@ if (result.status !== 0) { "src/application-identity.js", "src/cli.js", "src/commands/compilation.js", + "src/commands/login.js", + "src/commands/logout.js", "src/commands/plan-compile.js", "src/commands/plan-init.js", "src/commands/plan-publish.js", "src/commands/plan-push.js", "src/commands/plan-status.js", "src/compilation-artifact.js", + "src/credentials.js", "src/file-system.js", + "src/oauth.js", "src/plan-compile-progress.js", "src/plan-state.js", "src/root-output.js", diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 462a076..58b102a 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readdirSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +10,9 @@ process.chdir(fileURLToPath(new URL("..", import.meta.url))); const testFiles = findTestFiles("test"); assert.notEqual(testFiles.length, 0, "No test files found"); +// Saved `firstdraft login` credentials must never leak into, or out of, tests. +const configHome = mkdtempSync(path.join(tmpdir(), "firstdraft-test-config-")); + const result = spawnSync( process.execPath, ["--test", ...process.argv.slice(2), ...testFiles], @@ -19,9 +23,11 @@ const result = spawnSync( FIRSTDRAFT_API_URL: undefined, FIRSTDRAFT_API_TOKEN: undefined, FIRSTDRAFT_STAGING_API_TOKEN: undefined, + XDG_CONFIG_HOME: configHome, }, }, ); +rmSync(configHome, { recursive: true, force: true }); if (result.error) { throw result.error; diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 93b7fcb..3ef124e 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -28,6 +28,7 @@ const [planFormat] = JSON.parse( ).requires.foundation_plan_formats; const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "firstdraft-cli-")); const installationDirectory = path.join(temporaryDirectory, "installation"); +const configHome = path.join(temporaryDirectory, "config"); const packedExecutable = path.join( installationDirectory, "node_modules", @@ -262,6 +263,25 @@ try { "Invalid arguments. Run 'firstdraft compilation status --help' for usage.", }); + const invalidLogin = spawnPackedCli( + ["login", "--canary-secret-option"], + installationDirectory, + ); + assertHandledFailure(invalidLogin, 2, { + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft login --help' for usage.", + }); + + const emptyLogout = spawnPackedCli(["logout"], installationDirectory, { + FIRSTDRAFT_API_TOKEN: "", + }); + assert.equal(emptyLogout.status, 0); + assert.equal( + emptyLogout.stdout, + "Not logged in to https://firstdraft.com; no saved token was found.\n", + ); + assert.equal(emptyLogout.stderr, ""); + exercisePackedEnvironmentSelection(temporaryDirectory); await exercisePackedCompilation(projectDirectory); } finally { @@ -309,6 +329,7 @@ function spawnPackedCli(arguments_, cwd = process.cwd(), environment = {}) { FIRSTDRAFT_API_URL: undefined, FIRSTDRAFT_API_TOKEN: apiToken, FIRSTDRAFT_STAGING_API_TOKEN: "", + XDG_CONFIG_HOME: configHome, ...environment, }, }); @@ -326,6 +347,7 @@ async function spawnPackedCliAsync(arguments_, cwd) { FIRSTDRAFT_API_URL: undefined, FIRSTDRAFT_API_TOKEN: apiToken, FIRSTDRAFT_STAGING_API_TOKEN: "", + XDG_CONFIG_HOME: configHome, }, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/src/api-authentication.js b/src/api-authentication.js index c95dc11..7af9d13 100644 --- a/src/api-authentication.js +++ b/src/api-authentication.js @@ -4,6 +4,37 @@ export const STAGING_API_URL = "https://staging.firstdraft.com"; export class ApiAuthenticationRequiredError extends Error {} +/** + * Rejects `--staging` together with a FIRSTDRAFT_API_URL that names another + * origin. + * + * @param {string | undefined} apiUrl + * @param {boolean} staging + */ +export function assertStagingSelection(apiUrl, staging) { + if ( + staging && + apiUrl !== undefined && + normalizeApiUrl(apiUrl) !== STAGING_API_URL + ) { + throw new PlanStateConfigurationError( + "--staging conflicts with FIRSTDRAFT_API_URL. Unset it or select the staging origin.", + ); + } +} + +/** + * Selects the environment token for an origin: staging uses its own variable, + * production and custom origins share FIRSTDRAFT_API_TOKEN. + * + * @param {string} origin + * @param {{apiToken?: string, stagingApiToken?: string}} tokens + */ +export function environmentTokenFor(origin, { apiToken, stagingApiToken }) { + const token = origin === STAGING_API_URL ? stagingApiToken : apiToken; + return hasToken(token) ? token : undefined; +} + /** * @param {object} options * @param {typeof globalThis.fetch} [options.fetchFunction] @@ -11,6 +42,9 @@ export class ApiAuthenticationRequiredError extends Error {} * @param {string} [options.stagingApiToken] * @param {string} [options.apiUrl] * @param {boolean} [options.staging] + * @param {() => Readonly>} [options.readStoredTokens] + * Stored `firstdraft login` tokens keyed by exact origin. Consulted only when + * the environment has no token for the requested origin. */ export function authenticateApiCommand({ fetchFunction, @@ -18,19 +52,17 @@ export function authenticateApiCommand({ stagingApiToken, apiUrl, staging = false, + readStoredTokens = () => ({}), }) { - if ( - staging && - apiUrl !== undefined && - normalizeApiUrl(apiUrl) !== STAGING_API_URL - ) { - throw new PlanStateConfigurationError( - "--staging conflicts with FIRSTDRAFT_API_URL. Unset it or select the staging origin.", - ); - } + assertStagingSelection(apiUrl, staging); const configured = staging ? STAGING_API_URL : apiUrl; - if (!hasToken(apiToken) && !hasToken(stagingApiToken)) return null; + if ( + !hasToken(apiToken) && + !hasToken(stagingApiToken) && + !Object.values(readStoredTokens()).some(hasToken) + ) + return null; let selectedOrigin = staging ? STAGING_API_URL : undefined; const request = fetchFunction ?? globalThis.fetch; @@ -43,8 +75,9 @@ export function authenticateApiCommand({ ); } const token = - endpoint.origin === STAGING_API_URL ? stagingApiToken : apiToken; - if (!hasToken(token)) throw new ApiAuthenticationRequiredError(); + environmentTokenFor(endpoint.origin, { apiToken, stagingApiToken }) ?? + storedToken(readStoredTokens(), endpoint.origin); + if (token === undefined) throw new ApiAuthenticationRequiredError(); selectedOrigin = endpoint.origin; return request(input, { ...init, @@ -57,7 +90,16 @@ export function authenticateApiCommand({ return { apiUrl: configured, fetchFunction: authorizedFetch }; } -/** @param {string | undefined} token */ +/** @param {Readonly>} tokens @param {string} origin */ +function storedToken(tokens, origin) { + const token = Object.hasOwn(tokens, origin) ? tokens[origin] : undefined; + return hasToken(token) ? token : undefined; +} + +/** + * @param {string | undefined} token + * @returns {token is string} + */ function hasToken(token) { return token !== undefined && token.trim().length > 0; } diff --git a/src/api-response.js b/src/api-response.js index ed53e9d..b995b49 100644 --- a/src/api-response.js +++ b/src/api-response.js @@ -55,6 +55,18 @@ export const SERVICE_ROUTES = { method: "GET", path: "/v1/projects/{project_id}/github-publication", }, + requestOAuthToken: { + method: "POST", + path: "/oauth/token", + }, + requestDeviceAuthorization: { + method: "POST", + path: "/oauth/device_authorization", + }, + revokeOAuthToken: { + method: "POST", + path: "/oauth/revoke", + }, }; /** @typedef {{method: string, url: URL}} ServiceEndpoint */ diff --git a/src/cli.js b/src/cli.js index d062ba3..0aa8025 100644 --- a/src/cli.js +++ b/src/cli.js @@ -8,7 +8,9 @@ import { } from "./application-identity.js"; import { ApiAuthenticationRequiredError, + STAGING_API_URL, authenticateApiCommand, + environmentTokenFor, isAuthenticationProblem, } from "./api-authentication.js"; import { @@ -42,6 +44,14 @@ import { compilePlanToGitHub, compilePlanToDirectory, } from "./commands/plan-compile.js"; +import { + LoginDeniedError, + LoginExpiredError, + LoginFailedError, + login, + resolveLoginOrigin, +} from "./commands/login.js"; +import { LogoutFailedError, logout } from "./commands/logout.js"; import { initializePlan } from "./commands/plan-init.js"; import { PublicationCancelledError, @@ -70,6 +80,7 @@ import { PlanStatusTimeoutError, readPlanStatus, } from "./commands/plan-status.js"; +import { storedTokenReader } from "./credentials.js"; import { isFileSystemError } from "./file-system.js"; import { createPlanCompileProgressReporter } from "./plan-compile-progress.js"; import { isUuidV7 } from "./plan-state.js"; @@ -85,6 +96,8 @@ Usage: Commands: compilation Inspect and download Compilations generate Generate local values + login Log in to First Draft and save a token + logout Revoke and remove the saved token plan Work with Foundation Plans Options: @@ -275,6 +288,45 @@ Provide at least one of --application-key or --name. The command derives a missing key from the name or a missing display name from the key. `; +const LOGIN_HELP = `First Draft CLI + +Usage: + firstdraft login [--interactive] + +Options: + --staging Log in to staging + -i, --interactive Approve with a code on another device (no local browser) + --device Same as --interactive + -h, --help Show help + +Environment: + FIRSTDRAFT_API_URL Log in to a custom API origin + XDG_CONFIG_HOME Credentials directory base (default: ~/.config) + +By default the command prints a URL to open in a browser on this machine, then +waits up to five minutes for First Draft to redirect to a one-time listener on +127.0.0.1. The token is saved for the selected origin in +firstdraft/credentials.json under the configuration directory and is never +printed. Token environment variables take precedence over a saved login. +`; + +const LOGOUT_HELP = `First Draft CLI + +Usage: + firstdraft logout + +Options: + --staging Log out of staging + -h, --help Show help + +Environment: + FIRSTDRAFT_API_URL Log out of a custom API origin + +The command asks First Draft to revoke the saved token for the selected origin, +then removes it from the credentials file even if revocation is not confirmed. +Token environment variables are not changed. +`; + const ROOT_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; const ROOT_UNKNOWN_COMMAND = @@ -304,7 +356,23 @@ const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; const AUTHENTICATION_REQUIRED_DETAIL = - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging."; + "First Draft authentication is required. Run 'firstdraft login' for the same environment, or set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging."; +const LOGIN_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft login --help' for usage."; +const LOGOUT_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft logout --help' for usage."; +const LOGIN_DENIED_DETAIL = + "The login was denied in the browser. No credential was saved. Run 'firstdraft login' again to retry."; +const LOGIN_EXPIRED_DETAIL = + "The login was not approved before it expired. No credential was saved. Run 'firstdraft login' again to retry."; +const LOGIN_FAILED_DETAIL = + "The login could not be completed. No credential was saved. Run 'firstdraft login' again, or use 'firstdraft login --interactive' if this machine has no browser."; +const LOGIN_CREDENTIALS_DETAIL = + "The credentials file could not be read. No network request was made. Repair or remove it, then run 'firstdraft login' again."; +const CREDENTIALS_LOCKED_RECOVERY = + "if no other firstdraft command is running, delete credentials.json.lock next to credentials_path"; +const LOGOUT_FAILED_DETAIL = + "The credentials file could not be read, so no token was revoked or removed. Repair or remove it, then retry."; const PLAN_STATUS_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan status --help' for usage."; const PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL = @@ -445,6 +513,14 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {string} [apiUrl] * @property {string} [apiToken] * @property {string} [stagingApiToken] + * @property {Readonly>} [env] Locates the credentials file (XDG_CONFIG_HOME) + * @property {() => string} [homedir] + * @property {import("./credentials.js").CredentialsFileSystem} [credentialsFileSystem] + * @property {number} [credentialsLockTimeoutMs] + * @property {() => string} [hostname] + * @property {(delayMs: number, signal?: AbortSignal) => Promise} [loginSleep] + * @property {() => number} [loginNow] + * @property {import("./oauth.js").StartLoopbackServer} [startLoopbackServer] */ /** @@ -476,6 +552,11 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {string} [apiToken] * @property {string} [stagingApiToken] * @property {boolean} [staging] + * @property {import("./credentials.js").CredentialStore} [credentials] + * @property {() => string} [hostname] + * @property {(delayMs: number, signal?: AbortSignal) => Promise} [loginSleep] + * @property {() => number} [loginNow] + * @property {import("./oauth.js").StartLoopbackServer} [startLoopbackServer] */ /** @@ -490,6 +571,10 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @typedef {Omit & {cwd?: string, getCwd: () => string}} CompilationCommandOptions */ +/** + * @typedef {Pick} LoginCommandOptions + */ + /** @param {RunOptions} options */ export async function run({ argv, @@ -519,9 +604,43 @@ export async function run({ apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, stagingApiToken = process.env.FIRSTDRAFT_STAGING_API_TOKEN, + env = process.env, + homedir, + credentialsFileSystem, + credentialsLockTimeoutMs, + hostname, + loginSleep, + loginNow, + startLoopbackServer, }) { const staging = argv[0] === "--staging"; if (staging) argv = argv.slice(1); + /** @type {import("./credentials.js").CredentialStore} */ + const credentials = { + env, + homedir, + fileSystem: credentialsFileSystem, + lockTimeoutMs: credentialsLockTimeoutMs, + }; + + if (argv[0] === "login" || argv[0] === "logout") { + return (argv[0] === "login" ? runLogin : runLogout)({ + argv: argv.slice(1), + stdout, + stderr, + fetchFunction, + createRequestSignal, + credentials, + hostname, + loginSleep, + loginNow, + startLoopbackServer, + apiUrl, + apiToken, + stagingApiToken, + staging, + }); + } if (argv[0] === "generate") { return runGenerate({ @@ -561,6 +680,7 @@ export async function run({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -580,6 +700,7 @@ export async function run({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -658,6 +779,7 @@ async function runPlan({ apiToken, stagingApiToken, staging, + credentials, }) { if (argv[0] === "init") { return runPlanInit({ @@ -684,6 +806,7 @@ async function runPlan({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -702,6 +825,7 @@ async function runPlan({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -729,6 +853,7 @@ async function runPlan({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -776,6 +901,7 @@ async function runCompilation({ apiToken, stagingApiToken, staging, + credentials, }) { if (argv[0] === "status") { return runCompilationStatus({ @@ -792,6 +918,7 @@ async function runCompilation({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -808,6 +935,7 @@ async function runCompilation({ apiToken, stagingApiToken, staging, + credentials, }); } @@ -840,7 +968,7 @@ async function runCompilation({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runCompilationStatus({ argv, @@ -856,6 +984,7 @@ async function runCompilationStatus({ apiToken, stagingApiToken, staging, + credentials, }) { const parsed = parseArguments(() => parseArgs({ @@ -900,6 +1029,7 @@ async function runCompilationStatus({ apiToken, stagingApiToken, staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), }); if (authentication === null) { writeAuthenticationRequired(stderr); @@ -925,7 +1055,7 @@ async function runCompilationStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runCompilationDownload({ argv, @@ -939,6 +1069,7 @@ async function runCompilationDownload({ apiToken, stagingApiToken, staging, + credentials, }) { const parsed = parseArguments(() => parseArgs({ @@ -989,6 +1120,7 @@ async function runCompilationDownload({ apiToken, stagingApiToken, staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), }); if (authentication === null) { writeAuthenticationRequired(stderr); @@ -1161,6 +1293,257 @@ function writeCompilationReadError(writer, error, throwUnknown = true) { return null; } +/** @param {LoginCommandOptions} options */ +async function runLogin({ + argv, + stdout, + stderr, + fetchFunction, + createRequestSignal, + credentials, + hostname, + loginSleep, + loginNow, + startLoopbackServer, + apiUrl, + apiToken, + stagingApiToken, + staging, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + staging: { type: "boolean" }, + interactive: { type: "boolean", short: "i" }, + device: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if ( + !parsed || + repeatedValueOption(parsed.tokens) || + (parsed.values.interactive && parsed.values.device) + ) { + writeJson(stderr, { + error: "invalid_arguments", + detail: LOGIN_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(LOGIN_HELP); + return 0; + } + + const origin = selectLoginOrigin(stderr, { + apiUrl, + staging: staging || parsed.values.staging, + }); + if (origin === null) return 2; + + try { + await login({ + origin, + device: parsed.values.interactive || parsed.values.device, + prompt: (text) => stderr.write(text), + fetchFunction, + createRequestSignal, + sleep: loginSleep, + now: loginNow, + hostname, + startLoopback: startLoopbackServer, + credentials, + }); + } catch (error) { + if (error instanceof LoginDeniedError) { + writeJson(stderr, { + error: "authorization_denied", + detail: LOGIN_DENIED_DETAIL, + }); + return 1; + } + + if (error instanceof LoginExpiredError) { + writeJson(stderr, { + error: "authorization_expired", + detail: LOGIN_EXPIRED_DETAIL, + }); + return 1; + } + + if (error instanceof LoginFailedError) { + writeJson(stderr, { + error: "login_failed", + detail: + error.credentialsPath === undefined + ? LOGIN_FAILED_DETAIL + : error.phase === "write" + ? `First Draft issued a token, but it could not be saved. Revoke the new token at ${origin}/api-tokens, then ${credentialsRecovery(error.reason)} and retry login.` + : LOGIN_CREDENTIALS_DETAIL, + ...(error.phase === undefined ? {} : { phase: error.phase }), + ...(error.reason === undefined ? {} : { reason: error.reason }), + ...(error.status === undefined ? {} : { status: error.status }), + ...(error.credentialsPath === undefined + ? {} + : { credentials_path: error.credentialsPath }), + }); + return 1; + } + + throw error; + } + + stdout.write(`Logged in to ${origin}\n`); + writeEnvironmentTokenNote(stderr, origin, { apiToken, stagingApiToken }); + return 0; +} + +/** @param {LoginCommandOptions} options */ +async function runLogout({ + argv, + stdout, + stderr, + fetchFunction, + createRequestSignal, + credentials, + apiUrl, + apiToken, + stagingApiToken, + staging, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + staging: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: LOGOUT_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(LOGOUT_HELP); + return 0; + } + + const origin = selectLoginOrigin(stderr, { + apiUrl, + staging: staging || parsed.values.staging, + }); + if (origin === null) return 2; + + let result; + try { + result = await logout({ + origin, + fetchFunction, + createRequestSignal, + credentials, + }); + } catch (error) { + if (!(error instanceof LogoutFailedError)) throw error; + + writeJson(stderr, { + error: "logout_failed", + detail: + error.phase === "read" + ? LOGOUT_FAILED_DETAIL + : error.revoked + ? `First Draft confirmed token revocation, but the local credential could not be removed. To remove it, ${credentialsRecovery(error.reason)} and retry logout.` + : `First Draft did not confirm token revocation, and the local credential could not be removed. Revoke the token at ${origin}/api-tokens, then ${credentialsRecovery(error.reason)} and retry logout.`, + phase: error.phase, + ...(error.revoked === undefined ? {} : { revoked: error.revoked }), + reason: error.reason, + credentials_path: error.credentialsPath, + }); + return 1; + } + + if (!result.stored) { + stdout.write(`Not logged in to ${origin}; no saved token was found.\n`); + } else { + stdout.write(`Logged out of ${origin}\n`); + if (result.changed) { + stderr.write( + `The saved token for ${origin} changed while logout was running, so the current saved token was left in place. Run 'firstdraft logout' again to remove it.\n`, + ); + } + if (!result.revoked) { + stderr.write( + `First Draft did not confirm that the token was revoked. The local copy was removed; revoke the token at ${new URL("/api-tokens", origin).href} if it may still be active.\n`, + ); + } + } + writeEnvironmentTokenNote(stderr, origin, { apiToken, stagingApiToken }); + return 0; +} + +/** + * The recovery step after a credentials update failed, phrased to follow + * "then" in a detail message. + * + * @param {string | undefined} reason + */ +function credentialsRecovery(reason) { + return reason === "credentials_locked" + ? CREDENTIALS_LOCKED_RECOVERY + : "repair the credentials file"; +} + +/** + * @param {Writer} stderr + * @param {{apiUrl?: string, staging?: boolean}} options + * @returns {string | null} + */ +function selectLoginOrigin(stderr, options) { + try { + return resolveLoginOrigin(options); + } catch (error) { + if (!(error instanceof PlanPushConfigurationError)) throw error; + + writeJson(stderr, { + error: "invalid_configuration", + detail: error.message, + }); + return null; + } +} + +/** + * @param {Writer} stderr + * @param {string} origin + * @param {{apiToken?: string, stagingApiToken?: string}} tokens + */ +function writeEnvironmentTokenNote(stderr, origin, tokens) { + if (environmentTokenFor(origin, tokens) === undefined) return; + + const variable = + origin === STAGING_API_URL + ? "FIRSTDRAFT_STAGING_API_TOKEN" + : "FIRSTDRAFT_API_TOKEN"; + stderr.write( + `Note: ${variable} is set and takes precedence over the saved login for ${origin}.\n`, + ); +} + /** * @param {GenerateCommandOptions} options */ @@ -1296,7 +1679,7 @@ function runGenerateApplicationKey({ argv, stdout, stderr }) { } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanPush({ argv, @@ -1311,6 +1694,7 @@ async function runPlanPush({ apiToken, stagingApiToken, staging, + credentials, }) { const parsed = parseArguments(() => parseArgs({ @@ -1346,6 +1730,7 @@ async function runPlanPush({ apiToken, stagingApiToken, staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), }); if (authentication === null) { writeAuthenticationRequired(stderr); @@ -1441,7 +1826,7 @@ async function runPlanPush({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanStatus({ argv, @@ -1457,6 +1842,7 @@ async function runPlanStatus({ apiToken, stagingApiToken, staging, + credentials, }) { const parsed = parseArguments(() => parseArgs({ @@ -1493,6 +1879,7 @@ async function runPlanStatus({ apiToken, stagingApiToken, staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), }); if (authentication === null) { writeAuthenticationRequired(stderr); @@ -1605,7 +1992,7 @@ async function runPlanStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanCompile({ argv, @@ -1630,6 +2017,7 @@ async function runPlanCompile({ apiToken, stagingApiToken, staging, + credentials, }) { const parsed = parseArguments(() => parseArgs({ @@ -1676,6 +2064,7 @@ async function runPlanCompile({ apiToken, stagingApiToken, staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), }); if (authentication === null) { writeAuthenticationRequired(stderr); diff --git a/src/commands/login.js b/src/commands/login.js new file mode 100644 index 0000000..7d509f0 --- /dev/null +++ b/src/commands/login.js @@ -0,0 +1,314 @@ +import { hostname as osHostname } from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; + +import { + STAGING_API_URL, + assertStagingSelection, +} from "../api-authentication.js"; +import { + CredentialsInvalidError, + CredentialsLockedError, + credentialsPath, + readCredentials, + writeCredential, +} from "../credentials.js"; +import { isFileSystemError } from "../file-system.js"; +import { + LOOPBACK_TIMEOUT_MS, + OAuthError, + authorizationUrl, + createPkcePair, + createState, + exchangeAuthorizationCode, + pollDeviceToken, + requestDeviceAuthorization, + startLoopbackServer, +} from "../oauth.js"; +import { normalizeApiUrl } from "../plan-state.js"; +import { DEFAULT_API_URL } from "./plan-push.js"; + +const MAX_DEVICE_NAME_LENGTH = 100; + +export class LoginDeniedError extends Error {} +export class LoginExpiredError extends Error {} + +export class LoginFailedError extends Error { + /** + * @param {string} message + * @param {{reason?: string, status?: number, credentialsPath?: string, phase?: "read" | "write", cause?: unknown}} [options] + */ + constructor(message, options = {}) { + super(message, { cause: options.cause }); + this.reason = options.reason; + this.status = options.status; + this.credentialsPath = options.credentialsPath; + this.phase = options.phase; + } +} + +/** + * Selects the origin exactly as other API commands select their initial + * origin: `--staging`, else FIRSTDRAFT_API_URL, else production. + * + * @param {{apiUrl?: string, staging?: boolean}} options + */ +export function resolveLoginOrigin({ apiUrl, staging = false }) { + assertStagingSelection(apiUrl, staging); + if (staging) return STAGING_API_URL; + return apiUrl === undefined ? DEFAULT_API_URL : normalizeApiUrl(apiUrl); +} + +/** + * @typedef {object} LoginOptions + * @property {string} origin + * @property {boolean} [device] use the device authorization grant + * @property {(text: string) => void} prompt writes user instructions (stderr) + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number, signal?: AbortSignal) => Promise} [sleep] + * @property {() => number} [now] + * @property {() => string} [hostname] + * @property {import("../oauth.js").StartLoopbackServer} [startLoopback] + * @property {import("../credentials.js").CredentialStore} [credentials] + */ + +/** + * Runs the browser (loopback + PKCE) or device flow and stores the resulting + * token for `origin`. Never returns or prints the token. + * + * @param {LoginOptions} options + */ +export async function login({ + origin, + device = false, + prompt, + fetchFunction, + createRequestSignal, + sleep = defaultSleep, + now = Date.now, + hostname = osHostname, + startLoopback = startLoopbackServer, + credentials = {}, +}) { + // Reject an unreadable or invalid store before any network request. + // A later write can still fail after a token has been issued. + readStore(credentials); + const deviceName = safeDeviceName(hostname); + const request = { origin, fetchFunction, createRequestSignal }; + + /** @param {import("../oauth.js").AccessToken} token */ + const save = (token) => { + try { + writeCredential(credentials, origin, { + access_token: token.access_token, + token_type: token.token_type, + created_at: new Date(now()).toISOString(), + }); + } catch (error) { + throw credentialsError(error, credentials, "write"); + } + }; + + try { + if (device) { + save(await deviceLogin({ ...request, deviceName, prompt, sleep, now })); + } else { + await browserLogin({ + ...request, + deviceName, + prompt, + sleep, + startLoopback, + save, + }); + } + } catch (error) { + throw loginError(error); + } + return { origin }; +} + +/** + * @param {import("../oauth.js").RequestOptions & { + * deviceName: string | undefined, + * prompt: (text: string) => void, + * sleep: (delayMs: number, signal?: AbortSignal) => Promise, + * startLoopback: import("../oauth.js").StartLoopbackServer, + * save: (token: import("../oauth.js").AccessToken) => void, + * }} options + */ +async function browserLogin({ + deviceName, + prompt, + sleep, + startLoopback, + save, + ...request +}) { + const state = createState(); + const { verifier, challenge } = createPkcePair(); + + let listener; + try { + listener = await startLoopback({ state }); + } catch (error) { + if (!(error instanceof Error)) throw error; + throw new LoginFailedError( + "Could not start the local login callback server.", + { reason: "loopback_unavailable", cause: error }, + ); + } + + const timer = new AbortController(); + try { + const url = authorizationUrl(request.origin, { + redirectUri: listener.redirectUri, + state, + challenge, + deviceName, + }); + prompt( + `To log in to ${request.origin}, open this URL in your browser:\n\n ${url}\n\n` + + "Waiting up to 5 minutes for approval. On a machine without a browser, " + + "run 'firstdraft login --interactive' instead.\n", + ); + /** @type {Promise<"timeout">} */ + const timeout = sleep(LOOPBACK_TIMEOUT_MS, timer.signal).then( + () => "timeout", + () => new Promise(() => {}), + ); + const outcome = await Promise.race([listener.result, timeout]); + timer.abort(); + + if (outcome === "timeout") { + throw new LoginExpiredError("No browser callback arrived in time."); + } + if ("error" in outcome) { + listener.respond(outcome.error === "access_denied" ? "denied" : "failed"); + throw new OAuthError("The authorization was not granted.", { + code: outcome.error, + }); + } + + // The browser waits on the held callback until the token is exchanged and + // saved, so its page reports what the terminal will report. + save( + await exchangeAuthorizationCode({ + ...request, + code: outcome.code, + redirectUri: listener.redirectUri, + verifier, + }), + ); + listener.respond("approved"); + } finally { + timer.abort(); + // Answers any still-held callback as failed. + await listener.close(); + } +} + +/** + * @param {import("../oauth.js").RequestOptions & { + * deviceName: string | undefined, + * prompt: (text: string) => void, + * sleep: (delayMs: number) => Promise, + * now: () => number, + * }} options + */ +async function deviceLogin({ deviceName, prompt, sleep, now, ...request }) { + const authorization = await requestDeviceAuthorization({ + ...request, + deviceName, + }); + prompt( + `To log in to ${request.origin}, open this URL in a browser on any device:\n\n` + + ` ${authorization.verificationUri}\n\n` + + `and enter the code: ${authorization.userCode}\n\n` + + (authorization.verificationUriComplete === undefined + ? "" + : `Or open this URL, which already includes the code:\n\n ${authorization.verificationUriComplete}\n\n`) + + "Waiting for approval...\n", + ); + return pollDeviceToken({ + ...request, + deviceCode: authorization.deviceCode, + interval: authorization.interval, + expiresIn: authorization.expiresIn, + sleep, + now, + }); +} + +/** @param {import("../credentials.js").CredentialStore} credentials */ +function readStore(credentials) { + try { + return readCredentials(credentials); + } catch (error) { + throw credentialsError(error, credentials, "read"); + } +} + +/** + * @param {unknown} error + * @param {import("../credentials.js").CredentialStore} credentials + * @param {"read" | "write"} phase + */ +function credentialsError(error, credentials, phase) { + if (!(error instanceof CredentialsInvalidError) && !isFileSystemError(error)) + throw error; + return new LoginFailedError("The credentials file could not be used.", { + reason: + error instanceof CredentialsLockedError + ? "credentials_locked" + : error instanceof CredentialsInvalidError + ? "credentials_invalid" + : "credentials_unavailable", + credentialsPath: credentialsPath(credentials), + phase, + cause: error, + }); +} + +/** @param {unknown} error */ +function loginError(error) { + if ( + error instanceof LoginDeniedError || + error instanceof LoginExpiredError || + error instanceof LoginFailedError + ) + return error; + if (!(error instanceof OAuthError)) throw error; + + if (error.code === "access_denied") { + return new LoginDeniedError("The authorization was denied."); + } + if (error.code === "expired_token") { + return new LoginExpiredError("The authorization expired."); + } + return new LoginFailedError("The login could not be completed.", { + reason: error.code, + status: error.status, + cause: error, + }); +} + +/** @param {() => string} hostname */ +function safeDeviceName(hostname) { + let name; + try { + name = hostname(); + } catch { + return undefined; + } + // eslint-disable-next-line no-control-regex + const cleaned = name.replace(/[\u0000-\u001f\u007f]/g, "").trim(); + return cleaned.length === 0 + ? undefined + : cleaned.slice(0, MAX_DEVICE_NAME_LENGTH); +} + +/** @param {number} delayMs @param {AbortSignal} [signal] */ +function defaultSleep(delayMs, signal) { + return delay(delayMs, undefined, { signal }); +} diff --git a/src/commands/logout.js b/src/commands/logout.js new file mode 100644 index 0000000..8c465fb --- /dev/null +++ b/src/commands/logout.js @@ -0,0 +1,107 @@ +import { + CredentialsInvalidError, + CredentialsLockedError, + credentialsPath, + deleteCredential, + readCredentials, +} from "../credentials.js"; +import { isFileSystemError } from "../file-system.js"; +import { revokeToken } from "../oauth.js"; + +export class LogoutFailedError extends Error { + /** + * @param {string} message + * @param {{reason: string, credentialsPath: string, phase: "read" | "write", revoked?: boolean, cause?: unknown}} options + */ + constructor(message, options) { + super(message, { cause: options.cause }); + this.reason = options.reason; + this.credentialsPath = options.credentialsPath; + this.phase = options.phase; + this.revoked = options.revoked; + } +} + +/** + * @typedef {object} LogoutOptions + * @property {string} origin + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + * @property {import("../credentials.js").CredentialStore} [credentials] + */ + +/** + * @typedef {object} LogoutResult + * @property {boolean} stored whether a credential for the origin was stored + * @property {boolean} revoked whether First Draft confirmed revocation + * @property {boolean} changed whether the saved token changed during logout, so + * the current entry was left in place + */ + +/** + * Revokes the stored token for `origin` (best effort) and deletes the local + * entry if it still holds that token. Other origins are untouched. + * + * @param {LogoutOptions} options + * @returns {Promise} + */ +export async function logout({ + origin, + fetchFunction, + createRequestSignal, + credentials = {}, +}) { + const entry = withCredentials( + credentials, + () => readCredentials(credentials).origins[origin], + { phase: "read" }, + ); + if (entry === undefined) + return { stored: false, revoked: false, changed: false }; + + const revoked = await revokeToken({ + origin, + token: entry.access_token, + fetchFunction, + createRequestSignal, + }); + const removed = withCredentials( + credentials, + () => + deleteCredential(credentials, origin, { + accessToken: entry.access_token, + }), + { phase: "write", revoked }, + ); + return { stored: true, revoked, changed: !removed }; +} + +/** + * @template T + * @param {import("../credentials.js").CredentialStore} credentials + * @param {() => T} callback + * @param {{phase: "read" | "write", revoked?: boolean}} outcome + * @returns {T} + */ +function withCredentials(credentials, callback, outcome) { + try { + return callback(); + } catch (error) { + if ( + !(error instanceof CredentialsInvalidError) && + !isFileSystemError(error) + ) + throw error; + throw new LogoutFailedError("The credentials file could not be used.", { + reason: + error instanceof CredentialsLockedError + ? "credentials_locked" + : error instanceof CredentialsInvalidError + ? "credentials_invalid" + : "credentials_unavailable", + credentialsPath: credentialsPath(credentials), + ...outcome, + cause: error, + }); + } +} diff --git a/src/credentials.js b/src/credentials.js new file mode 100644 index 0000000..efa8861 --- /dev/null +++ b/src/credentials.js @@ -0,0 +1,343 @@ +import { randomBytes } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { isFileSystemError } from "./file-system.js"; + +export const CREDENTIALS_FORMAT = "firstdraft.cli-credentials/1"; +const MAX_CREDENTIALS_BYTES = 256 * 1024; +const LOCK_TIMEOUT_MS = 10_000; +const LOCK_RETRY_MS = 25; + +/** + * @typedef {object} CredentialsFileSystem + * @property {typeof mkdirSync} mkdirSync + * @property {typeof readFileSync} readFileSync + * @property {typeof renameSync} renameSync + * @property {typeof rmSync} rmSync + * @property {typeof statSync} statSync + * @property {typeof writeFileSync} writeFileSync + */ + +/** + * Where and how the CLI stores login credentials. Every member is injectable so + * tests never touch the real configuration directory. + * + * @typedef {object} CredentialStore + * @property {Readonly>} [env] + * @property {() => string} [homedir] + * @property {CredentialsFileSystem} [fileSystem] + * @property {() => string} [createTemporaryId] + * @property {number} [lockTimeoutMs] how long an update waits for another + * process's lock before failing + */ + +/** + * @typedef {object} CredentialEntry + * @property {string} access_token + * @property {string} token_type + * @property {string} created_at + */ + +/** + * @typedef {object} Credentials + * @property {typeof CREDENTIALS_FORMAT} format + * @property {Record} origins + */ + +/** @type {CredentialsFileSystem} */ +const DEFAULT_FILE_SYSTEM = { + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +}; + +export class CredentialsInvalidError extends Error {} + +/** + * The credentials lock stayed held for the whole wait: another command is + * updating the file, or one exited while holding the lock. The lock is never + * removed automatically, because no file-system check can tell an abandoned + * lock from one another command has just taken. Its `code` lets callers treat + * it like any other unavailable credentials file. + */ +export class CredentialsLockedError extends Error { + code = "ELOCKED"; +} + +/** + * `$XDG_CONFIG_HOME/firstdraft/credentials.json`, falling back to + * `~/.config/firstdraft/credentials.json`. A relative XDG_CONFIG_HOME is + * ignored, as the XDG Base Directory specification requires. + * + * @param {CredentialStore} [store] + */ +export function credentialsPath(store = {}) { + const env = store.env ?? process.env; + const configHome = env.XDG_CONFIG_HOME; + const base = + configHome !== undefined && path.isAbsolute(configHome) + ? configHome + : path.join((store.homedir ?? homedir)(), ".config"); + return path.join(base, "firstdraft", "credentials.json"); +} + +/** + * Reads the credentials file. A missing file is an empty store; an unreadable + * or malformed file throws. + * + * @param {CredentialStore} [store] + * @returns {Credentials} + */ +export function readCredentials(store = {}) { + const fileSystem = store.fileSystem ?? DEFAULT_FILE_SYSTEM; + let source; + try { + source = fileSystem.readFileSync(credentialsPath(store)); + } catch (error) { + if (isMissingFileError(error)) return emptyCredentials(); + throw error; + } + + if (source.byteLength > MAX_CREDENTIALS_BYTES) { + throw new CredentialsInvalidError("The credentials file is too large."); + } + + let parsed; + try { + parsed = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source), + ); + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof TypeError)) + throw error; + throw new CredentialsInvalidError("The credentials file is invalid."); + } + + if ( + !isRecord(parsed) || + parsed.format !== CREDENTIALS_FORMAT || + !isRecord(parsed.origins) + ) { + throw new CredentialsInvalidError("The credentials file is invalid."); + } + + const origins = emptyOrigins(); + for (const [origin, entry] of Object.entries(parsed.origins)) { + if (!isCredentialEntry(entry)) { + throw new CredentialsInvalidError("The credentials file is invalid."); + } + origins[origin] = { + access_token: entry.access_token, + token_type: entry.token_type, + created_at: entry.created_at, + }; + } + return { format: CREDENTIALS_FORMAT, origins }; +} + +/** + * Stores (or replaces) the credential for one origin, keeping the others. + * + * @param {CredentialStore} store + * @param {string} origin + * @param {CredentialEntry} entry + */ +export function writeCredential(store, origin, entry) { + withStoreLock(store, () => { + const credentials = readCredentials(store); + credentials.origins[origin] = entry; + saveCredentials(store, credentials); + }); +} + +/** + * Removes the credential for one origin. With `accessToken`, removes it only + * while it still holds that token, so a newer login saved in the meantime is + * kept. + * + * @param {CredentialStore} store + * @param {string} origin + * @param {{accessToken?: string}} [options] + * @returns {boolean} whether an entry was removed + */ +export function deleteCredential(store, origin, { accessToken } = {}) { + if (!Object.hasOwn(readCredentials(store).origins, origin)) return false; + + return withStoreLock(store, () => { + const credentials = readCredentials(store); + const entry = credentials.origins[origin]; + if ( + entry === undefined || + (accessToken !== undefined && entry.access_token !== accessToken) + ) + return false; + delete credentials.origins[origin]; + saveCredentials(store, credentials); + return true; + }); +} + +/** + * Returns a memoized, never-throwing reader of stored access tokens keyed by + * origin. An unreadable or malformed file reads as empty. + * + * @param {CredentialStore} [store] + * @returns {() => Readonly>} + */ +export function storedTokenReader(store = {}) { + /** @type {Record | undefined} */ + let tokens; + return () => { + if (tokens !== undefined) return tokens; + /** @type {Record} */ + const loaded = Object.create(null); + try { + for (const [origin, entry] of Object.entries( + readCredentials(store).origins, + )) { + loaded[origin] = entry.access_token; + } + } catch (error) { + if ( + !(error instanceof CredentialsInvalidError) && + !isFileSystemError(error) + ) + throw error; + } + tokens = loaded; + return tokens; + }; +} + +/** + * Runs `callback` while holding `credentials.json.lock`, so concurrent + * processes cannot interleave a read-modify-write and lose each other's + * entries. Reads need no lock because each save is an atomic rename. + * + * @template T + * @param {CredentialStore} store + * @param {() => T} callback + * @returns {T} + */ +function withStoreLock(store, callback) { + const fileSystem = store.fileSystem ?? DEFAULT_FILE_SYSTEM; + const directory = path.dirname(credentialsPath(store)); + const lock = path.join(directory, "credentials.json.lock"); + const deadline = Date.now() + (store.lockTimeoutMs ?? LOCK_TIMEOUT_MS); + + fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 }); + while (true) { + try { + fileSystem.writeFileSync(lock, `${process.pid}\n`, { + flag: "wx", + mode: 0o600, + }); + break; + } catch (error) { + if (!isExistingFileError(error)) throw error; + } + if (Date.now() >= deadline) { + throw new CredentialsLockedError( + "Another process is updating the credentials file.", + ); + } + sleep(LOCK_RETRY_MS); + } + + try { + return callback(); + } finally { + try { + fileSystem.rmSync(lock, { force: true }); + } catch { + // The callback's outcome matters more; a leftover lock is reported later. + } + } +} + +/** @param {number} milliseconds */ +function sleep(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +/** @param {CredentialStore} store @param {Credentials} credentials */ +function saveCredentials(store, credentials) { + const fileSystem = store.fileSystem ?? DEFAULT_FILE_SYSTEM; + const target = credentialsPath(store); + const directory = path.dirname(target); + const temporaryId = + store.createTemporaryId?.() ?? randomBytes(8).toString("hex"); + const temporary = path.join( + directory, + `.credentials.json.${temporaryId}.tmp`, + ); + + try { + fileSystem.writeFileSync( + temporary, + `${JSON.stringify(credentials, null, 2)}\n`, + { flag: "wx", mode: 0o600, flush: true }, + ); + fileSystem.renameSync(temporary, target); + } catch (error) { + try { + fileSystem.rmSync(temporary, { force: true }); + } catch { + // The original failure is the useful one. + } + throw error; + } +} + +/** @returns {Credentials} */ +function emptyCredentials() { + return { format: CREDENTIALS_FORMAT, origins: emptyOrigins() }; +} + +/** + * Origins are keyed by URL origin; a null prototype keeps keys such as + * `__proto__` inert. + * + * @returns {Record} + */ +function emptyOrigins() { + return Object.create(null); +} + +/** @param {unknown} value @returns {value is CredentialEntry} */ +function isCredentialEntry(value) { + return ( + isRecord(value) && + typeof value.access_token === "string" && + value.access_token.length > 0 && + typeof value.token_type === "string" && + typeof value.created_at === "string" + ); +} + +/** @param {unknown} error */ +function isExistingFileError(error) { + return error instanceof Error && "code" in error && error.code === "EEXIST"; +} + +/** @param {unknown} error */ +function isMissingFileError(error) { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/oauth.js b/src/oauth.js new file mode 100644 index 0000000..09e7c4e --- /dev/null +++ b/src/oauth.js @@ -0,0 +1,565 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; + +import { + SERVICE_ROUTES, + readResponseBody, + serviceEndpoint, +} from "./api-response.js"; + +export const CLIENT_ID = "firstdraft-cli"; +export const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; +export const LOOPBACK_HOST = "127.0.0.1"; +export const LOOPBACK_PATH = "/callback"; +export const SLOW_DOWN_INCREMENT_MS = 5000; +const DEFAULT_DEVICE_INTERVAL_SECONDS = 5; +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_OAUTH_RESPONSE_BYTES = 64 * 1024; +const OAUTH_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/; +const USER_CODE = /^[\x21-\x7e]{1,64}$/; + +/** @type {Record} */ +const OUTCOME_PAGES = { + approved: [ + "Login complete", + "First Draft CLI is logged in. You can close this tab and return to your terminal.", + ], + denied: [ + "Login cancelled", + "First Draft CLI was not authorized. You can close this tab and return to your terminal.", + ], + failed: [ + "Login failed", + "First Draft CLI could not finish logging in. Return to your terminal for details.", + ], +}; + +// `default-src 'none'` also blocks inline styles, so the one stylesheet is +// allowed by its hash rather than by loosening the policy. +const CALLBACK_PAGE_STYLE = + "body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem}"; +const CALLBACK_PAGE_CSP = `default-src 'none'; style-src 'sha256-${createHash( + "sha256", +) + .update(CALLBACK_PAGE_STYLE) + .digest("base64")}'`; + +/** + * An OAuth endpoint answered with an RFC 6749 error, or the exchange could not + * be completed. `code` is the validated OAuth error code when there is one. + */ +export class OAuthError extends Error { + /** + * @param {string} message + * @param {{code?: string, status?: number, cause?: unknown}} [options] + */ + constructor(message, options = {}) { + super(message, { cause: options.cause }); + this.code = options.code; + this.status = options.status; + } +} + +/** + * @typedef {object} AccessToken + * @property {string} access_token + * @property {"Bearer"} token_type + */ + +/** + * @typedef {object} RequestOptions + * @property {string} origin + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + */ + +/** @param {(size: number) => Buffer} [random] */ +export function createPkcePair(random = randomBytes) { + const verifier = random(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** @param {(size: number) => Buffer} [random] */ +export function createState(random = randomBytes) { + return random(32).toString("base64url"); +} + +/** + * @param {string} origin + * @param {{redirectUri: string, state: string, challenge: string, deviceName?: string}} options + */ +export function authorizationUrl( + origin, + { redirectUri, state, challenge, deviceName }, +) { + const url = new URL("/oauth/authorize", origin); + url.search = new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: redirectUri, + state, + code_challenge: challenge, + code_challenge_method: "S256", + ...(deviceName ? { device_name: deviceName } : {}), + }).toString(); + return url.href; +} + +/** + * @typedef {{code: string} | {error: string}} LoopbackResult + */ + +/** + * @typedef {"approved" | "denied" | "failed"} LoopbackOutcome + */ + +/** + * @typedef {object} LoopbackListener + * @property {string} redirectUri + * @property {Promise} result + * Settles once, with the first callback that carries the expected state. + * @property {(outcome: LoopbackOutcome) => void} respond + * Answers the accepted callback, which is held open until the login knows + * its real outcome. Later calls do nothing. + * @property {() => Promise} close + * Answers a still-held callback as failed, then stops listening. + */ + +/** + * @typedef {(options: {state: string}) => Promise} StartLoopbackServer + */ + +/** + * Listens on 127.0.0.1 at an ephemeral port for exactly one OAuth redirect. + * + * Only `GET /callback` with a `Host` of the bound address and a `state` equal + * to the expected value is accepted. Anything else (a favicon request, a + * missing or wrong state, a rebinding `Host`) receives an error response and + * does not end the wait, so a stray or hostile local request can neither + * inject a code nor cancel the login. The first accepted callback settles the + * result; later requests receive 404. The accepted request is answered only + * through `respond`, so the browser never reports success before the code + * exchange and credential write have finished. + * + * @type {StartLoopbackServer} + */ +export async function startLoopbackServer({ state }) { + /** @type {(result: LoopbackResult) => void} */ + let settle = () => {}; + /** @type {Promise} */ + const result = new Promise((resolve) => { + settle = resolve; + }); + let settled = false; + /** @type {Promise} */ + let responded = Promise.resolve(); + /** @type {import("node:http").ServerResponse | undefined} */ + let held; + let port = 0; + + const server = createServer((request, response) => { + /** @param {number} status @param {string} title @param {string} message */ + const reply = (status, title, message) => + writeCallbackPage(response, status, title, message); + + const url = parseRequestUrl(request.url); + if ( + settled || + request.method !== "GET" || + url === null || + url.pathname !== LOOPBACK_PATH + ) { + reply(404, "Not found", "This address only accepts the login callback."); + return; + } + + const params = url.searchParams; + const states = params.getAll("state"); + if ( + request.headers.host !== `${LOOPBACK_HOST}:${port}` || + states.length !== 1 || + !equalSecrets(states[0] ?? "", state) + ) { + reply( + 400, + "Login not accepted", + "This callback does not match the login in progress. Return to your terminal.", + ); + return; + } + + settled = true; + held = response; + responded = new Promise((resolve) => { + response.once("close", resolve); + }); + const codes = params.getAll("code"); + const errors = params.getAll("error"); + const [code] = codes; + const [error] = errors; + if (errors.length === 0 && codes.length === 1 && code) { + settle({ code }); + return; + } + + settle({ + error: + errors.length === 1 && + error !== undefined && + OAUTH_ERROR_CODE.test(error) + ? error + : "invalid_request", + }); + }); + + /** @param {LoopbackOutcome} outcome */ + const respond = (outcome) => { + const response = held; + held = undefined; + if (response === undefined) return; + const [title, message] = OUTCOME_PAGES[outcome]; + writeCallbackPage(response, 200, title, message); + }; + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, LOOPBACK_HOST, () => { + server.off("error", reject); + resolve(undefined); + }); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + throw new Error("The loopback server has no TCP address."); + } + port = address.port; + + return { + redirectUri: `http://${LOOPBACK_HOST}:${port}${LOOPBACK_PATH}`, + result, + respond, + close: async () => { + respond("failed"); + const closed = new Promise((resolve) => { + server.close(resolve); + }); + // Let the final browser page flush, but never wait on a stalled client. + await Promise.race([ + responded, + new Promise((resolve) => { + setTimeout(resolve, 1000).unref(); + }), + ]); + server.closeAllConnections(); + await closed; + }, + }; +} + +/** + * @param {RequestOptions & {code: string, redirectUri: string, verifier: string}} options + * @returns {Promise} + */ +export async function exchangeAuthorizationCode({ + code, + redirectUri, + verifier, + ...request +}) { + const response = await postForm(request, SERVICE_ROUTES.requestOAuthToken, { + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: CLIENT_ID, + code_verifier: verifier, + }); + return accessTokenFrom(response); +} + +/** + * @typedef {object} DeviceAuthorization + * @property {string} deviceCode + * @property {string} userCode + * @property {string} verificationUri + * @property {string | undefined} verificationUriComplete + * @property {number} expiresIn seconds + * @property {number} interval seconds + */ + +/** + * @param {RequestOptions & {deviceName?: string}} options + * @returns {Promise} + */ +export async function requestDeviceAuthorization({ deviceName, ...request }) { + const { status, body } = await postForm( + request, + SERVICE_ROUTES.requestDeviceAuthorization, + { + client_id: CLIENT_ID, + ...(deviceName ? { device_name: deviceName } : {}), + }, + ); + if (status !== 200) throw oauthFailure(status, body); + + const verificationUri = httpUrl(body?.verification_uri); + const verificationUriComplete = + body?.verification_uri_complete === undefined + ? undefined + : httpUrl(body.verification_uri_complete); + const interval = + body?.interval === undefined + ? DEFAULT_DEVICE_INTERVAL_SECONDS + : body.interval; + if ( + body === null || + typeof body.device_code !== "string" || + body.device_code.length === 0 || + typeof body.user_code !== "string" || + !USER_CODE.test(body.user_code) || + verificationUri === null || + verificationUriComplete === null || + !isPositiveInteger(body.expires_in) || + !isPositiveInteger(interval) + ) { + throw new OAuthError("The device authorization response is invalid.", { + status, + }); + } + + return { + deviceCode: body.device_code, + userCode: body.user_code, + verificationUri, + verificationUriComplete, + expiresIn: body.expires_in, + interval, + }; +} + +/** + * Polls the token endpoint until the device grant is approved, denied, or + * expired. Waits `interval` seconds before each poll and adds five seconds on + * every `slow_down` (RFC 8628 ยง3.5). + * + * @param {RequestOptions & { + * deviceCode: string, + * interval: number, + * expiresIn: number, + * sleep: (delayMs: number) => Promise, + * now: () => number, + * }} options + * @returns {Promise} + */ +export async function pollDeviceToken({ + deviceCode, + interval, + expiresIn, + sleep, + now, + ...request +}) { + const deadline = now() + expiresIn * 1000; + let intervalMs = interval * 1000; + + while (true) { + await sleep(intervalMs); + if (now() >= deadline) { + throw new OAuthError("The device code expired.", { + code: "expired_token", + }); + } + + const response = await postForm(request, SERVICE_ROUTES.requestOAuthToken, { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: CLIENT_ID, + }); + if (response.status === 200) return accessTokenFrom(response); + + const failure = oauthFailure(response.status, response.body); + if (failure.code === "authorization_pending") continue; + if (failure.code === "slow_down") { + intervalMs += SLOW_DOWN_INCREMENT_MS; + continue; + } + throw failure; + } +} + +/** + * Best-effort RFC 7009 revocation. Resolves true only for a 200 response. + * + * @param {RequestOptions & {token: string}} options + */ +export async function revokeToken({ token, ...request }) { + try { + const { status } = await postForm( + request, + SERVICE_ROUTES.revokeOAuthToken, + { + token, + client_id: CLIENT_ID, + }, + ); + return status === 200; + } catch (error) { + if (error instanceof OAuthError) return false; + throw error; + } +} + +/** + * @param {RequestOptions} request + * @param {{method: string, path: string}} route + * @param {Record} params + * @returns {Promise<{status: number, body: Record | null}>} + */ +async function postForm( + { + origin, + fetchFunction = globalThis.fetch, + createRequestSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + route, + params, +) { + const endpoint = serviceEndpoint(route, origin, {}); + let response; + try { + response = await fetchFunction(endpoint.url, { + method: endpoint.method, + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(params).toString(), + redirect: "error", + signal: createRequestSignal(), + }); + } catch (error) { + throw new OAuthError("The First Draft request failed.", { cause: error }); + } + + let body; + try { + body = await readResponseBody(response, MAX_OAUTH_RESPONSE_BYTES); + } catch (error) { + throw new OAuthError("The First Draft response failed.", { + status: response.status, + cause: error, + }); + } + return { status: response.status, body: isRecord(body) ? body : null }; +} + +/** + * @param {{status: number, body: Record | null}} response + * @returns {AccessToken} + */ +function accessTokenFrom({ status, body }) { + if (status !== 200) throw oauthFailure(status, body); + if ( + body === null || + typeof body.access_token !== "string" || + body.access_token.trim().length === 0 || + /\s/.test(body.access_token) || + typeof body.token_type !== "string" || + body.token_type.toLowerCase() !== "bearer" + ) { + throw new OAuthError("The token response is invalid.", { status }); + } + return { access_token: body.access_token, token_type: "Bearer" }; +} + +/** + * @param {number} status + * @param {Record | null} body + */ +function oauthFailure(status, body) { + const code = + typeof body?.error === "string" && OAUTH_ERROR_CODE.test(body.error) + ? body.error + : undefined; + return new OAuthError("First Draft rejected the OAuth request.", { + code, + status, + }); +} + +/** @param {unknown} value @returns {string | null} */ +function httpUrl(value) { + if (typeof value !== "string") return null; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" + ? url.href + : null; + } catch { + return null; + } +} + +/** @param {string | undefined} value */ +function parseRequestUrl(value) { + if (value === undefined || !value.startsWith("/")) return null; + try { + return new URL(value, `http://${LOOPBACK_HOST}`); + } catch { + return null; + } +} + +/** @param {string} actual @param {string} expected */ +function equalSecrets(actual, expected) { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + return ( + actualBytes.length === expectedBytes.length && + timingSafeEqual(actualBytes, expectedBytes) + ); +} + +/** + * @param {import("node:http").ServerResponse} response + * @param {number} status + * @param {string} title + * @param {string} message + */ +function writeCallbackPage(response, status, title, message) { + const body = callbackPage(title, message); + response.writeHead(status, { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store", + "Content-Security-Policy": CALLBACK_PAGE_CSP, + "Referrer-Policy": "no-referrer", + Connection: "close", + }); + response.end(body); +} + +/** @param {string} title @param {string} message */ +function callbackPage(title, message) { + return ` + +${title} ยท First Draft CLI + +

${title}

+

${message}

+ + +`; +} + +/** @param {unknown} value @returns {value is number} */ +function isPositiveInteger(value) { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/test/api-environments.test.js b/test/api-environments.test.js index 3fa7dd9..47bf3c9 100644 --- a/test/api-environments.test.js +++ b/test/api-environments.test.js @@ -12,6 +12,10 @@ const PROJECT_ID = "01900000-0000-7000-8000-000000008001"; const COMPILATION_ID = "01900000-0000-7000-8000-000000008002"; const PRODUCTION_TOKEN = "canary-production-token"; const STAGING_TOKEN = "canary-staging-token"; +const CUSTOM = "http://127.0.0.1:4300"; +const SAVED_PRODUCTION_TOKEN = "fd_canary-saved-production-token"; +const SAVED_STAGING_TOKEN = "fd_canary-saved-staging-token"; +const SAVED_CUSTOM_TOKEN = "fd_canary-saved-custom-token"; const REMOTE_COMMANDS = [ ["plan", "push"], ["plan", "status"], @@ -198,6 +202,142 @@ test("a custom initial origin retains explicit support and uses its own supplied assert.equal(errorEnvelope(result.stderr).status, 401); }); +test("a saved login authenticates its exact origin when the environment has no token", async (context) => { + for (const argv of REMOTE_COMMANDS) { + for (const [origin, token] of [ + [PRODUCTION, SAVED_PRODUCTION_TOKEN], + [STAGING, SAVED_STAGING_TOKEN], + [CUSTOM, SAVED_CUSTOM_TOKEN], + ]) { + let requests = 0; + const result = await invoke(argv, { + cwd: projectDirectory(context, origin), + apiToken: "", + stagingApiToken: undefined, + env: savedLogins(context, { + [PRODUCTION]: SAVED_PRODUCTION_TOKEN, + [STAGING]: SAVED_STAGING_TOKEN, + [CUSTOM]: SAVED_CUSTOM_TOKEN, + }), + fetchFunction: async (input, init) => { + requests += 1; + assert.equal(new URL(String(input)).origin, origin); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${token}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1, argv.join(" ")); + assert.equal(errorEnvelope(result.stderr).status, 401); + assert.doesNotMatch(result.stdout + result.stderr, /canary/); + } + } +}); + +test("an environment token takes precedence over a saved login", async (context) => { + for (const argv of REMOTE_COMMANDS) { + for (const [origin, token] of [ + [PRODUCTION, PRODUCTION_TOKEN], + [STAGING, STAGING_TOKEN], + ]) { + let requests = 0; + await invoke(argv, { + cwd: projectDirectory(context, origin), + env: savedLogins(context, { + [PRODUCTION]: SAVED_PRODUCTION_TOKEN, + [STAGING]: SAVED_STAGING_TOKEN, + }), + fetchFunction: async (_input, init) => { + requests += 1; + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${token}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1); + } + } +}); + +test("a saved login never authenticates a different origin", async (context) => { + /** @type {[string, Record][]} */ + const cases = [ + [ + PRODUCTION, + { [STAGING]: SAVED_STAGING_TOKEN, [CUSTOM]: SAVED_CUSTOM_TOKEN }, + ], + [STAGING, { [PRODUCTION]: SAVED_PRODUCTION_TOKEN }], + [CUSTOM, { [PRODUCTION]: SAVED_PRODUCTION_TOKEN }], + ]; + for (const argv of REMOTE_COMMANDS) { + for (const [origin, saved] of cases) { + const result = await invoke(argv, { + cwd: projectDirectory(context, origin), + apiToken: "", + stagingApiToken: "", + env: savedLogins(context, saved), + }); + assert.equal(result.status, 1); + assert.deepEqual(errorEnvelope(result.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Run 'firstdraft login' for the same environment, or set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging.", + }); + assert.doesNotMatch(result.stderr, /canary/); + } + } +}); + +test("an unreadable saved login file is treated as no saved login", async (context) => { + const configHome = mkdtempSync(path.join(tmpdir(), "firstdraft-saved-")); + context.after(() => rmSync(configHome, { recursive: true, force: true })); + mkdirSync(path.join(configHome, "firstdraft")); + writeFileSync( + path.join(configHome, "firstdraft", "credentials.json"), + `{"canary": "${SAVED_PRODUCTION_TOKEN}"`, + ); + const result = await invoke(["plan", "push"], { + cwd: projectDirectory(context, PRODUCTION), + apiToken: "", + stagingApiToken: "", + env: { XDG_CONFIG_HOME: configHome }, + }); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).error, "authentication_required"); + assert.doesNotMatch(result.stderr, /canary/); +}); + +/** + * @param {import("node:test").TestContext} context + * @param {Record} tokens + */ +function savedLogins(context, tokens) { + const configHome = mkdtempSync(path.join(tmpdir(), "firstdraft-saved-")); + context.after(() => rmSync(configHome, { recursive: true, force: true })); + mkdirSync(path.join(configHome, "firstdraft")); + writeFileSync( + path.join(configHome, "firstdraft", "credentials.json"), + JSON.stringify({ + format: "firstdraft.cli-credentials/1", + origins: Object.fromEntries( + Object.entries(tokens).map(([origin, token]) => [ + origin, + { + access_token: token, + token_type: "Bearer", + created_at: "2026-09-29T12:00:00.000Z", + }, + ]), + ), + }), + ); + return { XDG_CONFIG_HOME: configHome }; +} + /** @param {import("node:test").TestContext} context @param {string} [origin] */ function projectDirectory(context, origin) { const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-environment-")); @@ -230,6 +370,7 @@ async function invoke(argv, options = {}) { stderr: { write: (text) => (stderr += text) }, apiToken: PRODUCTION_TOKEN, stagingApiToken: STAGING_TOKEN, + env: { XDG_CONFIG_HOME: "/nonexistent/firstdraft-test-config" }, fetchFunction: async () => assert.fail("No network request was expected"), ...options, }); diff --git a/test/cli.test.js b/test/cli.test.js index 84a227d..dd45689 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -17,6 +17,8 @@ Usage: Commands: compilation Inspect and download Compilations generate Generate local values + login Log in to First Draft and save a token + logout Revoke and remove the saved token plan Work with Foundation Plans Options: diff --git a/test/credentials.test.js b/test/credentials.test.js new file mode 100644 index 0000000..be621d7 --- /dev/null +++ b/test/credentials.test.js @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CredentialsInvalidError, + CredentialsLockedError, + credentialsPath, + deleteCredential, + readCredentials, + storedTokenReader, + writeCredential, +} from "../src/credentials.js"; + +const ORIGIN = "https://firstdraft.com"; +const OTHER = "http://127.0.0.1:3000"; + +test("the credentials path follows XDG_CONFIG_HOME, else ~/.config", () => { + assert.equal( + credentialsPath({ env: { XDG_CONFIG_HOME: "/xdg" }, homedir: () => "/h" }), + path.join("/xdg", "firstdraft", "credentials.json"), + ); + for (const XDG_CONFIG_HOME of [undefined, "", "relative/config"]) { + assert.equal( + credentialsPath({ env: { XDG_CONFIG_HOME }, homedir: () => "/home/u" }), + path.join("/home/u", ".config", "firstdraft", "credentials.json"), + ); + } +}); + +test("a missing file reads as empty and writes create private files atomically", (context) => { + const store = temporaryStore(context); + const empty = readCredentials(store); + assert.equal(empty.format, "firstdraft.cli-credentials/1"); + assert.deepEqual(Object.keys(empty.origins), []); + + writeCredential(store, ORIGIN, entry("fd_one")); + writeCredential(store, OTHER, entry("fd_two")); + writeCredential(store, ORIGIN, entry("fd_three")); + + const file = credentialsPath(store); + if (process.platform !== "win32") { + assert.equal(statSync(file).mode & 0o777, 0o600); + assert.equal(statSync(path.dirname(file)).mode & 0o777, 0o700); + } + assert.deepEqual(readdirSync(path.dirname(file)), ["credentials.json"]); + assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { + format: "firstdraft.cli-credentials/1", + origins: { [ORIGIN]: entry("fd_three"), [OTHER]: entry("fd_two") }, + }); + + assert.equal(deleteCredential(store, ORIGIN), true); + assert.equal(deleteCredential(store, ORIGIN), false); + assert.deepEqual(Object.keys(readCredentials(store).origins), [OTHER]); +}); + +test("a failed write leaves the previous file and no temporary file", (context) => { + const store = temporaryStore(context); + writeCredential(store, ORIGIN, entry("fd_before")); + const file = credentialsPath(store); + const before = readFileSync(file, "utf8"); + + const failing = { + ...store, + fileSystem: { + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, + /** @type {typeof import("node:fs").renameSync} */ + renameSync: () => { + throw Object.assign(new Error("Injected rename failure"), { + code: "EIO", + }); + }, + }, + }; + assert.throws( + () => writeCredential(failing, ORIGIN, entry("fd_after")), + /Injected/, + ); + assert.equal(readFileSync(file, "utf8"), before); + assert.deepEqual(readdirSync(path.dirname(file)), ["credentials.json"]); +}); + +test("a delete with a token removes the entry only while it still holds that token", (context) => { + const store = temporaryStore(context); + writeCredential(store, ORIGIN, entry("fd_newer")); + + assert.equal( + deleteCredential(store, ORIGIN, { accessToken: "fd_older" }), + false, + ); + assert.deepEqual(readCredentials(store).origins[ORIGIN], entry("fd_newer")); + assert.equal( + deleteCredential(store, ORIGIN, { accessToken: "fd_newer" }), + true, + ); + assert.deepEqual(Object.keys(readCredentials(store).origins), []); +}); + +test("an update waits for another update's lock instead of interleaving", (context) => { + const store = temporaryStore(context); + writeCredential(store, ORIGIN, entry("fd_before")); + const file = credentialsPath(store); + /** @type {unknown} */ + let concurrentFailure; + + // The concurrent update starts after the outer one has read the store but + // before it renames its replacement into place. + writeCredential( + { + ...store, + fileSystem: { + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + /** @type {typeof writeFileSync} */ + writeFileSync: (target, data, options) => { + if ( + String(target).endsWith(".tmp") && + concurrentFailure === undefined + ) { + try { + writeCredential( + { ...store, lockTimeoutMs: 50 }, + OTHER, + entry("fd_other"), + ); + } catch (error) { + concurrentFailure = error; + } + } + return writeFileSync(target, data, options); + }, + }, + }, + ORIGIN, + entry("fd_after"), + ); + + assert(concurrentFailure instanceof CredentialsLockedError); + assert.deepEqual(Object.keys(readCredentials(store).origins), [ORIGIN]); + assert.deepEqual(readdirSync(path.dirname(file)), ["credentials.json"]); + + writeCredential(store, OTHER, entry("fd_other")); + assert.deepEqual( + { ...readCredentials(store).origins }, + { + [ORIGIN]: entry("fd_after"), + [OTHER]: entry("fd_other"), + }, + ); +}); + +test("a held lock times out without changing the file, however old the lock is", (context) => { + const store = temporaryStore(context); + writeCredential(store, ORIGIN, entry("fd_before")); + const file = credentialsPath(store); + const lock = path.join(path.dirname(file), "credentials.json.lock"); + const before = readFileSync(file, "utf8"); + + writeFileSync(lock, "12345\n"); + // Age alone never makes a lock removable: two updates reclaiming the same + // abandoned lock could both proceed and drop each other's entries. + const abandoned = new Date(Date.now() - 24 * 60 * 60 * 1000); + for (const time of [new Date(), abandoned]) { + utimesSync(lock, time, time); + assert.throws( + () => + writeCredential({ ...store, lockTimeoutMs: 50 }, OTHER, entry("fd_x")), + CredentialsLockedError, + ); + assert.throws( + () => deleteCredential({ ...store, lockTimeoutMs: 50 }, ORIGIN), + CredentialsLockedError, + ); + assert.equal(readFileSync(file, "utf8"), before); + assert.equal(readFileSync(lock, "utf8"), "12345\n"); + } + + rmSync(lock); + writeCredential({ ...store, lockTimeoutMs: 50 }, OTHER, entry("fd_other")); + assert.deepEqual(Object.keys(readCredentials(store).origins), [ + ORIGIN, + OTHER, + ]); + assert.deepEqual(readdirSync(path.dirname(file)), ["credentials.json"]); +}); + +test("malformed credentials are rejected rather than guessed", (context) => { + const store = temporaryStore(context); + const file = credentialsPath(store); + mkdirSync(path.dirname(file), { recursive: true }); + for (const source of [ + "not json", + "[]", + '{"format":"firstdraft.cli-credentials/2","origins":{}}', + '{"format":"firstdraft.cli-credentials/1"}', + '{"format":"firstdraft.cli-credentials/1","origins":{"https://x.test":{"access_token":""}}}', + Buffer.from([0xff, 0xfe]), + ]) { + writeFileSync(file, source); + assert.throws(() => readCredentials(store), CredentialsInvalidError); + assert.deepEqual(Object.keys(storedTokenReader(store)()), []); + } +}); + +test("a __proto__ origin key stays inert", (context) => { + const store = temporaryStore(context); + const file = credentialsPath(store); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync( + file, + JSON.stringify({ + format: "firstdraft.cli-credentials/1", + origins: JSON.parse( + `{"__proto__": ${JSON.stringify(entry("fd_proto"))}, "${ORIGIN}": ${JSON.stringify(entry("fd_real"))}}`, + ), + }), + ); + const tokens = storedTokenReader(store)(); + assert.equal(tokens[ORIGIN], "fd_real"); + assert.equal(Object.getPrototypeOf(tokens), null); + assert.equal( + /** @type {Record} */ ({}).access_token, + undefined, + ); +}); + +/** @param {import("node:test").TestContext} context */ +function temporaryStore(context) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-credentials-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return { + env: { XDG_CONFIG_HOME: path.join(directory, "config") }, + homedir: () => assert.fail("The home directory must not be used"), + }; +} + +/** @param {string} token */ +function entry(token) { + return { + access_token: token, + token_type: "Bearer", + created_at: "2026-09-29T12:00:00.000Z", + }; +} diff --git a/test/login.test.js b/test/login.test.js new file mode 100644 index 0000000..c626c72 --- /dev/null +++ b/test/login.test.js @@ -0,0 +1,842 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, + mkdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const PRODUCTION = "https://firstdraft.com"; +const STAGING = "https://staging.firstdraft.com"; +const TOKEN = "fd_canary-login-token"; +const DEVICE_CODE = "canary-device-code"; +const CODE = "canary-authorization-code"; + +const LOGIN_HELP = `First Draft CLI + +Usage: + firstdraft login [--interactive] + +Options: + --staging Log in to staging + -i, --interactive Approve with a code on another device (no local browser) + --device Same as --interactive + -h, --help Show help + +Environment: + FIRSTDRAFT_API_URL Log in to a custom API origin + XDG_CONFIG_HOME Credentials directory base (default: ~/.config) + +By default the command prints a URL to open in a browser on this machine, then +waits up to five minutes for First Draft to redirect to a one-time listener on +127.0.0.1. The token is saved for the selected origin in +firstdraft/credentials.json under the configuration directory and is never +printed. Token environment variables take precedence over a saved login. +`; + +test("login help is printed on stdout", async (context) => { + for (const argv of [ + ["login", "--help"], + ["login", "-h"], + ]) { + const result = await invoke(context, argv); + assert.deepEqual( + { status: result.status, stdout: result.stdout, stderr: result.stderr }, + { status: 0, stdout: LOGIN_HELP, stderr: "" }, + ); + } +}); + +test("login rejects invalid, repeated, and conflicting options", async (context) => { + for (const argv of [ + ["login", "extra"], + ["login", "--canary-secret-option"], + ["login", "-i", "-i"], + ["login", "--interactive", "--interactive"], + ["login", "-i", "--device"], + ["login", "--interactive", "--device"], + ["login", "--staging", "--staging"], + ["logout", "extra"], + ["logout", "--interactive"], + ]) { + const result = await invoke(context, argv, { + startLoopbackServer: async () => assert.fail("No server was expected"), + }); + assert.equal(result.status, 2, argv.join(" ")); + assert.equal(result.stdout, ""); + assert.equal(errorEnvelope(result.stderr).error, "invalid_arguments"); + assert.doesNotMatch(result.stderr, /canary/); + } +}); + +test("browser login exchanges a PKCE code from the loopback callback and saves the token", async (context) => { + /** @type {URL | undefined} */ + let authorization; + /** @type {ReturnType | undefined} */ + let callbackResponse; + /** @type {string[]} */ + const events = []; + const configHome = temporaryConfigHome(context); + const result = await invoke(context, ["login"], { + configHome, + hostname: () => "canary-laptop\u001b[31m", + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (!url) return; + authorization = url; + callbackResponse = callback(url, { code: CODE }).then((page) => { + events.push( + `page:${existsSync(path.join(configHome, "firstdraft", "credentials.json")) ? "saved" : "unsaved"}`, + ); + return page; + }); + }, + fetchFunction: async (input, init) => { + events.push("exchange"); + const endpoint = new URL(String(input)); + assert.equal(endpoint.href, `${PRODUCTION}/oauth/token`); + assert.equal(init?.method, "POST"); + assert.equal( + new Headers(init?.headers).get("content-type"), + "application/x-www-form-urlencoded", + ); + assert(authorization); + const form = new URLSearchParams(String(init?.body)); + assert.equal(form.get("grant_type"), "authorization_code"); + assert.equal(form.get("code"), CODE); + assert.equal(form.get("client_id"), "firstdraft-cli"); + assert.equal( + form.get("redirect_uri"), + authorization.searchParams.get("redirect_uri"), + ); + const verifier = form.get("code_verifier"); + assert.match(String(verifier), /^[A-Za-z0-9_-]{43}$/); + assert.equal( + createHash("sha256").update(String(verifier)).digest("base64url"), + authorization.searchParams.get("code_challenge"), + ); + return tokenResponse(); + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `Logged in to ${PRODUCTION}\n`); + assertNoSecrets(result); + + assert(authorization); + assert.equal(authorization.origin, PRODUCTION); + assert.equal(authorization.pathname, "/oauth/authorize"); + const params = authorization.searchParams; + assert.equal(params.get("response_type"), "code"); + assert.equal(params.get("client_id"), "firstdraft-cli"); + assert.equal(params.get("code_challenge_method"), "S256"); + assert.match(String(params.get("code_challenge")), /^[A-Za-z0-9_-]{43}$/); + assert.match(String(params.get("state")), /^[A-Za-z0-9_-]{43}$/); + assert.equal(params.get("device_name"), "canary-laptop[31m"); + assert.match( + String(params.get("redirect_uri")), + /^http:\/\/127\.0\.0\.1:\d+\/callback$/, + ); + + const page = await callbackResponse; + assert(page); + assert.equal(page.status, 200); + assert.match(page.body, /

Login complete<\/h1>/); + assert.match(page.body, /close this tab/); + assert.match(page.contentType, /^text\/html/); + assertStyledUnderCsp(page); + assert.deepEqual( + events, + ["exchange", "page:saved"], + "the browser is answered only after the exchange and credential write", + ); + + await assert.rejects( + fetch( + `${params.get("redirect_uri")}?code=late&state=${params.get("state")}`, + ), + "the loopback server must be closed after login", + ); + + const credentialsFile = path.join( + result.configHome, + "firstdraft", + "credentials.json", + ); + if (process.platform !== "win32") { + assert.equal(statSync(credentialsFile).mode & 0o777, 0o600); + assert.equal(statSync(path.dirname(credentialsFile)).mode & 0o777, 0o700); + } + assert.deepEqual(JSON.parse(readFileSync(credentialsFile, "utf8")), { + format: "firstdraft.cli-credentials/1", + origins: { + [PRODUCTION]: { + access_token: TOKEN, + token_type: "Bearer", + created_at: "2026-09-29T12:00:00.000Z", + }, + }, + }); + assert.deepEqual(readdirSync(path.dirname(credentialsFile)), [ + "credentials.json", + ]); +}); + +test("the loopback listener rejects stray requests and a wrong state without ending the wait", async (context) => { + /** @type {number[]} */ + const strayStatuses = []; + const result = await invoke(context, ["login"], { + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (!url) return; + const redirect = String(url.searchParams.get("redirect_uri")); + const state = String(url.searchParams.get("state")); + void (async () => { + const origin = new URL(redirect).origin; + for (const target of [ + `${origin}/favicon.ico`, + `${redirect}?code=canary-injected-code`, + `${redirect}?code=canary-injected-code&state=wrong`, + `${redirect}?code=canary-injected-code&state=${state}&state=${state}`, + `${redirect}?error=access_denied&state=wrong`, + ]) { + const response = await fetch(target); + await response.text(); + strayStatuses.push(response.status); + } + const accepted = await callback(url, { code: CODE }); + assert.equal(accepted.status, 200); + })(); + }, + fetchFunction: async (_input, init) => { + const form = new URLSearchParams(String(init?.body)); + assert.equal(form.get("code"), CODE); + return tokenResponse(); + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(strayStatuses, [404, 400, 400, 400, 400]); + assertNoSecrets(result); +}); + +test("the loopback listener rejects a request with a foreign Host header", async (context) => { + const { startLoopbackServer } = await import("../src/oauth.js"); + const listener = await startLoopbackServer({ state: "expected-state" }); + context.after(() => listener.close()); + const { request } = await import("node:http"); + const redirect = new URL(listener.redirectUri); + const status = await new Promise((resolve, reject) => { + const outgoing = request( + { + host: redirect.hostname, + port: redirect.port, + path: "/callback?code=x&state=expected-state", + headers: { Host: "attacker.example" }, + }, + (response) => { + response.resume(); + resolve(response.statusCode); + }, + ); + outgoing.on("error", reject); + outgoing.end(); + }); + assert.equal(status, 400); + const settled = await Promise.race([ + listener.result.then(() => true), + new Promise((resolve) => { + setTimeout(resolve, 50, false); + }), + ]); + assert.equal(settled, false); +}); + +test("browser login times out after five minutes and closes the listener", async (context) => { + /** @type {number[]} */ + const delays = []; + let redirect = ""; + const result = await invoke(context, ["login"], { + loginSleep: async (delayMs) => { + delays.push(delayMs); + }, + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) redirect = String(url.searchParams.get("redirect_uri")); + }, + }); + + assert.equal(result.status, 1); + assert.deepEqual(delays, [5 * 60 * 1000]); + assert.equal(errorEnvelope(result.stderr).error, "authorization_expired"); + assert.equal(result.stdout, ""); + assert.notEqual(redirect, ""); + await assert.rejects(fetch(`${redirect}?code=late&state=late`)); + assertNoCredentialsFile(result.configHome); +}); + +test("browser login reports a denied authorization", async (context) => { + /** @type {ReturnType | undefined} */ + let callbackResponse; + const result = await invoke(context, ["login"], { + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) callbackResponse = callback(url, { error: "access_denied" }); + }, + }); + + const page = await callbackResponse; + assert(page); + assert.match(page.body, /

Login cancelled<\/h1>/); + assertStyledUnderCsp(page); + + assert.equal(result.status, 1); + assert.deepEqual(errorEnvelope(result.stderr), { + error: "authorization_denied", + detail: + "The login was denied in the browser. No credential was saved. Run 'firstdraft login' again to retry.", + }); + assertNoCredentialsFile(result.configHome); +}); + +test("browser login reports token endpoint failures without printing responses", async (context) => { + /** @type {{response: Response, reason?: string, status: number}[]} */ + const cases = [ + { + response: jsonResponse(400, { + error: "invalid_grant", + error_description: "canary description", + }), + reason: "invalid_grant", + status: 400, + }, + { response: jsonResponse(200, { token_type: "Bearer" }), status: 200 }, + { + response: jsonResponse(200, { access_token: TOKEN, token_type: "mac" }), + status: 200, + }, + { + response: new Response("canary", { status: 500 }), + status: 500, + }, + ]; + for (const { response, ...expected } of cases) { + /** @type {ReturnType | undefined} */ + let callbackResponse; + const result = await invoke(context, ["login"], { + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) callbackResponse = callback(url, { code: CODE }); + }, + fetchFunction: async () => response, + }); + const page = await callbackResponse; + assert(page); + assert.match( + page.body, + /

Login failed<\/h1>/, + "the browser must not report success when the exchange fails", + ); + assert.doesNotMatch(page.body, /canary/); + assert.equal(result.status, 1); + const envelope = errorEnvelope(result.stderr); + assert.equal(envelope.error, "login_failed"); + assert.equal(envelope.reason, expected.reason); + assert.equal(envelope.status, expected.status); + assertNoSecrets(result); + assertNoCredentialsFile(result.configHome); + } +}); + +test("browser login reports a network failure as login_failed", async (context) => { + const result = await invoke(context, ["login"], { + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) void callback(url, { code: CODE }); + }, + fetchFunction: async () => { + throw new TypeError("canary network failure"); + }, + }); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).error, "login_failed"); + assertNoSecrets(result); +}); + +test("login selects staging, a custom origin, or production like other commands", async (context) => { + /** @type {{argv: string[], apiUrl?: string, origin: string}[]} */ + const cases = [ + { argv: ["--staging", "login"], origin: STAGING }, + { argv: ["login", "--staging"], apiUrl: `${STAGING}/`, origin: STAGING }, + { + argv: ["login"], + apiUrl: "http://127.0.0.1:3000/", + origin: "http://127.0.0.1:3000", + }, + { argv: ["login"], origin: PRODUCTION }, + ]; + for (const { argv, apiUrl, origin } of cases) { + /** @type {string[]} */ + const origins = []; + const result = await invoke(context, argv, { + apiUrl, + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (!url) return; + origins.push(url.origin); + void callback(url, { code: CODE }); + }, + fetchFunction: async (input) => { + origins.push(new URL(String(input)).origin); + return tokenResponse(); + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(origins, [origin, origin]); + assert.equal(result.stdout, `Logged in to ${origin}\n`); + assert.deepEqual(Object.keys(storedCredentials(result.configHome)), [ + origin, + ]); + } +}); + +test("login rejects a staging conflict or invalid URL before any request", async (context) => { + /** @type {[string[], string][]} */ + const cases = [ + [["--staging", "login"], "https://canary-conflict.test"], + [["login", "--staging"], PRODUCTION], + [["login"], "ftp://canary.test"], + [["logout", "--staging"], PRODUCTION], + ]; + for (const [argv, apiUrl] of cases) { + const result = await invoke(context, argv, { + apiUrl, + startLoopbackServer: async () => assert.fail("No server was expected"), + }); + assert.equal(result.status, 2); + assert.equal(errorEnvelope(result.stderr).error, "invalid_configuration"); + assert.doesNotMatch(result.stderr, /canary/); + } +}); + +test("login keeps credentials for other origins", async (context) => { + const configHome = temporaryConfigHome(context); + writeCredentials(configHome, { + [STAGING]: entry("fd_canary-staging-token"), + [PRODUCTION]: entry("fd_canary-old-token"), + }); + const result = await invoke(context, ["login"], { + configHome, + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) void callback(url, { code: CODE }); + }, + fetchFunction: async () => tokenResponse(), + }); + assert.equal(result.status, 0, result.stderr); + const stored = storedCredentials(configHome); + assert.equal(stored[STAGING]?.access_token, "fd_canary-staging-token"); + assert.equal(stored[PRODUCTION]?.access_token, TOKEN); +}); + +test("login refuses an unusable credentials file before contacting First Draft", async (context) => { + const configHome = temporaryConfigHome(context); + mkdirSync(path.join(configHome, "firstdraft"), { recursive: true }); + const file = path.join(configHome, "firstdraft", "credentials.json"); + for (const source of ["{canary", '{"format":"other","origins":{}}']) { + writeFileSync(file, source); + const result = await invoke(context, ["login"], { + configHome, + startLoopbackServer: async () => assert.fail("No server was expected"), + }); + assert.equal(result.status, 1); + assert.deepEqual(errorEnvelope(result.stderr), { + error: "login_failed", + detail: + "The credentials file could not be read. No network request was made. Repair or remove it, then run 'firstdraft login' again.", + reason: "credentials_invalid", + phase: "read", + credentials_path: file, + }); + assert.equal(readFileSync(file, "utf8"), source); + } +}); + +test("login reports an issued but unsaved token for browser and device flows", async (context) => { + for (const device of [false, true]) { + const configHome = temporaryConfigHome(context); + const origins = { + [PRODUCTION]: entry("fd_canary-old-token"), + [STAGING]: entry("fd_canary-staging-token"), + }; + writeCredentials(configHome, origins); + const clock = fakeClock(); + let exchanges = 0; + /** @type {ReturnType | undefined} */ + let callbackResponse; + const result = await invoke(context, device ? ["login", "-i"] : ["login"], { + configHome, + ...(device ? { loginSleep: clock.sleep, loginNow: clock.now } : {}), + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) callbackResponse = callback(url, { code: CODE }); + }, + credentialsFileSystem: { + ...fs, + renameSync: () => { + throw Object.assign(new Error(TOKEN), { code: "EACCES" }); + }, + }, + fetchFunction: async (input) => { + if (new URL(String(input)).pathname === "/oauth/device_authorization") + return deviceAuthorizationResponse(); + exchanges += 1; + return tokenResponse(); + }, + }); + if (!device) { + const page = await callbackResponse; + assert(page); + assert.match(page.body, /

Login failed<\/h1>/); + } + assert.equal(exchanges, 1); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + const envelope = errorEnvelope(result.stderr); + assert.equal(envelope.error, "login_failed"); + assert.equal(envelope.reason, "credentials_unavailable"); + assert.equal(envelope.phase, "write"); + assert.equal( + envelope.credentials_path, + path.join(configHome, "firstdraft", "credentials.json"), + ); + assert.match(envelope.detail, /issued a token, but it could not be saved/); + assert.match(envelope.detail, /https:\/\/firstdraft\.com\/api-tokens/); + assert.deepEqual(storedCredentials(configHome), origins); + assert.deepEqual(readdirSync(path.join(configHome, "firstdraft")), [ + "credentials.json", + ]); + assertNoSecrets(result); + } +}); + +test("login notes when an environment token overrides the saved login", async (context) => { + const result = await invoke(context, ["login"], { + apiToken: "fd_canary-environment-token", + onStderr: (text) => { + const url = authorizationUrlIn(text); + if (url) void callback(url, { code: CODE }); + }, + fetchFunction: async () => tokenResponse(), + }); + assert.equal(result.status, 0); + assert.match( + result.stderr, + /Note: FIRSTDRAFT_API_TOKEN is set and takes precedence over the saved login for https:\/\/firstdraft\.com\.\n$/, + ); + assertNoSecrets(result); +}); + +test("device login prints the code, honors interval and slow_down, and saves the token", async (context) => { + for (const flag of ["-i", "--interactive", "--device"]) { + const clock = fakeClock(); + /** @type {string[]} */ + const polls = []; + const outcomes = ["authorization_pending", "slow_down", null]; + const result = await invoke(context, ["login", flag], { + hostname: () => "canary-host", + loginSleep: clock.sleep, + loginNow: clock.now, + startLoopbackServer: async () => assert.fail("No server was expected"), + fetchFunction: async (input, init) => { + const endpoint = new URL(String(input)); + const form = new URLSearchParams(String(init?.body)); + assert.equal(form.get("client_id"), "firstdraft-cli"); + if (endpoint.pathname === "/oauth/device_authorization") { + assert.equal(form.get("device_name"), "canary-host"); + return deviceAuthorizationResponse(); + } + assert.equal(endpoint.href, `${PRODUCTION}/oauth/token`); + assert.equal( + form.get("grant_type"), + "urn:ietf:params:oauth:grant-type:device_code", + ); + assert.equal(form.get("device_code"), DEVICE_CODE); + polls.push(String(clock.now())); + const outcome = outcomes.shift(); + return outcome + ? jsonResponse(400, { error: outcome }) + : tokenResponse(); + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `Logged in to ${PRODUCTION}\n`); + assert.deepEqual(clock.delays, [5000, 5000, 10000]); + assert.equal(polls.length, 3); + assert.match(result.stderr, /https:\/\/firstdraft\.com\/device\n/); + assert.match(result.stderr, /enter the code: BCDF-GHJK\n/); + assert.match( + result.stderr, + /https:\/\/firstdraft\.com\/device\?user_code=BCDF-GHJK\n/, + ); + assertNoSecrets(result); + assert.doesNotMatch(result.stderr, /canary-device-code/); + assert.equal( + storedCredentials(result.configHome)[PRODUCTION]?.access_token, + TOKEN, + ); + } +}); + +test("device login reports denial and server-side expiry", async (context) => { + for (const [code, error] of [ + ["access_denied", "authorization_denied"], + ["expired_token", "authorization_expired"], + ["invalid_grant", "login_failed"], + ]) { + const clock = fakeClock(); + const result = await invoke(context, ["login", "-i"], { + loginSleep: clock.sleep, + loginNow: clock.now, + fetchFunction: async (input) => + new URL(String(input)).pathname === "/oauth/device_authorization" + ? deviceAuthorizationResponse() + : jsonResponse(400, { error: code }), + }); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).error, error); + assert.equal(result.stdout, ""); + assertNoCredentialsFile(result.configHome); + } +}); + +test("device login stops polling at expires_in", async (context) => { + const clock = fakeClock(); + let polls = 0; + const result = await invoke(context, ["login", "-i"], { + loginSleep: clock.sleep, + loginNow: clock.now, + fetchFunction: async (input) => { + if (new URL(String(input)).pathname === "/oauth/device_authorization") { + return deviceAuthorizationResponse({ expires_in: 12, interval: 5 }); + } + polls += 1; + return jsonResponse(400, { error: "authorization_pending" }); + }, + }); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).error, "authorization_expired"); + assert.equal(polls, 2); + assert.deepEqual(clock.delays, [5000, 5000, 5000]); +}); + +test("device login rejects an invalid device authorization response", async (context) => { + for (const body of [ + { ...deviceAuthorizationBody(), user_code: "BCDF\u001b[31m" }, + { ...deviceAuthorizationBody(), verification_uri: "javascript:alert(1)" }, + { ...deviceAuthorizationBody(), expires_in: 0 }, + { error: "invalid_client" }, + ]) { + const result = await invoke(context, ["login", "-i"], { + fetchFunction: async () => + jsonResponse("error" in body ? 400 : 200, body), + loginSleep: async () => assert.fail("No polling was expected"), + }); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).error, "login_failed"); + assert.equal(result.stderr.includes("\u001b"), false); + assert.doesNotMatch(result.stderr, /javascript/); + } +}); + +/** + * @param {import("node:test").TestContext} context + * @param {readonly string[]} argv + * @param {Partial & {configHome?: string, onStderr?: (text: string) => void}} [options] + */ +async function invoke(context, argv, options = {}) { + const { + configHome = temporaryConfigHome(context), + onStderr, + ...rest + } = options; + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { + write: (text) => { + stderr += text; + onStderr?.(text); + }, + }, + apiToken: "", + stagingApiToken: "", + env: { XDG_CONFIG_HOME: configHome }, + homedir: () => assert.fail("The home directory must not be used"), + hostname: () => "test-host", + loginNow: () => Date.parse("2026-09-29T12:00:00.000Z"), + fetchFunction: async () => assert.fail("No network request was expected"), + ...rest, + }); + return { status, stdout, stderr, configHome }; +} + +/** @param {import("node:test").TestContext} context */ +function temporaryConfigHome(context) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-login-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +/** @param {string} text */ +function authorizationUrlIn(text) { + const match = /https?:\/\/\S+\/oauth\/authorize\?\S+/.exec(text); + return match ? new URL(match[0]) : undefined; +} + +/** + * @param {URL} authorization + * @param {Record} params + */ +async function callback(authorization, params) { + const redirect = new URL( + String(authorization.searchParams.get("redirect_uri")), + ); + redirect.search = new URLSearchParams({ + ...params, + state: String(authorization.searchParams.get("state")), + }).toString(); + const response = await fetch(redirect); + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + csp: response.headers.get("content-security-policy") ?? "", + body: await response.text(), + }; +} + +/** + * The page's only stylesheet must be allowed by its CSP hash, because + * `default-src 'none'` blocks inline styles otherwise. + * + * @param {{csp: string, body: string}} page + */ +function assertStyledUnderCsp(page) { + const style = /