diff --git a/README.md b/README.md index 040acad..65c4802 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,20 @@ npm install @doist/cli-core ## What's in it -| Module | Key exports | Purpose | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `runOAuthFlow`, `refreshAccessToken`, `createPkceProvider`, `createSecureStore`, `createKeyringTokenStore`, `migrateLegacyAuth`, `persistBundle`, `bundleFromExchange`, PKCE helpers, `AuthProvider` / `TokenStore` / `TokenBundle` / `ActiveBundleSnapshot` / `RefreshInput` / `AccountRef` / `SecureStore` / `UserRecordStore` types, `AttachLogoutRevokeContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`), a thin cross-platform OS-keyring wrapper (`createSecureStore`), and a multi-account keyring-backed `TokenStore` (`createKeyringTokenStore`) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). The store contract supports an optional `setBundle(account, bundle)` write method (required on `KeyringTokenStore`) so consumers that need refresh-token persistence can opt in via `TokenBundle`; `active()` stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. `AuthProvider` and `TokenStore` remain the escape hatches for DCR or fully bespoke backends. `logout` / `status` / `token` always attach `--user ` and thread the parsed ref to `store.active(ref)` (and `store.clear(ref)` on `logout`). `commander` (when using the attachers), `open` (browser launch), `@napi-rs/keyring` (when using `createSecureStore` or the keyring `TokenStore`), and `oauth4webapi` (when a consumer opts into silent refresh) are optional peer/optional deps. | -| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | -| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | -| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | -| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | -| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | -| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | -| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | -| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | -| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | -| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | -| `testing` (subpath) | `describeEmptyMachineOutput` | Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering `--json` / `--ndjson` / human modes). | +| Module | Key exports | Purpose | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `runOAuthFlow`, `refreshAccessToken`, `createPkceProvider`, `createDcrProvider`, `createSecureStore`, `createKeyringTokenStore`, `migrateLegacyAuth`, `persistBundle`, `bundleFromExchange`, PKCE helpers, `AuthProvider` / `TokenStore` / `TokenBundle` / `ActiveBundleSnapshot` / `RefreshInput` / `AccountRef` / `SecureStore` / `UserRecordStore` types, `AttachLogoutRevokeContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`), the RFC 7591 Dynamic Client Registration flow (`createDcrProvider`), a thin cross-platform OS-keyring wrapper (`createSecureStore`), and a multi-account keyring-backed `TokenStore` (`createKeyringTokenStore`) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). The store contract supports an optional `setBundle(account, bundle)` write method (required on `KeyringTokenStore`) so consumers that need refresh-token persistence can opt in via `TokenBundle`; `active()` stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. `AuthProvider` and `TokenStore` remain the escape hatches for fully bespoke backends (device code, magic-link, …). `logout` / `status` / `token` always attach `--user ` and thread the parsed ref to `store.active(ref)` (and `store.clear(ref)` on `logout`). `commander` (when using the attachers), `open` (browser launch), `@napi-rs/keyring` (when using `createSecureStore` or the keyring `TokenStore`), and `oauth4webapi` (when a consumer opts into silent refresh or uses `createDcrProvider`) are optional peer/optional deps. | +| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | +| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | +| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | +| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | +| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | +| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | +| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | +| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | +| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | +| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | +| `testing` (subpath) | `describeEmptyMachineOutput` | Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering `--json` / `--ndjson` / human modes). | ## Usage @@ -124,7 +124,7 @@ The semver helpers (`parseVersion`, `compareVersions`, `isNewer`, `getInstallTag ### Auth (optional subpath) -Wire ` [auth] login` and the supporting OAuth runtime. cli-core ships the standard public-client PKCE flow (`createPkceProvider`) and the `attachLoginCommand` Commander helper that drives `runOAuthFlow` end-to-end. Bespoke flows (Dynamic Client Registration, device code, magic link, username / password) implement the `AuthProvider` interface directly — no cli-core release needed. Token storage is a `TokenStore` the consumer provides; cli-core does not ship a default. +Wire ` [auth] login` and the supporting OAuth runtime. cli-core ships the standard public-client PKCE flow (`createPkceProvider`), the RFC 7591 Dynamic Client Registration flow (`createDcrProvider`), and the `attachLoginCommand` Commander helper that drives `runOAuthFlow` end-to-end. Other bespoke flows (device code, magic link, username / password) implement the `AuthProvider` interface directly — no cli-core release needed. Token storage is a `TokenStore` the consumer provides; cli-core does not ship a default. #### Install @@ -171,6 +171,32 @@ attachLoginCommand(auth, { The `authorizeUrl` / `tokenUrl` / `clientId` resolvers may return `string` **or** `Promise` — so a consumer can resolve the base URL or client id asynchronously (reading config, prompting the user) without abandoning `createPkceProvider`. An injected `fetchImpl` is used for the token exchange **and** the refresh grant (threaded into `oauth4webapi` via its `customFetch`), so a custom transport — proxy dispatcher, decompression — applies on every OAuth call rather than being bypassed by the library's global `fetch`. +#### Quick start (Dynamic Client Registration) + +For providers that issue per-install `client_id` / `client_secret` via [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591). `createDcrProvider` registers in `prepare()`, then drives the standard PKCE authorize / token-exchange dance against the resulting client. Registration and token exchange run through [`oauth4webapi`](https://github.com/panva/oauth4webapi) (the same optional peer dep PKCE refresh uses — `npm install oauth4webapi`), so endpoints must be HTTPS and the registration endpoint must return RFC 7591-conformant `201` responses. + +```ts +import { attachLoginCommand, createDcrProvider } from '@doist/cli-core/auth' + +const provider = createDcrProvider({ + registrationUrl: 'https://example.com/oauth/register', + authorizeUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + clientMetadata: { + clientName: 'Example CLI', + clientUri: 'https://github.com/example/cli', + logoUri: 'https://example.com/logo.png', + applicationType: 'native', + tokenEndpointAuthMethod: 'client_secret_basic', // default + }, + validate: async ({ token }) => probeUser(token), +}) +``` + +The DCR-issued `client_id` (and `client_secret`, if returned) are stashed in the handshake and threaded through the rest of the flow. The server-returned `token_endpoint_auth_method` is authoritative (RFC 7591 §3.2.1) and overrides the configured one. By default the token exchange uses `Authorization: Basic` with form-url-encoded credentials (RFC 6749 §2.3.1); pass `tokenEndpointAuthMethod: 'client_secret_post'` to send credentials in the body instead, or `'none'` for a public-client registration. When the registration response carries no `client_secret`, the exchange falls back to a public-client POST regardless of the requested method. Any extra registration metadata (e.g. `software_statement`) goes in `clientMetadata.extra`. cli-core does **not** cache the registered client — each login mints a fresh one. + +Both `createPkceProvider` and `createDcrProvider` accept an optional `errorHints: string[]` that is prepended to every `CliError` they throw. Use it for CLI-specific remediation that should accompany every auth failure (e.g. `['Try again: tw auth login', 'Or set TWIST_API_TOKEN environment variable']`). Server-returned response bodies (for non-2xx replies) are appended after the user hints so the actionable hint stays at the top. + #### Sibling attachers (`logout` / `status` / `token`) The same registrar shape covers the other three auth subcommands. Each returns the new `Command` for chaining and shares the same `TokenStore` instance. @@ -310,7 +336,7 @@ Error contract: - `AUTH_REFRESH_TRANSIENT` — 5xx, network, non-JSON body, lock timeout. Caller may retry. - `AUTH_REFRESH_UNAVAILABLE` — refresh isn't possible in the current setup: no refresh token stored, the store doesn't implement **both** `activeBundle` and `setBundle` (a full bundle must be readable and persistable), the credential was removed mid-refresh, the provider doesn't implement `refreshToken`, or the optional `oauth4webapi` peer dep isn't installed. -The PKCE provider (`createPkceProvider`) implements `refreshToken` via the [`oauth4webapi`](https://github.com/panva/oauth4webapi) library, declared as an **optional peer dependency** — only CLIs that opt into refresh need to install it (`npm install oauth4webapi`). Providers built directly against the `AuthProvider` interface (e.g. DCR, device code) implement the `refreshToken?` hook themselves; the storage and helper contract is identical. +The PKCE provider (`createPkceProvider`) implements `refreshToken` via the [`oauth4webapi`](https://github.com/panva/oauth4webapi) library, declared as an **optional peer dependency** — only CLIs that opt into refresh or use `createDcrProvider` need to install it (`npm install oauth4webapi`). Providers built directly against the `AuthProvider` interface (e.g. device code) implement the `refreshToken?` hook themselves; the storage and helper contract is identical. #### Keyring primitive (`createSecureStore`) @@ -447,9 +473,9 @@ Account-selection resolvers (env > `--user` > default > single-only > error), `a A `TokenStore` MAY throw `CliError('AUTH_STORE_READ_FAILED', …)` from `active(ref)` when a matching record exists but the token itself can't be read (e.g. an OS keyring backing the store is offline). `attachLogoutCommand` catches this specific code on the explicit-ref path and proceeds with `clear(ref)` — local logout doesn't need the token, and the `revokeToken` hook is skipped because there's no token to send. Every other error from `active(ref)` (notably `ACCOUNT_NOT_FOUND` from a genuine ref miss, plus any consumer-thrown code) still propagates so a real miss isn't masked. Without `--user`, the logout pre-flight swallows any snapshot read failure so the local clear always runs. `attachStatusCommand` and `attachTokenViewCommand` propagate `AUTH_STORE_READ_FAILED` since they have no way to render or print without the token. -#### Custom `AuthProvider` (non-PKCE flows) +#### Custom `AuthProvider` (non-PKCE, non-DCR flows) -Implement `AuthProvider` directly for Dynamic Client Registration, device code, magic-link, etc. The four hooks fire in this order during `runOAuthFlow`: +Implement `AuthProvider` directly for device code, magic-link, username / password, or any other flow not covered by `createPkceProvider` / `createDcrProvider`. The four hooks fire in this order during `runOAuthFlow`: | Hook | When | Purpose | | --------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | @@ -493,6 +519,7 @@ Every failure in this subpath surfaces as a `CliError`: | `AUTH_OAUTH_FAILED` | Provider returned `?error=...`, the flow was aborted via `signal`, or the callback server stopped before completion. | | `AUTH_CALLBACK_TIMEOUT` | No valid callback within `timeoutMs` (default 3 minutes). | | `AUTH_PORT_BIND_FAILED` | Could not bind any port in `[preferredPort, preferredPort + portFallbackCount]`, or `--callback-port` was out of range. | +| `AUTH_DCR_FAILED` | `createDcrProvider` registration failed (network error, non-`201`, non-JSON body, response missing `client_id`, or the `oauth4webapi` peer dep isn't installed). | | `AUTH_TOKEN_EXCHANGE_FAILED` | Token endpoint network error, non-2xx response, non-JSON body, or missing `access_token`. | | `AUTH_STORE_WRITE_FAILED` | `TokenStore.set` threw a non-`CliError`. (`CliError`s thrown from `set` propagate unchanged.) | | `NOT_AUTHENTICATED` | `status` / `token` ran with an empty `TokenStore` (and no `onNotAuthenticated` callback for `status`). Default message: `'Not signed in.'`. | diff --git a/src/auth/errors.ts b/src/auth/errors.ts index 439e987..7624b36 100644 --- a/src/auth/errors.ts +++ b/src/auth/errors.ts @@ -7,6 +7,7 @@ export type AuthErrorCode = | 'AUTH_OAUTH_FAILED' | 'AUTH_CALLBACK_TIMEOUT' | 'AUTH_PORT_BIND_FAILED' + | 'AUTH_DCR_FAILED' | 'AUTH_TOKEN_EXCHANGE_FAILED' | 'AUTH_STORE_WRITE_FAILED' | 'AUTH_STORE_READ_FAILED' diff --git a/src/auth/index.ts b/src/auth/index.ts index 4616185..9da95d0 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -23,9 +23,15 @@ export type { GenerateVerifierOptions } from './pkce.js' export { bundleFromExchange, persistBundle } from './persist.js' export type { PersistBundleOptions } from './persist.js' export { createPkceProvider } from './providers/pkce.js' -export type { PkceLazyString, PkceProviderOptions } from './providers/pkce.js' +export type { OAuthLazyString, PkceLazyString, PkceProviderOptions } from './providers/pkce.js' export { refreshAccessToken } from './refresh.js' export type { RefreshAccessTokenOptions, RefreshAccessTokenResult } from './refresh.js' +export { createDcrProvider } from './providers/dcr.js' +export type { + DcrClientMetadata, + DcrProviderOptions, + DcrTokenEndpointAuthMethod, +} from './providers/dcr.js' export type { AccountRef, ActiveBundleSnapshot, diff --git a/src/auth/providers/dcr.test.ts b/src/auth/providers/dcr.test.ts new file mode 100644 index 0000000..c81fef8 --- /dev/null +++ b/src/auth/providers/dcr.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createDcrProvider } from './dcr.js' + +type Account = { id: string; label?: string } + +const respond = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + +/** RFC 7591 success: 201 Created. `client_secret_expires_at` is required when a secret is issued. */ +const registration = (body: Record): Response => + respond('client_secret' in body ? { client_secret_expires_at: 0, ...body } : body, 201) + +/** oauth4webapi requires `token_type` on a token response. */ +const token = (body: Record): Response => + respond({ token_type: 'bearer', ...body }) + +const validate = async () => ({ id: '1' }) as Account + +const REGISTRATION_URL = 'https://example.com/oauth/register' +const AUTHORIZE_URL = 'https://example.com/oauth/authorize' +const TOKEN_URL = 'https://example.com/oauth/token' +const REDIRECT_URI = 'http://localhost:8765/callback' + +type FetchCall = { url: string; init: RequestInit } + +function makeFetchRecorder(handler: (url: string) => Response): { + calls: FetchCall[] + fetchImpl: typeof fetch +} { + const calls: FetchCall[] = [] + const fetchImpl = ((url: RequestInfo | URL, init: RequestInit = {}) => { + const u = String(url) + calls.push({ url: u, init }) + return Promise.resolve(handler(u)) + }) as typeof fetch + return { calls, fetchImpl } +} + +const headersOf = (call: FetchCall): Headers => new Headers(call.init.headers as HeadersInit) +const bodyOf = (call: FetchCall): URLSearchParams => new URLSearchParams(call.init.body as string) + +describe('createDcrProvider', () => { + it('prepare POSTs RFC 7591 metadata, authorize uses the issued client_id, exchangeCode sends Basic auth', async () => { + const { calls, fetchImpl } = makeFetchRecorder((u) => + u === REGISTRATION_URL + ? registration({ client_id: 'issuedid', client_secret: 'issuedsecret' }) + : token({ access_token: 'tok-1', expires_in: 3600 }), + ) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { + clientName: 'Twist CLI', + clientUri: 'https://github.com/doist/twist-cli', + logoUri: 'https://example.com/logo.png', + applicationType: 'native', + }, + validate, + fetchImpl, + }) + + const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + expect(prepared.handshake).toEqual({ clientId: 'issuedid', clientSecret: 'issuedsecret' }) + + const regBody = JSON.parse(calls[0].init.body as string) as Record + expect(regBody).toMatchObject({ + client_name: 'Twist CLI', + client_uri: 'https://github.com/doist/twist-cli', + logo_uri: 'https://example.com/logo.png', + application_type: 'native', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_basic', + }) + + const authorize = await provider.authorize({ + redirectUri: REDIRECT_URI, + state: 'state-123', + scopes: ['user:read', 'threads:read'], + readOnly: false, + flags: {}, + handshake: prepared.handshake, + }) + const url = new URL(authorize.authorizeUrl) + expect(url.searchParams.get('client_id')).toBe('issuedid') + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI) + expect(url.searchParams.get('state')).toBe('state-123') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/) + expect(url.searchParams.get('scope')).toBe('user:read threads:read') + expect(typeof authorize.handshake.codeVerifier).toBe('string') + expect(authorize.handshake.clientSecret).toBe('issuedsecret') + + const result = await provider.exchangeCode({ + code: 'auth-code', + state: 'state-123', + redirectUri: REDIRECT_URI, + handshake: authorize.handshake, + }) + expect(result.accessToken).toBe('tok-1') + expect(result.expiresAt).toBeGreaterThan(Date.now()) + + const tokenCall = calls.find((c) => c.url === TOKEN_URL)! + // oauth4webapi form-url-encodes the credentials per RFC 6749 §2.3.1 + // before base64; alphanumeric id/secret round-trip unchanged. + expect(headersOf(tokenCall).get('authorization')).toBe( + `Basic ${Buffer.from('issuedid:issuedsecret', 'utf8').toString('base64')}`, + ) + const tokenBody = bodyOf(tokenCall) + expect(tokenBody.get('grant_type')).toBe('authorization_code') + expect(tokenBody.get('code')).toBe('auth-code') + expect(tokenBody.get('redirect_uri')).toBe(REDIRECT_URI) + expect(tokenBody.get('code_verifier')).toBe(authorize.handshake.codeVerifier as string) + expect(tokenBody.has('client_id')).toBe(false) + expect(tokenBody.has('client_secret')).toBe(false) + }) + + it('client_secret_post puts the secret in the body, omits the Authorization header, and forwards the auth method in the registration POST', async () => { + const { calls, fetchImpl } = makeFetchRecorder((u) => + u === REGISTRATION_URL + ? registration({ client_id: 'cid', client_secret: 'sec' }) + : token({ access_token: 'tok-2' }), + ) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'client_secret_post' }, + validate, + fetchImpl, + }) + + const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + const regBody = JSON.parse(calls[0].init.body as string) as Record + expect(regBody.token_endpoint_auth_method).toBe('client_secret_post') + + await provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: REDIRECT_URI, + handshake: { ...prepared.handshake, codeVerifier: 'v' }, + }) + + const tokenCall = calls.find((c) => c.url === TOKEN_URL)! + const tokenBody = bodyOf(tokenCall) + expect(headersOf(tokenCall).has('authorization')).toBe(false) + expect(tokenBody.get('client_id')).toBe('cid') + expect(tokenBody.get('client_secret')).toBe('sec') + }) + + it('falls back to public-client POST when registration omits client_secret even though client_secret_post was requested', async () => { + const { calls, fetchImpl } = makeFetchRecorder((u) => + u === REGISTRATION_URL + ? registration({ client_id: 'pub-cid' }) // server returned no client_secret + : token({ access_token: 'tok' }), + ) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + // Configured method asked for client_secret_post — but the registration + // came back without a secret, so the token request must still drop to + // public-client POST instead of sending a half-baked credential. + clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'client_secret_post' }, + validate, + fetchImpl, + }) + + const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + expect(prepared.handshake.clientSecret).toBeUndefined() + + await provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: REDIRECT_URI, + handshake: { ...prepared.handshake, codeVerifier: 'v' }, + }) + + const tokenCall = calls.find((c) => c.url === TOKEN_URL)! + const tokenBody = bodyOf(tokenCall) + expect(headersOf(tokenCall).has('authorization')).toBe(false) + expect(tokenBody.get('client_id')).toBe('pub-cid') + expect(tokenBody.has('client_secret')).toBe(false) + }) + + it("honours the server's token_endpoint_auth_method from the registration response over the configured one (RFC 7591 §3.2.1)", async () => { + // Configured: client_secret_basic. Server downgrades to client_secret_post. + // Effective method on the token request must follow the server. + const { calls, fetchImpl } = makeFetchRecorder((u) => + u === REGISTRATION_URL + ? registration({ + client_id: 'cid', + client_secret: 'sec', + token_endpoint_auth_method: 'client_secret_post', + }) + : token({ access_token: 'tok' }), + ) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'client_secret_basic' }, + validate, + fetchImpl, + }) + + const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + expect(prepared.handshake.tokenEndpointAuthMethod).toBe('client_secret_post') + + await provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: REDIRECT_URI, + handshake: { ...prepared.handshake, codeVerifier: 'v' }, + }) + + const tokenCall = calls.find((c) => c.url === TOKEN_URL)! + const tokenBody = bodyOf(tokenCall) + expect(headersOf(tokenCall).has('authorization')).toBe(false) + expect(tokenBody.get('client_id')).toBe('cid') + expect(tokenBody.get('client_secret')).toBe('sec') + }) + + it('tokenEndpointAuthMethod=none (or missing client_secret) sends client_id in the body and no Authorization header', async () => { + const { calls, fetchImpl } = makeFetchRecorder((u) => + u === REGISTRATION_URL + ? registration({ client_id: 'pub-cid' }) // public-client DCR: no client_secret + : token({ access_token: 'tok-3' }), + ) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'none' }, + validate, + fetchImpl, + }) + + const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + expect(prepared.handshake.clientSecret).toBeUndefined() + + await provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: REDIRECT_URI, + handshake: { ...prepared.handshake, codeVerifier: 'v' }, + }) + + const tokenCall = calls.find((c) => c.url === TOKEN_URL)! + const tokenBody = bodyOf(tokenCall) + expect(headersOf(tokenCall).has('authorization')).toBe(false) + expect(tokenBody.get('client_id')).toBe('pub-cid') + expect(tokenBody.has('client_secret')).toBe(false) + }) + + it('surfaces the server OAuth error from a failed registration as AUTH_DCR_FAILED, hints first', async () => { + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI' }, + validate, + errorHints: ['Re-run: cli auth login'], + fetchImpl: (() => + Promise.resolve(respond({ error: 'invalid_redirect_uri' }, 400))) as typeof fetch, + }) + await expect( + provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }), + ).rejects.toMatchObject({ + code: 'AUTH_DCR_FAILED', + hints: ['Re-run: cli auth login', 'invalid_redirect_uri'], + }) + }) + + it('DCR response missing client_id or returning non-JSON is AUTH_DCR_FAILED', async () => { + const make = (fetchImpl: typeof fetch) => + createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI' }, + validate, + fetchImpl, + }) + const cases: Array<() => Promise> = [ + () => Promise.resolve(respond({ scope: 'read' }, 201)), // 201 but no client_id + () => + Promise.resolve( + new Response('oops', { + status: 201, + headers: { 'Content-Type': 'text/html' }, + }), + ), + ] + for (const fetchImpl of cases) { + await expect( + make(fetchImpl as typeof fetch).prepare!({ + redirectUri: REDIRECT_URI, + flags: {}, + }), + ).rejects.toMatchObject({ code: 'AUTH_DCR_FAILED' }) + } + }) + + it('clientMetadata.extra fields appear in the registration POST body verbatim; named fields win on collisions', async () => { + const { calls, fetchImpl } = makeFetchRecorder(() => registration({ client_id: 'cid' })) + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { + clientName: 'CLI', + extra: { + software_statement: 'eyJhbGciOiJSUzI1NiJ9.test', + contacts: ['ops@example.com'], + client_name: 'should-be-overridden', + }, + }, + validate, + fetchImpl, + }) + await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }) + const body = JSON.parse(calls[0].init.body as string) as Record + expect(body.software_statement).toBe('eyJhbGciOiJSUzI1NiJ9.test') + expect(body.contacts).toEqual(['ops@example.com']) + expect(body.client_name).toBe('CLI') + }) + + it('maps an oauth4webapi load failure to AUTH_DCR_FAILED, prepending the provider errorHints', async () => { + // Force the lazy `import('oauth4webapi')` to reject by mocking the + // module to throw, then re-importing the provider so its memoised + // import resolves to the throwing mock. (vitest substitutes its own + // error for a factory throw, so the ERR_MODULE_NOT_FOUND-specific + // branch isn't reachable here — but the load-failure → AUTH_DCR_FAILED + // mapping and the errorHints-prepend contract are.) + vi.resetModules() + vi.doMock('oauth4webapi', () => { + throw new Error('boom') + }) + try { + const { createDcrProvider: freshCreate } = await import('./dcr.js') + const provider = freshCreate({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI' }, + validate, + errorHints: ['Re-run: cli auth login'], + fetchImpl: (() => + Promise.resolve(registration({ client_id: 'x' }))) as typeof fetch, + }) + await expect( + provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }), + ).rejects.toMatchObject({ + code: 'AUTH_DCR_FAILED', + hints: ['Re-run: cli auth login'], + }) + } finally { + vi.doUnmock('oauth4webapi') + vi.resetModules() + } + }) + + it('fails fast when the registration server selects an unsupported token_endpoint_auth_method', async () => { + const provider = createDcrProvider({ + registrationUrl: REGISTRATION_URL, + authorizeUrl: AUTHORIZE_URL, + tokenUrl: TOKEN_URL, + clientMetadata: { clientName: 'CLI' }, + validate, + fetchImpl: (() => + Promise.resolve( + registration({ + client_id: 'cid', + client_secret: 'sec', + token_endpoint_auth_method: 'private_key_jwt', + }), + )) as typeof fetch, + }) + await expect( + provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }), + ).rejects.toMatchObject({ code: 'AUTH_DCR_FAILED' }) + }) +}) diff --git a/src/auth/providers/dcr.ts b/src/auth/providers/dcr.ts new file mode 100644 index 0000000..ca6b014 --- /dev/null +++ b/src/auth/providers/dcr.ts @@ -0,0 +1,334 @@ +import type { AuthorizationServer, Client, ClientAuth } from 'oauth4webapi' + +import { getErrorMessage } from '../../errors.js' +import type { CliError } from '../../errors.js' +import type { AuthErrorCode } from '../errors.js' +import { deriveChallenge, generateVerifier } from '../pkce.js' +import type { + AuthAccount, + AuthProvider, + AuthorizeInput, + AuthorizeResult, + ExchangeInput, + ExchangeResult, + PrepareInput, + PrepareResult, + ValidateInput, +} from '../types.js' +import { + buildAuthError, + buildPkceAuthorizeUrl, + expiresAtFromExpiresIn, + loadOauth4webapi, + resolve, +} from './oauth.js' +import type { OAuthLazyString } from './pkce.js' + +export type DcrTokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none' + +/** + * RFC 7591 Dynamic Client Registration metadata POSTed to the registration + * endpoint. Only fields the CLI typically cares about are named; pass anything + * else (`software_statement`, `jwks`, …) via `extra`. + */ +export type DcrClientMetadata = { + clientName: string + clientUri?: string + logoUri?: string + applicationType?: 'native' | 'web' + /** + * Requested token-endpoint auth method. Defaults to `'client_secret_basic'`. + * The registration response is authoritative per RFC 7591 §3.2.1 — when + * the server returns its own `token_endpoint_auth_method`, that value + * wins over this configured one. + */ + tokenEndpointAuthMethod?: DcrTokenEndpointAuthMethod + /** Defaults to `['authorization_code']`. */ + grantTypes?: string[] + /** Defaults to `['code']`. */ + responseTypes?: string[] + /** Merged verbatim into the registration POST body. */ + extra?: Record +} + +export type DcrProviderOptions = { + /** RFC 7591 registration endpoint. Function form supports per-flow base URLs. */ + registrationUrl: OAuthLazyString + /** OAuth 2.0 authorize endpoint. */ + authorizeUrl: OAuthLazyString + /** OAuth 2.0 token endpoint. */ + tokenUrl: OAuthLazyString + clientMetadata: DcrClientMetadata + /** How to join scopes in the authorize URL. Default `' '` (RFC 6749). */ + scopeSeparator?: string + verifierAlphabet?: string + /** Default 64. */ + verifierLength?: number + /** Probe an authenticated endpoint to confirm the token works and resolve the account. */ + validate: (input: ValidateInput) => Promise + /** + * User-facing remediation hints attached to every CliError this factory + * throws (`AUTH_DCR_FAILED` from `prepare()` / `authorize()` and + * `AUTH_TOKEN_EXCHANGE_FAILED` from `exchangeCode()`). Server-returned + * error details are appended after these so the actionable hint stays + * first. + */ + errorHints?: string[] + /** Inject a fetch implementation (tests / custom transport). */ + fetchImpl?: typeof fetch +} + +const MISSING_PEER_HINTS = ['Run `npm install oauth4webapi` in your CLI.'] + +const VALID_AUTH_METHODS: ReadonlySet = new Set([ + 'client_secret_basic', + 'client_secret_post', + 'none', +]) + +/** + * Build an `AuthProvider` for the RFC 7591 Dynamic Client Registration flow, + * driven by [`oauth4webapi`](https://github.com/panva/oauth4webapi) (an + * optional peer dep — installed only by DCR/refresh consumers). + * + * - `prepare`: register via `dynamicClientRegistrationRequest`. Stash the + * issued `client_id`, optional `client_secret`, and the server-returned + * `token_endpoint_auth_method` (RFC 7591 §3.2.1 — server is authoritative) + * in the handshake. + * - `authorize`: standard PKCE S256 with `client_id` read from the handshake. + * - `exchangeCode`: `authorizationCodeGrantRequest` authenticated per the + * handshake's server-returned auth method (falling back to the configured + * one) — `ClientSecretBasic` / `ClientSecretPost` / `None` (the last also + * when the registration response carried no `client_secret`). + * - `validateToken`: caller-supplied. + */ +export function createDcrProvider( + options: DcrProviderOptions, +): AuthProvider { + const scopeSeparator = options.scopeSeparator ?? ' ' + const configuredAuthMethod: DcrTokenEndpointAuthMethod = + options.clientMetadata.tokenEndpointAuthMethod ?? 'client_secret_basic' + + return { + async prepare(input: PrepareInput): Promise { + const oauth = await loadOauth4webapi({ + code: 'AUTH_DCR_FAILED', + missingMessage: 'oauth4webapi is required for Dynamic Client Registration.', + userHints: options.errorHints, + missingHints: MISSING_PEER_HINTS, + }) + const registrationUrl = await resolve(options.registrationUrl, {}, input.flags) + const as: AuthorizationServer = { + issuer: registrationUrl, + registration_endpoint: registrationUrl, + } + const metadata = buildRegistrationMetadata( + options.clientMetadata, + input.redirectUri, + configuredAuthMethod, + ) + + let client: Client + try { + const response = await oauth.dynamicClientRegistrationRequest( + as, + metadata as Parameters[1], + customFetchOptions(oauth, options.fetchImpl), + ) + client = await oauth.processDynamicClientRegistrationResponse(response) + } catch (error) { + throw mapOauthError( + error, + oauth, + 'AUTH_DCR_FAILED', + 'Dynamic Client Registration failed.', + options.errorHints, + ) + } + + const handshake: Record = { clientId: client.client_id } + if (typeof client.client_secret === 'string') { + handshake.clientSecret = client.client_secret + } + // Per RFC 7591 §3.2.1 the server's chosen method is authoritative. + // Honour a supported one; fail fast on a method we can't perform + // (e.g. `private_key_jwt`) rather than silently authenticating the + // token request with the wrong scheme. + const serverMethod = client.token_endpoint_auth_method + if (typeof serverMethod === 'string') { + if (!VALID_AUTH_METHODS.has(serverMethod as DcrTokenEndpointAuthMethod)) { + throw buildAuthError( + 'AUTH_DCR_FAILED', + `Registration server selected an unsupported token_endpoint_auth_method: ${serverMethod}.`, + options.errorHints, + ) + } + handshake.tokenEndpointAuthMethod = serverMethod + } + return { handshake } + }, + + async authorize(input: AuthorizeInput): Promise { + const clientId = input.handshake.clientId + if (typeof clientId !== 'string') { + throw buildAuthError( + 'AUTH_DCR_FAILED', + 'Internal: DCR handshake missing clientId before authorize.', + options.errorHints, + ) + } + + const verifier = generateVerifier({ + alphabet: options.verifierAlphabet, + length: options.verifierLength, + }) + const challenge = deriveChallenge(verifier) + const authorizeUrl = buildPkceAuthorizeUrl({ + authorizeUrl: await resolve(options.authorizeUrl, input.handshake, input.flags), + clientId, + redirectUri: input.redirectUri, + state: input.state, + scopes: input.scopes, + scopeSeparator, + codeChallenge: challenge, + }) + + return { + authorizeUrl, + handshake: { ...input.handshake, codeVerifier: verifier }, + } + }, + + async exchangeCode(input: ExchangeInput): Promise> { + const verifier = input.handshake.codeVerifier + const clientId = input.handshake.clientId + if (typeof verifier !== 'string' || typeof clientId !== 'string') { + throw buildAuthError( + 'AUTH_TOKEN_EXCHANGE_FAILED', + 'Internal: DCR handshake state lost between authorize and exchange.', + options.errorHints, + ) + } + const clientSecretRaw = input.handshake.clientSecret + const clientSecret = typeof clientSecretRaw === 'string' ? clientSecretRaw : undefined + const issuedMethodRaw = input.handshake.tokenEndpointAuthMethod + const issuedMethod: DcrTokenEndpointAuthMethod | undefined = + typeof issuedMethodRaw === 'string' && + VALID_AUTH_METHODS.has(issuedMethodRaw as DcrTokenEndpointAuthMethod) + ? (issuedMethodRaw as DcrTokenEndpointAuthMethod) + : undefined + // Server-issued method wins (RFC 7591 §3.2.1). Fall back to the + // configured one only when the server didn't echo a known method. + const effectiveAuthMethod = issuedMethod ?? configuredAuthMethod + + const oauth = await loadOauth4webapi({ + code: 'AUTH_TOKEN_EXCHANGE_FAILED', + missingMessage: 'oauth4webapi is required for the DCR token exchange.', + userHints: options.errorHints, + missingHints: MISSING_PEER_HINTS, + }) + const flags = (input.handshake.flags as Record | undefined) ?? {} + const tokenUrl = await resolve(options.tokenUrl, input.handshake, flags) + const as: AuthorizationServer = { issuer: tokenUrl, token_endpoint: tokenUrl } + const client: Client = { client_id: clientId } + + // Public-client fallback: a registration with no `client_secret` + // can't authenticate Basic/Post regardless of the requested method, + // so we POST `client_id` like a non-confidential client. Otherwise + // honour the effective auth method. + let clientAuth: ClientAuth + if (!clientSecret || effectiveAuthMethod === 'none') { + clientAuth = oauth.None() + } else if (effectiveAuthMethod === 'client_secret_post') { + clientAuth = oauth.ClientSecretPost(clientSecret) + } else { + clientAuth = oauth.ClientSecretBasic(clientSecret) + } + + try { + // The flow runtime owns CSRF state validation; skip oauth4webapi's + // own state check (it only brands the params for the grant call). + const callbackParameters = oauth.validateAuthResponse( + as, + client, + new URLSearchParams({ code: input.code }), + oauth.skipStateCheck, + ) + const response = await oauth.authorizationCodeGrantRequest( + as, + client, + clientAuth, + callbackParameters, + input.redirectUri, + verifier, + customFetchOptions(oauth, options.fetchImpl), + ) + const result = await oauth.processAuthorizationCodeResponse(as, client, response) + return { + accessToken: result.access_token, + refreshToken: result.refresh_token, + expiresAt: expiresAtFromExpiresIn(result.expires_in), + } + } catch (error) { + throw mapOauthError( + error, + oauth, + 'AUTH_TOKEN_EXCHANGE_FAILED', + 'Token exchange failed.', + options.errorHints, + ) + } + }, + + validateToken: options.validate, + } +} + +/** Thread an injected `fetchImpl` into oauth4webapi via its `customFetch` symbol. */ +function customFetchOptions( + oauth: typeof import('oauth4webapi'), + fetchImpl: typeof fetch | undefined, +): { [k: symbol]: typeof fetch } | undefined { + return fetchImpl ? { [oauth.customFetch]: fetchImpl } : undefined +} + +/** + * Translate an oauth4webapi failure into a typed `CliError`. A `ResponseBodyError` + * carries the server's OAuth error JSON (`error` / `error_description`) — surface + * it so a misconfigured server is diagnosable. Everything else (non-conform + * status, non-JSON body, network failure) collapses to the raw message. + */ +function mapOauthError( + error: unknown, + oauth: typeof import('oauth4webapi'), + code: AuthErrorCode, + message: string, + hints: string[] | undefined, +): CliError { + if (error instanceof oauth.ResponseBodyError) { + const detail = error.error_description + ? `${error.error} (${error.error_description})` + : error.error + return buildAuthError(code, message, hints, detail) + } + return buildAuthError(code, message, hints, getErrorMessage(error)) +} + +function buildRegistrationMetadata( + metadata: DcrClientMetadata, + redirectUri: string, + tokenEndpointAuthMethod: DcrTokenEndpointAuthMethod, +): Record { + const body: Record = { + ...metadata.extra, + client_name: metadata.clientName, + redirect_uris: [redirectUri], + grant_types: metadata.grantTypes ?? ['authorization_code'], + response_types: metadata.responseTypes ?? ['code'], + token_endpoint_auth_method: tokenEndpointAuthMethod, + } + if (metadata.clientUri) body.client_uri = metadata.clientUri + if (metadata.logoUri) body.logo_uri = metadata.logoUri + if (metadata.applicationType) body.application_type = metadata.applicationType + return body +} diff --git a/src/auth/providers/oauth.test.ts b/src/auth/providers/oauth.test.ts new file mode 100644 index 0000000..e31d183 --- /dev/null +++ b/src/auth/providers/oauth.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { postTokenEndpoint } from './oauth.js' + +const TOKEN_URL = 'https://example.com/oauth/token' + +describe('postTokenEndpoint', () => { + it('POSTs the form body, returns access_token + refresh_token + expiresAt, sets no Authorization header', async () => { + let captured: { url: string; init: RequestInit } | undefined + const result = await postTokenEndpoint({ + url: TOKEN_URL, + body: new URLSearchParams({ grant_type: 'authorization_code', code: 'c' }), + fetchImpl: ((url, init = {}) => { + captured = { url: String(url), init } + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'tok', + refresh_token: 'rtok', + expires_in: 60, + }), + { status: 200 }, + ), + ) + }) as typeof fetch, + }) + expect(result).toMatchObject({ accessToken: 'tok', refreshToken: 'rtok' }) + expect(result.expiresAt).toBeGreaterThan(Date.now()) + expect(captured?.url).toBe(TOKEN_URL) + const headers = captured?.init.headers as Record + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded') + expect(headers.Authorization).toBeUndefined() + }) + + it('non-2xx wraps as AUTH_TOKEN_EXCHANGE_FAILED with user errorHints first and body text second', async () => { + await expect( + postTokenEndpoint({ + url: TOKEN_URL, + body: new URLSearchParams(), + errorHints: ['Re-run login'], + fetchImpl: (() => + Promise.resolve( + new Response('invalid_grant', { status: 400 }), + )) as typeof fetch, + }), + ).rejects.toMatchObject({ + code: 'AUTH_TOKEN_EXCHANGE_FAILED', + hints: ['Re-run login', 'invalid_grant'], + }) + }) + + it('network errors, non-JSON bodies, and responses missing access_token all become AUTH_TOKEN_EXCHANGE_FAILED', async () => { + const cases: Array<() => Promise> = [ + () => Promise.reject(new Error('econnrefused')), + () => + Promise.resolve( + new Response('oops', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }), + ), + () => + Promise.resolve( + new Response(JSON.stringify({ refresh_token: 'r' }), { status: 200 }), + ), + ] + for (const fetchImpl of cases) { + await expect( + postTokenEndpoint({ + url: TOKEN_URL, + body: new URLSearchParams(), + fetchImpl: fetchImpl as typeof fetch, + }), + ).rejects.toMatchObject({ code: 'AUTH_TOKEN_EXCHANGE_FAILED' }) + } + }) +}) diff --git a/src/auth/providers/oauth.ts b/src/auth/providers/oauth.ts new file mode 100644 index 0000000..a4167cd --- /dev/null +++ b/src/auth/providers/oauth.ts @@ -0,0 +1,240 @@ +import { CliError, getErrorMessage } from '../../errors.js' +import type { AuthErrorCode } from '../errors.js' +import type { OAuthLazyString } from './pkce.js' + +/** + * Build a `CliError` with user-supplied `errorHints` prepended and an optional + * server-derived `extra` detail appended. Centralises the "user-actionable + * first, diagnostic second" ordering used everywhere in this directory. + */ +export function buildAuthError( + code: AuthErrorCode, + message: string, + userHints: string[] | undefined, + extra?: string, +): CliError { + const hints = [...(userHints ?? []), ...(extra ? [extra] : [])] + return new CliError(code, message, hints.length > 0 ? { hints } : {}) +} + +/** + * Resolve a literal-or-function endpoint/clientId against the current handshake + * and runtime flags. Used by every provider in this directory. + */ +export async function resolve( + resolver: OAuthLazyString, + handshake: Record, + flags: Record, +): Promise { + return typeof resolver === 'function' ? resolver({ handshake, flags }) : resolver +} + +/** Read a response body without letting a stream error escape — used for hints. */ +export async function safeReadText(response: Response): Promise { + try { + const text = (await response.text()).trim() + return text.length > 0 ? text : undefined + } catch { + return undefined + } +} + +type BuildPkceAuthorizeUrlInput = { + authorizeUrl: string + clientId: string + redirectUri: string + state: string + scopes: string[] + scopeSeparator: string + codeChallenge: string +} + +/** Construct the standard PKCE S256 authorize URL. */ +export function buildPkceAuthorizeUrl(input: BuildPkceAuthorizeUrlInput): string { + const url = new URL(input.authorizeUrl) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', input.clientId) + url.searchParams.set('redirect_uri', input.redirectUri) + url.searchParams.set('state', input.state) + url.searchParams.set('code_challenge', input.codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + if (input.scopes.length > 0) { + url.searchParams.set('scope', input.scopes.join(input.scopeSeparator)) + } + return url.toString() +} + +type PostAndParseJsonInput = { + url: string + headers: Record + /** Pre-encoded request body. */ + body: string + /** Error code wrapped around every failure mode. */ + errorCode: AuthErrorCode + /** Prefix for error messages, e.g. `'Token endpoint'` or `'Registration endpoint'`. */ + errorLabel: string + errorHints?: string[] + fetchImpl: typeof fetch +} + +/** + * POST a request, parse a JSON response, and wrap every failure mode as a + * typed `CliError`. Common backbone for the OAuth token endpoint and the + * RFC 7591 dynamic-client-registration endpoint — both POST a body, both + * expect a JSON reply, both want uniform error handling. + * + * Throws `errorCode` with the configured hints on: + * - network failure (fetch rejection) + * - non-2xx response (body text appended as a hint after `errorHints`) + * - non-JSON 2xx body (a misconfigured proxy returning HTML, etc.) + * + * Success-shape validation (e.g. `access_token` present) is the caller's + * job, because it differs per endpoint. + */ +export async function postAndParseJson(input: PostAndParseJsonInput): Promise { + const fail = (message: string, extra?: string): CliError => + buildAuthError(input.errorCode, message, input.errorHints, extra) + + let response: Response + try { + response = await input.fetchImpl(input.url, { + method: 'POST', + headers: input.headers, + body: input.body, + }) + } catch (error) { + throw fail(`${input.errorLabel} request failed: ${getErrorMessage(error)}`) + } + + if (!response.ok) { + const detail = await safeReadText(response) + throw fail(`${input.errorLabel} returned HTTP ${response.status}.`, detail) + } + + // Parse defensively — a misconfigured proxy can return a 2xx HTML error + // page that would otherwise blow up with a raw SyntaxError. + try { + return (await response.json()) as T + } catch (error) { + throw fail(`${input.errorLabel} returned non-JSON response: ${getErrorMessage(error)}`) + } +} + +type PostTokenEndpointInput = { + url: string + /** Form-encoded body. Caller owns grant_type + grant-specific params. */ + body: URLSearchParams + /** + * User-facing remediation hints attached to every `CliError` this helper + * throws (network failure, non-2xx, parse failure, missing access_token). + * The server-returned response body (for non-2xx) is appended after these + * so user hints stay at the top. + */ + errorHints?: string[] + fetchImpl: typeof fetch +} + +type PostTokenEndpointResult = { + accessToken: string + refreshToken?: string + /** Unix-epoch ms. Computed from `expires_in` when the server returns it. */ + expiresAt?: number +} + +/** + * POST to an OAuth 2.0 token endpoint and parse the standard JSON response. + * Covers the public-client `authorization_code` exchange (PKCE) — the caller + * owns `grant_type` and the grant-specific params via `body`. + * + * Failures uniformly throw `CliError('AUTH_TOKEN_EXCHANGE_FAILED', …)`: + * network errors, non-2xx responses (with body text as a hint), non-JSON + * bodies, and responses missing `access_token`. + */ +export async function postTokenEndpoint( + input: PostTokenEndpointInput, +): Promise { + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + } + + const payload = await postAndParseJson<{ + access_token?: string + refresh_token?: string + expires_in?: number + }>({ + url: input.url, + headers, + body: input.body.toString(), + errorCode: 'AUTH_TOKEN_EXCHANGE_FAILED', + errorLabel: 'Token endpoint', + errorHints: input.errorHints, + fetchImpl: input.fetchImpl, + }) + if (!payload.access_token) { + throw buildAuthError( + 'AUTH_TOKEN_EXCHANGE_FAILED', + 'Token endpoint response missing access_token.', + input.errorHints, + ) + } + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + expiresAt: expiresAtFromExpiresIn(payload.expires_in), + } +} + +/** Convert an OAuth `expires_in` (seconds from now) into a Unix-epoch ms deadline. */ +export function expiresAtFromExpiresIn(expiresIn: number | undefined): number | undefined { + return typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : undefined +} + +// Optional peer dep — only DCR and refresh consumers install it. The dynamic +// import (and a missing-peer failure) is memoised so it isn't repeated on every +// call that sits on the authenticated-call path. +let oauthModulePromise: Promise | undefined + +type LoadOauthOptions = { + /** Error code wrapped around a missing/broken peer dep. */ + code: AuthErrorCode + /** Message when the peer dep isn't installed. */ + missingMessage: string + /** Caller-supplied remediation hints (e.g. provider `errorHints`), prepended first. */ + userHints?: string[] + /** Install hint for the missing-peer case, appended after `userHints`. */ + missingHints?: string[] +} + +/** + * Lazily import `oauth4webapi`, surfacing a typed `CliError` when the optional + * peer dep is absent (vs. installed-but-broken). Shared by `createPkceProvider` + * (refresh) and `createDcrProvider` (registration + token exchange). Caller + * `userHints` are prepended on both failure branches so the provider's + * `errorHints` contract holds even when the dep is missing. + */ +export async function loadOauth4webapi( + options: LoadOauthOptions, +): Promise { + oauthModulePromise ??= import('oauth4webapi') + try { + return await oauthModulePromise + } catch (error) { + const moduleCode = (error as NodeJS.ErrnoException | undefined)?.code + if (moduleCode === 'ERR_MODULE_NOT_FOUND' || moduleCode === 'MODULE_NOT_FOUND') { + const hints = [...(options.userHints ?? []), ...(options.missingHints ?? [])] + throw new CliError( + options.code, + options.missingMessage, + hints.length > 0 ? { hints } : {}, + ) + } + // Installed but failed to initialise — surface the real cause rather + // than a misleading "install it" hint. + throw buildAuthError( + options.code, + `Failed to load oauth4webapi: ${getErrorMessage(error)}`, + options.userHints, + ) + } +} diff --git a/src/auth/providers/pkce.test.ts b/src/auth/providers/pkce.test.ts index f81148a..ea37bf6 100644 --- a/src/auth/providers/pkce.test.ts +++ b/src/auth/providers/pkce.test.ts @@ -148,6 +148,41 @@ describe('createPkceProvider', () => { ).rejects.toMatchObject({ code: 'AUTH_TOKEN_EXCHANGE_FAILED' }) }) + it('forwards errorHints onto both the token-endpoint failure and the handshake-lost guard', async () => { + const provider = createPkceProvider({ + authorizeUrl: 'unused', + tokenUrl: 'https://example.com/oauth/token', + clientId: 'cid', + validate, + errorHints: ['Re-run login'], + fetchImpl: (() => + Promise.resolve(new Response('invalid_grant', { status: 400 }))) as typeof fetch, + }) + await expect( + provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: 'http://localhost/callback', + handshake: { codeVerifier: 'v', clientId: 'cid' }, + }), + ).rejects.toMatchObject({ + code: 'AUTH_TOKEN_EXCHANGE_FAILED', + hints: ['Re-run login', 'invalid_grant'], + }) + // Same hints flow through the internal handshake-lost guard. + await expect( + provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: 'http://localhost/callback', + handshake: {}, + }), + ).rejects.toMatchObject({ + code: 'AUTH_TOKEN_EXCHANGE_FAILED', + hints: ['Re-run login'], + }) + }) + it('throws AUTH_TOKEN_EXCHANGE_FAILED when the handshake state was lost between authorize and exchange', async () => { const provider = createPkceProvider({ authorizeUrl: 'unused', diff --git a/src/auth/providers/pkce.ts b/src/auth/providers/pkce.ts index 9c52f85..e182b10 100644 --- a/src/auth/providers/pkce.ts +++ b/src/auth/providers/pkce.ts @@ -11,20 +11,26 @@ import type { RefreshInput, ValidateInput, } from '../types.js' +import { + buildAuthError, + buildPkceAuthorizeUrl, + expiresAtFromExpiresIn, + loadOauth4webapi, + postTokenEndpoint, + resolve, +} from './oauth.js' // Upper bound on the refresh-token POST. Kept under the refresh helper's // stale-lock threshold so a timed-out grant releases the lock before another // invocation would consider it abandoned. const REFRESH_TIMEOUT_MS = 10_000 -function expiresAtFromExpiresIn(expiresIn: number | undefined): number | undefined { - return typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : undefined -} - /** * Lazy resolver: a literal string, or a function that builds one from the - * current PKCE handshake (so callers can derive the URL or client_id from - * the active session's `baseUrl` / per-flow flags). + * current OAuth handshake (so callers can derive the URL or client_id from + * the active session's `baseUrl` / per-flow flags). Used by both + * `createPkceProvider` and `createDcrProvider`; prefer the grant-agnostic + * alias `OAuthLazyString` for new code. */ export type PkceLazyString = | string @@ -33,6 +39,9 @@ export type PkceLazyString = flags: Record }) => string | Promise) +/** Grant-agnostic alias for {@link PkceLazyString}. Identical type. */ +export type OAuthLazyString = PkceLazyString + export type PkceProviderOptions = { /** OAuth 2.0 authorize endpoint. Function form supports per-flow base URLs (Outline self-hosted). */ authorizeUrl: PkceLazyString @@ -47,6 +56,13 @@ export type PkceProviderOptions = { verifierLength?: number /** Probe an authenticated endpoint to confirm the token works and resolve the account. */ validate: (input: ValidateInput) => Promise + /** + * User-facing remediation hints attached to every CliError this factory + * throws (token-endpoint failures, internal handshake-state guards). + * Server-returned response bodies are appended after these so the + * actionable hint stays first. + */ + errorHints?: string[] /** Inject a fetch implementation (tests). */ fetchImpl?: typeof fetch } @@ -61,8 +77,8 @@ export type PkceProviderOptions = { * `runOAuthFlow` and arrives on `AuthorizeInput.scopes`; this factory does * not own scope resolution. * - * Flows that need DCR or HTTP Basic auth on the token endpoint implement - * the `AuthProvider` interface directly. + * Flows that need DCR or HTTP Basic auth on the token endpoint use + * `createDcrProvider` (or implement the `AuthProvider` interface directly). */ export function createPkceProvider( options: PkceProviderOptions, @@ -77,24 +93,23 @@ export function createPkceProvider( length: options.verifierLength, }) const challenge = deriveChallenge(verifier) - const [clientId, authorizeUrl] = await Promise.all([ + // Resolve concurrently — both may be async (config read / prompt). + const [clientId, authorizeBaseUrl] = await Promise.all([ resolve(options.clientId, input.handshake, input.flags), resolve(options.authorizeUrl, input.handshake, input.flags), ]) - - const url = new URL(authorizeUrl) - url.searchParams.set('response_type', 'code') - url.searchParams.set('client_id', clientId) - url.searchParams.set('redirect_uri', input.redirectUri) - url.searchParams.set('state', input.state) - url.searchParams.set('code_challenge', challenge) - url.searchParams.set('code_challenge_method', 'S256') - if (input.scopes.length > 0) { - url.searchParams.set('scope', input.scopes.join(scopeSeparator)) - } + const authorizeUrl = buildPkceAuthorizeUrl({ + authorizeUrl: authorizeBaseUrl, + clientId, + redirectUri: input.redirectUri, + state: input.state, + scopes: input.scopes, + scopeSeparator, + codeChallenge: challenge, + }) return { - authorizeUrl: url.toString(), + authorizeUrl, handshake: { ...input.handshake, codeVerifier: verifier, clientId }, } }, @@ -103,9 +118,10 @@ export function createPkceProvider( const verifier = input.handshake.codeVerifier const clientId = input.handshake.clientId if (typeof verifier !== 'string' || typeof clientId !== 'string') { - throw new CliError( + throw buildAuthError( 'AUTH_TOKEN_EXCHANGE_FAILED', 'Internal: PKCE handshake state lost between authorize and exchange.', + options.errorHints, ) } // `runOAuthFlow` folds the runtime `flags` into the handshake @@ -122,60 +138,27 @@ export function createPkceProvider( code_verifier: verifier, }) - let response: Response - try { - response = await fetchImpl(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body: body.toString(), - }) - } catch (error) { - throw new CliError( - 'AUTH_TOKEN_EXCHANGE_FAILED', - `Token endpoint request failed: ${getErrorMessage(error)}`, - ) - } - - if (!response.ok) { - const detail = await safeReadText(response) - throw new CliError( - 'AUTH_TOKEN_EXCHANGE_FAILED', - `Token endpoint returned HTTP ${response.status}.`, - detail ? { hints: [detail] } : {}, - ) - } - - // Parse defensively — a misconfigured proxy can return a 2xx HTML - // error page that would otherwise blow up with a raw SyntaxError. - let payload: { access_token?: string; refresh_token?: string; expires_in?: number } - try { - payload = (await response.json()) as typeof payload - } catch (error) { - throw new CliError( - 'AUTH_TOKEN_EXCHANGE_FAILED', - `Token endpoint returned non-JSON response: ${getErrorMessage(error)}`, - ) - } - if (!payload.access_token) { - throw new CliError( - 'AUTH_TOKEN_EXCHANGE_FAILED', - 'Token endpoint response missing access_token.', - ) - } + const result = await postTokenEndpoint({ + url: tokenUrl, + body, + errorHints: options.errorHints, + fetchImpl, + }) return { - accessToken: payload.access_token, - refreshToken: payload.refresh_token, - expiresAt: expiresAtFromExpiresIn(payload.expires_in), + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresAt: result.expiresAt, } }, validateToken: options.validate, async refreshToken(input: RefreshInput): Promise> { - const oauth = await loadOauth4webapi() + const oauth = await loadOauth4webapi({ + code: 'AUTH_REFRESH_UNAVAILABLE', + missingMessage: 'oauth4webapi is required for refresh-token support.', + missingHints: ['Run `npm install oauth4webapi` in your CLI.'], + }) // Mirror `exchangeCode`: a resolver that reads `flags` sees the // same view during silent refresh as it did at authorize time. const flags = (input.handshake.flags as Record | undefined) ?? {} @@ -249,47 +232,3 @@ export function createPkceProvider( }, } } - -async function resolve( - resolver: PkceLazyString, - handshake: Record, - flags: Record, -): Promise { - return typeof resolver === 'function' ? resolver({ handshake, flags }) : resolver -} - -// Optional peer dep — only refresh consumers install it. The dynamic import -// (and a missing-peer failure) is memoised so it isn't repeated on every -// refresh, which sits on the authenticated-call path. -let oauthModulePromise: Promise | undefined - -async function loadOauth4webapi(): Promise { - oauthModulePromise ??= import('oauth4webapi') - try { - return await oauthModulePromise - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code - if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { - throw new CliError( - 'AUTH_REFRESH_UNAVAILABLE', - 'oauth4webapi is required for refresh-token support.', - { hints: ['Run `npm install oauth4webapi` in your CLI.'] }, - ) - } - // Installed but failed to initialise — surface the real cause rather - // than a misleading "install it" hint. - throw new CliError( - 'AUTH_REFRESH_UNAVAILABLE', - `Failed to load oauth4webapi: ${getErrorMessage(error)}`, - ) - } -} - -async function safeReadText(response: Response): Promise { - try { - const text = (await response.text()).trim() - return text.length > 0 ? text : undefined - } catch { - return undefined - } -}