返回源码地图

packages/mcp/src/oauth/flow.ts

v1.0.0 · a13d35a742c6 · 09:issuer条件、scope与授权流程;未真实认证E2E

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/*
2 * Adapted from modelcontextprotocol/typescript-sdk v1.29.0 src/client/auth.ts.
3 * Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
4 * Modified to remove SDK/Zod dependencies and use WebCrypto for PKCE.
5 */
6
7import type { AuthProvider, McpFetch, UnauthorizedContext } from "../auth-provider.ts";
8import { isObject } from "../protocol/jsonrpc.ts";
9import {
10 discoverAuthorizationServerMetadata,
11 discoverOAuthServerInfo,
12 parseWwwAuthenticate,
13 selectResource,
14} from "./discovery.ts";
15import {
16 McpOAuthAuthorizationRequiredError,
17 OAuthError,
18 OAuthInsecureEndpointError,
19 OAuthIssuerMismatchError,
20 OAuthRegistrationError,
21} from "./errors.ts";
22import {
23 type AuthorizationServerMetadata,
24 type OAuthClientInformation,
25 type OAuthClientInformationFull,
26 type OAuthClientInformationMixed,
27 type OAuthClientMetadata,
28 type OAuthDiscoveryState,
29 type OAuthTokens,
30 parseClientInformation,
31 parseOAuthTokens,
32} from "./types.ts";
33
34export type AddClientAuthentication = (
35 headers: Headers,
36 params: URLSearchParams,
37 url: string | URL,
38 metadata?: AuthorizationServerMetadata,
39) => void | Promise<void>;
40
41export interface OAuthClientProvider {
42 readonly redirectUrl: string | URL;
43 readonly clientMetadata: OAuthClientMetadata;
44 readonly clientMetadataUrl?: string;
45 state?(): string | Promise<string>;
46 clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;
47 saveClientInformation?(information: OAuthClientInformationMixed): void | Promise<void>;
48 tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>;
49 saveTokens(tokens: OAuthTokens): void | Promise<void>;
50 redirectToAuthorization(url: URL): void | Promise<void>;
51 saveCodeVerifier(verifier: string): void | Promise<void>;
52 codeVerifier(): string | Promise<string>;
53 addClientAuthentication?: AddClientAuthentication;
54 invalidateCredentials?(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): void | Promise<void>;
55 saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise<void>;
56 discoveryState?(): OAuthDiscoveryState | undefined | Promise<OAuthDiscoveryState | undefined>;
57}
58
59export interface OAuthFlowOptions {
60 serverUrl: string | URL;
61 authorizationCode?: string;
62 /** `iss` parameter of the authorization response that delivered `authorizationCode` (RFC 9207). */
63 iss?: string;
64 scope?: string;
65 resourceMetadataUrl?: URL;
66 /**
67 * Authorization server metadata document to use instead of discovery, for servers that advertise a
68 * wrong authorization server or none. It is trusted as configured. Must use https, except on loopback.
69 */
70 authorizationServerMetadataUrl?: URL;
71 fetch?: McpFetch;
72 skipIssuerValidation?: boolean;
73 /**
74 * Go straight to the authorization redirect instead of refreshing stored tokens, for example when the
75 * server asks for scopes the current grant lacks (a refresh keeps the old scope).
76 */
77 skipRefresh?: boolean;
78}
79
80export type OAuthFlowResult = "AUTHORIZED" | "REDIRECT";
81type ClientAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
82
83export interface TokenRequestOptions {
84 metadata?: AuthorizationServerMetadata;
85 clientInformation: OAuthClientInformationMixed;
86 resource?: string;
87 addClientAuthentication?: AddClientAuthentication;
88 fetch?: McpFetch;
89}
90
91function loopback(hostname: string): boolean {
92 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
93}
94
95function secureEndpoint(value: string | URL): URL {
96 const url = new URL(value);
97 if (url.protocol !== "https:" && !loopback(url.hostname)) throw new OAuthInsecureEndpointError(url.href);
98 return url;
99}
100
101function selectClientAuthMethod(information: OAuthClientInformationMixed, supported: string[]): ClientAuthMethod {
102 const hinted = "token_endpoint_auth_method" in information ? information.token_endpoint_auth_method : undefined;
103 if (
104 hinted &&
105 ["client_secret_basic", "client_secret_post", "none"].includes(hinted) &&
106 (supported.length === 0 || supported.includes(hinted))
107 ) {
108 return hinted as ClientAuthMethod;
109 }
110 if (supported.length === 0) return information.client_secret ? "client_secret_basic" : "none";
111 if (information.client_secret && supported.includes("client_secret_basic")) return "client_secret_basic";
112 if (information.client_secret && supported.includes("client_secret_post")) return "client_secret_post";
113 if (supported.includes("none")) return "none";
114 return information.client_secret ? "client_secret_post" : "none";
115}
116
117function applyClientAuthentication(
118 method: ClientAuthMethod,
119 information: OAuthClientInformation,
120 headers: Headers,
121 params: URLSearchParams,
122): void {
123 if (method === "client_secret_basic") {
124 if (!information.client_secret) throw new Error("client_secret_basic requires a client secret");
125 headers.set(
126 "Authorization",
127 `Basic ${Buffer.from(`${information.client_id}:${information.client_secret}`).toString("base64")}`,
128 );
129 } else {
130 params.set("client_id", information.client_id);
131 if (method === "client_secret_post" && information.client_secret)
132 params.set("client_secret", information.client_secret);
133 }
134}
135
136async function pkce(): Promise<{ verifier: string; challenge: string }> {
137 const bytes = crypto.getRandomValues(new Uint8Array(32));
138 const verifier = Buffer.from(bytes).toString("base64url");
139 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
140 return { verifier, challenge: Buffer.from(digest).toString("base64url") };
141}
142
143export async function startAuthorization(
144 authorizationServerUrl: string | URL,
145 options: {
146 metadata?: AuthorizationServerMetadata;
147 clientInformation: OAuthClientInformationMixed;
148 redirectUrl: string | URL;
149 scope?: string;
150 state?: string;
151 resource?: string;
152 },
153): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
154 const metadata = options.metadata;
155 if (metadata && !metadata.response_types_supported.includes("code")) {
156 throw new Error("Authorization server does not support authorization codes");
157 }
158 if (metadata?.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
159 throw new Error("Authorization server does not support PKCE S256");
160 }
161 const url = new URL(metadata?.authorization_endpoint ?? new URL("/authorize", authorizationServerUrl));
162 const { verifier, challenge } = await pkce();
163 url.searchParams.set("response_type", "code");
164 url.searchParams.set("client_id", options.clientInformation.client_id);
165 url.searchParams.set("code_challenge", challenge);
166 url.searchParams.set("code_challenge_method", "S256");
167 url.searchParams.set("redirect_uri", String(options.redirectUrl));
168 if (options.state) url.searchParams.set("state", options.state);
169 if (options.scope) url.searchParams.set("scope", options.scope);
170 if (options.scope?.split(/\s+/).includes("offline_access")) url.searchParams.set("prompt", "consent");
171 if (options.resource) url.searchParams.set("resource", options.resource);
172 return { authorizationUrl: url, codeVerifier: verifier };
173}
174
175async function tokenRequest(
176 authorizationServerUrl: string | URL,
177 options: TokenRequestOptions,
178 params: URLSearchParams,
179): Promise<OAuthTokens> {
180 const url = secureEndpoint(options.metadata?.token_endpoint ?? new URL("/token", authorizationServerUrl));
181 const headers = new Headers({ Accept: "application/json", "content-type": "application/x-www-form-urlencoded" });
182 if (options.resource) params.set("resource", options.resource);
183 if (options.addClientAuthentication) {
184 await options.addClientAuthentication(headers, params, url, options.metadata);
185 } else {
186 applyClientAuthentication(
187 selectClientAuthMethod(
188 options.clientInformation,
189 options.metadata?.token_endpoint_auth_methods_supported ?? [],
190 ),
191 options.clientInformation,
192 headers,
193 params,
194 );
195 }
196 const response = await (options.fetch ?? globalThis.fetch)(url, { method: "POST", headers, body: params });
197 const text = await response.text();
198 let value: unknown;
199 try {
200 value = JSON.parse(text);
201 } catch {}
202 // Servers may report OAuth errors with any status, so check the body before the status.
203 if (isObject(value) && typeof value.error === "string") {
204 throw new OAuthError(
205 value.error,
206 typeof value.error_description === "string" ? value.error_description : value.error,
207 typeof value.error_uri === "string" ? value.error_uri : undefined,
208 );
209 }
210 if (!response.ok) throw new OAuthError("server_error", `HTTP ${response.status}: ${text}`);
211 return parseOAuthTokens(value);
212}
213
214export async function registerClient(
215 authorizationServerUrl: string | URL,
216 options: {
217 metadata?: AuthorizationServerMetadata;
218 clientMetadata: OAuthClientMetadata;
219 scope?: string;
220 fetch?: McpFetch;
221 },
222): Promise<OAuthClientInformationFull> {
223 const endpoint = options.metadata?.registration_endpoint;
224 if (options.metadata && !endpoint)
225 throw new Error("Authorization server does not support dynamic client registration");
226 const response = await (options.fetch ?? globalThis.fetch)(
227 new URL(endpoint ?? new URL("/register", authorizationServerUrl)),
228 {
229 method: "POST",
230 headers: { Accept: "application/json", "content-type": "application/json" },
231 body: JSON.stringify({ ...options.clientMetadata, ...(options.scope ? { scope: options.scope } : {}) }),
232 },
233 );
234 if (!response.ok) throw new OAuthRegistrationError(response.status, await response.text());
235 return parseClientInformation(await response.json());
236}
237
238export async function exchangeAuthorizationCode(
239 authorizationServerUrl: string | URL,
240 options: TokenRequestOptions & { code: string; codeVerifier: string; redirectUrl: string | URL },
241): Promise<OAuthTokens> {
242 return tokenRequest(
243 authorizationServerUrl,
244 options,
245 new URLSearchParams({
246 grant_type: "authorization_code",
247 code: options.code,
248 code_verifier: options.codeVerifier,
249 redirect_uri: String(options.redirectUrl),
250 }),
251 );
252}
253
254export async function refreshAuthorization(
255 authorizationServerUrl: string | URL,
256 options: TokenRequestOptions & { refreshToken: string },
257): Promise<OAuthTokens> {
258 const tokens = await tokenRequest(
259 authorizationServerUrl,
260 options,
261 new URLSearchParams({ grant_type: "refresh_token", refresh_token: options.refreshToken }),
262 );
263 return { refresh_token: options.refreshToken, ...tokens };
264}
265
266function withScope(tokens: OAuthTokens, scope: string | undefined): OAuthTokens {
267 return tokens.scope === undefined && scope ? { ...tokens, scope } : tokens;
268}
269
270/**
271 * Scopes for a step-up authorization: the challenged scopes plus the ones granted so far, since a
272 * challenge may list only the missing scopes and a token with just those would lose access the old
273 * one had (SEP-2350). Without challenged scopes, `undefined` lets the flow pick its default.
274 */
275export function stepUpScope(granted: string | undefined, challenged: string | undefined): string | undefined {
276 if (!challenged) return undefined;
277 const scopes = [granted, challenged].flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []);
278 return [...new Set(scopes)].join(" ");
279}
280
281async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise<OAuthFlowResult> {
282 const metadataUrl = options.authorizationServerMetadataUrl && secureEndpoint(options.authorizationServerMetadataUrl);
283 // With a configured metadata URL, discovery is not cached, so changing the URL applies at once.
284 const cached = metadataUrl ? undefined : await provider.discoveryState?.();
285 const discovered = cached?.authorizationServerUrl
286 ? {
287 authorizationServerUrl: cached.authorizationServerUrl,
288 authorizationServerMetadata:
289 cached.authorizationServerMetadata ??
290 (await discoverAuthorizationServerMetadata(cached.authorizationServerUrl, {
291 fetch: options.fetch,
292 skipIssuerValidation: options.skipIssuerValidation,
293 })),
294 resourceMetadata: cached.resourceMetadata,
295 }
296 : await discoverOAuthServerInfo(options.serverUrl, {
297 resourceMetadataUrl: options.resourceMetadataUrl,
298 authorizationServerMetadataUrl: metadataUrl,
299 fetch: options.fetch,
300 skipIssuerValidation: options.skipIssuerValidation,
301 });
302 if (!metadataUrl) {
303 await provider.saveDiscoveryState?.({
304 ...discovered,
305 ...(options.resourceMetadataUrl ? { resourceMetadataUrl: options.resourceMetadataUrl.href } : {}),
306 });
307 }
308 const metadata = discovered.authorizationServerMetadata;
309 const resource = selectResource(options.serverUrl, discovered.resourceMetadata);
310 // `||`, not `??`: an empty scope (for example from `scopes_supported: []`) falls through to the next source.
311 const scope =
312 options.scope || discovered.resourceMetadata?.scopes_supported?.join(" ") || provider.clientMetadata.scope;
313 let client = await provider.clientInformation();
314 if (!client) {
315 if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange");
316 if (metadata?.client_id_metadata_document_supported && provider.clientMetadataUrl) {
317 const url = new URL(provider.clientMetadataUrl);
318 if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");
319 client = { client_id: provider.clientMetadataUrl };
320 await provider.saveClientInformation?.(client);
321 } else {
322 if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");
323 client = await registerClient(discovered.authorizationServerUrl, {
324 metadata,
325 clientMetadata: provider.clientMetadata,
326 scope,
327 fetch: options.fetch,
328 });
329 await provider.saveClientInformation(client);
330 }
331 }
332 const tokenOptions: TokenRequestOptions = {
333 metadata,
334 clientInformation: client,
335 resource,
336 addClientAuthentication: provider.addClientAuthentication,
337 fetch: options.fetch,
338 };
339 if (options.authorizationCode) {
340 // RFC 9207: never send a code from another authorization server to this one.
341 const iss = options.iss;
342 if (metadata && (iss !== undefined || metadata.authorization_response_iss_parameter_supported)) {
343 if (iss !== metadata.issuer) throw new OAuthIssuerMismatchError(metadata.issuer, iss);
344 }
345 const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, {
346 ...tokenOptions,
347 code: options.authorizationCode,
348 codeVerifier: await provider.codeVerifier(),
349 redirectUrl: provider.redirectUrl,
350 });
351 // A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can
352 // keep it. Callers pass the options of the authorization request, so `scope` is what was requested.
353 await provider.saveTokens(withScope(tokens, scope));
354 return "AUTHORIZED";
355 }
356 const existing = options.skipRefresh ? undefined : await provider.tokens();
357 if (existing?.refresh_token) {
358 try {
359 const tokens = await refreshAuthorization(discovered.authorizationServerUrl, {
360 ...tokenOptions,
361 refreshToken: existing.refresh_token,
362 });
363 // A refresh without `scope` keeps the scope of the grant (RFC 6749 §6).
364 await provider.saveTokens(withScope(tokens, existing.scope));
365 return "AUTHORIZED";
366 } catch (error) {
367 if (error instanceof OAuthInsecureEndpointError) throw error;
368 if (error instanceof OAuthError && error.code !== "server_error") throw error;
369 }
370 }
371 const state = await provider.state?.();
372 const authorization = await startAuthorization(discovered.authorizationServerUrl, {
373 metadata,
374 clientInformation: client,
375 redirectUrl: provider.redirectUrl,
376 scope,
377 state,
378 resource,
379 });
380 await provider.saveCodeVerifier(authorization.codeVerifier);
381 await provider.redirectToAuthorization(authorization.authorizationUrl);
382 return "REDIRECT";
383}
384
385export async function authorizeMcp(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise<OAuthFlowResult> {
386 try {
387 return await runFlow(provider, options);
388 } catch (error) {
389 if (error instanceof OAuthError && ["invalid_client", "unauthorized_client"].includes(error.code)) {
390 await provider.invalidateCredentials?.("all");
391 return runFlow(provider, options);
392 }
393 if (error instanceof OAuthError && error.code === "invalid_grant") {
394 await provider.invalidateCredentials?.("tokens");
395 return runFlow(provider, options);
396 }
397 throw error;
398 }
399}
400
401/**
402 * Auth provider for `StreamableHttpTransport`. After a 401 it refreshes the tokens, or throws
403 * `McpOAuthAuthorizationRequiredError` when the user has to authorize (again). Concurrent 401s share
404 * one refresh, and a request whose token was already replaced is just retried: with rotating refresh
405 * tokens, a second refresh with the old refresh token would fail and discard the new grant.
406 */
407export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
408 let inFlight: Promise<void> | undefined;
409 return {
410 token: async () => (await provider.tokens())?.access_token,
411 onUnauthorized: async (context: UnauthorizedContext) => {
412 const challenge = parseWwwAuthenticate(context.response.headers.get("www-authenticate"));
413 const insufficientScope = challenge.error === "insufficient_scope";
414 if (!insufficientScope && !inFlight && context.token !== undefined) {
415 const current = (await provider.tokens())?.access_token;
416 if (current !== undefined && current !== context.token) return;
417 }
418 inFlight ??= Promise.resolve(insufficientScope ? provider.tokens() : undefined)
419 .then((granted) =>
420 authorizeMcp(provider, {
421 serverUrl: context.serverUrl,
422 resourceMetadataUrl: challenge.resourceMetadataUrl,
423 scope: insufficientScope ? stepUpScope(granted?.scope, challenge.scope) : challenge.scope,
424 fetch: context.fetch,
425 skipRefresh: insufficientScope,
426 }),
427 )
428 .then((result) => {
429 if (result === "REDIRECT") throw new McpOAuthAuthorizationRequiredError();
430 })
431 .finally(() => {
432 inFlight = undefined;
433 });
434 await inFlight;
435 },
436 };
437}