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.
This commit is contained in:
Armin Ronacher 2026-11-03 01:35:52 +01:00
parent 032656b6c6
commit bb266bf359
16 changed files with 1638 additions and 420 deletions

View file

@ -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

View file

@ -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 `<agent-dir>/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 `<callback ID>/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<string, McpOAuthState>;
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<T>(fn: () => Promise<T>): Promise<T>;
}
/** 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 `<callback ID>/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<void>;
}
/**
* 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<void> | 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<string | undefined>;
}
export class McpSignInCancelledError extends Error {
constructor() {
super("Sign-in cancelled");
this.name = "McpSignInCancelledError";
}
}
type AuthorizationResponse = Pick<OAuthCallback, "code" | "iss">;
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<AuthorizationResponse> {
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<OAuthCallbackServer> {
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<void> {
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();
}

View file

@ -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;

View file

@ -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";

View file

@ -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

View file

@ -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.

View file

@ -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<void>;
}
/** 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<McpOAuthClientOptions, "settings"> & {
settings: () => McpOAuthSettings;
onChallenge: (challenge: OAuthChallenge) => void;
},
): McpAuthProvider {
const { store } = options;
let refreshing: Promise<void> | 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<OAuthFlowResult> {
// 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,
});
}

View file

@ -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:<port>/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: `<baseUrl>/client.json`, and for each MCP
* server `<baseUrl>/<callback ID>/client.json`, whose redirect URIs carry the path `<path>/<callback ID>`.
*/
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<T>(fn: () => Promise<T>): Promise<T>;
}
/** 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: `<path>/<callback ID>`. */
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,
};
}

View file

@ -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<OAuthFlowResult> {
/** 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<OAuthFlowResult> {
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<OAuthFlowResult> {
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") {

View file

@ -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,

View file

@ -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<McpLoopbackCallback> {
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<string | undefined>;
signal?: AbortSignal;
}): Promise<McpAuthorizationResponse> {
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<never>((_, 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<McpSignInRedirect, "state" | "redirectUrl">,
): 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 } : {}) };
}

View file

@ -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<McpSignInStart> {
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<McpSignInRedirect> {
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<void> {
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<T>(signal: AbortSignal | undefined, fn: () => Promise<T>): Promise<T> {
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;
}
}

View file

@ -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<McpSignInRedirect> {
const started = await startMcpSignIn(options);
if (started.kind !== "redirect") throw new Error("Expected the browser step");
return started;
}
async function browse(authorization: McpSignInRedirect): Promise<McpAuthorizationResponse> {
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<void> {
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");
});
});

View file

@ -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 `<origin>/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<string, unknown>[] = [];
const authorizations: URLSearchParams[] = [];
const tokenRequests: URLSearchParams[] = [];
const challenges = new Map<string, string>();
const refreshTokens = new Set<string>();
const stall = new Set<string>();
let refreshFailure: { error: string; onRequest?: () => Promise<void> } | 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<string, unknown>;
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<void>) => {
refreshFailure = { error, onRequest };
},
};
}
export type AuthorizationServer = Awaited<ReturnType<typeof startAuthorizationServer>>;
/** An in-memory store whose refresh lock only runs `fn`: there is one process. */
export function memoryStore(): McpOAuthServerStore {
return Object.assign(new MemoryOAuthStateStore(), { withRefreshLock: <T>(fn: () => Promise<T>) => 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<string> {
const response = await fetch(authorizationUrl, { redirect: "manual" });
return response.headers.get("location") ?? "";
}

View file

@ -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<McpLoopbackCallback> {
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");
});
});

View file

@ -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<McpSignInOptions> = {},
settings: McpOAuthSettings = {},
): McpSignInOptions {
return {
serverUrl: server.serverUrl,
store: memoryStore(),
settings,
redirectUrl: ROUTE,
clientName: "app",
...options,
};
}
async function startRedirect(options: McpSignInOptions): Promise<McpSignInRedirect> {
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<McpSignInRedirect> {
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([]);
});
});