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
*/7
import type { AuthProvider, McpFetch, UnauthorizedContext } from "../auth-provider.ts";8
import { isObject } from "../protocol/jsonrpc.ts";9
import {10
discoverAuthorizationServerMetadata,11
discoverOAuthServerInfo,12
parseWwwAuthenticate,13
selectResource,14
} from "./discovery.ts";15
import {16
McpOAuthAuthorizationRequiredError,17
OAuthError,18
OAuthInsecureEndpointError,19
OAuthIssuerMismatchError,20
OAuthRegistrationError,21
} from "./errors.ts";22
import {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";34
export type AddClientAuthentication = (35
headers: Headers,36
params: URLSearchParams,37
url: string | URL,38
metadata?: AuthorizationServerMetadata,39
) => void | Promise<void>;41
export 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
}59
export 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 a68
* 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 the75
* server asks for scopes the current grant lacks (a refresh keeps the old scope).76
*/77
skipRefresh?: boolean;78
}80
export type OAuthFlowResult = "AUTHORIZED" | "REDIRECT";81
type ClientAuthMethod = "client_secret_basic" | "client_secret_post" | "none";83
export interface TokenRequestOptions {84
metadata?: AuthorizationServerMetadata;85
clientInformation: OAuthClientInformationMixed;86
resource?: string;87
addClientAuthentication?: AddClientAuthentication;88
fetch?: McpFetch;89
}91
function loopback(hostname: string): boolean {92
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";93
}95
function 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
}101
function 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
}117
function 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
}136
async 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
}143
export 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
}175
async 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
}214
export 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
}238
export 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
}254
export 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
}266
function withScope(tokens: OAuthTokens, scope: string | undefined): OAuthTokens {267
return tokens.scope === undefined && scope ? { ...tokens, scope } : tokens;268
}270
/**271
* Scopes for a step-up authorization: the challenged scopes plus the ones granted so far, since a272
* challenge may list only the missing scopes and a token with just those would lose access the old273
* one had (SEP-2350). Without challenged scopes, `undefined` lets the flow pick its default.274
*/275
export 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
}281
async 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?.authorizationServerUrl286
? {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 can352
// 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
}385
export 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
}401
/**402
* Auth provider for `StreamableHttpTransport`. After a 401 it refreshes the tokens, or throws403
* `McpOAuthAuthorizationRequiredError` when the user has to authorize (again). Concurrent 401s share404
* one refresh, and a request whose token was already replaced is just retried: with rotating refresh405
* tokens, a second refresh with the old refresh token would fail and discard the new grant.406
*/407
export 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
}