pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
368 lines (334 loc) • 14.1 kB
text/typescript
/**
* Pure OAuth 2.1 client-information, issuer-binding, token, and state-invalidation policy.
*
* Keeps deterministic OAuth rules (DCR metadata construction, redirect URI fallback
* and mismatch detection, client_secret expiry, static vs. DCR credential shaping,
* GHSA-6qxp-vccf-f47h authorization-server issuer binding, OAuthTokens <-> StoredTokens
* mapping, single-use refresh peer-rotation detection, and StoredState invalidation)
* separate from lock and filesystem I/O in oauth-provider.ts.
*/
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { McpError } from "./errors.js";
import type {
StoredClientInfo,
StoredState,
StoredTokens,
} from "./oauth-state-store.js";
// ─── URL Canonicalization & Origin-Binding Helpers ────────────────────────────
export function canonicalServerUrl(rawUrl: string): string {
try {
const u = new URL(rawUrl);
const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
return `${u.origin}${path}${u.search}`;
} catch {
// WHY: new URL() throws TypeError per WHATWG URL spec on malformed or relative
// URL strings. Falling back to trimmed rawUrl allows graceful degradation for
// non-standard or unparseable URLs without crashing callers.
return rawUrl.trim();
}
}
export function urlsMatch(storedUrl: string, expectedUrl: string): boolean {
return canonicalServerUrl(storedUrl) === canonicalServerUrl(expectedUrl);
}
function isUrlPrefixCompatible(req: URL, cfg: URL): boolean {
if (req.origin !== cfg.origin) return false;
if (req.pathname.length < cfg.pathname.length) return false;
const reqPath = req.pathname.endsWith("/") ? req.pathname : `${req.pathname}/`;
const cfgPath = cfg.pathname.endsWith("/") ? cfg.pathname : `${cfg.pathname}/`;
return reqPath.startsWith(cfgPath);
}
export function isResourceCompatibleWithServerUrl(resource: string, expectedServerUrl: string): boolean {
if (urlsMatch(resource, expectedServerUrl)) return true;
try {
const req = new URL(canonicalServerUrl(expectedServerUrl));
const cfg = new URL(canonicalServerUrl(resource));
return isUrlPrefixCompatible(req, cfg);
} catch {
// WHY: new URL() throws TypeError per WHATWG URL spec if canonicalServerUrl produced
// an invalid or relative URL. If either URL cannot be parsed into an absolute URL,
// they cannot be origin/path-compatible, so returning false is the safe fallback.
return false;
}
}
// ─── Types & Constants ────────────────────────────────────────────────────────
/** Auth config in mcp.json server entry. Matches the Zod AuthConfigSchema in config.ts. */
export interface AuthConfig {
/** Auth type. Currently only "oauth" is supported. */
type?: "oauth" | undefined;
/**
* Callback URL the OAuth server redirects to after authorization.
* Default: auto-detected local callback server.
*/
redirectUrl?: string | undefined;
/**
* Optional scope to request during authorization.
*/
scope?: string | undefined;
/**
* Pre-registered client_id (skip dynamic client registration).
*/
clientId?: string | undefined;
/**
* Pre-registered client_secret.
*/
clientSecret?: string | undefined;
/** `client_name` sent with Dynamic Client Registration. Default: `pi-mcp/<server name>`. */
clientName?: string | undefined;
}
export const DEFAULT_CALLBACK_PORT = 19876;
export const DEFAULT_REDIRECT_URL = `http://127.0.0.1:${DEFAULT_CALLBACK_PORT}/callback`;
export interface ClientInformationExtras {
client_id_issued_at: number;
client_secret_expires_at: number;
token_endpoint_auth_method: string;
redirect_uris: Array<string | URL>;
}
export type InvalidationScope = "all" | "client" | "tokens" | "verifier" | "discovery";
// ─── Pure Client-Information & Redirect Helpers ───────────────────────────────
export function isClientSecretExpired(info: StoredClientInfo, nowMs: number = Date.now()): boolean {
if (info.client_secret === undefined) return false;
if (typeof info.client_secret_expires_at !== "number") return false;
if (info.client_secret_expires_at <= 0) return false;
return info.client_secret_expires_at * 1000 <= nowMs;
}
export function hasRedirectUriMismatch(info: StoredClientInfo, redirectUrl: string): boolean {
if (!Array.isArray(info.redirect_uris) || info.redirect_uris.length === 0) return false;
return !info.redirect_uris.includes(redirectUrl);
}
export function toOAuthClientInformation(info: StoredClientInfo): OAuthClientInformationMixed {
return {
client_id: info.client_id,
...(info.client_secret !== undefined && { client_secret: info.client_secret }),
...(info.client_id_issued_at !== undefined && { client_id_issued_at: info.client_id_issued_at }),
...(info.client_secret_expires_at !== undefined && {
client_secret_expires_at: info.client_secret_expires_at,
}),
...(info.token_endpoint_auth_method !== undefined && {
token_endpoint_auth_method: info.token_endpoint_auth_method,
}),
...(info.redirect_uris !== undefined && { redirect_uris: info.redirect_uris }),
...(info.issuer !== undefined && { issuer: info.issuer }),
};
}
export function withoutStaticClientId<T>(
hasStaticClientId: boolean,
value: T | undefined,
): T | undefined {
return hasStaticClientId ? undefined : value;
}
export function numberOrUndefined(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
export function stringOrUndefined(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function redirectUrisOrUndefined(value: unknown): string[] | undefined {
return Array.isArray(value) ? value.map((uri) => String(uri)) : undefined;
}
export function buildInteractiveStoredClientInfo(
clientInformation: OAuthClientInformationMixed,
hasStaticClientId: boolean,
issuerToBind: string | undefined,
): StoredClientInfo {
const fullInfo = clientInformation as Partial<ClientInformationExtras>;
return {
client_id: clientInformation.client_id,
client_secret: withoutStaticClientId(hasStaticClientId, clientInformation.client_secret),
client_id_issued_at: withoutStaticClientId(
hasStaticClientId,
numberOrUndefined(fullInfo.client_id_issued_at),
),
client_secret_expires_at: withoutStaticClientId(
hasStaticClientId,
numberOrUndefined(fullInfo.client_secret_expires_at),
),
token_endpoint_auth_method: withoutStaticClientId(
hasStaticClientId,
stringOrUndefined(fullInfo.token_endpoint_auth_method),
),
redirect_uris: withoutStaticClientId(
hasStaticClientId,
redirectUrisOrUndefined(fullInfo.redirect_uris),
),
issuer: issuerToBind,
};
}
export function resolveProviderRedirectUrl(
mode: "silent" | "interactive",
explicitRedirectUrl: string | URL | undefined,
authConfig: AuthConfig,
storedRedirectUris?: string[],
): string | URL {
if (mode === "silent" && storedRedirectUris?.[0] !== undefined) {
// WHY: A silent provider never starts an authorization flow, but clientMetadata.redirect_uris
// must still match what the client was registered with. Prefer the stored DCR redirect_uris
// (preserved per AGENTS.md), then the configured auth.redirectUrl, then the default 127.0.0.1:19876 callback.
return storedRedirectUris[0];
}
// WHY: In interactive mode, redirectUrl is the URL the callback server actually bound
// (passed in by authorize()), falling back to configured auth.redirectUrl, then the default
// 127.0.0.1:19876 callback URL.
return explicitRedirectUrl ?? authConfig.redirectUrl ?? DEFAULT_REDIRECT_URL;
}
export function buildOAuthClientMetadata(
serverName: string,
authConfig: AuthConfig,
mode: "silent" | "interactive",
explicitRedirectUrl: string | URL | undefined,
storedRedirectUris?: string[],
): OAuthClientMetadata {
// WHY: A silent provider never starts an authorization flow, but clientMetadata.redirect_uris
// must still match what the client was registered with. Prefer the stored DCR redirect_uris
// (preserved per AGENTS.md), then the configured auth.redirectUrl, then the default 127.0.0.1:19876 callback.
const redirectUris =
mode === "silent" && storedRedirectUris && storedRedirectUris.length > 0
? storedRedirectUris
: [String(resolveProviderRedirectUrl(mode, explicitRedirectUrl, authConfig))];
return {
client_name: authConfig.clientName ?? `pi-mcp/${serverName}`,
redirect_uris: redirectUris,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: authConfig.clientSecret ? "client_secret_basic" : "none",
...(authConfig.scope && { scope: authConfig.scope }),
};
}
export function buildStaticClientInformation(
authConfig: AuthConfig,
storedClient: StoredClientInfo | undefined,
): OAuthClientInformationMixed {
const boundIssuer =
authConfig.clientId !== undefined && storedClient?.client_id === authConfig.clientId
? storedClient.issuer
: undefined;
return {
client_id: authConfig.clientId ?? "",
...(authConfig.clientSecret && { client_secret: authConfig.clientSecret }),
...(boundIssuer !== undefined && { issuer: boundIssuer }),
};
}
// ─── Pure Issuer-Binding & Silent Client-Information Helpers ──────────────────
export function assertIssuerBindingCompatible(
serverName: string,
existingIssuer: string | undefined,
nextIssuer: string | undefined,
): void {
if (existingIssuer === undefined || nextIssuer === undefined || existingIssuer === nextIssuer) {
return;
}
// WHY: A stored client already bound to an authorization server issuer must never be
// re-bound to a different issuer. Switching authorization servers requires an explicit reset.
throw new McpError(
`OAuth client for "${serverName}" is already bound to authorization server ${existingIssuer} and cannot be bound to ${nextIssuer}. Run /mcp:auth ${serverName} --reset to re-authorize.`,
serverName,
"auth",
);
}
export function mergeSilentStoredClientInfo(
storedClient: StoredClientInfo,
authConfig: AuthConfig,
issuerToBind: string | undefined,
): StoredClientInfo {
// WHY: Mirror silent saveTokens: pure issuer binding of the already-known client
// preserves all stored credentials and DCR metadata on disk while stamping issuer.
return {
...storedClient,
client_secret: authConfig.clientId ? undefined : storedClient.client_secret,
issuer: issuerToBind ?? storedClient.issuer,
};
}
export function buildNewSilentStoredClientInfo(
clientInformation: OAuthClientInformationMixed,
authConfig: AuthConfig,
issuerToBind: string | undefined,
): StoredClientInfo {
return {
client_id: clientInformation.client_id,
client_secret: authConfig.clientId
? undefined
: (authConfig.clientSecret ?? clientInformation.client_secret),
client_id_issued_at: undefined,
client_secret_expires_at: undefined,
token_endpoint_auth_method: undefined,
redirect_uris: undefined,
issuer: issuerToBind,
};
}
// ─── Pure Token & State-Invalidation Helpers ──────────────────────────────────
export function isPeerRotatedTokens(
diskTokens: StoredTokens | undefined,
lastReadTokens: StoredTokens | undefined,
): diskTokens is StoredTokens {
if (!diskTokens) return false;
return (
diskTokens.refresh_token !== lastReadTokens?.refresh_token ||
diskTokens.access_token !== lastReadTokens?.access_token
);
}
export function toOAuthTokens(storedTokens: StoredTokens): OAuthTokens {
// Build OAuthTokens — only include defined fields
const tokens: Record<string, unknown> = {
access_token: storedTokens.access_token,
};
if (storedTokens.token_type !== undefined) tokens.token_type = storedTokens.token_type;
if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
if (storedTokens.expires_in !== undefined) tokens.expires_in = storedTokens.expires_in;
if (storedTokens.scope !== undefined) tokens.scope = storedTokens.scope;
if (storedTokens.issuer !== undefined) tokens.issuer = storedTokens.issuer;
return tokens as unknown as OAuthTokens;
}
export function toStoredTokens(
tokens: OAuthTokens,
savedAt: string = new Date().toISOString(),
): StoredTokens {
return {
access_token: tokens.access_token,
token_type: tokens.token_type,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
scope: tokens.scope,
saved_at: savedAt,
issuer: typeof tokens.issuer === "string" ? tokens.issuer : undefined,
};
}
export function stampStateServerUrl(state: StoredState, serverUrl: string | undefined): void {
if (serverUrl) {
state.serverUrl = canonicalServerUrl(serverUrl);
}
}
export function applyStoredStateInvalidation(
state: StoredState,
scope: InvalidationScope,
): boolean {
let changedDisk = false;
switch (scope) {
case "all":
changedDisk =
state.clientInfo !== undefined ||
state.tokens !== undefined ||
state.discoveryState !== undefined;
state.clientInfo = undefined;
state.tokens = undefined;
state.discoveryState = undefined;
break;
case "client":
changedDisk = state.clientInfo !== undefined;
state.clientInfo = undefined;
break;
case "tokens":
changedDisk = state.tokens !== undefined;
state.tokens = undefined;
break;
case "discovery":
changedDisk = state.discoveryState !== undefined;
state.discoveryState = undefined;
break;
case "verifier":
break;
}
return changedDisk;
}