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 = /