import type { MachineCredentials } from "./machine-credentials.js"; import { OAUTH_AUTHORIZE_PATH, type AuthorizationServerMetadata, type ClientRegistrationStoreLike } from "./oauth-dcr.js"; import { type DeviceTokenResponse } from "./oauth-device-flow.js"; /** * OAuth 2.1 authorization-code + PKCE login for **native/desktop** clients (RFC 8252 "OAuth 2.0 for * Native Apps"). This is the browser-based sibling of {@link ../oauth-device-flow.deviceLogin}: it * opens the system browser to the AS `/oauth/authorize` with a **PKCE S256** challenge, catches the * redirect on an ephemeral `127.0.0.1` loopback listener (RFC 8252 §7.3 — the IP literal, never a * public interface), and exchanges the returned `code` (with the PKCE `code_verifier` and the RFC * 8707 `resource` indicator) for the agent-plane token. It yields the SAME {@link MachineCredentials} * shape as {@link deviceLogin}, so it feeds the `MachineCredentialProvider` / App keychain path the * same way — the two login paths are surface-parallel and interchangeable at the call site. * * The RFC-MUSTs codified + unit-tested here: * - **PKCE (RFC 7636):** a 43+ char `code_verifier`; the authorize request carries only the S256 * `code_challenge` + `code_challenge_method=S256`; the token request carries the raw verifier. * - **CSRF (RFC 6749 §10.12):** an unguessable `state` is sent on the authorize request and the * redirect's `state` is compared for an **exact** match — a mismatch is refused, never exchanged. * - **Loopback (RFC 8252 §7.3 / §8.3):** the listener binds `127.0.0.1` on an **ephemeral** port, * the redirect URI is an exact-match loopback literal, and a non-loopback host is refused. * - **RFC 8707 resource:** exactly ONE `resource` on the authorize request AND the token exchange, * so the AS mints a single-audience token (reusing c1's threading; parity with `deviceLogin`). * * Injectable seams (mirroring `deviceLogin`) keep the whole flow testable against a mock AS with no * live network and no real browser: the browser `openBrowser` opener (default spawns the OS handler — * **no Electron dependency**; the App may inject its own), the loopback `listenerFactory`, the token * `transport`, the PKCE/`state` random source, the `registrationStore`, and the clock. When no * browser can be opened the flow returns a **clear error (never a hang)** so the caller can fall back * to `deviceLogin`. * * ## Client identity: dynamic registration by default (RFC 8414 + RFC 7591) * * `/oauth/authorize` resolves `client_id` by an exact lookup in the AS's client registry, whose only * writer is `POST /oauth/register` — so a **static client id is rejected** (`invalid_client`) unless * that exact id was registered out of band. Omit {@link AuthCodeLoginOptions.clientId} (the * recommended default) and this flow discovers the AS, reuses this installation's persisted * registration, or registers once — see {@link ../oauth-dcr}. Supplying `clientId` explicitly opts * OUT of registration and is honoured verbatim, which is what the engine CLIs' `EngineAdapter` * clientId path relies on. * * Because RFC 6749 §4.1.2.1 forbids redirecting an unverified `client_id` back to the `redirect_uri`, * an `invalid_client` never reaches the loopback listener — it is rendered in the user's browser * while the flow waits out its full timeout. So in dynamic mode the flow **probes the authorization * request itself before opening a browser**: a definite 4xx surfaces immediately instead of after a * five-minute "timeout", and an `invalid_client` (the AS garbage-collects unused clients after 30 * days, and a client can be revoked) triggers **exactly one** re-registration + retry — never a loop. * * Additive + backward-compatible: nothing here touches the credential store — a caller writes the * returned credentials only on `ok:true`, so a failure at any stage leaves the store untouched. */ export { OAUTH_AUTHORIZE_PATH }; /** RFC 6749 §4.1.3 grant type redeemed at the token endpoint for an authorization code. */ export declare const AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code"; /** RFC 7636 code-challenge method — SHA-256. Plain (`plain`) is intentionally NOT supported. */ export declare const PKCE_CODE_CHALLENGE_METHOD = "S256"; /** The loopback interface the redirect listener binds (RFC 8252 §7.3 — the IP literal, not `localhost`). */ export declare const DEFAULT_LOOPBACK_HOST = "127.0.0.1"; /** Default path the loopback redirect URI targets. */ export declare const DEFAULT_CALLBACK_PATH = "/callback"; /** Default wait (ms) for the browser round-trip before the listener times out (5 minutes). */ export declare const DEFAULT_AUTHCODE_TIMEOUT_MS = 300000; /** Absolute authorization URL for an AS root. */ export declare function authorizeUrl(serverBaseUrl: string): string; /** Source of cryptographic randomness; injectable so tests can pin verifier/state values. */ export type RandomBytesFn = (size: number) => Buffer; /** * Generate a PKCE `code_verifier` (RFC 7636 §4.1): 32 random bytes → 43-char base64url, comfortably * inside the required 43–128 char range and using only the unreserved `[A-Za-z0-9-._~]` set. */ export declare function generateCodeVerifier(randomBytes?: RandomBytesFn): string; /** Derive the PKCE S256 `code_challenge` from a verifier (RFC 7636 §4.2): base64url(SHA-256(verifier)). */ export declare function deriveCodeChallenge(codeVerifier: string): string; /** Generate an unguessable `state` for CSRF protection (RFC 6749 §10.12): 16 random bytes → base64url. */ export declare function generateState(randomBytes?: RandomBytesFn): string; /** Parameters for {@link buildAuthorizeUrl}. */ export interface BuildAuthorizeUrlParams { serverBaseUrl: string; clientId: string; redirectUri: string; scope: string; state: string; codeChallenge: string; /** RFC 8707 resource indicator; when set, exactly ONE `resource` is added to the authorize URL. */ resource?: string; /** * Absolute authorization endpoint discovered via RFC 8414, overriding `{serverBaseUrl}/oauth/authorize`. * Omitted → this package's hardcoded path (the pre-discovery behaviour). */ authorizeEndpoint?: string; } /** * Build the `/oauth/authorize` URL for the authorization-code + PKCE (S256) flow. Pure and * deterministic given its inputs, so the exact query shape (PKCE, `state`, `resource`) is unit * testable without a listener or a browser. */ export declare function buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string; /** True for a loopback host literal the listener is allowed to bind (127.0.0.0/8 or IPv6 `::1`). */ export declare function isLoopbackHost(host: string): boolean; /** The OAuth parameters carried on a captured loopback redirect. */ export interface LoopbackRedirect { code?: string; state?: string; error?: string; errorDescription?: string; } /** A single-use loopback HTTP listener that captures exactly one authorization redirect. */ export interface LoopbackListener { /** The exact `http://127.0.0.1:{port}{path}` redirect URI the AS must call back. */ readonly redirectUri: string; /** The ephemeral port the listener bound. */ readonly port: number; /** Resolve with the redirect params; reject with a timeout (`LoopbackTimeoutError`) / abort. */ waitForRedirect(): Promise; /** Idempotently stop the listener and drop any lingering sockets. */ close(): void; } /** Creates a {@link LoopbackListener}; injectable so the flow runs with a fake in tests. */ export type LoopbackListenerFactory = (options: { host: string; path: string; timeoutMs: number; signal?: AbortSignal; }) => Promise; /** Error the loopback listener rejects with when the browser round-trip exceeds the timeout. */ export declare class LoopbackTimeoutError extends Error { constructor(message?: string); } /** * The default {@link LoopbackListenerFactory}: an in-process `node:http` server bound to the loopback * interface on an ephemeral port. The request handler is attached BEFORE `listen` resolves, so a * redirect that arrives before `waitForRedirect` is awaited is never lost (no race). Only the exact * callback path is accepted; any other path is answered `404` so a stray probe cannot satisfy the * flow. `Connection: close` + connection tracking ensure {@link LoopbackListener.close} tears the * server down promptly (no lingering keep-alive socket keeps the process/test alive). */ export declare const createLoopbackListener: LoopbackListenerFactory; /** * Open a URL in the OS-default browser with NO Electron / third-party dependency: `cmd /c start` on * Windows, `open` on macOS, `xdg-open` on Linux. On a headless Linux host (no `DISPLAY` / * `WAYLAND_DISPLAY`) it throws synchronously with a clear message rather than spawning into the void, * so the caller gets an immediate error and can fall back to device-code login. The App may inject * its own opener via {@link AuthCodeLoginOptions.openBrowser}. */ export declare function defaultBrowserOpener(url: string): void; /** OAuth 2.1 token response for the authorization-code (and error) grant. Same shape as the device grant. */ export type AuthCodeTokenResponse = DeviceTokenResponse; /** Parameters for a single {@link AuthCodeTransport.exchangeCode} call. */ export interface ExchangeCodeParams { code: string; codeVerifier: string; redirectUri: string; signal?: AbortSignal; } /** * The token-exchange transport seam. Split from the flow so the code→token step can be exercised * against a mocked authorization server with no live network. */ export interface AuthCodeTransport { /** `POST /oauth/token` with `grant_type=authorization_code` + PKCE verifier (+ RFC 8707 resource). */ exchangeCode(params: ExchangeCodeParams): Promise; } /** Options for the default fetch-backed {@link HttpAuthCodeTransport}. */ export interface HttpAuthCodeTransportOptions { serverBaseUrl: string; clientId: string; /** * RFC 8707 resource indicator — when set, exactly ONE `resource` is sent on the token exchange, * yielding a single-audience token. Omitted → the legacy wire shape (no `resource`). */ resource?: string; /** * Absolute token endpoint discovered via RFC 8414, overriding `{serverBaseUrl}/oauth/token`. * Omitted → this package's hardcoded path (the pre-discovery behaviour). */ tokenEndpoint?: string; /** Injectable for tests; defaults to the global `fetch`. */ fetchImpl?: typeof fetch; /** Per-request network timeout (ms). Default 30s. */ timeoutMs?: number; } /** * The default {@link AuthCodeTransport}: a form-encoded `POST` to `/oauth/token` via `fetch` with * `grant_type=authorization_code`, the `code`, the `redirect_uri` (which the AS re-verifies), the * `client_id`, the PKCE `code_verifier`, and — when configured — exactly ONE RFC 8707 `resource`. */ export declare class HttpAuthCodeTransport implements AuthCodeTransport { private readonly _serverBaseUrl; private readonly _clientId; private readonly _resource; private readonly _tokenEndpoint; private readonly _fetch; private readonly _timeoutMs; constructor(options: HttpAuthCodeTransportOptions); exchangeCode({ code, codeVerifier, redirectUri, signal }: ExchangeCodeParams): Promise; private post; } /** Callbacks + injectable seams for {@link authCodeLogin}. */ export interface AuthCodeLoginOptions { /** The AS root (e.g. `https://ai-game.dev`) — NOT the `/mcp` hub URL. Used to build the transport. */ serverBaseUrl: string; /** * A **statically registered** client id, which opts OUT of dynamic client registration and is sent * verbatim (the engine CLIs' `EngineAdapter.clientId` path). **Omit it** — the recommended default * for a desktop app — and the flow performs RFC 8414 discovery + RFC 7591 registration instead, * reusing this installation's persisted `client_id`. A hardcoded id the AS never registered is * rejected with `invalid_client`, which is exactly the bug DCR exists to prevent. */ clientId?: string; /** * Human-readable product name registered as the client's `client_name` — **the user sees it on the * consent screen**. Defaults to `AI Game Dev`. Only used in dynamic-registration mode. */ clientName?: string; /** * Persistence seam for the dynamic registration; defaults to the on-disk * {@link ../oauth-dcr.ClientRegistrationStore} (`~/.ai-game-dev/oauth-clients.json`, keyed by AS * base URL). Only used in dynamic-registration mode. */ registrationStore?: ClientRegistrationStoreLike; /** * Pre-fetched RFC 8414 metadata. In dynamic-registration mode it also skips this flow's own * discovery call; in BOTH modes its `authorization_endpoint` / `token_endpoint` override this * package's hardcoded paths, so a caller supplying a static `clientId` can still point the flow at * an already-discovered AS. */ metadata?: AuthorizationServerMetadata; /** * Per-request network timeout (ms) for discovery / registration / the authorize probe. Default 30s. * Distinct from {@link timeoutMs}, which bounds the human browser round-trip. */ networkTimeoutMs?: number; /** Scope; defaults to `mcp:plugin`. Pass `MCP_AGENT_SCOPE` (`mcp:agent`) for the agent plane. */ scope?: string; /** * RFC 8707 resource indicator threaded into BOTH the authorize URL and the token exchange (exactly * ONE `resource` each → single-audience tokens). Omitted → legacy wire shape. Ignored when a custom * `transport` is supplied (the transport owns its token-request wire shape). */ resource?: string; /** Injectable `fetch` for the default transport (mock-AS tests). Ignored when `transport` is supplied. */ fetchImpl?: typeof fetch; /** * The server target recorded on the resulting credential (hosted vs local). Defaults to * `serverBaseUrl`. Kept distinct so a caller can record the hub URL if it prefers. */ serverTarget?: string; /** Loopback host to bind; defaults to `127.0.0.1`. A non-loopback host is refused. */ loopbackHost?: string; /** Redirect callback path; defaults to `/callback`. */ callbackPath?: string; /** Max wait (ms) for the browser redirect before timing out; defaults to 5 minutes. */ timeoutMs?: number; /** * Open the authorize URL in a browser. Defaults to {@link defaultBrowserOpener}. If this throws * (e.g. headless), the flow returns a clear `no_browser` error — never a hang. */ openBrowser?: (url: string) => void; /** Optional: observe the exact authorize URL the flow built (e.g. to print a manual fallback). */ onAuthorizeUrl?: (url: string) => void; /** Injectable loopback listener factory; defaults to {@link createLoopbackListener}. */ listenerFactory?: LoopbackListenerFactory; /** Injectable token-exchange transport; defaults to {@link HttpAuthCodeTransport}. */ transport?: AuthCodeTransport; /** Injectable randomness for the PKCE verifier + `state`; defaults to `crypto.randomBytes`. */ randomBytes?: RandomBytesFn; /** Force a specific PKCE `code_verifier` (tests / golden vectors). Default: generated. */ codeVerifier?: string; /** Force a specific `state` (tests). Default: generated. */ state?: string; /** Injectable clock (ms since epoch); defaults to `Date.now`. For deterministic `expiresAt`. */ now?: () => number; /** Cancellation. */ signal?: AbortSignal; } /** The outcome of {@link authCodeLogin}. Failures are values, not throws (network errors included). */ export type AuthCodeLoginResult = { ok: true; credentials: MachineCredentials; } | { ok: false; reason: "state_mismatch" | "timeout" | "no_browser" | "denied" | "error" | "cancelled"; message: string; }; /** * Run the RFC 8252 authorization-code + PKCE (S256) desktop login end to end and return full * {@link MachineCredentials} on success. The caller persists them (this function never writes the * store), so an early failure leaves the store untouched. Surface-parallel to `deviceLogin`. */ export declare function authCodeLogin(options: AuthCodeLoginOptions): Promise; //# sourceMappingURL=oauth-authcode-flow.d.ts.map