/** * **RFC 8414** authorization-server metadata discovery + **RFC 7591** Dynamic Client Registration * (DCR), with a durable per-authorization-server registration store. * * ## Why this module exists * * The ai-game.dev authorization server resolves `client_id` at `/oauth/authorize` by an **exact * lookup in its client registry**, and the ONLY writer of that registry is `POST /oauth/register`, * which **mints its own** `agd_client_<24-urlsafe>` id and ignores any caller-supplied one. A * hardcoded/static client id therefore can never resolve there — the AS answers * `HTTP 400 {"error":"invalid_client","error_description":"unknown client_id"}`. * * That failure is delivered to the **browser**, and RFC 6749 §4.1.2.1 forbids redirecting an * unverified `client_id` back to a `redirect_uri` — so the loopback listener never hears anything and * the desktop login appears to "time out" minutes later. **The timeout is a symptom; the unregistered * client id is the fault.** {@link ../oauth-authcode-flow.authCodeLogin} therefore probes the * authorize request before opening a browser (see its doc comment) instead of waiting the timeout out. * * The **device grant is deliberately untouched**: `/oauth/device_authorization` accepts ANY client id, * and the server binds each refresh-token family to the client id used at issue time — moving the * device flow onto a DCR-minted id would invalidate every existing CLI login. DCR is scoped to the * authorization-code flow only. * * ## What it does * * - {@link discoverAuthorizationServer} — `GET /.well-known/oauth-authorization-server` (RFC 8414 * §3.1: the well-known segment is inserted between host and issuer path) to locate the * `registration_endpoint` / `authorization_endpoint` / `token_endpoint`. Never throws: on any * failure it falls back to this package's hardcoded paths, so a server without RFC 8414 metadata * keeps working exactly as before. * - {@link registerClient} — a one-shot RFC 7591 `POST` that returns the AS-minted `client_id`. * - {@link ClientRegistrationStore} — persists that registration **keyed by authorization-server base * URL** (so a dev server and production can never collide) at * `~/.ai-game-dev/oauth-clients.json`, with the same atomic, owner-only write the credential store * uses. Reads are fault-tolerant: a missing or corrupt file simply means "not registered yet". * - {@link resolveClientRegistration} — the orchestrator: discover → reuse the stored registration → * otherwise register once and persist. `forceRegister` is the recovery path used exactly once when * the AS reports `invalid_client` (the server GCs unused clients after 30 days, and a client can be * revoked). * * ## Not secret material * * A registration here is a **public client** (`token_endpoint_auth_method: "none"` — the only method * the AS advertises): there is no client secret, and a `client_id` is not a credential. The file is * still written owner-only, and a `client_secret` returned by some other AS is deliberately **not * persisted** — using one would require confidential-client auth this flow does not perform. */ /** Path (relative to the AS root) of the RFC 8414 authorization-server metadata document. */ export declare const OAUTH_AS_METADATA_PATH = "/.well-known/oauth-authorization-server"; /** Path (relative to the AS root) of the RFC 7591 dynamic client registration endpoint. */ export declare const OAUTH_REGISTRATION_PATH = "/oauth/register"; /** Path (relative to the AS root) of the OAuth 2.1 authorization endpoint. */ export declare const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; /** File name (inside the machine store directory) of the client-registration document. */ export declare const CLIENT_REGISTRATIONS_FILE_NAME = "oauth-clients.json"; /** Current persisted-document schema version of {@link CLIENT_REGISTRATIONS_FILE_NAME}. */ export declare const CLIENT_REGISTRATIONS_SCHEMA_VERSION = 1; /** * Fallback `client_name`. This string is shown to the user on the AS consent screen, so it MUST stay * a human-readable product name — never a slug like `ai-game-dev-app`. */ export declare const DEFAULT_CLIENT_NAME = "AI Game Dev"; /** RFC 7591 auth method requested for the registration: a public client with no secret. */ export declare const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "none"; /** Grant types requested at registration for the desktop authorization-code login. */ export declare const DEFAULT_REGISTRATION_GRANT_TYPES: readonly string[]; /** Response types requested at registration. */ export declare const DEFAULT_REGISTRATION_RESPONSE_TYPES: readonly string[]; /** Default per-request network timeout (ms) for discovery and registration. */ export declare const DEFAULT_DCR_TIMEOUT_MS = 30000; /** * The subset of RFC 8414 authorization-server metadata this package consumes. Unknown members are * preserved (index signature) so a caller can read anything else the AS advertises. */ export interface AuthorizationServerMetadata { issuer?: string; authorization_endpoint?: string; token_endpoint?: string; registration_endpoint?: string; device_authorization_endpoint?: string; code_challenge_methods_supported?: string[]; grant_types_supported?: string[]; response_types_supported?: string[]; scopes_supported?: string[]; token_endpoint_auth_methods_supported?: string[]; [key: string]: unknown; } /** A client registration as minted by the AS (RFC 7591 §3.2.1), in this package's camelCase shape. */ export interface ClientRegistration { /** The AS-minted client identifier — the ONLY value `/oauth/authorize` will accept. */ clientId: string; /** Human-readable name shown on the consent screen. */ clientName?: string; /** The redirect URIs the AS recorded. Loopback ports float (RFC 8252 §7.3). */ redirectUris?: string[]; grantTypes?: string[]; responseTypes?: string[]; tokenEndpointAuthMethod?: string; scope?: string; /** RFC 7591 `client_id_issued_at` (seconds since epoch), when the AS supplies it. */ clientIdIssuedAt?: number; /** The registration endpoint that minted this id — recorded for diagnostics. */ registrationEndpoint?: string; /** ISO-8601 timestamp of when this client wrote the registration. */ registeredAt?: string; } /** * The persistence seam for {@link resolveClientRegistration}. {@link ClientRegistrationStore} is the * on-disk implementation; tests (and an App that prefers its own keychain) can substitute any object * with these three methods. */ export interface ClientRegistrationStoreLike { read(serverBaseUrl: string): ClientRegistration | null; save(serverBaseUrl: string, registration: ClientRegistration): void; delete(serverBaseUrl: string): void; } /** * The RFC 8414 §3.1 metadata URL for an issuer: the well-known segment is inserted **between the * host and the issuer path**, not appended to it (`https://as.example/tenant` → * `https://as.example/.well-known/oauth-authorization-server/tenant`). An issuer with no path — the * ai-game.dev case — reduces to the familiar `{base}/.well-known/oauth-authorization-server`. */ export declare function authorizationServerMetadataUrl(serverBaseUrl: string): string; /** Absolute RFC 7591 registration URL for an AS root (the pre-discovery fallback). */ export declare function registrationUrl(serverBaseUrl: string): string; /** * This package's hardcoded endpoint layout — the sane fallback when an AS serves no RFC 8414 * document (or it is unreachable). Identical to the paths every flow used before discovery existed, * so falling back is never a regression. */ export declare function fallbackAuthorizationServerMetadata(serverBaseUrl: string): AuthorizationServerMetadata; /** Options for {@link discoverAuthorizationServer}. */ export interface DiscoverAuthorizationServerOptions { /** The AS root (e.g. `https://ai-game.dev`) — NOT the `/mcp` hub URL. */ serverBaseUrl: string; /** Injectable `fetch` for tests; defaults to the global `fetch`. */ fetchImpl?: typeof fetch; /** Per-request network timeout (ms). Default 30s. */ timeoutMs?: number; signal?: AbortSignal; } /** The outcome of {@link discoverAuthorizationServer} — always a usable metadata document. */ export interface DiscoveredAuthorizationServer { /** Discovered members merged over {@link fallbackAuthorizationServerMetadata}. */ metadata: AuthorizationServerMetadata; /** True when a well-known document was actually fetched and parsed; false when falling back. */ discovered: boolean; } /** * Fetch the RFC 8414 metadata document for an AS. **Never throws and never rejects**: a 404, a * non-JSON body, a network error, an abort, or a document whose `issuer` contradicts the AS it was * fetched for (RFC 8414 §3.3 — see {@link issuerMatches}) all resolve to the hardcoded * {@link fallbackAuthorizationServerMetadata} with `discovered: false`, so discovery can only ever * improve on the status quo. A partial document is merged OVER the fallback, so a metadata doc that * omits (say) `registration_endpoint` still yields a usable one — and an endpoint the document * advertises as something this package cannot dereference (a relative URI, a non-string, a * non-HTTP scheme) is dropped the same way an omitted one is (see {@link dropUnusableEndpoints}). */ export declare function discoverAuthorizationServer(options: DiscoverAuthorizationServerOptions): Promise; /** Raised when the AS refuses (or bungles) an RFC 7591 registration. */ export declare class ClientRegistrationError extends Error { /** HTTP status of the registration response, when there was one. */ readonly status: number | undefined; /** The RFC 6749 §5.2-style `error` code from the response body, when present. */ readonly errorCode: string | undefined; constructor(message: string, details?: { status?: number; errorCode?: string; }); } /** Options for {@link registerClient}. */ export interface RegisterClientOptions { /** Absolute registration endpoint (from discovery, or {@link registrationUrl}). */ registrationEndpoint: string; /** * The redirect URIs to register. For a native app these are **portless** loopback URIs * (`http://127.0.0.1/callback`): RFC 8252 §7.3 lets the port float at authorize time, so ONE * registration serves every launch's random ephemeral port. Scheme/host/path/query must still * match exactly. */ redirectUris: string[]; /** Human-readable name shown on the consent screen. Defaults to {@link DEFAULT_CLIENT_NAME}. */ clientName?: string; grantTypes?: readonly string[]; responseTypes?: readonly string[]; tokenEndpointAuthMethod?: string; scope?: string; fetchImpl?: typeof fetch; timeoutMs?: number; signal?: AbortSignal; } /** * Register this installation as a new OAuth client (RFC 7591 §3.1) and return the AS-minted * identity. Throws {@link ClientRegistrationError} on any non-2xx response or a body without a * usable `client_id` — the caller decides whether that is fatal. */ export declare function registerClient(options: RegisterClientOptions): Promise; /** * Normalize an AS base URL into the store key: the AS root (a trailing `/mcp` hub segment stripped), * lowercased scheme + host + port. This is what keeps a local dev server * (`http://localhost:8000`) and production (`https://ai-game.dev`) in separate slots. */ export declare function registrationStoreKey(serverBaseUrl: string): string; /** * The durable client-registration store: `~/.ai-game-dev/oauth-clients.json`, one entry per * authorization server. Pass an explicit `baseDirectory` in tests. Writes are atomic and owner-only * (shared with the credential store); reads never throw — a missing, empty, or corrupt document * simply reads as "nothing registered", which makes the next login re-register instead of failing. */ export declare class ClientRegistrationStore implements ClientRegistrationStoreLike { private readonly _baseDirectory; constructor(baseDirectory?: string); /** Absolute path of the store directory. */ get baseDirectory(): string; /** Absolute path of the registration document. */ get registrationsPath(): string; /** Every stored registration, keyed by {@link registrationStoreKey}. Never throws. */ readAll(): Record; /** The registration for one AS, or null when this installation has not registered there yet. */ read(serverBaseUrl: string): ClientRegistration | null; /** Persist (or replace) the registration for one AS, leaving every other AS entry intact. */ save(serverBaseUrl: string, registration: ClientRegistration): void; /** Forget the registration for one AS (the `invalid_client` recovery path). */ delete(serverBaseUrl: string): void; private writeAll; } /** Options for {@link resolveClientRegistration}. */ export interface ResolveClientRegistrationOptions { /** The AS root (e.g. `https://ai-game.dev`). */ serverBaseUrl: string; /** The portless loopback redirect URIs to register / to validate a cached registration against. */ redirectUris: string[]; /** Human-readable consent-screen name. Defaults to {@link DEFAULT_CLIENT_NAME}. */ clientName?: string; scope?: string; grantTypes?: readonly string[]; responseTypes?: readonly string[]; /** Pre-discovered metadata; when omitted, {@link discoverAuthorizationServer} runs. */ metadata?: AuthorizationServerMetadata; /** Persistence seam; defaults to the on-disk {@link ClientRegistrationStore}. */ store?: ClientRegistrationStoreLike; fetchImpl?: typeof fetch; timeoutMs?: number; signal?: AbortSignal; /** * Discard any cached registration and mint a fresh one. This is the **bounded** recovery path the * login flow takes exactly once after the AS reports `invalid_client` (30-day GC of unused clients, * or a revocation) — never a loop. */ forceRegister?: boolean; } /** The resolved client identity the authorization-code flow will present. */ export interface ResolvedClientRegistration { /** The AS-minted client id to send to `/oauth/authorize` and `/oauth/token`. */ clientId: string; registration: ClientRegistration; /** The metadata used (discovered or fallback), so the caller can reuse the discovered endpoints. */ metadata: AuthorizationServerMetadata; /** True when the registration was reused from the store (no network registration happened). */ reused: boolean; } /** * Discover the AS, then reuse this installation's stored registration — or mint and persist one. * * A stored registration is reused only when it still covers every required redirect URI; otherwise * (a changed callback path or loopback host) it is replaced, because the AS matches * scheme/host/path/query exactly and only lets the PORT float. * * Persisting is **best-effort**: a read-only or full home directory degrades to "register again next * launch" rather than failing a sign-in that would otherwise succeed. */ export declare function resolveClientRegistration(options: ResolveClientRegistrationOptions): Promise; //# sourceMappingURL=oauth-dcr.d.ts.map