From bb266bf359c21f3af5c4aa5e4701de21bc67f0d7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 3 Nov 2026 01:35:52 +0100 Subject: [PATCH] feat(mcp): MCP OAuth sign-in in two halves, moved from coding-agent pi-mcp now has startMcpSignIn and completeMcpSignIn, so a server app can start a sign-in in one request and complete it in another (a callback route, a pasted redirect URL, or a loopback server via listenForLoopbackSignIn/waitForLoopbackSignIn), plus createMcpAuthProvider. Application specifics (client name, Client ID Metadata Document base URL and listed redirect URIs) are options. coding-agent's signInMcpServer is a thin wrapper over them. authorizeMcp gains refreshOnly: a failed refresh no longer registers a client or replaces a pending sign-in's PKCE verifier and state. Sign-in refreshes with the stored client before dropping it for another redirect URI. --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/extensions/mcp/oauth.ts | 458 +++--------------- .../src/extensions/mcp/runtime.ts | 17 +- .../test/mcp-oauth-refresh.test.ts | 3 +- packages/mcp/CHANGELOG.md | 1 + packages/mcp/README.md | 19 + packages/mcp/src/oauth/auth-provider.ts | 118 +++++ packages/mcp/src/oauth/client-settings.ts | 228 +++++++++ packages/mcp/src/oauth/flow.ts | 57 ++- packages/mcp/src/oauth/index.ts | 22 + packages/mcp/src/oauth/loopback.ts | 137 ++++++ packages/mcp/src/oauth/sign-in.ts | 212 ++++++++ packages/mcp/test/auth-provider.test.ts | 161 ++++++ packages/mcp/test/authorization-server.ts | 149 ++++++ packages/mcp/test/loopback.test.ts | 126 +++++ packages/mcp/test/sign-in.test.ts | 346 +++++++++++++ 16 files changed, 1638 insertions(+), 420 deletions(-) create mode 100644 packages/mcp/src/oauth/auth-provider.ts create mode 100644 packages/mcp/src/oauth/client-settings.ts create mode 100644 packages/mcp/src/oauth/loopback.ts create mode 100644 packages/mcp/src/oauth/sign-in.ts create mode 100644 packages/mcp/test/auth-provider.test.ts create mode 100644 packages/mcp/test/authorization-server.ts create mode 100644 packages/mcp/test/loopback.test.ts create mode 100644 packages/mcp/test/sign-in.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 54d021971..ac04acaea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,10 @@ - `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711)) - Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545)) +### Fixed + +- A failed token refresh of an MCP connection no longer registers an OAuth client or replaces the PKCE verifier of a sign-in in progress, which made that sign-in fail. MCP sign-in now refreshes the stored tokens with the stored client even when the loopback callback port changed, instead of registering a new client and opening the browser. + ## [1.5.0] - 2026-11-02 ### New Features diff --git a/packages/coding-agent/src/extensions/mcp/oauth.ts b/packages/coding-agent/src/extensions/mcp/oauth.ts index 97fb56793..724ef38e5 100644 --- a/packages/coding-agent/src/extensions/mcp/oauth.ts +++ b/packages/coding-agent/src/extensions/mcp/oauth.ts @@ -1,11 +1,11 @@ /** - * OAuth sign-in for remote MCP servers. + * OAuth sign-in for remote MCP servers: pi's credential store and its side of the sign-in. * * Connections never start a browser flow on their own. They send the stored access token and, after * a 401, try the stored refresh token. When that is not possible they fail with - * `McpOAuthAuthorizationRequiredError`, and the user signs in through `/mcp`, which runs - * the authorization code flow (PKCE, a Client ID Metadata Document or dynamic client registration) - * against a loopback callback. + * `McpOAuthAuthorizationRequiredError`, and the user signs in through `/mcp` or `pi mcp login`, which + * run pi-mcp's sign-in (PKCE, pi's Client ID Metadata Document or dynamic client registration) against + * a loopback callback or a pasted redirect URL. * * Credentials live in `/mcp-auth.json`, keyed by server name and URL. */ @@ -14,103 +14,31 @@ import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { oauthErrorHtml, oauthSuccessHtml } from "@earendil-works/pi-ai/utils/oauth-page"; -import type { AuthProvider, McpFetch } from "@earendil-works/pi-mcp"; import { - type AuthorizationServerMetadata, - authorizeMcp, - McpOAuthAuthorizationRequiredError, - McpOAuthProvider, + completeMcpSignIn, + createMcpAuthProvider as createAuthProvider, + listenForLoopbackSignIn, + type McpAuthProvider, + type McpClientMetadataDocuments, + type McpOAuthServerStore, + type McpOAuthSettings, type McpOAuthState, - type McpOAuthStateStore, - type OAuthCallback, type OAuthCallbackPage, - OAuthCallbackServer, type OAuthChallenge, - type OAuthClientInformationMixed, - type OAuthClientMetadataDocument, - parseWwwAuthenticate, - stepUpScope, + startMcpSignIn, + waitForLoopbackSignIn, } from "@earendil-works/pi-mcp/oauth"; import lockfile from "proper-lockfile"; import { APP_NAME, getAgentDir } from "../../config.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "../../core/auth-storage.ts"; import { isClientMetadataDocumentCallback, mcpNamespace } from "../../core/mcp-servers.ts"; -const CALLBACK_HOST = "127.0.0.1"; -const CALLBACK_PATH = "/callback"; -/** Where pi.dev serves pi's Client ID Metadata Documents: `client.json` and `/client.json`. */ -const CLIENT_METADATA_BASE_URL = "https://pi.dev/oauth"; -/** Redirect URI for refreshes when none is stored. Refreshing never redirects the user. */ -const FALLBACK_REDIRECT_URL = `http://${CALLBACK_HOST}${CALLBACK_PATH}`; -/** Access tokens this close to expiry are refreshed before they are sent. */ -const REFRESH_SKEW_MS = 30_000; -/** - * Bounds each request to the authorization server, so an unresponsive one cannot hold the refresh lock, - * delay shutdown, or keep a sign-in waiting for long. - */ -const OAUTH_REQUEST_TIMEOUT_MS = 15_000; /** A refresh lock that its holder stopped renewing (the process was killed) is taken over after this. */ const REFRESH_LOCK_STALE_MS = 20_000; /** How long to wait for another process's refresh: longer than a stale lock lives. */ const REFRESH_LOCK_WAIT_MS = 25_000; const REFRESH_LOCK_RETRY_MS = 100; -export interface McpOAuthSettings { - clientId?: string; - /** Already resolved. */ - clientSecret?: string; - callbackPort?: number; - /** Loopback redirect URI; see `McpOAuthConfig.callbackUrl`. */ - callbackUrl?: string; - /** Scopes to request, separated by spaces. */ - scope?: string; - /** `client_name` for dynamic client registration. Default: `APP_NAME`. */ - clientName?: string; - /** See `McpOAuthConfig.clientRegistration`. */ - clientRegistration?: "auto" | "dcr" | "cimd"; - /** See `McpOAuthConfig.authServerMetadataUrl`. */ - authServerMetadataUrl?: URL; -} - -/** Where the loopback callback server listens and the redirect URI it serves. */ -interface CallbackSettings { - /** Address to listen on. */ - host: string; - /** Host name in the redirect URI. */ - redirectHost: string; - port: number | undefined; - path: string; - /** The exact redirect URI, when the port is fixed. */ - fixedRedirectUrl: string | undefined; -} - -function callbackSettings(settings: McpOAuthSettings): CallbackSettings { - const url = new URL(settings.callbackUrl ?? `http://${CALLBACK_HOST}${CALLBACK_PATH}`); - const address = url.hostname.replace(/^\[|\]$/g, ""); - const port = url.port ? Number(url.port) : settings.callbackPort; - let fixedRedirectUrl: string | undefined; - // A configured URI with a port is sent exactly as written, since servers compare it as a string. - if (url.port) fixedRedirectUrl = settings.callbackUrl; - else if (port !== undefined) { - url.port = String(port); - fixedRedirectUrl = url.href; - } - return { - // `localhost` is served on 127.0.0.1; browsers fall back to it when ::1 refuses. - host: address === "localhost" ? CALLBACK_HOST : address, - redirectHost: address, - port, - path: url.pathname, - fixedRedirectUrl, - }; -} - -/** Scopes of both lists, each once. */ -function mergeScopes(...scopes: (string | undefined)[]): string | undefined { - const merged = [...new Set(scopes.flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []))]; - return merged.length > 0 ? merged.join(" ") : undefined; -} - type StoredStates = Record; function parseStates(content: string | undefined): StoredStates { @@ -132,11 +60,6 @@ function storeKeys(name: string, serverUrl: string): { key: string; legacyKey: s return { key: `${mcpNamespace(name)}|${legacyKey}`, legacyKey }; } -export interface McpOAuthServerStore extends McpOAuthStateStore { - /** Run `fn` while no other process refreshes the server's tokens. */ - withRefreshLock(fn: () => Promise): Promise; -} - /** Per-server OAuth state (client registration, tokens, pending PKCE verifier) in `mcp-auth.json`. */ export class McpOAuthCredentialStore { private readonly backend: AuthStorageBackend; @@ -223,171 +146,29 @@ export class McpOAuthCredentialStore { } } -/** `fetch` with `OAUTH_REQUEST_TIMEOUT_MS` per request, on top of the request's own signal. */ -function timedFetch(fetch: McpFetch = globalThis.fetch): McpFetch { - return (input, init) => { - const timeout = AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS); - return fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout }); - }; +/** pi's Client ID Metadata Documents on pi.dev: `client.json` and `/client.json`. */ +const PI_CLIENT_METADATA_DOCUMENTS: McpClientMetadataDocuments = { + baseUrl: "https://pi.dev/oauth", + listsRedirectUrl: isClientMetadataDocumentCallback, +}; + +/** pi's client identity for sign-in and refresh. */ +const PI_CLIENT = { clientName: APP_NAME, clientMetadataDocuments: PI_CLIENT_METADATA_DOCUMENTS }; + +/** `settings` for pi-mcp: a rebranded build would show up as pi with pi's documents, so `auto` registers. */ +function piSettings(settings: McpOAuthSettings): McpOAuthSettings { + const auto = (settings.clientRegistration ?? "auto") === "auto"; + return auto && APP_NAME !== "pi" ? { ...settings, clientRegistration: "dcr" } : settings; } -function registeredRedirectUrls(client: OAuthClientInformationMixed | undefined): string[] { - return client && "redirect_uris" in client ? client.redirect_uris : []; -} - -/** 12 characters identifying an MCP server URL in callback paths, computed like Codex does. */ -function callbackId(serverUrl: string): string { - const url = new URL(serverUrl); - url.hash = ""; - return createHash("sha256").update(url.href).digest().subarray(0, 9).toString("base64url"); -} - -/** - * Whether sign-in may identify with pi's Client ID Metadata Document instead of registering. `auto` - * registers when the user asked for a client name, the redirect URI is not one the documents list, or - * a rebranded build would show up as pi. - */ -function offersClientMetadataDocument(settings: McpOAuthSettings): boolean { - const registration = settings.clientRegistration ?? "auto"; - if (registration !== "auto") return registration === "cimd"; - if (settings.clientId || settings.clientName || APP_NAME !== "pi") return false; - return isClientMetadataDocumentCallback(settings.callbackUrl); -} - -/** - * pi's Client ID Metadata Document, chosen like Codex chooses its own, or `undefined` when the - * authorization server does not support documents for public clients. Without the `iss` parameter in - * authorization responses (RFC 9207), the redirect URI and the document are specific to the MCP server, - * so a response cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). - */ -function clientMetadataDocument( - serverUrl: string, - redirectUrl: string, - metadata: AuthorizationServerMetadata | undefined, -): OAuthClientMetadataDocument | undefined { - if ( - !metadata?.client_id_metadata_document_supported || - !metadata.token_endpoint_auth_methods_supported?.includes("none") - ) { - return undefined; - } - if (metadata.authorization_response_iss_parameter_supported) { - return { url: `${CLIENT_METADATA_BASE_URL}/client.json`, redirectUrl }; - } - const id = callbackId(serverUrl); - const redirect = new URL(redirectUrl); - redirect.pathname = `${CALLBACK_PATH}/${id}`; - return { url: `${CLIENT_METADATA_BASE_URL}/${id}/client.json`, redirectUrl: redirect.href }; -} - -function createProvider( - serverUrl: string, - store: McpOAuthStateStore, - settings: McpOAuthSettings, - redirectUrl: string, - onRedirect: (url: URL) => void, -): McpOAuthProvider { - return new McpOAuthProvider({ - serverUrl, - redirectUrl, - clientMetadata: { client_name: settings.clientName ?? APP_NAME }, - clientMetadataDocument: offersClientMetadataDocument(settings) - ? (metadata) => { - const document = clientMetadataDocument(serverUrl, redirectUrl, metadata); - if (!document && settings.clientRegistration === "cimd") { - throw new Error( - 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', - ); - } - return document; - } - : undefined, - clientId: settings.clientId, - clientSecret: settings.clientSecret, - store, - onRedirect, - }); -} - -export interface McpAuthProvider extends AuthProvider { - /** Resolves when no refresh is running, so shutdown does not drop rotated tokens before they are saved. */ - settled(): Promise; -} - -/** - * Auth provider for MCP connections: sends the stored access token and refreshes it when it is about - * to expire or after a 401. Throws `McpOAuthAuthorizationRequiredError` when the user has to sign in, - * including when the server asks for more scope (`insufficient_scope`). `onChallenge` receives the - * server's `WWW-Authenticate` challenge so sign-in can use its resource metadata URL and scope. - * `settings` is only called when a refresh is needed, so a secret that fails to resolve fails the - * refresh instead of the whole connection setup. - * - * Many servers rotate refresh tokens, so two refreshes with the same refresh token lose the grant. - * Requests in this process share one refresh, and other processes are kept out by the store's - * refresh lock, held from reading the tokens to saving new ones. Tokens that changed meanwhile - * (another process refreshed them, or the user signed in) are used without refreshing. - */ +/** pi-mcp's `createMcpAuthProvider` with pi's client identity. */ export function createMcpAuthProvider(options: { serverUrl: string; store: McpOAuthServerStore; settings: () => McpOAuthSettings; onChallenge: (challenge: OAuthChallenge) => void; }): McpAuthProvider { - const { serverUrl, store } = options; - let refreshing: Promise | undefined; - - /** Replace `staleToken`, the access token that expired or was rejected. */ - const refresh = (staleToken: string | undefined, fetch: McpFetch = globalThis.fetch, challenge?: OAuthChallenge) => { - refreshing ??= store - .withRefreshLock(async () => { - const state = await store.load(); - if (state?.tokens?.access_token !== staleToken) return; - if (!state?.tokens?.refresh_token) throw new McpOAuthAuthorizationRequiredError(); - const settings = options.settings(); - const redirectUrl = - callbackSettings(settings).fixedRedirectUrl ?? - registeredRedirectUrls(state.clientInformation)[0] ?? - FALLBACK_REDIRECT_URL; - const provider = createProvider(serverUrl, store, settings, redirectUrl, () => {}); - // Refreshes the tokens, or reports that a new sign-in is needed. Not cancellable: a refresh the - // server answered may have rotated the refresh token, so its answer must be saved. - const result = await authorizeMcp(provider, { - serverUrl, - resourceMetadataUrl: challenge?.resourceMetadataUrl, - authorizationServerMetadataUrl: settings.authServerMetadataUrl, - scope: challenge?.scope, - fetch: timedFetch(fetch), - }); - if (result === "REDIRECT") throw new McpOAuthAuthorizationRequiredError(); - }) - .finally(() => { - refreshing = undefined; - }); - return refreshing; - }; - - return { - token: async () => { - await refreshing?.catch(() => undefined); - const state = await store.load(); - const token = state?.tokens?.access_token; - const expired = state?.tokensExpireAt !== undefined && state.tokensExpireAt - REFRESH_SKEW_MS <= Date.now(); - if (!expired || !state?.tokens?.refresh_token) return token; - // Failures fall through: the request goes out with the old token and a 401 decides what happens. - await refresh(token).catch(() => undefined); - return (await store.load())?.tokens?.access_token; - }, - onUnauthorized: async (context) => { - const challenge = parseWwwAuthenticate(context.response.headers.get("www-authenticate")); - options.onChallenge(challenge); - // A refresh keeps the granted scope, so more scope needs a new sign-in. - if (challenge.error === "insufficient_scope") throw new McpOAuthAuthorizationRequiredError(); - await refresh(context.token, context.fetch, challenge); - }, - settled: async () => { - await refreshing?.catch(() => undefined); - }, - }; + return createAuthProvider({ ...options, ...PI_CLIENT, settings: () => piSettings(options.settings()) }); } export interface McpSignInPrompt { @@ -401,178 +182,47 @@ export interface McpSignInPrompt { promptForRedirectUrl(signal: AbortSignal): Promise; } -export class McpSignInCancelledError extends Error { - constructor() { - super("Sign-in cancelled"); - this.name = "McpSignInCancelledError"; - } -} - -type AuthorizationResponse = Pick; - -function responseFromRedirectUrl(input: string, state: string, redirectUrl: URL): AuthorizationResponse { - let url: URL; - try { - url = new URL(input.trim()); - } catch { - throw new Error("Expected the full redirect URL from the browser address bar"); - } - // A server-specific redirect URI tells authorization servers apart, so it must match exactly. - if (url.origin !== redirectUrl.origin || url.pathname !== redirectUrl.pathname) { - throw new Error("The redirect URL does not match this sign-in's redirect URI"); - } - const error = url.searchParams.get("error"); - if (error) throw new Error(url.searchParams.get("error_description") ?? error); - if (url.searchParams.get("state") !== state) throw new Error("The redirect URL belongs to a different sign-in"); - const code = url.searchParams.get("code"); - if (!code) throw new Error("The redirect URL does not contain an authorization code"); - return { code, iss: url.searchParams.get("iss") ?? undefined }; -} - -/** - * Wait for the browser callback or a pasted redirect URL, whichever comes first. Aborting `signal` - * aborts the prompt, which then cancels the sign-in. - */ -async function waitForAuthorizationResponse( - callback: OAuthCallbackServer, - state: string, - redirectUrl: URL, - prompt: McpSignInPrompt, - signal: AbortSignal | undefined, -): Promise { - const controller = new AbortController(); - const promptSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal; - const fromBrowser = callback.waitForCallback(state, redirectUrl.pathname); - const fromUser = prompt.promptForRedirectUrl(promptSignal).then((input) => { - if (!input?.trim()) throw new McpSignInCancelledError(); - return responseFromRedirectUrl(input, state, redirectUrl); - }); - try { - return await Promise.race([fromBrowser, fromUser]); - } finally { - // The losing side rejects once the prompt is aborted or the callback server closes. - controller.abort(); - fromBrowser.catch(() => undefined); - fromUser.catch(() => undefined); - } -} - -/** Listen on `port`, or on a free port when it is taken and not `required`. */ -async function listenForCallback( - settings: CallbackSettings, - extraPaths: string[], - port: number | undefined, - required: boolean, -): Promise { - const options = { - host: settings.host, - redirectHost: settings.redirectHost, - path: settings.path, - extraPaths, - renderPage: (page: OAuthCallbackPage) => - page.ok - ? oauthSuccessHtml("Signed in to the MCP server. You may now close this page.") - : oauthErrorHtml(page.message, page.details), - }; - try { - return await OAuthCallbackServer.listen({ ...options, port: port ?? 0 }); - } catch (error) { - if (required || port === undefined) throw error; - return OAuthCallbackServer.listen(options); - } +function renderCallbackPage(page: OAuthCallbackPage): string { + return page.ok + ? oauthSuccessHtml("Signed in to the MCP server. You may now close this page.") + : oauthErrorHtml(page.message, page.details); } /** * Sign in to an MCP server. Uses the stored refresh token when possible; otherwise runs the browser - * authorization code flow. Tokens are saved to `store`. Aborting `signal` stops the sign-in at any - * step with `McpSignInCancelledError`; requests to the authorization server are also time-limited. + * authorization code flow against a loopback callback or a pasted redirect URL. Tokens are saved to + * `store`. Aborting `signal` stops the sign-in at any step with `McpSignInCancelledError`; requests to + * the authorization server are also time-limited. */ export async function signInMcpServer(options: { serverUrl: string; - store: McpOAuthStateStore; + store: McpOAuthServerStore; settings: McpOAuthSettings; challenge?: OAuthChallenge; prompt: McpSignInPrompt; signal?: AbortSignal; }): Promise { - const { serverUrl, store, settings, signal } = options; - if (signal?.aborted) throw new McpSignInCancelledError(); - const stored = await store.load(); - const stepUp = options.challenge?.error === "insufficient_scope"; - const callbackOptions = callbackSettings(settings); - // Reuse the port of the registered redirect URI so the registered client stays valid. - const registered = registeredRedirectUrls(stored?.clientInformation)[0]; - const preferredPort = - callbackOptions.port ?? (registered ? Number(new URL(registered).port) || undefined : undefined); - const offersDocument = offersClientMetadataDocument(settings); - const callback = await listenForCallback( - callbackOptions, - // The redirect URI of a server-specific Client ID Metadata Document. - offersDocument ? [`${CALLBACK_PATH}/${callbackId(serverUrl)}`] : [], - preferredPort, - callbackOptions.port !== undefined, - ); - const redirectUrl = callbackOptions.fixedRedirectUrl ?? callback.redirectUrl; + const { serverUrl, store, challenge, prompt, signal } = options; + const settings = piSettings(options.settings); + const { callback, redirectUrl } = await listenForLoopbackSignIn({ + serverUrl, + store, + settings, + clientMetadataDocuments: PI_CLIENT_METADATA_DOCUMENTS, + renderPage: renderCallbackPage, + }); try { - if (stored) { - const next: McpOAuthState = { ...stored }; - // Every sign-in gets a fresh `state` parameter. - delete next.oauthState; - // A registered client cannot use another redirect URI, and its tokens belong to it. `cimd` replaces a - // registered client; `auto` keeps it. A Client ID Metadata Document is not stored, so tokens without - // a stored client belong to the document. - const keepClient = - settings.clientId || - (stored.clientInformation - ? settings.clientRegistration !== "cimd" && - registeredRedirectUrls(stored.clientInformation).includes(redirectUrl) - : offersDocument); - if (!keepClient) { - delete next.clientInformation; - delete next.tokens; - delete next.tokensExpireAt; - } - await store.save(next); - } - - let authorizationUrl: URL | undefined; - const provider = createProvider(serverUrl, store, settings, redirectUrl, (url) => { - authorizationUrl = url; - }); - const flow = { - serverUrl, - fetch: timedFetch(), - signal, - resourceMetadataUrl: options.challenge?.resourceMetadataUrl, - authorizationServerMetadataUrl: settings.authServerMetadataUrl, - // A server asking for more scope gets it on top of the configured scope and, since the challenge - // may list only the missing scopes, on top of the scope granted so far. - scope: mergeScopes( - settings.scope, - stepUp ? stepUpScope(stored?.tokens?.scope, options.challenge?.scope) : options.challenge?.scope, - ), - }; - // A refresh keeps the granted scope; a server asking for more needs the browser flow. - const skipRefresh = stepUp; - if ((await authorizeMcp(provider, { ...flow, skipRefresh })) === "AUTHORIZED") return; - if (!authorizationUrl) throw new Error("OAuth flow did not produce an authorization URL"); - - const state = await provider.state(); - // The flow picks the redirect URI, which may be specific to the MCP server. - const authorizationRedirectUrl = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? redirectUrl); - options.prompt.showAuthorizationUrl(authorizationUrl); - const { code, iss } = await waitForAuthorizationResponse( + const signIn = { serverUrl, store, settings, redirectUrl, challenge, signal, ...PI_CLIENT }; + const authorization = await startMcpSignIn(signIn); + if (authorization.kind === "authorized") return; + prompt.showAuthorizationUrl(new URL(authorization.authorizationUrl)); + const response = await waitForLoopbackSignIn({ callback, - state, - authorizationRedirectUrl, - options.prompt, + authorization, + promptForRedirectUrl: (promptSignal) => prompt.promptForRedirectUrl(promptSignal), signal, - ); - await authorizeMcp(provider, { ...flow, authorizationCode: code, iss }); - } catch (error) { - // Aborted requests fail with the signal's reason; report them as the cancellation they are. - if (signal?.aborted) throw new McpSignInCancelledError(); - throw error; + }); + await completeMcpSignIn({ ...signIn, authorization, response }); } finally { await callback.close(); } diff --git a/packages/coding-agent/src/extensions/mcp/runtime.ts b/packages/coding-agent/src/extensions/mcp/runtime.ts index f13d21b12..39a3d26a5 100644 --- a/packages/coding-agent/src/extensions/mcp/runtime.ts +++ b/packages/coding-agent/src/extensions/mcp/runtime.ts @@ -28,22 +28,23 @@ import { StdioTransport, StreamableHttpTransport, } from "@earendil-works/pi-mcp"; -import { McpOAuthAuthorizationRequiredError, type OAuthChallenge } from "@earendil-works/pi-mcp/oauth"; +import { + type McpAuthProvider, + McpOAuthAuthorizationRequiredError, + type McpOAuthSettings, + type OAuthChallenge, +} from "@earendil-works/pi-mcp/oauth"; import { VERSION } from "../../config.ts"; import { resolveConfigValueOrThrow, resolveHeadersOrThrow } from "../../core/resolve-config-value.ts"; import type { McpServerEntry } from "./config.ts"; import type { McpServerLog } from "./log.ts"; -import { - createMcpAuthProvider, - type McpAuthProvider, - type McpOAuthCredentialStore, - type McpOAuthSettings, -} from "./oauth.ts"; +import { createMcpAuthProvider, type McpOAuthCredentialStore } from "./oauth.ts"; import type { McpResourceServer } from "./resources.ts"; import type { McpToolCaller } from "./tools.ts"; +export { McpSignInCancelledError } from "@earendil-works/pi-mcp/oauth"; export { McpServerLog } from "./log.ts"; -export { McpOAuthCredentialStore, McpSignInCancelledError, signInMcpServer } from "./oauth.ts"; +export { McpOAuthCredentialStore, signInMcpServer } from "./oauth.ts"; const DEFAULT_TIMEOUT_SECONDS = 60; const STDERR_TAIL_CHARS = 2_000; diff --git a/packages/coding-agent/test/mcp-oauth-refresh.test.ts b/packages/coding-agent/test/mcp-oauth-refresh.test.ts index aed142a87..4b7c8fe7c 100644 --- a/packages/coding-agent/test/mcp-oauth-refresh.test.ts +++ b/packages/coding-agent/test/mcp-oauth-refresh.test.ts @@ -2,13 +2,12 @@ import { createHash } from "node:crypto"; import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type OAuthChallenge, OAuthIssuerMismatchError } from "@earendil-works/pi-mcp/oauth"; +import { type McpOAuthSettings, type OAuthChallenge, OAuthIssuerMismatchError } from "@earendil-works/pi-mcp/oauth"; import { afterEach, describe, expect, it } from "vitest"; import { InMemoryAuthStorageBackend } from "../src/core/auth-storage.ts"; import { createMcpAuthProvider, McpOAuthCredentialStore, - type McpOAuthSettings, type McpSignInPrompt, signInMcpServer, } from "../src/extensions/mcp/oauth.ts"; diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index a971d0b59..966cc0d07 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `PipeTransport`, MCP's stdio transport over a program started by the caller through an `McpPipe` (`write`, `closeStdin`, `close`, `exited`), for example `@earendil-works/pi-env`'s `openProcess()` on another machine. `StdioTransport` and `PipeTransport` share the newline-delimited framing and the stderr tail. +- Added MCP OAuth sign-in in two halves (`@earendil-works/pi-mcp/oauth`), moved from pi's coding agent: `startMcpSignIn()` refreshes the stored tokens or returns the authorization URL, and `completeMcpSignIn()` exchanges the code later, possibly in another request with the same state store. The code comes from a loopback callback server (`listenForLoopbackSignIn()`, `waitForLoopbackSignIn()`), the application's own callback route, or a pasted redirect URL (`responseFromRedirectUrl()`). Client ID Metadata Documents (`clientMetadataDocuments`) and the client name are the caller's. Also added `createMcpAuthProvider()`, which refreshes tokens once across requests and, through `McpOAuthServerStore.withRefreshLock()`, across processes, and the `refreshOnly` option of `authorizeMcp()`, which refreshes without registering a client or replacing a pending authorization's `state` and PKCE verifier. ## [1.5.0] - 2026-11-02 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index bce56542d..dc56ba8b3 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -124,6 +124,25 @@ await connected; Inject `McpOAuthStateStore` into `McpOAuthProvider` for durable credentials. The package does not open a browser or choose where credentials are stored. +#### Sign-in in two halves + +`startMcpSignIn()` and `completeMcpSignIn()` run the sign-in pi uses, split so the second half can run in another request with the same state store (an `McpOAuthServerStore`: a state store with a refresh lock). The caller picks the redirect URI: a loopback one, or an https callback route of its own. `startMcpSignIn()` first tries to refresh the stored tokens under the store's refresh lock, so never call it while holding that lock; its `redirect` result is plain data the application can persist until the response arrives. Pass `completeMcpSignIn()` the same options, including the redirect URI you chose (not `started.redirectUrl`, which may be a variant specific to the MCP server). + +```typescript +import { completeMcpSignIn, responseFromRedirectUrl, startMcpSignIn } from "@earendil-works/pi-mcp/oauth"; + +const signIn = { serverUrl, store, settings: {}, redirectUrl: "https://app.example/mcp/callback", clientName: "My app" }; +const started = await startMcpSignIn(signIn); +if (started.kind === "redirect") { + // Send the browser to started.authorizationUrl; keep `started` until the response arrives. + // Later, a callback route builds { code, state, iss } from its query; a pasted URL goes through: + const response = responseFromRedirectUrl(pastedUrl, started); + await completeMcpSignIn({ ...signIn, authorization: started, response }); +} +``` + +For a browser on the same machine, `listenForLoopbackSignIn()` starts a loopback callback server and returns its redirect URI, and `waitForLoopbackSignIn()` waits for the response on it or for a pasted redirect URL. `clientMetadataDocuments` lets authorization servers that support Client ID Metadata Documents identify the application by a document it publishes instead of registering a client. `createMcpAuthProvider()` is the matching `AuthProvider` for connections: it refreshes the stored tokens and throws `McpOAuthAuthorizationRequiredError` when the user has to sign in. + The OAuth implementation is adapted from the MIT-licensed Model Context Protocol TypeScript SDK v1.29.0. Its license is included under `LICENSES/`. An MCP transport owns framing and I/O. It delivers individual JSON-RPC messages to `McpClient`; the client owns request correlation, initialization, timeouts, cancellation, server requests, and protocol-level helpers. diff --git a/packages/mcp/src/oauth/auth-provider.ts b/packages/mcp/src/oauth/auth-provider.ts new file mode 100644 index 000000000..53cfe8b17 --- /dev/null +++ b/packages/mcp/src/oauth/auth-provider.ts @@ -0,0 +1,118 @@ +/** + * The auth provider for connections to an OAuth-protected MCP server: it sends the stored access token + * and refreshes it, but never starts a browser flow. When the user has to sign in, it fails with + * `McpOAuthAuthorizationRequiredError` and the application runs `startMcpSignIn`. + */ + +import type { AuthProvider, McpFetch } from "../auth-provider.ts"; +import { + createOAuthClientProvider, + DEFAULT_REDIRECT_URL, + loopbackSettings, + type McpOAuthClientOptions, + type McpOAuthSettings, + registeredRedirectUrls, + timedFetch, +} from "./client-settings.ts"; +import { parseWwwAuthenticate } from "./discovery.ts"; +import { McpOAuthAuthorizationRequiredError } from "./errors.ts"; +import { authorizeMcp, type OAuthFlowResult } from "./flow.ts"; +import type { McpOAuthState } from "./provider.ts"; +import type { OAuthChallenge } from "./types.ts"; + +/** An `AuthProvider` that can say when its refreshes have finished. */ +export interface McpAuthProvider extends AuthProvider { + /** Resolves when no refresh is running, so shutdown does not drop rotated tokens before they are saved. */ + settled(): Promise; +} + +/** Access tokens this close to expiry are refreshed before they are sent. */ +const REFRESH_SKEW_MS = 30_000; + +/** + * Auth provider for MCP connections: sends the stored access token and refreshes it when it is about + * to expire or after a 401. Throws `McpOAuthAuthorizationRequiredError` when the user has to sign in, + * including when the server asks for more scope (`insufficient_scope`). `onChallenge` receives the + * server's `WWW-Authenticate` challenge so sign-in can use its resource metadata URL and scope. + * `settings` is only called when a refresh is needed, so a secret that fails to resolve fails the + * refresh instead of the whole connection setup. + * + * Many servers rotate refresh tokens, so two refreshes with the same refresh token lose the grant. + * Requests in this process share one refresh, and other processes are kept out by the store's + * refresh lock, held from reading the tokens to saving new ones. Tokens that changed meanwhile + * (another process refreshed them, or the user signed in) are used without refreshing. + */ +export function createMcpAuthProvider( + options: Omit & { + settings: () => McpOAuthSettings; + onChallenge: (challenge: OAuthChallenge) => void; + }, +): McpAuthProvider { + const { store } = options; + let refreshing: Promise | undefined; + + /** Replace `staleToken`, the access token that expired or was rejected. */ + const refresh = (staleToken: string | undefined, fetch: McpFetch = globalThis.fetch, challenge?: OAuthChallenge) => { + refreshing ??= store + .withRefreshLock(async () => { + const state = await store.load(); + if (state?.tokens?.access_token !== staleToken) return; + if (!state?.tokens?.refresh_token) throw new McpOAuthAuthorizationRequiredError(); + const client = { ...options, settings: options.settings() }; + const result = await refreshStoredTokens(client, state, { fetch, challenge }); + if (result === "REDIRECT") throw new McpOAuthAuthorizationRequiredError(); + }) + .finally(() => { + refreshing = undefined; + }); + return refreshing; + }; + + return { + token: async () => { + await refreshing?.catch(() => undefined); + const state = await store.load(); + const token = state?.tokens?.access_token; + const expired = state?.tokensExpireAt !== undefined && state.tokensExpireAt - REFRESH_SKEW_MS <= Date.now(); + if (!expired || !state?.tokens?.refresh_token) return token; + // Failures fall through: the request goes out with the old token and a 401 decides what happens. + await refresh(token).catch(() => undefined); + return (await store.load())?.tokens?.access_token; + }, + onUnauthorized: async (context) => { + const challenge = parseWwwAuthenticate(context.response.headers.get("www-authenticate")); + options.onChallenge(challenge); + // A refresh keeps the granted scope, so more scope needs a new sign-in. + if (challenge.error === "insufficient_scope") throw new McpOAuthAuthorizationRequiredError(); + await refresh(context.token, context.fetch, challenge); + }, + settled: async () => { + await refreshing?.catch(() => undefined); + }, + }; +} + +/** + * Refresh the tokens of `state` with the stored client (or the document it signed in with), or report + * `REDIRECT` when that is not possible. Never registers a client or prepares an authorization request, + * so a pending sign-in stays usable. Call while holding the store's refresh lock. Not cancellable: a + * refresh the server answered may have rotated the refresh token, so its answer must be saved. + */ +export async function refreshStoredTokens( + client: McpOAuthClientOptions, + state: McpOAuthState, + options: { fetch?: McpFetch; challenge?: OAuthChallenge } = {}, +): Promise { + // Refreshing never redirects the user; the redirect URI only picks the client metadata. + const redirectUrl = + loopbackSettings(client.settings).fixedRedirectUrl ?? + registeredRedirectUrls(state.clientInformation)[0] ?? + DEFAULT_REDIRECT_URL; + return authorizeMcp(createOAuthClientProvider(client, redirectUrl), { + serverUrl: client.serverUrl, + resourceMetadataUrl: options.challenge?.resourceMetadataUrl, + authorizationServerMetadataUrl: client.settings.authServerMetadataUrl, + fetch: timedFetch(options.fetch), + refreshOnly: true, + }); +} diff --git a/packages/mcp/src/oauth/client-settings.ts b/packages/mcp/src/oauth/client-settings.ts new file mode 100644 index 000000000..e20083d49 --- /dev/null +++ b/packages/mcp/src/oauth/client-settings.ts @@ -0,0 +1,228 @@ +/** + * The OAuth client that sign-in and refreshes use for one MCP server: its settings, how it identifies + * itself (a pre-registered client, a Client ID Metadata Document, or dynamic client registration), and + * the `McpOAuthProvider` built from them. + */ + +import { createHash } from "node:crypto"; +import type { McpFetch } from "../auth-provider.ts"; +import type { OAuthClientMetadataDocument } from "./flow.ts"; +import { McpOAuthProvider, type McpOAuthStateStore } from "./provider.ts"; +import type { AuthorizationServerMetadata, OAuthClientInformationMixed } from "./types.ts"; + +/** Client settings of one MCP server, usually from the application's configuration. */ +export interface McpOAuthSettings { + /** Pre-registered client id. Without it, sign-in uses a Client ID Metadata Document or registers a client. */ + clientId?: string; + /** Already resolved. */ + clientSecret?: string; + /** + * Port of the loopback callback server, for clients registered with a fixed redirect URI. Without + * `callbackUrl`, the redirect URI is `http://127.0.0.1:/callback`. Only `listenForLoopbackSignIn` + * and the redirect URI of refreshes use it; `startMcpSignIn` and `completeMcpSignIn` take theirs from + * the caller. + */ + callbackPort?: number; + /** + * Loopback redirect URI registered for `clientId`, for example `http://localhost:8080/oauth/callback`. + * Without a port, the callback server listens on `callbackPort` or a free port, which is added to the + * URI (RFC 8252). Default: `http://127.0.0.1/callback`. Like `callbackPort`, only `listenForLoopbackSignIn` + * and refreshes use it. + */ + callbackUrl?: string; + /** Scopes to request, separated by spaces. Default: the scopes the server advertises. */ + scope?: string; + /** `client_name` for dynamic client registration. Default: the application's `clientName`. */ + clientName?: string; + /** + * How to identify without `clientId`. `auto` (default): a Client ID Metadata Document when the + * authorization server supports them for public clients and the documents list the redirect URI, + * otherwise dynamic client registration; with `clientName`, always dynamic client registration. + * `cimd`: always the document, and sign-in fails without support. `dcr`: always dynamic client + * registration. + */ + clientRegistration?: "auto" | "dcr" | "cimd"; + /** + * Authorization server metadata document to use instead of discovery through the server, for servers + * that advertise a wrong authorization server or none. It is trusted as configured. + */ + authServerMetadataUrl?: URL; +} + +/** + * The Client ID Metadata Documents an application publishes: `/client.json`, and for each MCP + * server `//client.json`, whose redirect URIs carry the path `/`. + */ +export interface McpClientMetadataDocuments { + /** For example `https://pi.dev/oauth`. */ + baseUrl: string; + /** Whether the documents list `redirectUrl`. Loopback redirect URIs match on any port (RFC 8252). */ + listsRedirectUrl(redirectUrl: string): boolean; +} + +/** State store of one MCP server, with a lock that keeps other processes from refreshing at the same time. */ +export interface McpOAuthServerStore extends McpOAuthStateStore { + /** + * Run `fn` while no other process refreshes the server's tokens. Not reentrant: `startMcpSignIn` and + * `createMcpAuthProvider` take it themselves, so never call them from `fn`. + */ + withRefreshLock(fn: () => Promise): Promise; +} + +/** The OAuth client of one MCP server: where its state lives, its settings, and the application's identity. */ +export interface McpOAuthClientOptions { + serverUrl: string; + store: McpOAuthServerStore; + settings: McpOAuthSettings; + /** `client_name` for dynamic client registration unless `settings.clientName` names another: the application. */ + clientName: string; + /** Omitted: dynamic client registration only. */ + clientMetadataDocuments?: McpClientMetadataDocuments; +} + +/** An `McpOAuthProvider` for the client, redirecting to `redirectUrl` or the document's variant of it. */ +export function createOAuthClientProvider( + options: McpOAuthClientOptions, + redirectUrl: string, + onRedirect: (url: URL) => void = () => {}, +): McpOAuthProvider { + const { serverUrl, settings, clientMetadataDocuments: documents } = options; + return new McpOAuthProvider({ + serverUrl, + redirectUrl, + clientMetadata: { client_name: settings.clientName ?? options.clientName }, + clientMetadataDocument: offersClientMetadataDocument(settings, documents, redirectUrl) + ? (metadata) => { + if (!documents?.listsRedirectUrl(redirectUrl)) { + throw new Error( + `oauth.clientRegistration "cimd" needs Client ID Metadata Documents that list the redirect URI ${redirectUrl}`, + ); + } + const document = clientMetadataDocument(serverUrl, redirectUrl, metadata, documents.baseUrl); + if (!document && settings.clientRegistration === "cimd") { + throw new Error( + 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', + ); + } + return document; + } + : undefined, + clientId: settings.clientId, + clientSecret: settings.clientSecret, + store: options.store, + onRedirect, + }); +} + +/** + * Whether the client may identify with a Client ID Metadata Document instead of registering. `auto` + * registers when the user asked for a client name or the documents do not list the redirect URI. + */ +export function offersClientMetadataDocument( + settings: McpOAuthSettings, + documents: McpClientMetadataDocuments | undefined, + redirectUrl: string, +): boolean { + const registration = settings.clientRegistration ?? "auto"; + if (registration !== "auto") return registration === "cimd"; + return !settings.clientId && !settings.clientName && documents?.listsRedirectUrl(redirectUrl) === true; +} + +/** + * The Client ID Metadata Document to use, chosen like Codex chooses its own, or `undefined` when the + * authorization server does not support documents for public clients. Without the `iss` parameter in + * authorization responses (RFC 9207), the redirect URI and the document are specific to the MCP server, + * so a response cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). + */ +function clientMetadataDocument( + serverUrl: string, + redirectUrl: string, + metadata: AuthorizationServerMetadata | undefined, + baseUrl: string, +): OAuthClientMetadataDocument | undefined { + if ( + !metadata?.client_id_metadata_document_supported || + !metadata.token_endpoint_auth_methods_supported?.includes("none") + ) { + return undefined; + } + if (metadata.authorization_response_iss_parameter_supported) return { url: `${baseUrl}/client.json`, redirectUrl }; + const redirect = new URL(redirectUrl); + redirect.pathname = serverSpecificPath(redirect.pathname, serverUrl); + return { url: `${baseUrl}/${callbackId(serverUrl)}/client.json`, redirectUrl: redirect.href }; +} + +/** The path of the redirect URI of a server-specific Client ID Metadata Document: `/`. */ +export function serverSpecificPath(path: string, serverUrl: string): string { + return `${path.replace(/\/$/, "")}/${callbackId(serverUrl)}`; +} + +/** 12 characters identifying an MCP server URL in callback paths, computed like Codex does. */ +function callbackId(serverUrl: string): string { + const url = new URL(serverUrl); + url.hash = ""; + return createHash("sha256").update(url.href).digest().subarray(0, 9).toString("base64url"); +} + +/** Redirect URIs a stored client was registered with. */ +export function registeredRedirectUrls(client: OAuthClientInformationMixed | undefined): string[] { + return client && "redirect_uris" in client ? client.redirect_uris : []; +} + +/** + * Bounds each request to the authorization server, so an unresponsive one cannot hold the refresh lock, + * delay shutdown, or keep a sign-in waiting for long. + */ +const OAUTH_REQUEST_TIMEOUT_MS = 15_000; + +/** `fetch` with `OAUTH_REQUEST_TIMEOUT_MS` per request, on top of the request's own signal. */ +export function timedFetch(fetch: McpFetch = globalThis.fetch): McpFetch { + return (input, init) => { + const timeout = AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS); + return fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout }); + }; +} + +const CALLBACK_HOST = "127.0.0.1"; +const CALLBACK_PATH = "/callback"; + +/** Redirect URI when the settings name none and no client is stored. */ +export const DEFAULT_REDIRECT_URL = `http://${CALLBACK_HOST}${CALLBACK_PATH}`; + +/** Where the loopback callback server listens and the redirect URI it serves, from `callbackUrl` and `callbackPort`. */ +export interface LoopbackSettings { + /** The redirect URI, without the port unless one is configured. */ + url: string; + /** Address to listen on. */ + host: string; + /** Host name in the redirect URI. */ + redirectHost: string; + port: number | undefined; + path: string; + /** The exact redirect URI, when the port is fixed. */ + fixedRedirectUrl: string | undefined; +} + +/** The loopback callback settings of `settings`. */ +export function loopbackSettings(settings: McpOAuthSettings): LoopbackSettings { + const configured = settings.callbackUrl ?? DEFAULT_REDIRECT_URL; + const url = new URL(configured); + const address = url.hostname.replace(/^\[|\]$/g, ""); + const port = url.port ? Number(url.port) : settings.callbackPort; + let fixedRedirectUrl: string | undefined; + // A configured URI with a port is sent exactly as written, since servers compare it as a string. + if (url.port) fixedRedirectUrl = settings.callbackUrl; + else if (port !== undefined) { + url.port = String(port); + fixedRedirectUrl = url.href; + } + return { + url: configured, + // `localhost` is served on 127.0.0.1; browsers fall back to it when ::1 refuses. + host: address === "localhost" ? CALLBACK_HOST : address, + redirectHost: address, + port, + path: url.pathname, + fixedRedirectUrl, + }; +} diff --git a/packages/mcp/src/oauth/flow.ts b/packages/mcp/src/oauth/flow.ts index 63821fcdb..993c0a7b1 100644 --- a/packages/mcp/src/oauth/flow.ts +++ b/packages/mcp/src/oauth/flow.ts @@ -89,6 +89,16 @@ export interface OAuthFlowOptions { * server asks for scopes the current grant lacks (a refresh keeps the old scope). */ skipRefresh?: boolean; + /** + * Only refresh the stored tokens: return `REDIRECT` instead of registering a client or preparing an + * authorization request, so a pending authorization (its `state` and PKCE verifier) stays usable. + */ + refreshOnly?: boolean; + /** + * With `authorizationCode`: the redirect URI the authorization request used. The exchange fails before + * sending the code when the flow would send another one (RFC 6749 section 4.1.3 requires the same). + */ + authorizationRedirectUrl?: string; } export type OAuthFlowResult = "AUTHORIZED" | "REDIRECT"; @@ -320,7 +330,17 @@ export function stepUpScope(granted: string | undefined, challenged: string | un return [...new Set(scopes)].join(" "); } -async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise { +/** What a flow used, for deciding what to invalidate after it failed. */ +interface FlowUse { + /** `client_id` of the stored client the flow used; `undefined` when none was stored. */ + storedClientId?: string; +} + +async function runFlow( + provider: OAuthClientProvider, + options: OAuthFlowOptions, + use: FlowUse = {}, +): Promise { const metadataUrl = options.authorizationServerMetadataUrl && secureEndpoint(options.authorizationServerMetadataUrl); // With a configured metadata URL, discovery is not cached, so changing the URL applies at once. const cached = metadataUrl ? undefined : await provider.discoveryState?.(); @@ -355,14 +375,30 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions) const scope = options.scope || discovered.resourceMetadata?.scopes_supported?.join(" ") || provider.clientMetadata.scope; const stored = await provider.clientInformation(); + use.storedClientId = stored?.client_id; const clientDocument = stored ? undefined : provider.clientMetadataDocument?.(metadata); if (clientDocument) { const url = new URL(clientDocument.url); if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL"); } + // The document's redirect URI may differ from the provider's, for example by a server-specific path. + const redirectUrl = clientDocument?.redirectUrl ?? String(provider.redirectUrl); + // Checked first: another redirect URI also explains a client that is missing for the exchange. + if (options.authorizationCode && options.authorizationRedirectUrl !== undefined) { + if (redirectUrl !== options.authorizationRedirectUrl) { + throw new Error( + `The code exchange would use the redirect URI ${redirectUrl}, but the authorization request used ${options.authorizationRedirectUrl}`, + ); + } + } let client = stored ?? (clientDocument && { client_id: clientDocument.url }); if (!client) { - if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange"); + if (options.refreshOnly) return "REDIRECT"; + if (options.authorizationCode) { + throw new Error( + "OAuth client information is missing during code exchange: the authorization request's client was removed, or the client settings changed", + ); + } if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted"); client = await registerClient(discovered.authorizationServerUrl, { metadata, @@ -373,8 +409,6 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions) }); await provider.saveClientInformation(client); } - // The document's redirect URI may differ from the provider's, for example by a server-specific path. - const redirectUrl = clientDocument?.redirectUrl ?? provider.redirectUrl; const tokenOptions: TokenRequestOptions = { metadata, clientInformation: client, @@ -416,6 +450,7 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions) if (error instanceof OAuthError && error.code !== "server_error") throw error; } } + if (options.refreshOnly) return "REDIRECT"; const state = await provider.state?.(); const authorization = await startAuthorization(discovered.authorizationServerUrl, { metadata, @@ -431,11 +466,21 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions) } export async function authorizeMcp(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise { + const use: FlowUse = {}; try { - return await runFlow(provider, options); + return await runFlow(provider, options, use); } catch (error) { if (error instanceof OAuthError && ["invalid_client", "unauthorized_client"].includes(error.code)) { - await provider.invalidateCredentials?.("all"); + // A refresh keeps a pending authorization's `state` and verifier: they are the user's sign-in. A client + // stored meanwhile (by a sign-in that completed) is not the one the server rejected, so it stays. + if (options.refreshOnly) { + if ((await provider.clientInformation())?.client_id === use.storedClientId) { + for (const kind of ["client", "tokens", "discovery"] as const) + await provider.invalidateCredentials?.(kind); + } + } else { + await provider.invalidateCredentials?.("all"); + } return runFlow(provider, options); } if (error instanceof OAuthError && error.code === "invalid_grant") { diff --git a/packages/mcp/src/oauth/index.ts b/packages/mcp/src/oauth/index.ts index c599681ce..6ef8e18ec 100644 --- a/packages/mcp/src/oauth/index.ts +++ b/packages/mcp/src/oauth/index.ts @@ -1,9 +1,16 @@ +export { createMcpAuthProvider, type McpAuthProvider } from "./auth-provider.ts"; export { type OAuthCallback, type OAuthCallbackPage, OAuthCallbackServer, type OAuthCallbackServerOptions, } from "./callback.ts"; +export type { + McpClientMetadataDocuments, + McpOAuthClientOptions, + McpOAuthServerStore, + McpOAuthSettings, +} from "./client-settings.ts"; export { buildAuthorizationServerDiscoveryUrls, discoverAuthorizationServerMetadata, @@ -35,6 +42,12 @@ export { stepUpScope, type TokenRequestOptions, } from "./flow.ts"; +export { + listenForLoopbackSignIn, + type McpLoopbackCallback, + responseFromRedirectUrl, + waitForLoopbackSignIn, +} from "./loopback.ts"; export { McpOAuthProvider, type McpOAuthProviderOptions, @@ -42,6 +55,15 @@ export { type McpOAuthStateStore, MemoryOAuthStateStore, } from "./provider.ts"; +export { + completeMcpSignIn, + type McpAuthorizationResponse, + McpSignInCancelledError, + type McpSignInOptions, + type McpSignInRedirect, + type McpSignInStart, + startMcpSignIn, +} from "./sign-in.ts"; export type { AuthorizationServerMetadata, OAuthChallenge, diff --git a/packages/mcp/src/oauth/loopback.ts b/packages/mcp/src/oauth/loopback.ts new file mode 100644 index 000000000..0cb08e339 --- /dev/null +++ b/packages/mcp/src/oauth/loopback.ts @@ -0,0 +1,137 @@ +/** + * How the authorization response of a sign-in reaches a local application: a loopback callback server + * the browser redirects to, or a redirect URL the user pastes from the browser address bar. + */ + +import { type OAuthCallbackPage, OAuthCallbackServer } from "./callback.ts"; +import { + loopbackSettings, + type McpClientMetadataDocuments, + type McpOAuthSettings, + offersClientMetadataDocument, + registeredRedirectUrls, + serverSpecificPath, +} from "./client-settings.ts"; +import type { McpOAuthStateStore } from "./provider.ts"; +import { + cancellable, + type McpAuthorizationResponse, + McpSignInCancelledError, + type McpSignInRedirect, +} from "./sign-in.ts"; + +/** A loopback callback server and the redirect URI it serves. */ +export interface McpLoopbackCallback { + callback: OAuthCallbackServer; + /** Pass to `startMcpSignIn` and `completeMcpSignIn`. */ + redirectUrl: string; +} + +/** + * Listen for the authorization response of a sign-in on loopback, where `settings` say: `callbackUrl` + * and `callbackPort`, else the port of the stored client's redirect URI (so that client stays valid) or + * a free one. Pass the sign-in's `clientMetadataDocuments`, so the callback server also serves the + * server-specific redirect URI. Close the callback server when the sign-in ends. + */ +export async function listenForLoopbackSignIn(options: { + serverUrl: string; + store: McpOAuthStateStore; + settings: McpOAuthSettings; + clientMetadataDocuments?: McpClientMetadataDocuments; + /** Renders the browser page after the redirect. Default: plain text. */ + renderPage?: (page: OAuthCallbackPage) => string; +}): Promise { + const { settings } = options; + const loopback = loopbackSettings(settings); + const registered = registeredRedirectUrls((await options.store.load())?.clientInformation)[0]; + const port = loopback.port ?? (registered ? Number(new URL(registered).port) || undefined : undefined); + const offersDocument = offersClientMetadataDocument(settings, options.clientMetadataDocuments, loopback.url); + const listenOptions = { + host: loopback.host, + redirectHost: loopback.redirectHost, + path: loopback.path, + // The redirect URI of a server-specific Client ID Metadata Document. + extraPaths: offersDocument ? [serverSpecificPath(loopback.path, options.serverUrl)] : [], + renderPage: options.renderPage, + }; + let callback: OAuthCallbackServer; + try { + callback = await OAuthCallbackServer.listen({ ...listenOptions, port: port ?? 0 }); + } catch (error) { + // A configured port is required; the stored client's port is only preferred. + if (loopback.port !== undefined || port === undefined) throw error; + callback = await OAuthCallbackServer.listen(listenOptions); + } + return { callback, redirectUrl: loopback.fixedRedirectUrl ?? callback.redirectUrl }; +} + +/** + * Wait for the authorization response on a loopback callback server, or for a redirect URL the user + * pastes from the browser address bar, whichever comes first. Aborting `signal` stops the wait with + * `McpSignInCancelledError`. + */ +export async function waitForLoopbackSignIn(options: { + callback: OAuthCallbackServer; + /** The `redirect` result of `startMcpSignIn`. */ + authorization: McpSignInRedirect; + /** + * Ask for the redirect URL, for when the browser cannot reach the loopback callback (for example over + * SSH). Aborted once the callback arrives or the sign-in is cancelled. Resolving to `undefined` or an + * empty string cancels the sign-in. + */ + promptForRedirectUrl?: (signal: AbortSignal) => Promise; + signal?: AbortSignal; +}): Promise { + const { authorization } = options; + const controller = new AbortController(); + const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal; + const fromBrowser = options.callback.waitForCallback( + authorization.state, + new URL(authorization.redirectUrl).pathname, + ); + const fromUser = options.promptForRedirectUrl + ? options.promptForRedirectUrl(signal).then((input) => { + if (!input?.trim()) throw new McpSignInCancelledError(); + return responseFromRedirectUrl(input, authorization); + }) + : new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new McpSignInCancelledError()), { once: true }); + }); + try { + return await cancellable(options.signal, () => Promise.race([fromBrowser, fromUser])); + } finally { + // The losing side rejects once the prompt is aborted or the callback server closes. + controller.abort(); + fromBrowser.catch(() => undefined); + fromUser.catch(() => undefined); + } +} + +/** + * The authorization response in a redirect URL the user pasted from the browser address bar. Fails when + * the URL is not on the sign-in's redirect URI, carries another `state`, or reports an error. + */ +export function responseFromRedirectUrl( + input: string, + authorization: Pick, +): McpAuthorizationResponse { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new Error("Expected the full redirect URL from the browser address bar"); + } + const redirectUrl = new URL(authorization.redirectUrl); + // A server-specific redirect URI tells authorization servers apart, so it must match exactly. + if (url.origin !== redirectUrl.origin || url.pathname !== redirectUrl.pathname) { + throw new Error("The redirect URL does not match this sign-in's redirect URI"); + } + const error = url.searchParams.get("error"); + if (error) throw new Error(url.searchParams.get("error_description") ?? error); + const state = url.searchParams.get("state"); + if (state !== authorization.state) throw new Error("The redirect URL belongs to a different sign-in"); + const code = url.searchParams.get("code"); + if (!code) throw new Error("The redirect URL does not contain an authorization code"); + const iss = url.searchParams.get("iss"); + return { code, state, ...(iss ? { iss } : {}) }; +} diff --git a/packages/mcp/src/oauth/sign-in.ts b/packages/mcp/src/oauth/sign-in.ts new file mode 100644 index 000000000..2299dcd99 --- /dev/null +++ b/packages/mcp/src/oauth/sign-in.ts @@ -0,0 +1,212 @@ +/** + * Sign-in to an OAuth-protected MCP server, in two halves so an application can complete it in another + * request with the same state store: `startMcpSignIn` refreshes the stored tokens when it can, or else + * prepares the browser step and returns the authorization URL; `completeMcpSignIn` exchanges the + * authorization code. The code arrives through a loopback callback server (`loopback.ts`), the + * application's own https callback route, or a redirect URL the user pastes. + */ + +import { refreshStoredTokens } from "./auth-provider.ts"; +import type { OAuthCallback } from "./callback.ts"; +import { + createOAuthClientProvider, + type McpOAuthClientOptions, + offersClientMetadataDocument, + registeredRedirectUrls, + timedFetch, +} from "./client-settings.ts"; +import { authorizeMcp, type OAuthFlowOptions, stepUpScope } from "./flow.ts"; +import type { McpOAuthState } from "./provider.ts"; +import type { OAuthChallenge } from "./types.ts"; + +/** Options of both halves of a sign-in. Pass the same values to `startMcpSignIn` and `completeMcpSignIn`. */ +export interface McpSignInOptions extends McpOAuthClientOptions { + /** + * Redirect URI chosen by the caller: a loopback one (`listenForLoopbackSignIn`) or an https callback + * route of the application. Pass the same one to both halves: the authorization request may use a + * variant specific to the MCP server (`McpSignInRedirect.redirectUrl`), which the flow derives from it. + */ + redirectUrl: string; + /** The server's challenge that asked for the sign-in; its resource metadata URL and scope apply. */ + challenge?: OAuthChallenge; + /** Aborting stops the sign-in with `McpSignInCancelledError`. A refresh already sent still completes. */ + signal?: AbortSignal; +} + +/** The browser step of a sign-in, from `startMcpSignIn`. Plain data, so an application can persist it. */ +export interface McpSignInRedirect { + kind: "redirect"; + /** Open in the user's browser. */ + authorizationUrl: string; + /** The redirect URI of the authorization request; the authorization response must arrive on it. */ + redirectUrl: string; + /** The `state` parameter the authorization response must carry. */ + state: string; + /** The scope requested, if any. Saved with the tokens when the token response names none. */ + scope?: string; +} + +/** Result of `startMcpSignIn`: signed in by refreshing the stored tokens, or the browser step. */ +export type McpSignInStart = { kind: "authorized" } | McpSignInRedirect; + +/** An authorization response: from a callback server, a callback route, or `responseFromRedirectUrl`. */ +export type McpAuthorizationResponse = OAuthCallback; + +/** The user or the application cancelled a sign-in (its `signal` was aborted, or the user gave up). */ +export class McpSignInCancelledError extends Error { + constructor() { + super("Sign-in cancelled"); + this.name = "McpSignInCancelledError"; + } +} + +/** + * First half of a sign-in. Without a step-up (`insufficient_scope`), it first tries to refresh the stored + * tokens with the stored client, whatever `redirectUrl` is (`authorized`). The refresh takes the store's + * refresh lock, so never call this while holding it; when the lock cannot be taken (for example it timed + * out), the refresh is skipped. Otherwise it prepares the browser step (`redirect`): a fresh `state`, and a + * stored client that cannot use `redirectUrl` is dropped with its tokens. Requests to the authorization + * server are time-limited. + */ +export async function startMcpSignIn(options: McpSignInOptions): Promise { + if (options.signal?.aborted) throw new McpSignInCancelledError(); + // A refresh keeps the granted scope; a server asking for more needs the browser step. + const stepUp = options.challenge?.error === "insufficient_scope"; + if (!stepUp && (await refreshBeforeSignIn(options)) === "AUTHORIZED") return { kind: "authorized" }; + return cancellable(options.signal, () => startBrowserStep(options, stepUp)); +} + +/** Refresh the stored tokens, unless `cimd` is to replace a registered client. */ +async function refreshBeforeSignIn(options: McpSignInOptions): Promise<"AUTHORIZED" | "REDIRECT"> { + const { store, settings, challenge } = options; + let locked = false; + try { + return await store.withRefreshLock(async () => { + locked = true; + const state = await store.load(); + if (!state?.tokens?.refresh_token) return "REDIRECT"; + if (settings.clientRegistration === "cimd" && state.clientInformation && !settings.clientId) return "REDIRECT"; + return refreshStoredTokens(options, state, { challenge }); + }); + } catch (error) { + // The lock's own failure: another process holds it too long. The user signs in instead. + if (!locked) return "REDIRECT"; + throw error; + } +} + +async function startBrowserStep(options: McpSignInOptions, stepUp: boolean): Promise { + const { store, settings, redirectUrl, challenge } = options; + const stored = await store.load(); + if (stored) await store.save(stateForSignIn(stored, options)); + let authorizationUrl: URL | undefined; + const provider = createOAuthClientProvider(options, redirectUrl, (url) => { + authorizationUrl = url; + }); + // A server asking for more scope gets it on top of the configured scope and, since the challenge + // may list only the missing scopes, on top of the scope granted so far. + const scope = mergeScopes( + settings.scope, + stepUp ? stepUpScope(stored?.tokens?.scope, challenge?.scope) : challenge?.scope, + ); + await authorizeMcp(provider, { ...flowOptions(options), scope, skipRefresh: true }); + if (!authorizationUrl) throw new Error("OAuth flow did not produce an authorization URL"); + const requestedScope = authorizationUrl.searchParams.get("scope"); + return { + kind: "redirect", + authorizationUrl: authorizationUrl.href, + // The flow picks the redirect URI, which may be specific to the MCP server. + redirectUrl: authorizationUrl.searchParams.get("redirect_uri") ?? redirectUrl, + state: await provider.state(), + ...(requestedScope ? { scope: requestedScope } : {}), + }; +} + +/** + * The stored state for a browser step: without the last sign-in's `state`, and without a client that + * cannot use `redirectUrl`, along with its tokens. + */ +function stateForSignIn(stored: McpOAuthState, options: McpSignInOptions): McpOAuthState { + const { settings, redirectUrl } = options; + const next: McpOAuthState = { ...stored }; + delete next.oauthState; + // A registered client cannot use another redirect URI, and its tokens belong to it. `cimd` replaces a + // registered client; `auto` keeps it. A Client ID Metadata Document is not stored, so tokens without + // a stored client belong to the document. + const keepClient = + settings.clientId || + (stored.clientInformation + ? settings.clientRegistration !== "cimd" && + registeredRedirectUrls(stored.clientInformation).includes(redirectUrl) + : offersClientMetadataDocument(settings, options.clientMetadataDocuments, redirectUrl)); + if (!keepClient) { + delete next.clientInformation; + delete next.tokens; + delete next.tokensExpireAt; + } + return next; +} + +/** Scopes of both lists, each once. */ +function mergeScopes(...scopes: (string | undefined)[]): string | undefined { + const merged = [...new Set(scopes.flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []))]; + return merged.length > 0 ? merged.join(" ") : undefined; +} + +/** + * Second half of a sign-in: exchanges the authorization code of `response` and saves the tokens. Pass + * the options given to `startMcpSignIn`, including its `redirectUrl` (not `authorization.redirectUrl`). + * Fails when the response carries another `state`, when another sign-in started with the store since, or + * when the exchange would use another redirect URI than the authorization request (the options changed). + * A redirect URL the user pasted goes through `responseFromRedirectUrl` first, which checks its path. + */ +export async function completeMcpSignIn( + options: McpSignInOptions & { + /** The `redirect` result of `startMcpSignIn`. */ + authorization: McpSignInRedirect; + response: McpAuthorizationResponse; + }, +): Promise { + await cancellable(options.signal, async () => { + const { authorization, response } = options; + if (response.state !== authorization.state) { + throw new Error("The authorization response belongs to a different sign-in"); + } + // The stored PKCE verifier belongs to the latest sign-in; exchanging with it would fail with + // `invalid_grant`, which discards the stored tokens. + if ((await options.store.load())?.oauthState !== authorization.state) { + throw new Error("Another sign-in to this MCP server started since; sign in again"); + } + await authorizeMcp(createOAuthClientProvider(options, options.redirectUrl), { + ...flowOptions(options), + // Saved with the tokens when the token response names no scope, so it must be the requested one. + scope: authorization.scope, + authorizationRedirectUrl: authorization.redirectUrl, + authorizationCode: response.code, + iss: response.iss, + }); + }); +} + +/** `authorizeMcp` options of a sign-in, without the scope. */ +function flowOptions(options: McpSignInOptions): OAuthFlowOptions { + return { + serverUrl: options.serverUrl, + fetch: timedFetch(), + signal: options.signal, + resourceMetadataUrl: options.challenge?.resourceMetadataUrl, + authorizationServerMetadataUrl: options.settings.authServerMetadataUrl, + }; +} + +/** Run `fn`, reporting an abort of `signal` as `McpSignInCancelledError`. */ +export async function cancellable(signal: AbortSignal | undefined, fn: () => Promise): Promise { + if (signal?.aborted) throw new McpSignInCancelledError(); + try { + return await fn(); + } catch (error) { + // Aborted requests fail with the signal's reason; report them as the cancellation they are. + if (signal?.aborted) throw new McpSignInCancelledError(); + throw error; + } +} diff --git a/packages/mcp/test/auth-provider.test.ts b/packages/mcp/test/auth-provider.test.ts new file mode 100644 index 000000000..df20bd813 --- /dev/null +++ b/packages/mcp/test/auth-provider.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + completeMcpSignIn, + createMcpAuthProvider, + type McpAuthorizationResponse, + McpOAuthAuthorizationRequiredError, + type McpSignInOptions, + type McpSignInRedirect, + startMcpSignIn, +} from "../src/oauth/index.ts"; +import { + type AuthorizationServer, + authorizeInBrowser, + memoryStore, + startAuthorizationServer, +} from "./authorization-server.ts"; +import { closeServers } from "./helpers.ts"; + +afterEach(closeServers); + +const ROUTE = "https://app.example/mcp/callback"; +const LOOPBACK = "http://127.0.0.1:1/callback"; + +async function startRedirect(options: McpSignInOptions): Promise { + const started = await startMcpSignIn(options); + if (started.kind !== "redirect") throw new Error("Expected the browser step"); + return started; +} + +async function browse(authorization: McpSignInRedirect): Promise { + const query = new URL(await authorizeInBrowser(authorization.authorizationUrl)).searchParams; + const iss = query.get("iss"); + return { code: query.get("code") ?? "", state: query.get("state") ?? "", ...(iss ? { iss } : {}) }; +} + +async function signIn(options: McpSignInOptions): Promise { + const authorization = await startRedirect(options); + await completeMcpSignIn({ ...options, authorization, response: await browse(authorization) }); +} + +/** What a connection's request reports after its token was rejected. */ +function unauthorized(server: AuthorizationServer, token: string) { + return { response: new Response(null, { status: 401 }), serverUrl: new URL(server.serverUrl), fetch, token }; +} + +describe("createMcpAuthProvider", () => { + it("refreshes a rejected token", async () => { + const server = await startAuthorizationServer(); + const options = { serverUrl: server.serverUrl, store: memoryStore(), redirectUrl: ROUTE, clientName: "app" }; + await signIn({ ...options, settings: {} }); + const provider = createMcpAuthProvider({ ...options, settings: () => ({}), onChallenge: () => {} }); + + await provider.onUnauthorized?.(unauthorized(server, "access-1")); + expect(await provider.token()).toBe("access-2"); + expect(server.tokenRequests[1].get("client_id")).toBe("client-1"); + }); + + it("leaves a pending sign-in usable when the refresh fails", async () => { + const server = await startAuthorizationServer(); + const options = { serverUrl: server.serverUrl, store: memoryStore(), redirectUrl: ROUTE, clientName: "app" }; + await signIn({ ...options, settings: {} }); + // A step-up keeps the tokens while the user is in the browser. + const stepUp = { ...options, settings: {}, challenge: { error: "insufficient_scope", scope: "more" } }; + const authorization = await startRedirect(stepUp); + const pending = await options.store.load(); + + server.revokeRefreshTokens(); + const provider = createMcpAuthProvider({ ...options, settings: () => ({}), onChallenge: () => {} }); + await expect(provider.onUnauthorized?.(unauthorized(server, "access-1"))).rejects.toBeInstanceOf( + McpOAuthAuthorizationRequiredError, + ); + + const state = await options.store.load(); + expect(state?.oauthState).toBe(pending?.oauthState); + expect(state?.codeVerifier).toBe(pending?.codeVerifier); + expect(server.registrations).toHaveLength(1); + await completeMcpSignIn({ ...stepUp, authorization, response: await browse(authorization) }); + expect((await options.store.load())?.tokens?.access_token).toBe("access-2"); + }); + + it("does not register a client to refresh", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + const options = { serverUrl: server.serverUrl, store: memoryStore(), redirectUrl: LOOPBACK, clientName: "app" }; + const clientMetadataDocuments = { baseUrl: "https://app.example/oauth", listsRedirectUrl: () => true }; + await signIn({ ...options, settings: {}, clientMetadataDocuments }); + expect((await options.store.load())?.clientInformation).toBeUndefined(); + + // Without the documents, the tokens have no client to refresh them with. + const provider = createMcpAuthProvider({ ...options, settings: () => ({}), onChallenge: () => {} }); + await expect(provider.onUnauthorized?.(unauthorized(server, "access-1"))).rejects.toBeInstanceOf( + McpOAuthAuthorizationRequiredError, + ); + expect(server.registrations).toEqual([]); + expect(server.authorizations).toHaveLength(1); + }); + + /** Signed in, with a step-up sign-in waiting for the browser, which keeps the tokens meanwhile. */ + async function pendingSignIn() { + const server = await startAuthorizationServer(); + const options = { serverUrl: server.serverUrl, store: memoryStore(), redirectUrl: ROUTE, clientName: "app" }; + await signIn({ ...options, settings: {} }); + const stepUp = { ...options, settings: {}, challenge: { error: "insufficient_scope", scope: "more" } }; + const authorization = await startRedirect(stepUp); + const pending = await options.store.load(); + const provider = createMcpAuthProvider({ ...options, settings: () => ({}), onChallenge: () => {} }); + return { server, store: options.store, stepUp, authorization, pending, provider }; + } + + it("drops a rejected client and its tokens but keeps the pending sign-in", async () => { + const { server, store, pending, provider } = await pendingSignIn(); + server.failNextRefresh("invalid_client"); + await expect(provider.onUnauthorized?.(unauthorized(server, "access-1"))).rejects.toBeInstanceOf( + McpOAuthAuthorizationRequiredError, + ); + const state = await store.load(); + expect(state?.oauthState).toBe(pending?.oauthState); + expect(state?.codeVerifier).toBe(pending?.codeVerifier); + expect(state?.clientInformation).toBeUndefined(); + expect(state?.tokens).toBeUndefined(); + expect(server.registrations).toHaveLength(1); + }); + + it("keeps a client stored while the rejected refresh ran", async () => { + const { server, store, provider } = await pendingSignIn(); + // Another sign-in completes with a new client while the server rejects the old one. + server.failNextRefresh("invalid_client", async () => { + const state = await store.load(); + if (state) + await store.save({ ...state, clientInformation: { client_id: "client-new", redirect_uris: [ROUTE] } }); + }); + await provider.onUnauthorized?.(unauthorized(server, "access-1")); + expect((await store.load())?.clientInformation?.client_id).toBe("client-new"); + expect(server.tokenRequests.at(-1)?.get("client_id")).toBe("client-new"); + expect(await provider.token()).toBe("access-2"); + }); + + it("keeps the tokens and the pending sign-in after a server error", async () => { + const { server, store, pending, provider } = await pendingSignIn(); + server.failNextRefresh("server_error"); + await expect(provider.onUnauthorized?.(unauthorized(server, "access-1"))).rejects.toBeInstanceOf( + McpOAuthAuthorizationRequiredError, + ); + const state = await store.load(); + expect(state?.oauthState).toBe(pending?.oauthState); + expect(state?.codeVerifier).toBe(pending?.codeVerifier); + expect(state?.tokens?.access_token).toBe("access-1"); + }); + + it("keeps the tokens and the pending sign-in after a network error", async () => { + const { server, store, pending, provider } = await pendingSignIn(); + const offline: typeof fetch = (input, init) => + String(input).endsWith("/token") ? Promise.reject(new TypeError("fetch failed")) : fetch(input, init); + await expect( + provider.onUnauthorized?.({ ...unauthorized(server, "access-1"), fetch: offline }), + ).rejects.toBeInstanceOf(McpOAuthAuthorizationRequiredError); + const state = await store.load(); + expect(state?.oauthState).toBe(pending?.oauthState); + expect(state?.codeVerifier).toBe(pending?.codeVerifier); + expect(state?.tokens?.access_token).toBe("access-1"); + }); +}); diff --git a/packages/mcp/test/authorization-server.ts b/packages/mcp/test/authorization-server.ts new file mode 100644 index 000000000..1d14a0042 --- /dev/null +++ b/packages/mcp/test/authorization-server.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { type McpOAuthServerStore, MemoryOAuthStateStore } from "../src/oauth/index.ts"; +import { listen, readBody } from "./helpers.ts"; + +function json(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); +} + +/** + * Authorization server of an MCP server at `/mcp`: discovery, dynamic client registration, + * PKCE, and rotating refresh tokens. Token responses name no scope. `cimd` advertises Client ID + * Metadata Documents, `issParameter` the `iss` parameter of authorization responses. Paths in `stall` + * never answer. Close it with `closeServers` from `afterEach`. + */ +export async function startAuthorizationServer(options: { cimd?: boolean; issParameter?: boolean } = {}) { + const registrations: Record[] = []; + const authorizations: URLSearchParams[] = []; + const tokenRequests: URLSearchParams[] = []; + const challenges = new Map(); + const refreshTokens = new Set(); + const stall = new Set(); + let refreshFailure: { error: string; onRequest?: () => Promise } | undefined; + let issued = 0; + + const issueTokens = () => { + issued++; + refreshTokens.add(`refresh-${issued}`); + return { access_token: `access-${issued}`, refresh_token: `refresh-${issued}`, token_type: "Bearer" }; + }; + + const origin = await listen(async (request: IncomingMessage, response: ServerResponse, origin: string) => { + const url = new URL(request.url ?? "/", origin); + if (stall.has(url.pathname)) return; + switch (url.pathname) { + case "/.well-known/oauth-protected-resource/mcp": + return json(response, 200, { resource: `${origin}/mcp`, authorization_servers: [origin] }); + case "/.well-known/oauth-authorization-server": + return json(response, 200, { + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ...(options.cimd ? { client_id_metadata_document_supported: true } : {}), + ...(options.issParameter ? { authorization_response_iss_parameter_supported: true } : {}), + }); + case "/register": { + const metadata = JSON.parse(await readBody(request)) as Record; + registrations.push(metadata); + return json(response, 201, { ...metadata, client_id: `client-${registrations.length}` }); + } + case "/authorize": { + authorizations.push(url.searchParams); + const code = `code-${authorizations.length}`; + challenges.set(code, url.searchParams.get("code_challenge") ?? ""); + const redirect = new URL(url.searchParams.get("redirect_uri") ?? ""); + redirect.searchParams.set("code", code); + redirect.searchParams.set("state", url.searchParams.get("state") ?? ""); + if (options.issParameter) redirect.searchParams.set("iss", origin); + response.writeHead(302, { location: redirect.href }).end(); + return; + } + case "/token": { + const params = new URLSearchParams(await readBody(request)); + tokenRequests.push(params); + if (params.get("grant_type") === "authorization_code") { + const code = params.get("code") ?? ""; + const verifier = createHash("sha256") + .update(params.get("code_verifier") ?? "") + .digest("base64url"); + if (challenges.get(code) !== verifier) return json(response, 400, { error: "invalid_grant" }); + challenges.delete(code); + return json(response, 200, issueTokens()); + } + const failure = refreshFailure; + refreshFailure = undefined; + if (failure) { + await failure.onRequest?.(); + return json(response, 400, { error: failure.error }); + } + if (!refreshTokens.delete(params.get("refresh_token") ?? "")) { + return json(response, 400, { error: "invalid_grant" }); + } + return json(response, 200, issueTokens()); + } + default: + response.writeHead(404).end(); + } + }); + return { + serverUrl: `${origin}/mcp`, + /** Client metadata of dynamic client registrations. */ + registrations, + /** Query parameters of authorization requests. */ + authorizations, + /** Parameters of token requests. */ + tokenRequests, + stall, + /** Makes every refresh fail with `invalid_grant`, like a revoked grant. */ + revokeRefreshTokens: () => refreshTokens.clear(), + /** Answers the next refresh with the OAuth `error`, after running `onRequest`. */ + failNextRefresh: (error: string, onRequest?: () => Promise) => { + refreshFailure = { error, onRequest }; + }, + }; +} + +export type AuthorizationServer = Awaited>; + +/** An in-memory store whose refresh lock only runs `fn`: there is one process. */ +export function memoryStore(): McpOAuthServerStore { + return Object.assign(new MemoryOAuthStateStore(), { withRefreshLock: (fn: () => Promise) => fn() }); +} + +/** + * An in-memory store that records whether each save happened while the refresh lock was held. With + * `lockFails`, taking the lock fails like a lock that timed out. + */ +export function recordingStore(options: { lockFails?: boolean } = {}) { + const memory = new MemoryOAuthStateStore(); + const saves: { locked: boolean; accessToken: string | undefined }[] = []; + let locked = false; + const store: McpOAuthServerStore = { + load: () => memory.load(), + save: (state) => { + saves.push({ locked, accessToken: state.tokens?.access_token }); + memory.save(state); + }, + withRefreshLock: async (fn) => { + if (options.lockFails) throw new Error("Lock file is already being held"); + locked = true; + try { + return await fn(); + } finally { + locked = false; + } + }, + }; + return { store, saves }; +} + +/** Follows the authorization URL like a browser, up to the redirect back to the application. */ +export async function authorizeInBrowser(authorizationUrl: string): Promise { + const response = await fetch(authorizationUrl, { redirect: "manual" }); + return response.headers.get("location") ?? ""; +} diff --git a/packages/mcp/test/loopback.test.ts b/packages/mcp/test/loopback.test.ts new file mode 100644 index 000000000..d5e04d0e8 --- /dev/null +++ b/packages/mcp/test/loopback.test.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { + completeMcpSignIn, + listenForLoopbackSignIn, + type McpLoopbackCallback, + McpSignInCancelledError, + type McpSignInRedirect, + responseFromRedirectUrl, + startMcpSignIn, + waitForLoopbackSignIn, +} from "../src/oauth/index.ts"; +import { authorizeInBrowser, memoryStore, startAuthorizationServer } from "./authorization-server.ts"; +import { closeServers } from "./helpers.ts"; + +const callbacks: McpLoopbackCallback[] = []; + +afterEach(async () => { + await Promise.all(callbacks.splice(0).map(({ callback }) => callback.close())); + await closeServers(); +}); + +async function listenAt(serverUrl: string, options: { documents?: boolean } = {}): Promise { + const loopback = await listenForLoopbackSignIn({ + serverUrl, + store: memoryStore(), + settings: {}, + ...(options.documents + ? { clientMetadataDocuments: { baseUrl: "https://app.example/oauth", listsRedirectUrl: () => true } } + : {}), + }); + callbacks.push(loopback); + return loopback; +} + +function pending(redirectUrl: string): McpSignInRedirect { + return { kind: "redirect", authorizationUrl: "https://auth.example/authorize", redirectUrl, state: "state-1" }; +} + +describe("loopback sign-in", () => { + it("completes through the loopback callback server", async () => { + const server = await startAuthorizationServer(); + const store = memoryStore(); + const loopback = await listenForLoopbackSignIn({ serverUrl: server.serverUrl, store, settings: {} }); + callbacks.push(loopback); + const { callback, redirectUrl } = loopback; + expect(redirectUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + + const options = { serverUrl: server.serverUrl, store, settings: {}, redirectUrl, clientName: "app" }; + const authorization = await startMcpSignIn(options); + if (authorization.kind !== "redirect") throw new Error("Expected the browser step"); + const response = waitForLoopbackSignIn({ callback, authorization }); + await fetch(await authorizeInBrowser(authorization.authorizationUrl)); + await completeMcpSignIn({ ...options, authorization, response: await response }); + + expect((await store.load())?.tokens?.access_token).toBe("access-1"); + expect(server.registrations).toMatchObject([{ redirect_uris: [redirectUrl], application_type: "native" }]); + }); + + it("rejects a response on another redirect path", async () => { + const serverUrl = "https://mcp.example/mcp"; + const { callback, redirectUrl } = await listenAt(serverUrl, { documents: true }); + const id = createHash("sha256").update(serverUrl).digest().subarray(0, 9).toString("base64url"); + const rejected = expect( + waitForLoopbackSignIn({ callback, authorization: pending(`${redirectUrl}/${id}`) }), + ).rejects.toThrow("arrived on another redirect URI"); + // A mixed-up authorization server redirects to the shared callback path. + await fetch(`${redirectUrl}?code=c&state=state-1`); + await rejected; + }); + + it("takes a pasted redirect URL while waiting for the callback", async () => { + const { callback, redirectUrl } = await listenAt("https://mcp.example/mcp"); + const response = await waitForLoopbackSignIn({ + callback, + authorization: pending(redirectUrl), + promptForRedirectUrl: async () => `${redirectUrl}?code=pasted&state=state-1`, + }); + expect(response).toEqual({ code: "pasted", state: "state-1" }); + }); + + it("cancels the wait, and an empty paste cancels too", async () => { + const { callback, redirectUrl } = await listenAt("https://mcp.example/mcp"); + const controller = new AbortController(); + const waiting = waitForLoopbackSignIn({ + callback, + authorization: pending(redirectUrl), + signal: controller.signal, + }); + controller.abort(); + await expect(waiting).rejects.toBeInstanceOf(McpSignInCancelledError); + + const pasted = waitForLoopbackSignIn({ + callback, + authorization: { ...pending(redirectUrl), state: "state-2" }, + promptForRedirectUrl: async () => "", + }); + await expect(pasted).rejects.toBeInstanceOf(McpSignInCancelledError); + }); +}); + +describe("pasted redirect URLs", () => { + const authorization = pending("http://127.0.0.1:1234/callback/server-id"); + const redirectUrl = authorization.redirectUrl; + + it("reads the code, state, and issuer", () => { + expect(responseFromRedirectUrl(` ${redirectUrl}?code=c&state=state-1&iss=x `, authorization)).toEqual({ + code: "c", + state: "state-1", + iss: "x", + }); + }); + + it("rejects another state, another redirect path, and errors", () => { + expect(() => responseFromRedirectUrl(`${redirectUrl}?code=c&state=other`, authorization)).toThrow( + "belongs to a different sign-in", + ); + expect(() => + responseFromRedirectUrl("http://127.0.0.1:1234/callback?code=c&state=state-1", authorization), + ).toThrow("does not match this sign-in's redirect URI"); + expect(() => responseFromRedirectUrl(`${redirectUrl}?error=access_denied`, authorization)).toThrow( + "access_denied", + ); + expect(() => responseFromRedirectUrl("code=c", authorization)).toThrow("Expected the full redirect URL"); + }); +}); diff --git a/packages/mcp/test/sign-in.test.ts b/packages/mcp/test/sign-in.test.ts new file mode 100644 index 000000000..f5a6d7bd1 --- /dev/null +++ b/packages/mcp/test/sign-in.test.ts @@ -0,0 +1,346 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { + completeMcpSignIn, + type McpAuthorizationResponse, + type McpClientMetadataDocuments, + type McpOAuthSettings, + McpSignInCancelledError, + type McpSignInOptions, + type McpSignInRedirect, + startMcpSignIn, +} from "../src/oauth/index.ts"; +import { + type AuthorizationServer, + authorizeInBrowser, + memoryStore, + recordingStore, + startAuthorizationServer, +} from "./authorization-server.ts"; +import { closeServers } from "./helpers.ts"; + +afterEach(closeServers); + +/** Callback routes of the application on two origins, like p2 through a relay and on its public URL. */ +const ROUTE = "https://app.example/mcp/callback"; +const OTHER_ROUTE = "https://relay.example/mcp/callback"; +/** A loopback redirect URI; tests read the redirect from the authorization server instead of following it. */ +const LOOPBACK = "http://127.0.0.1:1/callback"; + +/** Documents that list `/callback` on 127.0.0.1, like pi's on pi.dev. */ +const documents: McpClientMetadataDocuments = { + baseUrl: "https://app.example/oauth", + listsRedirectUrl: (redirectUrl) => { + const url = new URL(redirectUrl); + return url.hostname === "127.0.0.1" && url.pathname === "/callback"; + }, +}; + +function signInOptions( + server: AuthorizationServer, + options: Partial = {}, + settings: McpOAuthSettings = {}, +): McpSignInOptions { + return { + serverUrl: server.serverUrl, + store: memoryStore(), + settings, + redirectUrl: ROUTE, + clientName: "app", + ...options, + }; +} + +async function startRedirect(options: McpSignInOptions): Promise { + const started = await startMcpSignIn(options); + if (started.kind !== "redirect") throw new Error("Expected the browser step"); + return started; +} + +/** The response a callback route reads from the query of the redirect. */ +function routeResponse(location: string): McpAuthorizationResponse { + const query = new URL(location).searchParams; + const iss = query.get("iss"); + return { code: query.get("code") ?? "", state: query.get("state") ?? "", ...(iss ? { iss } : {}) }; +} + +/** Start, follow the browser step, and complete. */ +async function signIn(options: McpSignInOptions): Promise { + const authorization = await startRedirect(options); + const response = routeResponse(await authorizeInBrowser(authorization.authorizationUrl)); + await completeMcpSignIn({ ...options, authorization, response }); + return authorization; +} + +describe("start and complete", () => { + it("completes in a second call with the same store and a persisted redirect", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server, {}, { scope: "issues:read" }); + + const started = await startRedirect(options); + // Plain data: the application may store it and read it back in another request. + const authorization = JSON.parse(JSON.stringify(started)) as McpSignInRedirect; + expect(authorization).toEqual(started); + expect(authorization).toMatchObject({ redirectUrl: ROUTE, scope: "issues:read" }); + expect(new URL(authorization.authorizationUrl).searchParams.get("state")).toBe(authorization.state); + expect(server.registrations).toMatchObject([ + { client_name: "app", redirect_uris: [ROUTE], application_type: "web" }, + ]); + + const response = routeResponse(await authorizeInBrowser(authorization.authorizationUrl)); + await completeMcpSignIn({ ...options, authorization, response }); + + expect((await options.store.load())?.tokens).toMatchObject({ access_token: "access-1", scope: "issues:read" }); + expect(server.tokenRequests[0].get("redirect_uri")).toBe(ROUTE); + expect(server.tokenRequests[0].get("client_id")).toBe("client-1"); + }); + + it("refreshes the stored tokens instead of redirecting", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + await signIn(options); + + expect(await startMcpSignIn(options)).toEqual({ kind: "authorized" }); + expect(server.authorizations).toHaveLength(1); + expect(server.tokenRequests[1].get("grant_type")).toBe("refresh_token"); + expect((await options.store.load())?.tokens?.access_token).toBe("access-2"); + }); + + it("refreshes under the store's refresh lock", async () => { + const server = await startAuthorizationServer(); + const { store, saves } = recordingStore(); + const options = signInOptions(server, { store }); + await signIn(options); + + expect(await startMcpSignIn(options)).toEqual({ kind: "authorized" }); + expect(saves.filter((save) => save.accessToken === "access-2")).toEqual([ + { locked: true, accessToken: "access-2" }, + ]); + }); + + it("goes to the browser step when the refresh lock cannot be taken", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server, { store: recordingStore({ lockFails: true }).store }); + await signIn(options); + + expect((await startMcpSignIn(options)).kind).toBe("redirect"); + expect(server.tokenRequests.map((request) => request.get("grant_type"))).toEqual(["authorization_code"]); + }); + + it("refuses to complete with another redirect URI than the authorization request", async () => { + const server = await startAuthorizationServer({ cimd: true }); + const options = signInOptions(server, { redirectUrl: LOOPBACK, clientMetadataDocuments: documents }); + const authorization = await startRedirect(options); + const response = routeResponse(await authorizeInBrowser(authorization.authorizationUrl)); + // Another loopback port than the one passed to start: the server-specific variant differs too. + const wrong = { ...options, redirectUrl: "http://127.0.0.1:2/callback" }; + await expect(completeMcpSignIn({ ...wrong, authorization, response })).rejects.toThrow( + `but the authorization request used ${authorization.redirectUrl}`, + ); + expect(server.tokenRequests).toEqual([]); + }); + + it("gives every sign-in a fresh state and refuses to complete a superseded one", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + const first = await startRedirect(options); + const firstResponse = routeResponse(await authorizeInBrowser(first.authorizationUrl)); + const second = await startRedirect(options); + expect(second.state).not.toBe(first.state); + + await expect(completeMcpSignIn({ ...options, authorization: first, response: firstResponse })).rejects.toThrow( + "Another sign-in to this MCP server started since", + ); + expect(server.tokenRequests).toEqual([]); + }); + + it("refuses a response with another state", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + const authorization = await startRedirect(options); + const response = routeResponse(await authorizeInBrowser(authorization.authorizationUrl)); + await expect( + completeMcpSignIn({ ...options, authorization, response: { ...response, state: "other" } }), + ).rejects.toThrow("belongs to a different sign-in"); + expect(server.tokenRequests).toEqual([]); + }); +}); + +describe("stored client", () => { + it("refreshes with the stored client from another redirect URI", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + await signIn(options); + + expect(await startMcpSignIn({ ...options, redirectUrl: OTHER_ROUTE })).toEqual({ kind: "authorized" }); + expect(server.registrations).toHaveLength(1); + expect(server.tokenRequests[1].get("client_id")).toBe("client-1"); + expect((await options.store.load())?.clientInformation?.client_id).toBe("client-1"); + }); + + it("keeps the client for a browser step on its redirect URI", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + await signIn(options); + server.revokeRefreshTokens(); + + const authorization = await startRedirect(options); + expect(new URL(authorization.authorizationUrl).searchParams.get("client_id")).toBe("client-1"); + expect(server.registrations).toHaveLength(1); + }); + + it("drops the client and its tokens for a browser step on another redirect URI", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + await signIn(options); + server.revokeRefreshTokens(); + + const authorization = await startRedirect({ ...options, redirectUrl: OTHER_ROUTE }); + expect(new URL(authorization.authorizationUrl).searchParams.get("client_id")).toBe("client-2"); + expect(server.registrations[1]).toMatchObject({ redirect_uris: [OTHER_ROUTE] }); + const state = await options.store.load(); + expect(state?.clientInformation?.client_id).toBe("client-2"); + expect(state?.tokens).toBeUndefined(); + }); + + it("does not refresh with a registered client that cimd replaces", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + const options = signInOptions(server, { redirectUrl: LOOPBACK, clientMetadataDocuments: documents }); + await signIn({ ...options, settings: { clientRegistration: "dcr" } }); + expect((await options.store.load())?.clientInformation?.client_id).toBe("client-1"); + + const authorization = await startRedirect({ ...options, settings: { clientRegistration: "cimd" } }); + expect(new URL(authorization.authorizationUrl).searchParams.get("client_id")).toBe( + "https://app.example/oauth/client.json", + ); + expect(server.tokenRequests.map((request) => request.get("grant_type"))).toEqual(["authorization_code"]); + const state = await options.store.load(); + expect(state?.clientInformation).toBeUndefined(); + expect(state?.tokens).toBeUndefined(); + }); + + it("asks for the granted scope plus the challenged one on a step-up, without refreshing", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server, { challenge: { scope: "issues:read" } }); + await signIn(options); + expect((await options.store.load())?.tokens?.scope).toBe("issues:read"); + + // The challenge lists only the missing scope; requesting just that would lose issues:read. + const stepUp = { ...options, challenge: { error: "insufficient_scope", scope: "issues:write" } }; + const authorization = await signIn(stepUp); + expect(authorization.scope).toBe("issues:read issues:write"); + expect(server.tokenRequests.map((request) => request.get("grant_type"))).toEqual([ + "authorization_code", + "authorization_code", + ]); + expect((await options.store.load())?.tokens?.scope).toBe("issues:read issues:write"); + }); +}); + +describe("client metadata documents", () => { + const loopback = (server: AuthorizationServer, settings: McpOAuthSettings = {}) => + signInOptions(server, { redirectUrl: LOOPBACK, clientMetadataDocuments: documents }, settings); + + it("uses a document when the server supports them and the documents list the redirect URI", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + const options = loopback(server); + const authorization = await signIn(options); + expect(new URL(authorization.authorizationUrl).searchParams.get("client_id")).toBe( + "https://app.example/oauth/client.json", + ); + expect(authorization.redirectUrl).toBe(LOOPBACK); + expect(server.registrations).toEqual([]); + expect(server.tokenRequests[0].get("client_id")).toBe("https://app.example/oauth/client.json"); + expect((await options.store.load())?.clientInformation).toBeUndefined(); + }); + + it("uses a document and redirect URI specific to the MCP server without the iss parameter", async () => { + const server = await startAuthorizationServer({ cimd: true }); + const authorization = await signIn(loopback(server)); + const id = createHash("sha256").update(server.serverUrl).digest().subarray(0, 9).toString("base64url"); + expect(new URL(authorization.authorizationUrl).searchParams.get("client_id")).toBe( + `https://app.example/oauth/${id}/client.json`, + ); + expect(authorization.redirectUrl).toBe(`${LOOPBACK}/${id}`); + expect(server.tokenRequests[0].get("redirect_uri")).toBe(`${LOOPBACK}/${id}`); + }); + + it("appends the server-specific path without doubling a trailing slash", async () => { + const server = await startAuthorizationServer({ cimd: true }); + const authorization = await startRedirect({ + ...loopback(server), + redirectUrl: `${LOOPBACK}/`, + clientMetadataDocuments: { ...documents, listsRedirectUrl: () => true }, + }); + const id = createHash("sha256").update(server.serverUrl).digest().subarray(0, 9).toString("base64url"); + expect(authorization.redirectUrl).toBe(`${LOOPBACK}/${id}`); + }); + + it("registers dynamically when the server does not support documents", async () => { + const server = await startAuthorizationServer({ issParameter: true }); + await signIn(loopback(server)); + expect(server.registrations).toHaveLength(1); + expect(server.authorizations[0].get("client_id")).toBe("client-1"); + }); + + it("registers dynamically when the documents do not list the redirect URI", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + await signIn({ ...loopback(server), redirectUrl: ROUTE }); + expect(server.registrations).toHaveLength(1); + }); + + it("registers dynamically without documents or with a client name", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + await signIn({ ...loopback(server), clientMetadataDocuments: undefined }); + await signIn(loopback(server, { clientName: "Other" })); + expect(server.registrations).toMatchObject([{ client_name: "app" }, { client_name: "Other" }]); + }); + + it("fails with cimd when the server does not support documents", async () => { + const server = await startAuthorizationServer({ issParameter: true }); + await expect(startMcpSignIn(loopback(server, { clientRegistration: "cimd" }))).rejects.toThrow( + "does not support Client ID Metadata Documents", + ); + expect(server.registrations).toEqual([]); + }); + + it("fails with cimd when the documents do not list the redirect URI", async () => { + const server = await startAuthorizationServer({ cimd: true, issParameter: true }); + const options = { ...loopback(server, { clientRegistration: "cimd" }), redirectUrl: ROUTE }; + await expect(startMcpSignIn(options)).rejects.toThrow( + "needs Client ID Metadata Documents that list the redirect URI", + ); + expect(server.registrations).toEqual([]); + }); +}); + +describe("cancellation", () => { + it("cancels a start whose request does not answer", async () => { + const server = await startAuthorizationServer(); + server.stall.add("/register"); + const controller = new AbortController(); + const started = startMcpSignIn({ ...signInOptions(server), signal: controller.signal }); + setTimeout(() => controller.abort(), 50); + await expect(started).rejects.toBeInstanceOf(McpSignInCancelledError); + }); + + it("cancels a completion whose token request does not answer", async () => { + const server = await startAuthorizationServer(); + const options = signInOptions(server); + const authorization = await startRedirect(options); + const response = routeResponse(await authorizeInBrowser(authorization.authorizationUrl)); + server.stall.add("/token"); + const controller = new AbortController(); + const completed = completeMcpSignIn({ ...options, authorization, response, signal: controller.signal }); + setTimeout(() => controller.abort(), 50); + await expect(completed).rejects.toBeInstanceOf(McpSignInCancelledError); + }); + + it("fails at once with an aborted signal", async () => { + const server = await startAuthorizationServer(); + await expect(startMcpSignIn({ ...signInOptions(server), signal: AbortSignal.abort() })).rejects.toBeInstanceOf( + McpSignInCancelledError, + ); + expect(server.registrations).toEqual([]); + }); +});