mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): opt-in MCP OAuth client ID metadata documents
Adds oauth.clientRegistration: "cimd", which identifies pi with its Client ID Metadata Document on pi.dev instead of dynamic client registration, matching Codex: a shared document when the authorization server sends iss, otherwise a document and callback path specific to the MCP server. Authorization responses are only accepted on the exact redirect URI. Dynamic client registration stays the default. closes #10302
This commit is contained in:
parent
de7e675de2
commit
1499466d85
14 changed files with 344 additions and 40 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Added
|
||||
|
||||
- Added a copy key (`app.message.copy`, default `ctrl+x`) to OAuth sign-in screens in `/login`, `/mcp`, and `/mcp login`, which copies the sign-in URL when the browser cannot be opened or the wrapped link cannot be selected.
|
||||
- Added `oauth.clientRegistration: "cimd"` for MCP servers, which identifies pi with its Client ID Metadata Document on pi.dev instead of dynamic client registration, so authorization servers can allow pi by URL ([#10302](https://github.com/earendil-works/pi/issues/10302))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,18 @@ Pi registers as `pi`. Some servers only accept registrations from known clients.
|
|||
|
||||
The name is only sent when Pi registers a client. To register again under a new name, sign out first.
|
||||
|
||||
Some authorization servers allow clients by their Client ID Metadata Document URL instead of registering them. Set `clientRegistration` to `cimd` to identify as Pi's document on pi.dev instead of registering:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": { "url": "https://mcp.example.com/mcp", "oauth": { "clientRegistration": "cimd" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The client ID is `https://pi.dev/oauth/client.json` with the redirect URI `http://127.0.0.1:<port>/callback`. If the authorization server does not send the `iss` parameter in authorization responses (RFC 9207), Pi uses a document and redirect path specific to the MCP server instead: `https://pi.dev/oauth/<id>/client.json` with `http://127.0.0.1:<port>/callback/<id>`. The authorization server must advertise Client ID Metadata Document support and public clients, or sign-in fails. `cimd` cannot be combined with `clientId` or `clientName`, and a `callbackUrl` must use `localhost` or `127.0.0.1` with the path `/callback`.
|
||||
|
||||
Pi finds the authorization server through the server's protected resource metadata (RFC 9728) and checks that the authorization server's metadata names the expected issuer (RFC 8414). Some servers advertise the wrong authorization server or none, so sign-in opens a page that does not exist. Set `authServerMetadataUrl` to the metadata document of the right authorization server:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ export interface McpOAuthConfig {
|
|||
* Default: `pi`.
|
||||
*/
|
||||
clientName?: string;
|
||||
/**
|
||||
* How pi identifies itself without `clientId`. `dcr` (default): dynamic client registration. `cimd`:
|
||||
* pi's Client ID Metadata Document on pi.dev, for authorization servers that allow pi by that URL. The
|
||||
* server must support it for public clients, and the callback must use the default path `/callback`.
|
||||
*/
|
||||
clientRegistration?: "dcr" | "cimd";
|
||||
/**
|
||||
* Authorization server metadata document (RFC 8414 or OpenID Connect discovery) to use instead of
|
||||
* discovery through the server, for servers that advertise a wrong authorization server or none.
|
||||
|
|
@ -147,6 +153,16 @@ function validateOAuth(value: unknown): string | undefined {
|
|||
if (value.clientName !== undefined && (typeof value.clientName !== "string" || !value.clientName.trim())) {
|
||||
return "oauth.clientName must be a non-empty string";
|
||||
}
|
||||
if (value.clientRegistration !== undefined && value.clientRegistration !== "dcr") {
|
||||
if (value.clientRegistration !== "cimd") return 'oauth.clientRegistration must be "dcr" or "cimd"';
|
||||
if (value.clientId !== undefined || value.clientName !== undefined) {
|
||||
return 'oauth.clientRegistration "cimd" cannot be combined with oauth.clientId or oauth.clientName';
|
||||
}
|
||||
const callback = typeof value.callbackUrl === "string" ? new URL(value.callbackUrl) : undefined;
|
||||
if (callback && (callback.hostname === "[::1]" || callback.pathname !== "/callback")) {
|
||||
return 'oauth.clientRegistration "cimd" requires oauth.callbackUrl on localhost or 127.0.0.1 with path /callback';
|
||||
}
|
||||
}
|
||||
const metadataUrl = value.authServerMetadataUrl;
|
||||
if (metadataUrl !== undefined) {
|
||||
const url = typeof metadataUrl === "string" && URL.canParse(metadataUrl) ? new URL(metadataUrl) : undefined;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* 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, dynamic client registration) against a loopback callback.
|
||||
* the authorization code flow (PKCE, a Client ID Metadata Document or dynamic client registration)
|
||||
* against a loopback callback.
|
||||
*
|
||||
* Credentials live in `<agent-dir>/mcp-auth.json`, keyed by server name and URL.
|
||||
*/
|
||||
|
|
@ -15,6 +16,7 @@ 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,
|
||||
|
|
@ -25,6 +27,7 @@ import {
|
|||
OAuthCallbackServer,
|
||||
type OAuthChallenge,
|
||||
type OAuthClientInformationMixed,
|
||||
type OAuthClientMetadataDocument,
|
||||
parseWwwAuthenticate,
|
||||
stepUpScope,
|
||||
} from "@earendil-works/pi-mcp/oauth";
|
||||
|
|
@ -35,6 +38,8 @@ import { 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. */
|
||||
|
|
@ -58,6 +63,8 @@ export interface McpOAuthSettings {
|
|||
scope?: string;
|
||||
/** `client_name` for dynamic client registration. Default: `APP_NAME`. */
|
||||
clientName?: string;
|
||||
/** See `McpOAuthConfig.clientRegistration`. */
|
||||
clientRegistration?: "dcr" | "cimd";
|
||||
/** See `McpOAuthConfig.authServerMetadataUrl`. */
|
||||
authServerMetadataUrl?: URL;
|
||||
}
|
||||
|
|
@ -217,6 +224,41 @@ function registeredRedirectUrls(client: OAuthClientInformationMixed | undefined)
|
|||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* pi's Client ID Metadata Document, for `clientRegistration: "cimd"`, chosen like Codex chooses its own.
|
||||
* The configuration ensures the default callback path. 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 {
|
||||
if (
|
||||
!metadata?.client_id_metadata_document_supported ||
|
||||
!metadata.token_endpoint_auth_methods_supported?.includes("none")
|
||||
) {
|
||||
throw new Error(
|
||||
'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"',
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
|
@ -228,6 +270,10 @@ function createProvider(
|
|||
serverUrl,
|
||||
redirectUrl,
|
||||
clientMetadata: { client_name: settings.clientName ?? APP_NAME },
|
||||
clientMetadataDocument:
|
||||
settings.clientRegistration === "cimd"
|
||||
? (metadata) => clientMetadataDocument(serverUrl, redirectUrl, metadata)
|
||||
: undefined,
|
||||
clientId: settings.clientId,
|
||||
clientSecret: settings.clientSecret,
|
||||
store,
|
||||
|
|
@ -336,13 +382,17 @@ export class McpSignInCancelledError extends Error {
|
|||
|
||||
type AuthorizationResponse = Pick<OAuthCallback, "code" | "iss">;
|
||||
|
||||
function responseFromRedirectUrl(input: string, state: string): AuthorizationResponse {
|
||||
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");
|
||||
|
|
@ -355,13 +405,14 @@ function responseFromRedirectUrl(input: string, state: string): AuthorizationRes
|
|||
async function waitForAuthorizationResponse(
|
||||
callback: OAuthCallbackServer,
|
||||
state: string,
|
||||
redirectUrl: URL,
|
||||
prompt: McpSignInPrompt,
|
||||
): Promise<AuthorizationResponse> {
|
||||
const controller = new AbortController();
|
||||
const fromBrowser = callback.waitForCallback(state);
|
||||
const fromBrowser = callback.waitForCallback(state, redirectUrl.pathname);
|
||||
const fromUser = prompt.promptForRedirectUrl(controller.signal).then((input) => {
|
||||
if (!input?.trim()) throw new McpSignInCancelledError();
|
||||
return responseFromRedirectUrl(input, state);
|
||||
return responseFromRedirectUrl(input, state, redirectUrl);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fromBrowser, fromUser]);
|
||||
|
|
@ -376,6 +427,7 @@ async function waitForAuthorizationResponse(
|
|||
/** 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> {
|
||||
|
|
@ -383,6 +435,7 @@ async function listenForCallback(
|
|||
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.")
|
||||
|
|
@ -415,15 +468,26 @@ export async function signInMcpServer(options: {
|
|||
const registered = registeredRedirectUrls(stored?.clientInformation)[0];
|
||||
const preferredPort =
|
||||
callbackOptions.port ?? (registered ? Number(new URL(registered).port) || undefined : undefined);
|
||||
const callback = await listenForCallback(callbackOptions, preferredPort, callbackOptions.port !== undefined);
|
||||
const cimd = settings.clientRegistration === "cimd";
|
||||
const callback = await listenForCallback(
|
||||
callbackOptions,
|
||||
// The redirect URI of a server-specific Client ID Metadata Document.
|
||||
cimd ? [`${CALLBACK_PATH}/${callbackId(serverUrl)}`] : [],
|
||||
preferredPort,
|
||||
callbackOptions.port !== undefined,
|
||||
);
|
||||
const redirectUrl = callbackOptions.fixedRedirectUrl ?? callback.redirectUrl;
|
||||
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.
|
||||
if (!settings.clientId && !registeredRedirectUrls(stored.clientInformation).includes(redirectUrl)) {
|
||||
// A registered client cannot use another redirect URI, and its tokens belong to it. A Client ID
|
||||
// Metadata Document is not stored, so with one, a stored client was registered before and is replaced.
|
||||
const keepClient =
|
||||
settings.clientId ||
|
||||
(cimd ? !stored.clientInformation : registeredRedirectUrls(stored.clientInformation).includes(redirectUrl));
|
||||
if (!keepClient) {
|
||||
delete next.clientInformation;
|
||||
delete next.tokens;
|
||||
delete next.tokensExpireAt;
|
||||
|
|
@ -452,8 +516,15 @@ export async function signInMcpServer(options: {
|
|||
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(callback, state, options.prompt);
|
||||
const { code, iss } = await waitForAuthorizationResponse(
|
||||
callback,
|
||||
state,
|
||||
authorizationRedirectUrl,
|
||||
options.prompt,
|
||||
);
|
||||
await authorizeMcp(provider, { ...flow, authorizationCode: code, iss });
|
||||
} finally {
|
||||
await callback.close();
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ export class McpServerConnection implements McpToolCaller, McpResourceServer {
|
|||
callbackUrl: oauth.callbackUrl,
|
||||
scope: oauth.scope,
|
||||
clientName: oauth.clientName,
|
||||
clientRegistration: oauth.clientRegistration,
|
||||
authServerMetadataUrl: oauth.authServerMetadataUrl ? new URL(oauth.authServerMetadataUrl) : undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,18 +145,32 @@ describe("MCP config", () => {
|
|||
url: "https://a.example/mcp",
|
||||
oauth: { authServerMetadataUrl: "http://idp.example/m" },
|
||||
},
|
||||
// #10302
|
||||
cimd: {
|
||||
url: "https://a.example/mcp",
|
||||
oauth: { clientRegistration: "cimd", callbackUrl: "http://localhost/callback" },
|
||||
},
|
||||
badRegistration: { url: "https://a.example/mcp", oauth: { clientRegistration: "auto" } },
|
||||
cimdClient: { url: "https://a.example/mcp", oauth: { clientRegistration: "cimd", clientId: "x" } },
|
||||
cimdPath: {
|
||||
url: "https://a.example/mcp",
|
||||
oauth: { clientRegistration: "cimd", callbackUrl: "http://127.0.0.1/cb" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
const { servers, errors } = loadMcpConfig({ ...paths, projectTrusted: false });
|
||||
expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same", "named", "metadata"]);
|
||||
expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same", "named", "metadata", "cimd"]);
|
||||
expect(errors).toEqual([
|
||||
expect.stringContaining('server "remote": oauth.callbackUrl must be an http URI on localhost'),
|
||||
expect.stringContaining('server "both": oauth.callbackUrl and oauth.callbackPort name different ports'),
|
||||
expect.stringContaining('server "scope": oauth.scope must be a string'),
|
||||
expect.stringContaining('server "unnamed": oauth.clientName must be a non-empty string'),
|
||||
expect.stringContaining('server "plainMetadata": oauth.authServerMetadataUrl must be an https URL'),
|
||||
expect.stringContaining('server "badRegistration": oauth.clientRegistration must be "dcr" or "cimd"'),
|
||||
expect.stringContaining('server "cimdClient": oauth.clientRegistration "cimd" cannot be combined'),
|
||||
expect.stringContaining('server "cimdPath": oauth.clientRegistration "cimd" requires oauth.callbackUrl'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
createMcpAuthProvider,
|
||||
McpOAuthCredentialStore,
|
||||
type McpOAuthSettings,
|
||||
type McpSignInPrompt,
|
||||
signInMcpServer,
|
||||
} from "../src/extensions/mcp/oauth.ts";
|
||||
import { startOAuthMcpServer } from "./suite/mcp-oauth-server.ts";
|
||||
|
|
@ -157,3 +159,107 @@ describe("MCP OAuth sign-in", () => {
|
|||
await expect(signIn({}, settings)).rejects.toThrow("HTTP 404 loading authorization server metadata");
|
||||
});
|
||||
});
|
||||
|
||||
// #10302
|
||||
describe("MCP OAuth client ID metadata documents", () => {
|
||||
const cleanups: (() => Promise<void>)[] = [];
|
||||
const cimd: McpOAuthSettings = { clientRegistration: "cimd" };
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function startServer(options: { issParameter?: boolean; cimd?: boolean; redirectPath?: string }) {
|
||||
const server = await startOAuthMcpServer(options);
|
||||
cleanups.push(server.close);
|
||||
const store = new McpOAuthCredentialStore(new InMemoryAuthStorageBackend()).forServer("test", server.url);
|
||||
const browser: McpSignInPrompt = {
|
||||
showAuthorizationUrl: (url) => void fetch(url),
|
||||
promptForRedirectUrl: (signal) =>
|
||||
new Promise((resolve) => signal.addEventListener("abort", () => resolve(undefined), { once: true })),
|
||||
};
|
||||
const signIn = (settings: McpOAuthSettings, prompt = browser) =>
|
||||
signInMcpServer({ serverUrl: server.url, store, settings, prompt });
|
||||
return { server, store, signIn };
|
||||
}
|
||||
|
||||
it("registers dynamically by default, even when the server supports documents", async () => {
|
||||
const { server, signIn } = await startServer({ cimd: true, issParameter: true });
|
||||
await signIn({});
|
||||
expect(server.registrations).toHaveLength(1);
|
||||
expect(server.authorizations[0].get("client_id")).toBe("client-1");
|
||||
});
|
||||
|
||||
it("uses pi's document when authorization responses name their issuer", async () => {
|
||||
const { server, store, signIn } = await startServer({ cimd: true, issParameter: true });
|
||||
await signIn(cimd);
|
||||
const [authorization] = server.authorizations;
|
||||
expect(authorization.get("client_id")).toBe("https://pi.dev/oauth/client.json");
|
||||
const redirect = new URL(authorization.get("redirect_uri") ?? "");
|
||||
expect(`${redirect.hostname}${redirect.pathname}`).toBe("127.0.0.1/callback");
|
||||
expect(redirect.port).not.toBe("");
|
||||
expect(server.registrations).toEqual([]);
|
||||
expect(server.tokenRequests[0].get("client_id")).toBe("https://pi.dev/oauth/client.json");
|
||||
expect(server.tokenRequests[0].get("redirect_uri")).toBe(redirect.href);
|
||||
// The document is not stored, so signing in again refreshes the tokens instead of discarding them.
|
||||
expect((await store.load())?.clientInformation).toBeUndefined();
|
||||
await signIn(cimd);
|
||||
expect(server.authorizations).toHaveLength(1);
|
||||
expect(server.tokenRequests[1].get("grant_type")).toBe("refresh_token");
|
||||
expect(server.tokenRequests[1].get("client_id")).toBe("https://pi.dev/oauth/client.json");
|
||||
});
|
||||
|
||||
it("uses a document and callback path specific to the MCP server without the iss parameter", async () => {
|
||||
const { server, signIn } = await startServer({ cimd: true });
|
||||
await signIn(cimd);
|
||||
// Computed like Codex: the first 9 bytes of the SHA-256 of the MCP server URL.
|
||||
const id = createHash("sha256").update(server.url).digest().subarray(0, 9).toString("base64url");
|
||||
const [authorization] = server.authorizations;
|
||||
expect(authorization.get("client_id")).toBe(`https://pi.dev/oauth/${id}/client.json`);
|
||||
const redirect = new URL(authorization.get("redirect_uri") ?? "");
|
||||
expect(redirect.pathname).toBe(`/callback/${id}`);
|
||||
expect(server.registrations).toEqual([]);
|
||||
expect(server.tokenRequests[0].get("redirect_uri")).toBe(redirect.href);
|
||||
});
|
||||
|
||||
it("replaces a registered client when switching to the document", async () => {
|
||||
const { server, store, signIn } = await startServer({ cimd: true, issParameter: true });
|
||||
await signIn({});
|
||||
expect((await store.load())?.clientInformation?.client_id).toBe("client-1");
|
||||
await signIn(cimd);
|
||||
// The registered client's tokens are not refreshed with another client.
|
||||
expect(server.authorizations.map((authorization) => authorization.get("client_id"))).toEqual([
|
||||
"client-1",
|
||||
"https://pi.dev/oauth/client.json",
|
||||
]);
|
||||
expect((await store.load())?.clientInformation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts the authorization response only on the server-specific redirect URI", async () => {
|
||||
// A mixed-up authorization server redirects to the shared callback path.
|
||||
const { signIn } = await startServer({ cimd: true, redirectPath: "/callback" });
|
||||
await expect(signIn(cimd)).rejects.toThrow("arrived on another redirect URI");
|
||||
|
||||
// The same for a redirect URL pasted from the browser.
|
||||
const { signIn: signInByPaste } = await startServer({ cimd: true });
|
||||
let shown: URL | undefined;
|
||||
const paste: McpSignInPrompt = {
|
||||
showAuthorizationUrl: (url) => {
|
||||
shown = url;
|
||||
},
|
||||
promptForRedirectUrl: async () => {
|
||||
const redirect = new URL(shown?.searchParams.get("redirect_uri") ?? "");
|
||||
redirect.pathname = "/callback";
|
||||
redirect.search = `?code=code-1&state=${shown?.searchParams.get("state")}`;
|
||||
return redirect.href;
|
||||
},
|
||||
};
|
||||
await expect(signInByPaste(cimd, paste)).rejects.toThrow("does not match this sign-in's redirect URI");
|
||||
});
|
||||
|
||||
it("fails instead of registering when the server does not support documents", async () => {
|
||||
const { server, signIn } = await startServer({ issParameter: true });
|
||||
await expect(signIn(cimd)).rejects.toThrow("does not support Client ID Metadata Documents");
|
||||
expect(server.registrations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,11 +14,17 @@ function json(response: ServerResponse, status: number, body: unknown, headers:
|
|||
|
||||
/**
|
||||
* MCP server protected by OAuth, with its own authorization server (discovery, DCR, PKCE, refresh).
|
||||
* `iss` is sent as the `iss` parameter of authorization responses (RFC 9207).
|
||||
* `iss` is sent as the `iss` parameter of authorization responses (RFC 9207). `issParameter` advertises
|
||||
* that parameter and sends the server's issuer. `cimd` advertises Client ID Metadata Documents.
|
||||
* `redirectPath` replaces the path of the redirect URI, like a mixed-up authorization server.
|
||||
*/
|
||||
export async function startOAuthMcpServer(options: { iss?: string } = {}) {
|
||||
export async function startOAuthMcpServer(
|
||||
options: { iss?: string; issParameter?: boolean; cimd?: boolean; redirectPath?: string } = {},
|
||||
) {
|
||||
const log: string[] = [];
|
||||
const registrations: Record<string, unknown>[] = [];
|
||||
const authorizations: URLSearchParams[] = [];
|
||||
const tokenRequests: URLSearchParams[] = [];
|
||||
const validTokens = new Set<string>();
|
||||
const refreshTokens = new Set<string>();
|
||||
const challenges = new Map<string, string>();
|
||||
|
|
@ -87,6 +93,8 @@ export async function startOAuthMcpServer(options: { iss?: string } = {}) {
|
|||
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>;
|
||||
|
|
@ -95,17 +103,21 @@ export async function startOAuthMcpServer(options: { iss?: string } = {}) {
|
|||
return json(response, 201, { ...metadata, client_id: "client-1" });
|
||||
}
|
||||
case "/authorize": {
|
||||
authorizations.push(url.searchParams);
|
||||
const code = `code-${challenges.size + 1}`;
|
||||
challenges.set(code, url.searchParams.get("code_challenge") ?? "");
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
|
||||
if (options.redirectPath) redirect.pathname = options.redirectPath;
|
||||
redirect.searchParams.set("code", code);
|
||||
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
|
||||
if (options.iss) redirect.searchParams.set("iss", options.iss);
|
||||
const iss = options.iss ?? (options.issParameter ? origin : undefined);
|
||||
if (iss) redirect.searchParams.set("iss", iss);
|
||||
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 challenge = challenges.get(params.get("code") ?? "");
|
||||
const verifier = createHash("sha256")
|
||||
|
|
@ -140,6 +152,10 @@ export async function startOAuthMcpServer(options: { iss?: string } = {}) {
|
|||
log,
|
||||
/** Client metadata of dynamic client registrations. */
|
||||
registrations,
|
||||
/** Query parameters of authorization requests. */
|
||||
authorizations,
|
||||
/** Parameters of token requests. */
|
||||
tokenRequests,
|
||||
/** Simulates access token expiry. */
|
||||
expireAccessTokens: () => validTokens.clear(),
|
||||
close: () =>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `OAuthClientProvider.clientMetadataUrl` is replaced by `clientMetadataDocument(metadata)`, which returns the document URL and its redirect URI per authorization server, or `undefined` to register dynamically. It is called whenever no client information is stored, also when the server does not advertise support, and the document is no longer stored as client information ([#10302](https://github.com/earendil-works/pi/issues/10302))
|
||||
|
||||
### Added
|
||||
|
||||
- `McpOAuthProviderOptions.clientMetadataDocument`, `OAuthCallbackServerOptions.extraPaths`, and a `path` argument for `OAuthCallbackServer.waitForCallback` that rejects responses on other paths ([#10302](https://github.com/earendil-works/pi/issues/10302))
|
||||
|
||||
## [1.0.0] - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface OAuthCallbackServerOptions {
|
|||
redirectHost?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
/** More paths that receive the callback, for example a server-specific path of a redirect URI. */
|
||||
extraPaths?: string[];
|
||||
timeoutMs?: number;
|
||||
/** Render the browser page as HTML. Default: a plain-text message. */
|
||||
renderPage?: (page: OAuthCallbackPage) => string;
|
||||
|
|
@ -32,7 +34,7 @@ function plainText(page: OAuthCallbackPage): string {
|
|||
export class OAuthCallbackServer {
|
||||
readonly redirectUrl: string;
|
||||
private server: Server;
|
||||
private path: string;
|
||||
private paths: string[];
|
||||
private timeoutMs: number;
|
||||
private renderPage: ((page: OAuthCallbackPage) => string) | undefined;
|
||||
private pending = new Map<
|
||||
|
|
@ -41,19 +43,20 @@ export class OAuthCallbackServer {
|
|||
resolve: (callback: OAuthCallback) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
path: string | undefined;
|
||||
}
|
||||
>();
|
||||
|
||||
private constructor(
|
||||
server: Server,
|
||||
redirectUrl: string,
|
||||
path: string,
|
||||
paths: string[],
|
||||
timeoutMs: number,
|
||||
renderPage: ((page: OAuthCallbackPage) => string) | undefined,
|
||||
) {
|
||||
this.server = server;
|
||||
this.redirectUrl = redirectUrl;
|
||||
this.path = path;
|
||||
this.paths = paths;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.renderPage = renderPage;
|
||||
}
|
||||
|
|
@ -76,21 +79,25 @@ export class OAuthCallbackServer {
|
|||
instance = new OAuthCallbackServer(
|
||||
server,
|
||||
`http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${path}`,
|
||||
path,
|
||||
[path, ...(options.extraPaths ?? [])],
|
||||
options.timeoutMs ?? 5 * 60_000,
|
||||
options.renderPage,
|
||||
);
|
||||
return instance;
|
||||
}
|
||||
|
||||
waitForCallback(state: string): Promise<OAuthCallback> {
|
||||
/**
|
||||
* Wait for the authorization response with `state`. With `path`, a response on another path fails, so
|
||||
* a server-specific redirect URI can tell authorization servers apart (RFC 9700 section 4.4.2.2).
|
||||
*/
|
||||
waitForCallback(state: string, path?: string): Promise<OAuthCallback> {
|
||||
if (this.pending.has(state)) throw new Error("OAuth state is already pending");
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(state);
|
||||
reject(new Error("OAuth callback timed out"));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(state, { resolve, reject, timer });
|
||||
this.pending.set(state, { resolve, reject, timer, path });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +123,7 @@ export class OAuthCallbackServer {
|
|||
|
||||
private handle(rawUrl: string, response: ServerResponse): void {
|
||||
const url = new URL(rawUrl, this.redirectUrl);
|
||||
if (url.pathname !== this.path) {
|
||||
if (!this.paths.includes(url.pathname)) {
|
||||
this.reply(response, 404, { ok: false, message: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
|
@ -128,6 +135,11 @@ export class OAuthCallbackServer {
|
|||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(state);
|
||||
if (pending.path !== undefined && url.pathname !== pending.path) {
|
||||
pending.reject(new Error("The authorization response arrived on another redirect URI"));
|
||||
this.reply(response, 400, { ok: false, message: "Unexpected redirect URI" });
|
||||
return;
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
const description = url.searchParams.get("error_description") ?? error;
|
||||
|
|
|
|||
|
|
@ -38,10 +38,21 @@ export type AddClientAuthentication = (
|
|||
metadata?: AuthorizationServerMetadata,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** A Client ID Metadata Document: an https URL used as `client_id`, and a redirect URI it lists. */
|
||||
export interface OAuthClientMetadataDocument {
|
||||
url: string;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
export interface OAuthClientProvider {
|
||||
readonly redirectUrl: string | URL;
|
||||
readonly clientMetadata: OAuthClientMetadata;
|
||||
readonly clientMetadataUrl?: string;
|
||||
/**
|
||||
* Client ID Metadata Document to identify as instead of registering dynamically, or `undefined` to
|
||||
* register. Called when no client information is stored; the document is not stored. `metadata` is
|
||||
* `undefined` when the authorization server has none; check `client_id_metadata_document_supported`.
|
||||
*/
|
||||
clientMetadataDocument?(metadata: AuthorizationServerMetadata | undefined): OAuthClientMetadataDocument | undefined;
|
||||
state?(): string | Promise<string>;
|
||||
clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;
|
||||
saveClientInformation?(information: OAuthClientInformationMixed): void | Promise<void>;
|
||||
|
|
@ -310,25 +321,26 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions)
|
|||
// `||`, not `??`: an empty scope (for example from `scopes_supported: []`) falls through to the next source.
|
||||
const scope =
|
||||
options.scope || discovered.resourceMetadata?.scopes_supported?.join(" ") || provider.clientMetadata.scope;
|
||||
let client = await provider.clientInformation();
|
||||
const stored = await provider.clientInformation();
|
||||
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");
|
||||
}
|
||||
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 (metadata?.client_id_metadata_document_supported && provider.clientMetadataUrl) {
|
||||
const url = new URL(provider.clientMetadataUrl);
|
||||
if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");
|
||||
client = { client_id: provider.clientMetadataUrl };
|
||||
await provider.saveClientInformation?.(client);
|
||||
} else {
|
||||
if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");
|
||||
client = await registerClient(discovered.authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
scope,
|
||||
fetch: options.fetch,
|
||||
});
|
||||
await provider.saveClientInformation(client);
|
||||
}
|
||||
if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");
|
||||
client = await registerClient(discovered.authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
scope,
|
||||
fetch: options.fetch,
|
||||
});
|
||||
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,
|
||||
|
|
@ -346,7 +358,7 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions)
|
|||
...tokenOptions,
|
||||
code: options.authorizationCode,
|
||||
codeVerifier: await provider.codeVerifier(),
|
||||
redirectUrl: provider.redirectUrl,
|
||||
redirectUrl,
|
||||
});
|
||||
// A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can
|
||||
// keep it. Callers pass the options of the authorization request, so `scope` is what was requested.
|
||||
|
|
@ -372,7 +384,7 @@ async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions)
|
|||
const authorization = await startAuthorization(discovered.authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation: client,
|
||||
redirectUrl: provider.redirectUrl,
|
||||
redirectUrl,
|
||||
scope,
|
||||
state,
|
||||
resource,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export {
|
|||
adaptOAuthProvider,
|
||||
authorizeMcp,
|
||||
exchangeAuthorizationCode,
|
||||
type OAuthClientMetadataDocument,
|
||||
type OAuthClientProvider,
|
||||
type OAuthFlowOptions,
|
||||
type OAuthFlowResult,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { OAuthClientProvider } from "./flow.ts";
|
||||
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthDiscoveryState, OAuthTokens } from "./types.ts";
|
||||
import type { OAuthClientMetadataDocument, OAuthClientProvider } from "./flow.ts";
|
||||
import type {
|
||||
AuthorizationServerMetadata,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthDiscoveryState,
|
||||
OAuthTokens,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface McpOAuthState {
|
||||
serverUrl: string;
|
||||
|
|
@ -21,6 +27,10 @@ export interface McpOAuthProviderOptions {
|
|||
serverUrl: string | URL;
|
||||
redirectUrl: string | URL;
|
||||
clientMetadata: Omit<OAuthClientMetadata, "redirect_uris"> & { redirect_uris?: string[] };
|
||||
/** See `OAuthClientProvider.clientMetadataDocument`. */
|
||||
clientMetadataDocument?: (
|
||||
metadata: AuthorizationServerMetadata | undefined,
|
||||
) => OAuthClientMetadataDocument | undefined;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
store?: McpOAuthStateStore;
|
||||
|
|
@ -43,6 +53,9 @@ export class MemoryOAuthStateStore implements McpOAuthStateStore {
|
|||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
readonly redirectUrl: string;
|
||||
readonly clientMetadata: OAuthClientMetadata;
|
||||
readonly clientMetadataDocument?: (
|
||||
metadata: AuthorizationServerMetadata | undefined,
|
||||
) => OAuthClientMetadataDocument | undefined;
|
||||
private serverUrl: string;
|
||||
private configuredClient: OAuthClientInformationMixed | undefined;
|
||||
private store: McpOAuthStateStore;
|
||||
|
|
@ -60,6 +73,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||
token_endpoint_auth_method:
|
||||
options.clientMetadata.token_endpoint_auth_method ?? (options.clientSecret ? "client_secret_post" : "none"),
|
||||
};
|
||||
this.clientMetadataDocument = options.clientMetadataDocument;
|
||||
this.configuredClient = options.clientId
|
||||
? { client_id: options.clientId, ...(options.clientSecret ? { client_secret: options.clientSecret } : {}) }
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -514,6 +514,26 @@ describe("OAuthCallbackServer pages", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// #10302
|
||||
it("rejects a response on another path than the expected one", async () => {
|
||||
const callback = await OAuthCallbackServer.listen({ extraPaths: ["/callback/server-id"] });
|
||||
try {
|
||||
const origin = new URL(callback.redirectUrl).origin;
|
||||
const mixedUp = callback.waitForCallback("s1", "/callback/server-id");
|
||||
mixedUp.catch(() => undefined);
|
||||
const wrong = await fetch(`${origin}/callback?code=abc&state=s1`);
|
||||
expect(wrong.status).toBe(400);
|
||||
await expect(mixedUp).rejects.toThrow("arrived on another redirect URI");
|
||||
|
||||
const pending = callback.waitForCallback("s2", "/callback/server-id");
|
||||
const right = await fetch(`${origin}/callback/server-id?code=abc&state=s2`);
|
||||
expect(right.status).toBe(200);
|
||||
expect((await pending).code).toBe("abc");
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders pages through renderPage", async () => {
|
||||
const pages: OAuthCallbackPage[] = [];
|
||||
const callback = await OAuthCallbackServer.listen({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue