UNPKG

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.

209 lines (176 loc) • 6.24 kB
/** * Pure URL validation and parsing helpers for MCP OAuth authorization and callbacks. */ export const DEFAULT_PORT = 19876; export const DEFAULT_HOST = "127.0.0.1"; export const CALLBACK_PATH = "/callback"; export interface CallbackServerConfig { preferredPort: number; host: string; allowPortFallback: boolean; } export type CallbackUrlValidationReason = | "invalid_url" | "wrong_origin" | "wrong_path" | "missing_state" | "invalid_state" | "missing_code"; export class CallbackUrlValidationError extends Error { readonly reason: CallbackUrlValidationReason; constructor(reason: CallbackUrlValidationReason, message: string) { super(message); this.name = "CallbackUrlValidationError"; this.reason = reason; } } export interface ParseCallbackUrlOptions { redirectUrl?: string | URL | undefined; expectedState?: string | ((state: string) => boolean) | undefined; } export function isLocalCallbackHost(hostname: string): boolean { return ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" ); } /** * Build callback server settings from the configured OAuth redirect URL. * * Local HTTP redirect URLs must use a fixed listener port because OAuth * providers require exact redirect URI registration. When no local redirect URL * is configured, the callback server keeps the historical default behavior and * scans forward from the default port if needed. */ export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): CallbackServerConfig { // WHY: Only treat `undefined` (omitted) as default; an explicit empty string `""` // must fall through to `new URL("")` and throw a validation error. if (redirectUrl === undefined) { return { preferredPort: DEFAULT_PORT, host: DEFAULT_HOST, allowPortFallback: true, }; } const url = new URL(redirectUrl); if (url.protocol !== "http:" || !isLocalCallbackHost(url.hostname)) { throw new Error("Manual OAuth redirect URL must use HTTP with localhost, 127.0.0.1, or ::1"); } if (url.hash) { throw new Error("Manual OAuth redirect URL must not include a fragment"); } if (url.username || url.password) { throw new Error("Manual OAuth redirect URL must not include credentials"); } if (url.pathname !== CALLBACK_PATH) { throw new Error(`Local OAuth redirect URL must use path ${CALLBACK_PATH}`); } if (!url.port) { throw new Error("Manual OAuth redirect URL must include an explicit port (e.g. http://127.0.0.1:19876/callback)"); } const preferredPort = Number(url.port); if (preferredPort === 0) { throw new Error("Manual OAuth redirect URL must use a fixed non-zero port"); } return { preferredPort, host: url.hostname === "[::1]" ? "::1" : url.hostname, allowPortFallback: false, }; } /** * Validate that an OAuth authorization URL is safe to pass to a system browser opener. * Only allows `https:` URLs or loopback `http:` URLs (`localhost`, `127.0.0.1`, `::1`, `[::1]`). */ export function isSafeAuthorizationUrl(input: string | URL): boolean { let url: URL; try { url = input instanceof URL ? input : new URL(input); } catch { return false; } if (url.protocol === "https:") { return true; } if (url.protocol === "http:" && isLocalCallbackHost(url.hostname)) { return true; } return false; } function parseRedirectUrl(input: string): URL { try { return new URL(input.trim()); } catch { throw new CallbackUrlValidationError("invalid_url", "Invalid redirect URL"); } } function expectedRedirectUrl(redirectUrl: string | URL): URL { return redirectUrl instanceof URL ? redirectUrl : new URL(String(redirectUrl)); } /** Reject a callback URL whose origin or path differs from the configured redirect URL. */ function assertRedirectTargetMatches(url: URL, redirectUrl: string | URL | undefined): void { if (redirectUrl === undefined) { if (url.pathname !== CALLBACK_PATH) { throw new CallbackUrlValidationError("wrong_path", `Redirect URL path must be ${CALLBACK_PATH}`); } return; } const expected = expectedRedirectUrl(redirectUrl); if (url.protocol !== expected.protocol || url.hostname !== expected.hostname || url.port !== expected.port) { throw new CallbackUrlValidationError( "wrong_origin", `Redirect URL origin must match ${expected.origin}`, ); } if (url.pathname !== expected.pathname) { throw new CallbackUrlValidationError( "wrong_path", `Redirect URL path must be ${expected.pathname}`, ); } } const INVALID_STATE_MESSAGE = "Invalid or expired state parameter - potential CSRF attack"; /** Reject a callback whose `state` does not match the expected literal or predicate. */ function assertExpectedState( state: string, expectedState: string | ((state: string) => boolean) | undefined, ): void { if (expectedState === undefined) { return; } const matches = typeof expectedState === "string" ? state === expectedState : expectedState(state); if (!matches) { throw new CallbackUrlValidationError("invalid_state", INVALID_STATE_MESSAGE); } } /** * Parse and validate an OAuth callback URL (either from an incoming HTTP callback request * or from a user-pasted redirect URL). */ export function parseCallbackUrl( input: string, options: ParseCallbackUrlOptions = {}, ): { state: string; code: string } | { state: string; error: string } { const url = parseRedirectUrl(input); assertRedirectTargetMatches(url, options.redirectUrl); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description"); if (!state) { throw new CallbackUrlValidationError( "missing_state", "Missing required state parameter - potential CSRF attack", ); } assertExpectedState(state, options.expectedState); if (error) { return { state, error: errorDescription || error }; } if (!code) { throw new CallbackUrlValidationError("missing_code", "No authorization code provided"); } return { state, code }; }