chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
164
third_party/pi/packages/mcp/src/oauth/callback.ts
vendored
Normal file
164
third_party/pi/packages/mcp/src/oauth/callback.ts
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
|
||||
export interface OAuthCallback {
|
||||
code: string;
|
||||
state: string;
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
/** Outcome shown on the browser page after the redirect. */
|
||||
export type OAuthCallbackPage = { ok: true } | { ok: false; message: string; details?: string };
|
||||
|
||||
export interface OAuthCallbackServerOptions {
|
||||
/** Address to listen on. Default: `127.0.0.1`. */
|
||||
host?: string;
|
||||
/**
|
||||
* Host name in `redirectUrl`, for example `localhost` for a client registered with it while
|
||||
* listening on `127.0.0.1`. Default: `host`.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function plainText(page: OAuthCallbackPage): string {
|
||||
if (page.ok) return "Authorization complete. You may close this window.";
|
||||
return page.details ? `${page.message}\n\n${page.details}` : page.message;
|
||||
}
|
||||
|
||||
export class OAuthCallbackServer {
|
||||
readonly redirectUrl: string;
|
||||
private server: Server;
|
||||
private paths: string[];
|
||||
private timeoutMs: number;
|
||||
private renderPage: ((page: OAuthCallbackPage) => string) | undefined;
|
||||
private pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (callback: OAuthCallback) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
path: string | undefined;
|
||||
}
|
||||
>();
|
||||
|
||||
private constructor(
|
||||
server: Server,
|
||||
redirectUrl: string,
|
||||
paths: string[],
|
||||
timeoutMs: number,
|
||||
renderPage: ((page: OAuthCallbackPage) => string) | undefined,
|
||||
) {
|
||||
this.server = server;
|
||||
this.redirectUrl = redirectUrl;
|
||||
this.paths = paths;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.renderPage = renderPage;
|
||||
}
|
||||
|
||||
static async listen(options: OAuthCallbackServerOptions = {}): Promise<OAuthCallbackServer> {
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const redirectHost = options.redirectHost ?? host;
|
||||
const path = options.path ?? "/callback";
|
||||
let instance: OAuthCallbackServer | undefined;
|
||||
const server = createServer((request, response) => instance?.handle(request.url ?? "/", response));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(options.port ?? 0, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("OAuth callback server did not bind to TCP");
|
||||
instance = new OAuthCallbackServer(
|
||||
server,
|
||||
`http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${path}`,
|
||||
[path, ...(options.extraPaths ?? [])],
|
||||
options.timeoutMs ?? 5 * 60_000,
|
||||
options.renderPage,
|
||||
);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, path });
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("OAuth callback server closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
private reply(response: ServerResponse, status: number, page: OAuthCallbackPage): void {
|
||||
if (this.renderPage) {
|
||||
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
response.end(this.renderPage(page));
|
||||
} else {
|
||||
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(plainText(page));
|
||||
}
|
||||
}
|
||||
|
||||
private handle(rawUrl: string, response: ServerResponse): void {
|
||||
const url = new URL(rawUrl, this.redirectUrl);
|
||||
if (!this.paths.includes(url.pathname)) {
|
||||
this.reply(response, 404, { ok: false, message: "Not found" });
|
||||
return;
|
||||
}
|
||||
const state = url.searchParams.get("state");
|
||||
const pending = state ? this.pending.get(state) : undefined;
|
||||
if (!state || !pending) {
|
||||
this.reply(response, 400, { ok: false, message: "Invalid or expired OAuth state" });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
pending.reject(new Error(description));
|
||||
this.reply(response, 200, {
|
||||
ok: false,
|
||||
message: "Authorization failed. You may close this window.",
|
||||
details: description,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
pending.reject(new Error("OAuth callback did not include an authorization code"));
|
||||
this.reply(response, 400, { ok: false, message: "Missing authorization code" });
|
||||
return;
|
||||
}
|
||||
const iss = url.searchParams.get("iss");
|
||||
pending.resolve({ code, state, ...(iss ? { iss } : {}) });
|
||||
this.reply(response, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
215
third_party/pi/packages/mcp/src/oauth/discovery.ts
vendored
Normal file
215
third_party/pi/packages/mcp/src/oauth/discovery.ts
vendored
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0 src/client/auth.ts.
|
||||
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
|
||||
* Modified to remove Zod/CORS shims and enforce authorization-server issuer validation.
|
||||
*/
|
||||
|
||||
import type { McpFetch } from "../auth-provider.ts";
|
||||
import { LATEST_PROTOCOL_VERSION } from "../protocol/types.ts";
|
||||
import { OAuthIssuerMismatchError } from "./errors.ts";
|
||||
import {
|
||||
type AuthorizationServerMetadata,
|
||||
type OAuthChallenge,
|
||||
type OAuthProtectedResourceMetadata,
|
||||
type OAuthServerInfo,
|
||||
parseAuthorizationServerMetadata,
|
||||
parseProtectedResourceMetadata,
|
||||
} from "./types.ts";
|
||||
|
||||
function discard(response: Response | undefined): void {
|
||||
void response?.body?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
/** 4xx and 502 mean "not here", so discovery tries the next candidate URL. */
|
||||
function isDiscoveryMiss(status: number): boolean {
|
||||
return (status >= 400 && status < 500) || status === 502;
|
||||
}
|
||||
|
||||
/** Path suffix for `/.well-known/<kind><path>`; empty for the root path. */
|
||||
function pathSuffix(pathname: string): string {
|
||||
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
function field(header: string, name: string): string | undefined {
|
||||
const match = header.match(new RegExp(String.raw`(?:^|[,\s])${name}=(?:"([^"]*)"|([^\s,]+))`, "i"));
|
||||
// An empty value (`scope=""`) carries no information, so it counts as absent.
|
||||
return match?.[1] || match?.[2] || undefined;
|
||||
}
|
||||
|
||||
export function parseWwwAuthenticate(header: string | null): OAuthChallenge {
|
||||
if (!header) return {};
|
||||
const scheme = header.trimStart().split(/\s+/, 1)[0]?.toLowerCase();
|
||||
if (scheme !== "bearer" && scheme !== "dpop") return {};
|
||||
const resourceMetadata = field(header, "resource_metadata");
|
||||
let resourceMetadataUrl: URL | undefined;
|
||||
if (resourceMetadata) {
|
||||
try {
|
||||
resourceMetadataUrl = new URL(resourceMetadata);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
resourceMetadataUrl,
|
||||
scope: field(header, "scope"),
|
||||
error: field(header, "error"),
|
||||
errorDescription: field(header, "error_description"),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMetadata(
|
||||
url: URL,
|
||||
fetch: McpFetch,
|
||||
protocolVersion: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Response> {
|
||||
return fetch(url, {
|
||||
headers: { Accept: "application/json", "MCP-Protocol-Version": protocolVersion },
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function discoverProtectedResourceMetadata(
|
||||
serverUrl: string | URL,
|
||||
options: {
|
||||
resourceMetadataUrl?: string | URL;
|
||||
protocolVersion?: string;
|
||||
fetch?: McpFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<OAuthProtectedResourceMetadata> {
|
||||
const server = new URL(serverUrl);
|
||||
const fetch = options.fetch ?? globalThis.fetch;
|
||||
const version = options.protocolVersion ?? LATEST_PROTOCOL_VERSION;
|
||||
let response = await fetchMetadata(
|
||||
options.resourceMetadataUrl
|
||||
? new URL(options.resourceMetadataUrl)
|
||||
: new URL(`/.well-known/oauth-protected-resource${pathSuffix(server.pathname)}`, server.origin),
|
||||
fetch,
|
||||
version,
|
||||
options.signal,
|
||||
);
|
||||
if (!options.resourceMetadataUrl && server.pathname !== "/" && isDiscoveryMiss(response.status)) {
|
||||
discard(response);
|
||||
response = await fetchMetadata(
|
||||
new URL("/.well-known/oauth-protected-resource", server.origin),
|
||||
fetch,
|
||||
version,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
throw new Error(`HTTP ${response.status} loading OAuth protected resource metadata`);
|
||||
}
|
||||
return parseProtectedResourceMetadata(await response.json());
|
||||
}
|
||||
|
||||
export function buildAuthorizationServerDiscoveryUrls(
|
||||
authorizationServerUrl: string | URL,
|
||||
): { url: URL; type: "oauth" | "oidc" }[] {
|
||||
const issuer = new URL(authorizationServerUrl);
|
||||
const path = pathSuffix(issuer.pathname);
|
||||
const urls: { url: URL; type: "oauth" | "oidc" }[] = [
|
||||
{ url: new URL(`/.well-known/oauth-authorization-server${path}`, issuer.origin), type: "oauth" },
|
||||
{ url: new URL(`/.well-known/openid-configuration${path}`, issuer.origin), type: "oidc" },
|
||||
];
|
||||
if (path) urls.push({ url: new URL(`${path}/.well-known/openid-configuration`, issuer.origin), type: "oidc" });
|
||||
return urls;
|
||||
}
|
||||
|
||||
export async function discoverAuthorizationServerMetadata(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: { fetch?: McpFetch; protocolVersion?: string; skipIssuerValidation?: boolean; signal?: AbortSignal } = {},
|
||||
): Promise<AuthorizationServerMetadata | undefined> {
|
||||
const fetch = options.fetch ?? globalThis.fetch;
|
||||
for (const { url } of buildAuthorizationServerDiscoveryUrls(authorizationServerUrl)) {
|
||||
const response = await fetchMetadata(
|
||||
url,
|
||||
fetch,
|
||||
options.protocolVersion ?? LATEST_PROTOCOL_VERSION,
|
||||
options.signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
if (isDiscoveryMiss(response.status)) continue;
|
||||
throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`);
|
||||
}
|
||||
const metadata = parseAuthorizationServerMetadata(await response.json());
|
||||
if (!options.skipIssuerValidation) {
|
||||
const expected = String(authorizationServerUrl);
|
||||
// URL parsing adds a trailing slash to bare origins, so compare without one on either side.
|
||||
const trim = (value: string) => (value.endsWith("/") ? value.slice(0, -1) : value);
|
||||
if (trim(metadata.issuer) !== trim(expected)) throw new OAuthIssuerMismatchError(expected, metadata.issuer);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function discoverOAuthServerInfo(
|
||||
serverUrl: string | URL,
|
||||
options: {
|
||||
resourceMetadataUrl?: URL;
|
||||
/** Metadata document to use instead of discovery. It is trusted as configured, so its issuer is not checked. */
|
||||
authorizationServerMetadataUrl?: URL;
|
||||
fetch?: McpFetch;
|
||||
skipIssuerValidation?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<OAuthServerInfo> {
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
try {
|
||||
resourceMetadata = await discoverProtectedResourceMetadata(serverUrl, {
|
||||
resourceMetadataUrl: options.resourceMetadataUrl,
|
||||
fetch: options.fetch,
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) throw error;
|
||||
}
|
||||
if (options.authorizationServerMetadataUrl) {
|
||||
const url = options.authorizationServerMetadataUrl;
|
||||
const response = await fetchMetadata(
|
||||
url,
|
||||
options.fetch ?? globalThis.fetch,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
options.signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`);
|
||||
}
|
||||
const metadata = parseAuthorizationServerMetadata(await response.json());
|
||||
return { authorizationServerUrl: metadata.issuer, authorizationServerMetadata: metadata, resourceMetadata };
|
||||
}
|
||||
const authorizationServerUrl = resourceMetadata?.authorization_servers?.[0] ?? String(new URL("/", serverUrl));
|
||||
return {
|
||||
authorizationServerUrl,
|
||||
authorizationServerMetadata: await discoverAuthorizationServerMetadata(authorizationServerUrl, {
|
||||
fetch: options.fetch,
|
||||
skipIssuerValidation: options.skipIssuerValidation,
|
||||
signal: options.signal,
|
||||
}),
|
||||
resourceMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function resourceUrlFromServerUrl(value: string | URL): URL {
|
||||
const url = new URL(value);
|
||||
url.hash = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
export function selectResource(serverUrl: string | URL, metadata?: OAuthProtectedResourceMetadata): string | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const requested = resourceUrlFromServerUrl(serverUrl);
|
||||
const configured = new URL(metadata.resource);
|
||||
if (requested.origin !== configured.origin) {
|
||||
throw new Error(`Protected resource ${metadata.resource} does not match MCP server ${requested}`);
|
||||
}
|
||||
const requestedPath = requested.pathname.endsWith("/") ? requested.pathname : `${requested.pathname}/`;
|
||||
const configuredPath = configured.pathname.endsWith("/") ? configured.pathname : `${configured.pathname}/`;
|
||||
if (!requestedPath.startsWith(configuredPath)) {
|
||||
throw new Error(`Protected resource ${metadata.resource} does not match MCP server ${requested}`);
|
||||
}
|
||||
return metadata.resource;
|
||||
}
|
||||
55
third_party/pi/packages/mcp/src/oauth/errors.ts
vendored
Normal file
55
third_party/pi/packages/mcp/src/oauth/errors.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
export class OAuthError extends Error {
|
||||
readonly code: string;
|
||||
readonly errorUri: string | undefined;
|
||||
|
||||
constructor(code: string, message: string, errorUri?: string) {
|
||||
super(message || code);
|
||||
this.name = "OAuthError";
|
||||
this.code = code;
|
||||
this.errorUri = errorUri;
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthIssuerMismatchError extends Error {
|
||||
/** `undefined` when no authorization server metadata was discovered. */
|
||||
readonly expected: string | undefined;
|
||||
/** `undefined` when an authorization response lacks the `iss` parameter its server promised (RFC 9207). */
|
||||
readonly received: string | undefined;
|
||||
|
||||
constructor(expected: string | undefined, received: string | undefined) {
|
||||
const format = (issuer: string | undefined) => (issuer === undefined ? "none" : JSON.stringify(issuer));
|
||||
super(`OAuth issuer mismatch: expected ${format(expected)}, received ${format(received)}`);
|
||||
this.name = "OAuthIssuerMismatchError";
|
||||
this.expected = expected;
|
||||
this.received = received;
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthInsecureEndpointError extends Error {
|
||||
readonly endpoint: string;
|
||||
|
||||
constructor(endpoint: string) {
|
||||
super(`Refusing to send OAuth credentials to non-HTTPS endpoint ${endpoint}`);
|
||||
this.name = "OAuthInsecureEndpointError";
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthRegistrationError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
|
||||
constructor(status: number, body: string) {
|
||||
super(`OAuth dynamic client registration failed with status ${status}: ${body}`);
|
||||
this.name = "OAuthRegistrationError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthAuthorizationRequiredError extends Error {
|
||||
constructor() {
|
||||
super("MCP OAuth authorization requires user interaction");
|
||||
this.name = "McpOAuthAuthorizationRequiredError";
|
||||
}
|
||||
}
|
||||
482
third_party/pi/packages/mcp/src/oauth/flow.ts
vendored
Normal file
482
third_party/pi/packages/mcp/src/oauth/flow.ts
vendored
Normal file
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0 src/client/auth.ts.
|
||||
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
|
||||
* Modified to remove SDK/Zod dependencies and use WebCrypto for PKCE.
|
||||
*/
|
||||
|
||||
import type { AuthProvider, McpFetch, UnauthorizedContext } from "../auth-provider.ts";
|
||||
import { isObject } from "../protocol/jsonrpc.ts";
|
||||
import {
|
||||
discoverAuthorizationServerMetadata,
|
||||
discoverOAuthServerInfo,
|
||||
parseWwwAuthenticate,
|
||||
selectResource,
|
||||
} from "./discovery.ts";
|
||||
import {
|
||||
McpOAuthAuthorizationRequiredError,
|
||||
OAuthError,
|
||||
OAuthInsecureEndpointError,
|
||||
OAuthIssuerMismatchError,
|
||||
OAuthRegistrationError,
|
||||
} from "./errors.ts";
|
||||
import {
|
||||
type AuthorizationServerMetadata,
|
||||
type OAuthClientInformation,
|
||||
type OAuthClientInformationFull,
|
||||
type OAuthClientInformationMixed,
|
||||
type OAuthClientMetadata,
|
||||
type OAuthDiscoveryState,
|
||||
type OAuthTokens,
|
||||
parseClientInformation,
|
||||
parseOAuthTokens,
|
||||
} from "./types.ts";
|
||||
|
||||
export type AddClientAuthentication = (
|
||||
headers: Headers,
|
||||
params: URLSearchParams,
|
||||
url: string | URL,
|
||||
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;
|
||||
/**
|
||||
* 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>;
|
||||
tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>;
|
||||
saveTokens(tokens: OAuthTokens): void | Promise<void>;
|
||||
redirectToAuthorization(url: URL): void | Promise<void>;
|
||||
saveCodeVerifier(verifier: string): void | Promise<void>;
|
||||
codeVerifier(): string | Promise<string>;
|
||||
addClientAuthentication?: AddClientAuthentication;
|
||||
invalidateCredentials?(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): void | Promise<void>;
|
||||
saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise<void>;
|
||||
discoveryState?(): OAuthDiscoveryState | undefined | Promise<OAuthDiscoveryState | undefined>;
|
||||
}
|
||||
|
||||
export interface OAuthFlowOptions {
|
||||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
/** `iss` parameter of the authorization response that delivered `authorizationCode` (RFC 9207). */
|
||||
iss?: string;
|
||||
scope?: string;
|
||||
resourceMetadataUrl?: URL;
|
||||
/**
|
||||
* Authorization server metadata document to use instead of discovery, for servers that advertise a
|
||||
* wrong authorization server or none. It is trusted as configured. Must use https, except on loopback.
|
||||
*/
|
||||
authorizationServerMetadataUrl?: URL;
|
||||
fetch?: McpFetch;
|
||||
/** Aborts every request of the flow. Requests have no time limit of their own; combine with a timeout as needed. */
|
||||
signal?: AbortSignal;
|
||||
skipIssuerValidation?: boolean;
|
||||
/**
|
||||
* Go straight to the authorization redirect instead of refreshing stored tokens, for example when the
|
||||
* server asks for scopes the current grant lacks (a refresh keeps the old scope).
|
||||
*/
|
||||
skipRefresh?: boolean;
|
||||
}
|
||||
|
||||
export type OAuthFlowResult = "AUTHORIZED" | "REDIRECT";
|
||||
type ClientAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
|
||||
|
||||
export interface TokenRequestOptions {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientInformation: OAuthClientInformationMixed;
|
||||
resource?: string;
|
||||
addClientAuthentication?: AddClientAuthentication;
|
||||
fetch?: McpFetch;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function loopback(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenID Connect `application_type` for `redirect_uris` (MCP SEP-837). Without one, OpenID Connect servers
|
||||
* assume `web`, which rejects http loopback redirect URIs. Loopback hosts and custom schemes are native (RFC 8252).
|
||||
*/
|
||||
function applicationType(redirectUris: readonly string[]): "native" | "web" {
|
||||
const native = redirectUris.some((uri) => {
|
||||
if (!URL.canParse(uri)) return false;
|
||||
const url = new URL(uri);
|
||||
return (url.protocol !== "http:" && url.protocol !== "https:") || loopback(url.hostname);
|
||||
});
|
||||
return native ? "native" : "web";
|
||||
}
|
||||
|
||||
function secureEndpoint(value: string | URL): URL {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && !loopback(url.hostname)) throw new OAuthInsecureEndpointError(url.href);
|
||||
return url;
|
||||
}
|
||||
|
||||
function selectClientAuthMethod(information: OAuthClientInformationMixed, supported: string[]): ClientAuthMethod {
|
||||
const hinted = "token_endpoint_auth_method" in information ? information.token_endpoint_auth_method : undefined;
|
||||
if (
|
||||
hinted &&
|
||||
["client_secret_basic", "client_secret_post", "none"].includes(hinted) &&
|
||||
(supported.length === 0 || supported.includes(hinted))
|
||||
) {
|
||||
return hinted as ClientAuthMethod;
|
||||
}
|
||||
if (supported.length === 0) return information.client_secret ? "client_secret_basic" : "none";
|
||||
if (information.client_secret && supported.includes("client_secret_basic")) return "client_secret_basic";
|
||||
if (information.client_secret && supported.includes("client_secret_post")) return "client_secret_post";
|
||||
if (supported.includes("none")) return "none";
|
||||
return information.client_secret ? "client_secret_post" : "none";
|
||||
}
|
||||
|
||||
function applyClientAuthentication(
|
||||
method: ClientAuthMethod,
|
||||
information: OAuthClientInformation,
|
||||
headers: Headers,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (method === "client_secret_basic") {
|
||||
if (!information.client_secret) throw new Error("client_secret_basic requires a client secret");
|
||||
headers.set(
|
||||
"Authorization",
|
||||
`Basic ${Buffer.from(`${information.client_id}:${information.client_secret}`).toString("base64")}`,
|
||||
);
|
||||
} else {
|
||||
params.set("client_id", information.client_id);
|
||||
if (method === "client_secret_post" && information.client_secret)
|
||||
params.set("client_secret", information.client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
async function pkce(): Promise<{ verifier: string; challenge: string }> {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const verifier = Buffer.from(bytes).toString("base64url");
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
||||
return { verifier, challenge: Buffer.from(digest).toString("base64url") };
|
||||
}
|
||||
|
||||
export async function startAuthorization(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientInformation: OAuthClientInformationMixed;
|
||||
redirectUrl: string | URL;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
resource?: string;
|
||||
},
|
||||
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
|
||||
const metadata = options.metadata;
|
||||
if (metadata && !metadata.response_types_supported.includes("code")) {
|
||||
throw new Error("Authorization server does not support authorization codes");
|
||||
}
|
||||
if (metadata?.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
|
||||
throw new Error("Authorization server does not support PKCE S256");
|
||||
}
|
||||
const url = new URL(metadata?.authorization_endpoint ?? new URL("/authorize", authorizationServerUrl));
|
||||
const { verifier, challenge } = await pkce();
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", options.clientInformation.client_id);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("redirect_uri", String(options.redirectUrl));
|
||||
if (options.state) url.searchParams.set("state", options.state);
|
||||
if (options.scope) url.searchParams.set("scope", options.scope);
|
||||
if (options.scope?.split(/\s+/).includes("offline_access")) url.searchParams.set("prompt", "consent");
|
||||
if (options.resource) url.searchParams.set("resource", options.resource);
|
||||
return { authorizationUrl: url, codeVerifier: verifier };
|
||||
}
|
||||
|
||||
async function tokenRequest(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: TokenRequestOptions,
|
||||
params: URLSearchParams,
|
||||
): Promise<OAuthTokens> {
|
||||
const url = secureEndpoint(options.metadata?.token_endpoint ?? new URL("/token", authorizationServerUrl));
|
||||
const headers = new Headers({ Accept: "application/json", "content-type": "application/x-www-form-urlencoded" });
|
||||
if (options.resource) params.set("resource", options.resource);
|
||||
if (options.addClientAuthentication) {
|
||||
await options.addClientAuthentication(headers, params, url, options.metadata);
|
||||
} else {
|
||||
applyClientAuthentication(
|
||||
selectClientAuthMethod(
|
||||
options.clientInformation,
|
||||
options.metadata?.token_endpoint_auth_methods_supported ?? [],
|
||||
),
|
||||
options.clientInformation,
|
||||
headers,
|
||||
params,
|
||||
);
|
||||
}
|
||||
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: params,
|
||||
signal: options.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {}
|
||||
// Servers may report OAuth errors with any status, so check the body before the status.
|
||||
if (isObject(value) && typeof value.error === "string") {
|
||||
throw new OAuthError(
|
||||
value.error,
|
||||
typeof value.error_description === "string" ? value.error_description : value.error,
|
||||
typeof value.error_uri === "string" ? value.error_uri : undefined,
|
||||
);
|
||||
}
|
||||
if (!response.ok) throw new OAuthError("server_error", `HTTP ${response.status}: ${text}`);
|
||||
return parseOAuthTokens(value);
|
||||
}
|
||||
|
||||
export async function registerClient(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
clientMetadata: OAuthClientMetadata;
|
||||
scope?: string;
|
||||
fetch?: McpFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<OAuthClientInformationFull> {
|
||||
const endpoint = options.metadata?.registration_endpoint;
|
||||
if (options.metadata && !endpoint)
|
||||
throw new Error("Authorization server does not support dynamic client registration");
|
||||
const response = await (options.fetch ?? globalThis.fetch)(
|
||||
new URL(endpoint ?? new URL("/register", authorizationServerUrl)),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...options.clientMetadata,
|
||||
application_type:
|
||||
options.clientMetadata.application_type ?? applicationType(options.clientMetadata.redirect_uris),
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new OAuthRegistrationError(response.status, await response.text());
|
||||
return parseClientInformation(await response.json());
|
||||
}
|
||||
|
||||
export async function exchangeAuthorizationCode(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: TokenRequestOptions & { code: string; codeVerifier: string; redirectUrl: string | URL },
|
||||
): Promise<OAuthTokens> {
|
||||
return tokenRequest(
|
||||
authorizationServerUrl,
|
||||
options,
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
code_verifier: options.codeVerifier,
|
||||
redirect_uri: String(options.redirectUrl),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshAuthorization(
|
||||
authorizationServerUrl: string | URL,
|
||||
options: TokenRequestOptions & { refreshToken: string },
|
||||
): Promise<OAuthTokens> {
|
||||
const tokens = await tokenRequest(
|
||||
authorizationServerUrl,
|
||||
options,
|
||||
new URLSearchParams({ grant_type: "refresh_token", refresh_token: options.refreshToken }),
|
||||
);
|
||||
return { refresh_token: options.refreshToken, ...tokens };
|
||||
}
|
||||
|
||||
function withScope(tokens: OAuthTokens, scope: string | undefined): OAuthTokens {
|
||||
return tokens.scope === undefined && scope ? { ...tokens, scope } : tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes for a step-up authorization: the challenged scopes plus the ones granted so far, since a
|
||||
* challenge may list only the missing scopes and a token with just those would lose access the old
|
||||
* one had (SEP-2350). Without challenged scopes, `undefined` lets the flow pick its default.
|
||||
*/
|
||||
export function stepUpScope(granted: string | undefined, challenged: string | undefined): string | undefined {
|
||||
if (!challenged) return undefined;
|
||||
const scopes = [granted, challenged].flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []);
|
||||
return [...new Set(scopes)].join(" ");
|
||||
}
|
||||
|
||||
async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions): 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?.();
|
||||
const discovered = cached?.authorizationServerUrl
|
||||
? {
|
||||
authorizationServerUrl: cached.authorizationServerUrl,
|
||||
authorizationServerMetadata:
|
||||
cached.authorizationServerMetadata ??
|
||||
(await discoverAuthorizationServerMetadata(cached.authorizationServerUrl, {
|
||||
fetch: options.fetch,
|
||||
skipIssuerValidation: options.skipIssuerValidation,
|
||||
signal: options.signal,
|
||||
})),
|
||||
resourceMetadata: cached.resourceMetadata,
|
||||
}
|
||||
: await discoverOAuthServerInfo(options.serverUrl, {
|
||||
resourceMetadataUrl: options.resourceMetadataUrl,
|
||||
authorizationServerMetadataUrl: metadataUrl,
|
||||
fetch: options.fetch,
|
||||
skipIssuerValidation: options.skipIssuerValidation,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!metadataUrl) {
|
||||
await provider.saveDiscoveryState?.({
|
||||
...discovered,
|
||||
...(options.resourceMetadataUrl ? { resourceMetadataUrl: options.resourceMetadataUrl.href } : {}),
|
||||
});
|
||||
}
|
||||
const metadata = discovered.authorizationServerMetadata;
|
||||
const resource = selectResource(options.serverUrl, discovered.resourceMetadata);
|
||||
// `||`, 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;
|
||||
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 (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");
|
||||
client = await registerClient(discovered.authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
scope,
|
||||
fetch: options.fetch,
|
||||
signal: options.signal,
|
||||
});
|
||||
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,
|
||||
resource,
|
||||
addClientAuthentication: provider.addClientAuthentication,
|
||||
fetch: options.fetch,
|
||||
signal: options.signal,
|
||||
};
|
||||
if (options.authorizationCode) {
|
||||
// RFC 9207: never send a code from another authorization server to this one. Without metadata there
|
||||
// is no issuer to compare with, so any `iss` is rejected.
|
||||
const iss = options.iss;
|
||||
if (iss !== undefined || metadata?.authorization_response_iss_parameter_supported) {
|
||||
if (iss !== metadata?.issuer) throw new OAuthIssuerMismatchError(metadata?.issuer, iss);
|
||||
}
|
||||
const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, {
|
||||
...tokenOptions,
|
||||
code: options.authorizationCode,
|
||||
codeVerifier: await provider.codeVerifier(),
|
||||
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.
|
||||
await provider.saveTokens(withScope(tokens, scope));
|
||||
return "AUTHORIZED";
|
||||
}
|
||||
const existing = options.skipRefresh ? undefined : await provider.tokens();
|
||||
if (existing?.refresh_token) {
|
||||
try {
|
||||
const tokens = await refreshAuthorization(discovered.authorizationServerUrl, {
|
||||
...tokenOptions,
|
||||
refreshToken: existing.refresh_token,
|
||||
});
|
||||
// A refresh without `scope` keeps the scope of the grant (RFC 6749 §6).
|
||||
await provider.saveTokens(withScope(tokens, existing.scope));
|
||||
return "AUTHORIZED";
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted || error instanceof OAuthInsecureEndpointError) throw error;
|
||||
if (error instanceof OAuthError && error.code !== "server_error") throw error;
|
||||
}
|
||||
}
|
||||
const state = await provider.state?.();
|
||||
const authorization = await startAuthorization(discovered.authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation: client,
|
||||
redirectUrl,
|
||||
scope,
|
||||
state,
|
||||
resource,
|
||||
});
|
||||
await provider.saveCodeVerifier(authorization.codeVerifier);
|
||||
await provider.redirectToAuthorization(authorization.authorizationUrl);
|
||||
return "REDIRECT";
|
||||
}
|
||||
|
||||
export async function authorizeMcp(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise<OAuthFlowResult> {
|
||||
try {
|
||||
return await runFlow(provider, options);
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError && ["invalid_client", "unauthorized_client"].includes(error.code)) {
|
||||
await provider.invalidateCredentials?.("all");
|
||||
return runFlow(provider, options);
|
||||
}
|
||||
if (error instanceof OAuthError && error.code === "invalid_grant") {
|
||||
await provider.invalidateCredentials?.("tokens");
|
||||
return runFlow(provider, options);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth provider for `StreamableHttpTransport`. After a 401 it refreshes the tokens, or throws
|
||||
* `McpOAuthAuthorizationRequiredError` when the user has to authorize (again). Concurrent 401s share
|
||||
* one refresh, and a request whose token was already replaced is just retried: with rotating refresh
|
||||
* tokens, a second refresh with the old refresh token would fail and discard the new grant.
|
||||
*/
|
||||
export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
|
||||
let inFlight: Promise<void> | undefined;
|
||||
return {
|
||||
token: async () => (await provider.tokens())?.access_token,
|
||||
onUnauthorized: async (context: UnauthorizedContext) => {
|
||||
const challenge = parseWwwAuthenticate(context.response.headers.get("www-authenticate"));
|
||||
const insufficientScope = challenge.error === "insufficient_scope";
|
||||
if (!insufficientScope && !inFlight && context.token !== undefined) {
|
||||
const current = (await provider.tokens())?.access_token;
|
||||
if (current !== undefined && current !== context.token) return;
|
||||
}
|
||||
inFlight ??= Promise.resolve(insufficientScope ? provider.tokens() : undefined)
|
||||
.then((granted) =>
|
||||
authorizeMcp(provider, {
|
||||
serverUrl: context.serverUrl,
|
||||
resourceMetadataUrl: challenge.resourceMetadataUrl,
|
||||
scope: insufficientScope ? stepUpScope(granted?.scope, challenge.scope) : challenge.scope,
|
||||
fetch: context.fetch,
|
||||
skipRefresh: insufficientScope,
|
||||
}),
|
||||
)
|
||||
.then((result) => {
|
||||
if (result === "REDIRECT") throw new McpOAuthAuthorizationRequiredError();
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
await inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
56
third_party/pi/packages/mcp/src/oauth/index.ts
vendored
Normal file
56
third_party/pi/packages/mcp/src/oauth/index.ts
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
export {
|
||||
type OAuthCallback,
|
||||
type OAuthCallbackPage,
|
||||
OAuthCallbackServer,
|
||||
type OAuthCallbackServerOptions,
|
||||
} from "./callback.ts";
|
||||
export {
|
||||
buildAuthorizationServerDiscoveryUrls,
|
||||
discoverAuthorizationServerMetadata,
|
||||
discoverOAuthServerInfo,
|
||||
discoverProtectedResourceMetadata,
|
||||
parseWwwAuthenticate,
|
||||
resourceUrlFromServerUrl,
|
||||
selectResource,
|
||||
} from "./discovery.ts";
|
||||
export {
|
||||
McpOAuthAuthorizationRequiredError,
|
||||
OAuthError,
|
||||
OAuthInsecureEndpointError,
|
||||
OAuthIssuerMismatchError,
|
||||
OAuthRegistrationError,
|
||||
} from "./errors.ts";
|
||||
export {
|
||||
type AddClientAuthentication,
|
||||
adaptOAuthProvider,
|
||||
authorizeMcp,
|
||||
exchangeAuthorizationCode,
|
||||
type OAuthClientMetadataDocument,
|
||||
type OAuthClientProvider,
|
||||
type OAuthFlowOptions,
|
||||
type OAuthFlowResult,
|
||||
refreshAuthorization,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
stepUpScope,
|
||||
type TokenRequestOptions,
|
||||
} from "./flow.ts";
|
||||
export {
|
||||
McpOAuthProvider,
|
||||
type McpOAuthProviderOptions,
|
||||
type McpOAuthState,
|
||||
type McpOAuthStateStore,
|
||||
MemoryOAuthStateStore,
|
||||
} from "./provider.ts";
|
||||
export type {
|
||||
AuthorizationServerMetadata,
|
||||
OAuthChallenge,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthDiscoveryState,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthServerInfo,
|
||||
OAuthTokens,
|
||||
} from "./types.ts";
|
||||
168
third_party/pi/packages/mcp/src/oauth/provider.ts
vendored
Normal file
168
third_party/pi/packages/mcp/src/oauth/provider.ts
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { OAuthClientMetadataDocument, OAuthClientProvider } from "./flow.ts";
|
||||
import type {
|
||||
AuthorizationServerMetadata,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthDiscoveryState,
|
||||
OAuthTokens,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface McpOAuthState {
|
||||
serverUrl: string;
|
||||
clientInformation?: OAuthClientInformationMixed;
|
||||
tokens?: OAuthTokens;
|
||||
/** When the access token expires, in milliseconds since the epoch, from `expires_in` at the time it was saved. */
|
||||
tokensExpireAt?: number;
|
||||
codeVerifier?: string;
|
||||
oauthState?: string;
|
||||
discovery?: OAuthDiscoveryState;
|
||||
}
|
||||
|
||||
export interface McpOAuthStateStore {
|
||||
load(): McpOAuthState | undefined | Promise<McpOAuthState | undefined>;
|
||||
save(state: McpOAuthState): void | Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
onRedirect(url: URL): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class MemoryOAuthStateStore implements McpOAuthStateStore {
|
||||
private value: McpOAuthState | undefined;
|
||||
|
||||
load(): McpOAuthState | undefined {
|
||||
return this.value === undefined ? undefined : structuredClone(this.value);
|
||||
}
|
||||
|
||||
save(state: McpOAuthState): void {
|
||||
this.value = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Default stateful provider for one exact MCP server URL. Applications inject durable storage if needed. */
|
||||
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;
|
||||
private onRedirect: (url: URL) => void | Promise<void>;
|
||||
private writes: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: McpOAuthProviderOptions) {
|
||||
this.serverUrl = String(new URL(options.serverUrl));
|
||||
this.redirectUrl = String(options.redirectUrl);
|
||||
this.clientMetadata = {
|
||||
...options.clientMetadata,
|
||||
redirect_uris: options.clientMetadata.redirect_uris ?? [this.redirectUrl],
|
||||
grant_types: options.clientMetadata.grant_types ?? ["authorization_code", "refresh_token"],
|
||||
response_types: options.clientMetadata.response_types ?? ["code"],
|
||||
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;
|
||||
this.store = options.store ?? new MemoryOAuthStateStore();
|
||||
this.onRedirect = options.onRedirect;
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
const existing = (await this.load()).oauthState;
|
||||
if (existing) return existing;
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
|
||||
await this.update((value) => ({ ...value, oauthState: state }));
|
||||
return state;
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
return this.configuredClient ?? (await this.load()).clientInformation;
|
||||
}
|
||||
|
||||
async saveClientInformation(information: OAuthClientInformationMixed): Promise<void> {
|
||||
if (this.configuredClient) return;
|
||||
await this.update((value) => ({ ...value, clientInformation: information }));
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return (await this.load()).tokens;
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const expiresAt = tokens.expires_in === undefined ? undefined : Date.now() + tokens.expires_in * 1000;
|
||||
await this.update((value) => {
|
||||
const next: McpOAuthState = { ...value, tokens };
|
||||
if (expiresAt === undefined) delete next.tokensExpireAt;
|
||||
else next.tokensExpireAt = expiresAt;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async redirectToAuthorization(url: URL): Promise<void> {
|
||||
await this.onRedirect(url);
|
||||
}
|
||||
|
||||
async saveCodeVerifier(verifier: string): Promise<void> {
|
||||
await this.update((value) => ({ ...value, codeVerifier: verifier }));
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
const verifier = (await this.load()).codeVerifier;
|
||||
if (!verifier) throw new Error("No OAuth PKCE code verifier is stored");
|
||||
return verifier;
|
||||
}
|
||||
|
||||
async invalidateCredentials(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
||||
await this.update((value) => {
|
||||
const next = { ...value };
|
||||
if (kind === "all" || kind === "client") delete next.clientInformation;
|
||||
if (kind === "all" || kind === "tokens") {
|
||||
delete next.tokens;
|
||||
delete next.tokensExpireAt;
|
||||
}
|
||||
if (kind === "all" || kind === "verifier") delete next.codeVerifier;
|
||||
if (kind === "all" || kind === "discovery") delete next.discovery;
|
||||
if (kind === "all") delete next.oauthState;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async saveDiscoveryState(discovery: OAuthDiscoveryState): Promise<void> {
|
||||
await this.update((value) => ({ ...value, discovery }));
|
||||
}
|
||||
|
||||
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
||||
return (await this.load()).discovery;
|
||||
}
|
||||
|
||||
private async load(): Promise<McpOAuthState> {
|
||||
await this.writes;
|
||||
return this.own(await this.store.load());
|
||||
}
|
||||
|
||||
private async update(update: (state: McpOAuthState) => McpOAuthState): Promise<void> {
|
||||
this.writes = this.writes.then(async () => {
|
||||
await this.store.save(update(this.own(await this.store.load())));
|
||||
});
|
||||
await this.writes;
|
||||
}
|
||||
|
||||
/** Stored state for another server URL is ignored so credentials never leak across servers. */
|
||||
private own(state: McpOAuthState | undefined): McpOAuthState {
|
||||
return state?.serverUrl === this.serverUrl ? state : { serverUrl: this.serverUrl };
|
||||
}
|
||||
}
|
||||
209
third_party/pi/packages/mcp/src/oauth/types.ts
vendored
Normal file
209
third_party/pi/packages/mcp/src/oauth/types.ts
vendored
Normal file
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0.
|
||||
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
|
||||
* Modified to use dependency-free structural validation.
|
||||
*/
|
||||
|
||||
import { isObject } from "../protocol/jsonrpc.ts";
|
||||
|
||||
export interface OAuthProtectedResourceMetadata {
|
||||
resource: string;
|
||||
authorization_servers?: string[];
|
||||
scopes_supported?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthorizationServerMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
registration_endpoint?: string;
|
||||
scopes_supported?: string[];
|
||||
response_types_supported: string[];
|
||||
grant_types_supported?: string[];
|
||||
token_endpoint_auth_methods_supported?: string[];
|
||||
code_challenge_methods_supported?: string[];
|
||||
client_id_metadata_document_supported?: boolean;
|
||||
/** Whether authorization responses carry an `iss` parameter (RFC 9207). */
|
||||
authorization_response_iss_parameter_supported?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
}
|
||||
|
||||
export interface OAuthClientMetadata {
|
||||
redirect_uris: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
/**
|
||||
* OpenID Connect client type, `native` or `web`. Dynamic client registration derives it from
|
||||
* `redirect_uris` when absent (MCP SEP-837).
|
||||
*/
|
||||
application_type?: string;
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
scope?: string;
|
||||
contacts?: string[];
|
||||
tos_uri?: string;
|
||||
policy_uri?: string;
|
||||
jwks_uri?: string;
|
||||
jwks?: unknown;
|
||||
software_id?: string;
|
||||
software_version?: string;
|
||||
software_statement?: string;
|
||||
}
|
||||
|
||||
export interface OAuthClientInformation {
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_id_issued_at?: number;
|
||||
client_secret_expires_at?: number;
|
||||
}
|
||||
|
||||
export type OAuthClientInformationFull = OAuthClientInformation & OAuthClientMetadata;
|
||||
export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull;
|
||||
|
||||
export interface OAuthDiscoveryState {
|
||||
authorizationServerUrl: string;
|
||||
authorizationServerMetadata?: AuthorizationServerMetadata;
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
resourceMetadataUrl?: string;
|
||||
}
|
||||
|
||||
export interface OAuthServerInfo {
|
||||
authorizationServerUrl: string;
|
||||
authorizationServerMetadata?: AuthorizationServerMetadata;
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
}
|
||||
|
||||
export interface OAuthChallenge {
|
||||
resourceMetadataUrl?: URL;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
function object(value: unknown, name: string): Record<string, unknown> {
|
||||
if (!isObject(value)) throw new Error(`Invalid ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Drops `undefined` values so optional fields are absent rather than present-but-undefined. */
|
||||
function compact<T extends object>(value: T): T {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Treats `null` and `""` as absent: servers send them for fields they have no value for, like `scope: ""`. */
|
||||
function absent(value: unknown): value is undefined | null | "" {
|
||||
return value === undefined || value === null || value === "";
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, name: string): string | undefined {
|
||||
if (absent(value)) return undefined;
|
||||
return requiredString(value, name);
|
||||
}
|
||||
|
||||
function optionalStrings(value: unknown, name: string): string[] | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`Invalid ${name}`);
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function safeUrl(value: unknown, name: string): string {
|
||||
const text = requiredString(value, name);
|
||||
// URL parsing throws a `TypeError`, which discovery reserves for network failures.
|
||||
if (!URL.canParse(text)) throw new Error(`Invalid ${name}`);
|
||||
const url = new URL(text);
|
||||
if (["javascript:", "data:", "vbscript:"].includes(url.protocol)) throw new Error(`Invalid ${name}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalUrl(value: unknown, name: string): string | undefined {
|
||||
return absent(value) ? undefined : safeUrl(value, name);
|
||||
}
|
||||
|
||||
export function parseProtectedResourceMetadata(value: unknown): OAuthProtectedResourceMetadata {
|
||||
const input = object(value, "OAuth protected resource metadata");
|
||||
return compact({
|
||||
...input,
|
||||
resource: safeUrl(input.resource, "OAuth protected resource metadata resource"),
|
||||
authorization_servers: optionalStrings(input.authorization_servers, "authorization_servers")?.map((url) =>
|
||||
safeUrl(url, "authorization server URL"),
|
||||
),
|
||||
scopes_supported: optionalStrings(input.scopes_supported, "scopes_supported"),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseAuthorizationServerMetadata(value: unknown): AuthorizationServerMetadata {
|
||||
const input = object(value, "authorization server metadata");
|
||||
const responseTypes = optionalStrings(input.response_types_supported, "response_types_supported");
|
||||
if (!responseTypes) throw new Error("Invalid response_types_supported");
|
||||
return compact({
|
||||
...input,
|
||||
issuer: safeUrl(input.issuer, "authorization server issuer"),
|
||||
authorization_endpoint: safeUrl(input.authorization_endpoint, "authorization endpoint"),
|
||||
token_endpoint: safeUrl(input.token_endpoint, "token endpoint"),
|
||||
registration_endpoint: optionalUrl(input.registration_endpoint, "registration endpoint"),
|
||||
scopes_supported: optionalStrings(input.scopes_supported, "scopes_supported"),
|
||||
response_types_supported: responseTypes,
|
||||
grant_types_supported: optionalStrings(input.grant_types_supported, "grant_types_supported"),
|
||||
token_endpoint_auth_methods_supported: optionalStrings(
|
||||
input.token_endpoint_auth_methods_supported,
|
||||
"token_endpoint_auth_methods_supported",
|
||||
),
|
||||
code_challenge_methods_supported: optionalStrings(
|
||||
input.code_challenge_methods_supported,
|
||||
"code_challenge_methods_supported",
|
||||
),
|
||||
client_id_metadata_document_supported:
|
||||
typeof input.client_id_metadata_document_supported === "boolean"
|
||||
? input.client_id_metadata_document_supported
|
||||
: undefined,
|
||||
authorization_response_iss_parameter_supported:
|
||||
typeof input.authorization_response_iss_parameter_supported === "boolean"
|
||||
? input.authorization_response_iss_parameter_supported
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseOAuthTokens(value: unknown): OAuthTokens {
|
||||
const input = object(value, "OAuth token response");
|
||||
// `Number(null)` is 0, which would mark the token as expired at once.
|
||||
const expires = absent(input.expires_in) ? undefined : Number(input.expires_in);
|
||||
if (expires !== undefined && !Number.isFinite(expires)) throw new Error("Invalid expires_in");
|
||||
return compact({
|
||||
access_token: requiredString(input.access_token, "access_token"),
|
||||
token_type: requiredString(input.token_type, "token_type"),
|
||||
expires_in: expires,
|
||||
scope: optionalString(input.scope, "scope"),
|
||||
refresh_token: optionalString(input.refresh_token, "refresh_token"),
|
||||
id_token: optionalString(input.id_token, "id_token"),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseClientInformation(value: unknown): OAuthClientInformationFull {
|
||||
const input = object(value, "OAuth client registration response");
|
||||
return compact({
|
||||
...(input as unknown as OAuthClientMetadata),
|
||||
client_id: requiredString(input.client_id, "client_id"),
|
||||
client_secret: optionalString(input.client_secret, "client_secret"),
|
||||
client_id_issued_at: typeof input.client_id_issued_at === "number" ? input.client_id_issued_at : undefined,
|
||||
client_secret_expires_at:
|
||||
typeof input.client_secret_expires_at === "number" ? input.client_secret_expires_at : undefined,
|
||||
redirect_uris: optionalStrings(input.redirect_uris, "redirect_uris") ?? [],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user