mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
032656b6c6
commit
bb266bf359
16 changed files with 1638 additions and 420 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
118
packages/mcp/src/oauth/auth-provider.ts
Normal file
118
packages/mcp/src/oauth/auth-provider.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
228
packages/mcp/src/oauth/client-settings.ts
Normal file
228
packages/mcp/src/oauth/client-settings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
137
packages/mcp/src/oauth/loopback.ts
Normal file
137
packages/mcp/src/oauth/loopback.ts
Normal 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 } : {}) };
|
||||
}
|
||||
212
packages/mcp/src/oauth/sign-in.ts
Normal file
212
packages/mcp/src/oauth/sign-in.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
161
packages/mcp/test/auth-provider.test.ts
Normal file
161
packages/mcp/test/auth-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
149
packages/mcp/test/authorization-server.ts
Normal file
149
packages/mcp/test/authorization-server.ts
Normal 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") ?? "";
|
||||
}
|
||||
126
packages/mcp/test/loopback.test.ts
Normal file
126
packages/mcp/test/loopback.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
346
packages/mcp/test/sign-in.test.ts
Normal file
346
packages/mcp/test/sign-in.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue