auth.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. import pkceChallenge from 'pkce-challenge';
  2. import { LATEST_PROTOCOL_VERSION } from '../types.js';
  3. import { OAuthErrorResponseSchema, OpenIdProviderDiscoveryMetadataSchema } from '../shared/auth.js';
  4. import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from '../shared/auth.js';
  5. import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js';
  6. import { InvalidClientError, InvalidClientMetadataError, InvalidGrantError, OAUTH_ERRORS, OAuthError, ServerError, UnauthorizedClientError } from '../server/auth/errors.js';
  7. export class UnauthorizedError extends Error {
  8. constructor(message) {
  9. super(message ?? 'Unauthorized');
  10. }
  11. }
  12. function isClientAuthMethod(method) {
  13. return ['client_secret_basic', 'client_secret_post', 'none'].includes(method);
  14. }
  15. const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code';
  16. const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256';
  17. /**
  18. * Determines the best client authentication method to use based on server support and client configuration.
  19. *
  20. * Priority order (highest to lowest):
  21. * 1. client_secret_basic (if client secret is available)
  22. * 2. client_secret_post (if client secret is available)
  23. * 3. none (for public clients)
  24. *
  25. * @param clientInformation - OAuth client information containing credentials
  26. * @param supportedMethods - Authentication methods supported by the authorization server
  27. * @returns The selected authentication method
  28. */
  29. export function selectClientAuthMethod(clientInformation, supportedMethods) {
  30. const hasClientSecret = clientInformation.client_secret !== undefined;
  31. // Prefer the method returned by the server during client registration, if valid.
  32. // When server metadata is present we also require the method to be listed as supported;
  33. // when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone.
  34. if ('token_endpoint_auth_method' in clientInformation &&
  35. clientInformation.token_endpoint_auth_method &&
  36. isClientAuthMethod(clientInformation.token_endpoint_auth_method) &&
  37. (supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method))) {
  38. return clientInformation.token_endpoint_auth_method;
  39. }
  40. // If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the
  41. // default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP
  42. // Basic authentication for clients with a secret, making it the safest default.
  43. if (supportedMethods.length === 0) {
  44. return hasClientSecret ? 'client_secret_basic' : 'none';
  45. }
  46. // Try methods in priority order (most secure first)
  47. if (hasClientSecret && supportedMethods.includes('client_secret_basic')) {
  48. return 'client_secret_basic';
  49. }
  50. if (hasClientSecret && supportedMethods.includes('client_secret_post')) {
  51. return 'client_secret_post';
  52. }
  53. if (supportedMethods.includes('none')) {
  54. return 'none';
  55. }
  56. // Fallback: use what we have
  57. return hasClientSecret ? 'client_secret_post' : 'none';
  58. }
  59. /**
  60. * Applies client authentication to the request based on the specified method.
  61. *
  62. * Implements OAuth 2.1 client authentication methods:
  63. * - client_secret_basic: HTTP Basic authentication (RFC 6749 Section 2.3.1)
  64. * - client_secret_post: Credentials in request body (RFC 6749 Section 2.3.1)
  65. * - none: Public client authentication (RFC 6749 Section 2.1)
  66. *
  67. * @param method - The authentication method to use
  68. * @param clientInformation - OAuth client information containing credentials
  69. * @param headers - HTTP headers object to modify
  70. * @param params - URL search parameters to modify
  71. * @throws {Error} When required credentials are missing
  72. */
  73. function applyClientAuthentication(method, clientInformation, headers, params) {
  74. const { client_id, client_secret } = clientInformation;
  75. switch (method) {
  76. case 'client_secret_basic':
  77. applyBasicAuth(client_id, client_secret, headers);
  78. return;
  79. case 'client_secret_post':
  80. applyPostAuth(client_id, client_secret, params);
  81. return;
  82. case 'none':
  83. applyPublicAuth(client_id, params);
  84. return;
  85. default:
  86. throw new Error(`Unsupported client authentication method: ${method}`);
  87. }
  88. }
  89. /**
  90. * Applies HTTP Basic authentication (RFC 6749 Section 2.3.1)
  91. */
  92. function applyBasicAuth(clientId, clientSecret, headers) {
  93. if (!clientSecret) {
  94. throw new Error('client_secret_basic authentication requires a client_secret');
  95. }
  96. const credentials = btoa(`${clientId}:${clientSecret}`);
  97. headers.set('Authorization', `Basic ${credentials}`);
  98. }
  99. /**
  100. * Applies POST body authentication (RFC 6749 Section 2.3.1)
  101. */
  102. function applyPostAuth(clientId, clientSecret, params) {
  103. params.set('client_id', clientId);
  104. if (clientSecret) {
  105. params.set('client_secret', clientSecret);
  106. }
  107. }
  108. /**
  109. * Applies public client authentication (RFC 6749 Section 2.1)
  110. */
  111. function applyPublicAuth(clientId, params) {
  112. params.set('client_id', clientId);
  113. }
  114. /**
  115. * Parses an OAuth error response from a string or Response object.
  116. *
  117. * If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
  118. * and an instance of the appropriate OAuthError subclass will be returned.
  119. * If parsing fails, it falls back to a generic ServerError that includes
  120. * the response status (if available) and original content.
  121. *
  122. * @param input - A Response object or string containing the error response
  123. * @returns A Promise that resolves to an OAuthError instance
  124. */
  125. export async function parseErrorResponse(input) {
  126. const statusCode = input instanceof Response ? input.status : undefined;
  127. const body = input instanceof Response ? await input.text() : input;
  128. try {
  129. const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
  130. const { error, error_description, error_uri } = result;
  131. const errorClass = OAUTH_ERRORS[error] || ServerError;
  132. return new errorClass(error_description || '', error_uri);
  133. }
  134. catch (error) {
  135. // Not a valid OAuth error response, but try to inform the user of the raw data anyway
  136. const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
  137. return new ServerError(errorMessage);
  138. }
  139. }
  140. /**
  141. * Orchestrates the full auth flow with a server.
  142. *
  143. * This can be used as a single entry point for all authorization functionality,
  144. * instead of linking together the other lower-level functions in this module.
  145. */
  146. export async function auth(provider, options) {
  147. try {
  148. return await authInternal(provider, options);
  149. }
  150. catch (error) {
  151. // Handle recoverable error types by invalidating credentials and retrying
  152. if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) {
  153. await provider.invalidateCredentials?.('all');
  154. return await authInternal(provider, options);
  155. }
  156. else if (error instanceof InvalidGrantError) {
  157. await provider.invalidateCredentials?.('tokens');
  158. return await authInternal(provider, options);
  159. }
  160. // Throw otherwise
  161. throw error;
  162. }
  163. }
  164. async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
  165. // Check if the provider has cached discovery state to skip discovery
  166. const cachedState = await provider.discoveryState?.();
  167. let resourceMetadata;
  168. let authorizationServerUrl;
  169. let metadata;
  170. // If resourceMetadataUrl is not provided, try to load it from cached state
  171. // This handles browser redirects where the URL was saved before navigation
  172. let effectiveResourceMetadataUrl = resourceMetadataUrl;
  173. if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) {
  174. effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl);
  175. }
  176. if (cachedState?.authorizationServerUrl) {
  177. // Restore discovery state from cache
  178. authorizationServerUrl = cachedState.authorizationServerUrl;
  179. resourceMetadata = cachedState.resourceMetadata;
  180. metadata =
  181. cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }));
  182. // If resource metadata wasn't cached, try to fetch it for selectResourceURL
  183. if (!resourceMetadata) {
  184. try {
  185. resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn);
  186. }
  187. catch {
  188. // RFC 9728 not available — selectResourceURL will handle undefined
  189. }
  190. }
  191. // Re-save if we enriched the cached state with missing metadata
  192. if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) {
  193. await provider.saveDiscoveryState?.({
  194. authorizationServerUrl: String(authorizationServerUrl),
  195. resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
  196. resourceMetadata,
  197. authorizationServerMetadata: metadata
  198. });
  199. }
  200. }
  201. else {
  202. // Full discovery via RFC 9728
  203. const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
  204. authorizationServerUrl = serverInfo.authorizationServerUrl;
  205. metadata = serverInfo.authorizationServerMetadata;
  206. resourceMetadata = serverInfo.resourceMetadata;
  207. // Persist discovery state for future use
  208. // TODO: resourceMetadataUrl is only populated when explicitly provided via options
  209. // or loaded from cached state. The URL derived internally by
  210. // discoverOAuthProtectedResourceMetadata() is not captured back here.
  211. await provider.saveDiscoveryState?.({
  212. authorizationServerUrl: String(authorizationServerUrl),
  213. resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
  214. resourceMetadata,
  215. authorizationServerMetadata: metadata
  216. });
  217. }
  218. const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
  219. // Apply scope selection strategy (SEP-835):
  220. // 1. WWW-Authenticate scope (passed via `scope` param)
  221. // 2. PRM scopes_supported
  222. // 3. Client metadata scope (user-configured fallback)
  223. // The resolved scope is used consistently for both DCR and the authorization request.
  224. const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
  225. // Handle client registration if needed
  226. let clientInformation = await Promise.resolve(provider.clientInformation());
  227. if (!clientInformation) {
  228. if (authorizationCode !== undefined) {
  229. throw new Error('Existing OAuth client information is required when exchanging an authorization code');
  230. }
  231. const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true;
  232. const clientMetadataUrl = provider.clientMetadataUrl;
  233. if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
  234. throw new InvalidClientMetadataError(`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`);
  235. }
  236. const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl;
  237. if (shouldUseUrlBasedClientId) {
  238. // SEP-991: URL-based Client IDs
  239. clientInformation = {
  240. client_id: clientMetadataUrl
  241. };
  242. await provider.saveClientInformation?.(clientInformation);
  243. }
  244. else {
  245. // Fallback to dynamic registration
  246. if (!provider.saveClientInformation) {
  247. throw new Error('OAuth client information must be saveable for dynamic registration');
  248. }
  249. const fullInformation = await registerClient(authorizationServerUrl, {
  250. metadata,
  251. clientMetadata: provider.clientMetadata,
  252. scope: resolvedScope,
  253. fetchFn
  254. });
  255. await provider.saveClientInformation(fullInformation);
  256. clientInformation = fullInformation;
  257. }
  258. }
  259. // Non-interactive flows (e.g., client_credentials, jwt-bearer) don't need a redirect URL
  260. const nonInteractiveFlow = !provider.redirectUrl;
  261. // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows
  262. if (authorizationCode !== undefined || nonInteractiveFlow) {
  263. const tokens = await fetchToken(provider, authorizationServerUrl, {
  264. metadata,
  265. resource,
  266. authorizationCode,
  267. fetchFn
  268. });
  269. await provider.saveTokens(tokens);
  270. return 'AUTHORIZED';
  271. }
  272. const tokens = await provider.tokens();
  273. // Handle token refresh or new authorization
  274. if (tokens?.refresh_token) {
  275. try {
  276. // Attempt to refresh the token
  277. const newTokens = await refreshAuthorization(authorizationServerUrl, {
  278. metadata,
  279. clientInformation,
  280. refreshToken: tokens.refresh_token,
  281. resource,
  282. addClientAuthentication: provider.addClientAuthentication,
  283. fetchFn
  284. });
  285. await provider.saveTokens(newTokens);
  286. return 'AUTHORIZED';
  287. }
  288. catch (error) {
  289. // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
  290. if (!(error instanceof OAuthError) || error instanceof ServerError) {
  291. // Could not refresh OAuth tokens
  292. }
  293. else {
  294. // Refresh failed for another reason, re-throw
  295. throw error;
  296. }
  297. }
  298. }
  299. const state = provider.state ? await provider.state() : undefined;
  300. // Start new authorization flow
  301. const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
  302. metadata,
  303. clientInformation,
  304. state,
  305. redirectUrl: provider.redirectUrl,
  306. scope: resolvedScope,
  307. resource
  308. });
  309. await provider.saveCodeVerifier(codeVerifier);
  310. await provider.redirectToAuthorization(authorizationUrl);
  311. return 'REDIRECT';
  312. }
  313. /**
  314. * SEP-991: URL-based Client IDs
  315. * Validate that the client_id is a valid URL with https scheme
  316. */
  317. export function isHttpsUrl(value) {
  318. if (!value)
  319. return false;
  320. try {
  321. const url = new URL(value);
  322. return url.protocol === 'https:' && url.pathname !== '/';
  323. }
  324. catch {
  325. return false;
  326. }
  327. }
  328. export async function selectResourceURL(serverUrl, provider, resourceMetadata) {
  329. const defaultResource = resourceUrlFromServerUrl(serverUrl);
  330. // If provider has custom validation, delegate to it
  331. if (provider.validateResourceURL) {
  332. return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource);
  333. }
  334. // Only include resource parameter when Protected Resource Metadata is present
  335. if (!resourceMetadata) {
  336. return undefined;
  337. }
  338. // Validate that the metadata's resource is compatible with our request
  339. if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
  340. throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
  341. }
  342. // Prefer the resource from metadata since it's what the server is telling us to request
  343. return new URL(resourceMetadata.resource);
  344. }
  345. /**
  346. * Extract resource_metadata, scope, and error from WWW-Authenticate header.
  347. */
  348. export function extractWWWAuthenticateParams(res) {
  349. const authenticateHeader = res.headers.get('WWW-Authenticate');
  350. if (!authenticateHeader) {
  351. return {};
  352. }
  353. const [type, scheme] = authenticateHeader.split(' ');
  354. if (type.toLowerCase() !== 'bearer' || !scheme) {
  355. return {};
  356. }
  357. const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined;
  358. let resourceMetadataUrl;
  359. if (resourceMetadataMatch) {
  360. try {
  361. resourceMetadataUrl = new URL(resourceMetadataMatch);
  362. }
  363. catch {
  364. // Ignore invalid URL
  365. }
  366. }
  367. const scope = extractFieldFromWwwAuth(res, 'scope') || undefined;
  368. const error = extractFieldFromWwwAuth(res, 'error') || undefined;
  369. return {
  370. resourceMetadataUrl,
  371. scope,
  372. error
  373. };
  374. }
  375. /**
  376. * Extracts a specific field's value from the WWW-Authenticate header string.
  377. *
  378. * @param response The HTTP response object containing the headers.
  379. * @param fieldName The name of the field to extract (e.g., "realm", "nonce").
  380. * @returns The field value
  381. */
  382. function extractFieldFromWwwAuth(response, fieldName) {
  383. const wwwAuthHeader = response.headers.get('WWW-Authenticate');
  384. if (!wwwAuthHeader) {
  385. return null;
  386. }
  387. const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`);
  388. const match = wwwAuthHeader.match(pattern);
  389. if (match) {
  390. // Pattern matches: field_name="value" or field_name=value (unquoted)
  391. return match[1] || match[2];
  392. }
  393. return null;
  394. }
  395. /**
  396. * Extract resource_metadata from response header.
  397. * @deprecated Use `extractWWWAuthenticateParams` instead.
  398. */
  399. export function extractResourceMetadataUrl(res) {
  400. const authenticateHeader = res.headers.get('WWW-Authenticate');
  401. if (!authenticateHeader) {
  402. return undefined;
  403. }
  404. const [type, scheme] = authenticateHeader.split(' ');
  405. if (type.toLowerCase() !== 'bearer' || !scheme) {
  406. return undefined;
  407. }
  408. const regex = /resource_metadata="([^"]*)"/;
  409. const match = regex.exec(authenticateHeader);
  410. if (!match) {
  411. return undefined;
  412. }
  413. try {
  414. return new URL(match[1]);
  415. }
  416. catch {
  417. return undefined;
  418. }
  419. }
  420. /**
  421. * Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata.
  422. *
  423. * If the server returns a 404 for the well-known endpoint, this function will
  424. * return `undefined`. Any other errors will be thrown as exceptions.
  425. */
  426. export async function discoverOAuthProtectedResourceMetadata(serverUrl, opts, fetchFn = fetch) {
  427. const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, {
  428. protocolVersion: opts?.protocolVersion,
  429. metadataUrl: opts?.resourceMetadataUrl
  430. });
  431. if (!response || response.status === 404) {
  432. await response?.body?.cancel();
  433. throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
  434. }
  435. if (!response.ok) {
  436. await response.body?.cancel();
  437. throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`);
  438. }
  439. return OAuthProtectedResourceMetadataSchema.parse(await response.json());
  440. }
  441. /**
  442. * Helper function to handle fetch with CORS retry logic
  443. */
  444. async function fetchWithCorsRetry(url, headers, fetchFn = fetch) {
  445. try {
  446. return await fetchFn(url, { headers });
  447. }
  448. catch (error) {
  449. if (error instanceof TypeError) {
  450. if (headers) {
  451. // CORS errors come back as TypeError, retry without headers
  452. return fetchWithCorsRetry(url, undefined, fetchFn);
  453. }
  454. else {
  455. // We're getting CORS errors on retry too, return undefined
  456. return undefined;
  457. }
  458. }
  459. throw error;
  460. }
  461. }
  462. /**
  463. * Constructs the well-known path for auth-related metadata discovery
  464. */
  465. function buildWellKnownPath(wellKnownPrefix, pathname = '', options = {}) {
  466. // Strip trailing slash from pathname to avoid double slashes
  467. if (pathname.endsWith('/')) {
  468. pathname = pathname.slice(0, -1);
  469. }
  470. return options.prependPathname ? `${pathname}/.well-known/${wellKnownPrefix}` : `/.well-known/${wellKnownPrefix}${pathname}`;
  471. }
  472. /**
  473. * Tries to discover OAuth metadata at a specific URL
  474. */
  475. async function tryMetadataDiscovery(url, protocolVersion, fetchFn = fetch) {
  476. const headers = {
  477. 'MCP-Protocol-Version': protocolVersion
  478. };
  479. return await fetchWithCorsRetry(url, headers, fetchFn);
  480. }
  481. /**
  482. * Determines if fallback to root discovery should be attempted
  483. */
  484. function shouldAttemptFallback(response, pathname) {
  485. return !response || (response.status >= 400 && response.status < 500 && pathname !== '/');
  486. }
  487. /**
  488. * Generic function for discovering OAuth metadata with fallback support
  489. */
  490. async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, opts) {
  491. const issuer = new URL(serverUrl);
  492. const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
  493. let url;
  494. if (opts?.metadataUrl) {
  495. url = new URL(opts.metadataUrl);
  496. }
  497. else {
  498. // Try path-aware discovery first
  499. const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
  500. url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
  501. url.search = issuer.search;
  502. }
  503. let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
  504. // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
  505. if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
  506. const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
  507. response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
  508. }
  509. return response;
  510. }
  511. /**
  512. * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
  513. *
  514. * If the server returns a 404 for the well-known endpoint, this function will
  515. * return `undefined`. Any other errors will be thrown as exceptions.
  516. *
  517. * @deprecated This function is deprecated in favor of `discoverAuthorizationServerMetadata`.
  518. */
  519. export async function discoverOAuthMetadata(issuer, { authorizationServerUrl, protocolVersion } = {}, fetchFn = fetch) {
  520. if (typeof issuer === 'string') {
  521. issuer = new URL(issuer);
  522. }
  523. if (!authorizationServerUrl) {
  524. authorizationServerUrl = issuer;
  525. }
  526. if (typeof authorizationServerUrl === 'string') {
  527. authorizationServerUrl = new URL(authorizationServerUrl);
  528. }
  529. protocolVersion ?? (protocolVersion = LATEST_PROTOCOL_VERSION);
  530. const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, {
  531. protocolVersion,
  532. metadataServerUrl: authorizationServerUrl
  533. });
  534. if (!response || response.status === 404) {
  535. await response?.body?.cancel();
  536. return undefined;
  537. }
  538. if (!response.ok) {
  539. await response.body?.cancel();
  540. throw new Error(`HTTP ${response.status} trying to load well-known OAuth metadata`);
  541. }
  542. return OAuthMetadataSchema.parse(await response.json());
  543. }
  544. /**
  545. * Builds a list of discovery URLs to try for authorization server metadata.
  546. * URLs are returned in priority order:
  547. * 1. OAuth metadata at the given URL
  548. * 2. OIDC metadata endpoints at the given URL
  549. */
  550. export function buildDiscoveryUrls(authorizationServerUrl) {
  551. const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
  552. const hasPath = url.pathname !== '/';
  553. const urlsToTry = [];
  554. if (!hasPath) {
  555. // Root path: https://example.com/.well-known/oauth-authorization-server
  556. urlsToTry.push({
  557. url: new URL('/.well-known/oauth-authorization-server', url.origin),
  558. type: 'oauth'
  559. });
  560. // OIDC: https://example.com/.well-known/openid-configuration
  561. urlsToTry.push({
  562. url: new URL(`/.well-known/openid-configuration`, url.origin),
  563. type: 'oidc'
  564. });
  565. return urlsToTry;
  566. }
  567. // Strip trailing slash from pathname to avoid double slashes
  568. let pathname = url.pathname;
  569. if (pathname.endsWith('/')) {
  570. pathname = pathname.slice(0, -1);
  571. }
  572. // 1. OAuth metadata at the given URL
  573. // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1
  574. urlsToTry.push({
  575. url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin),
  576. type: 'oauth'
  577. });
  578. // 2. OIDC metadata endpoints
  579. // RFC 8414 style: Insert /.well-known/openid-configuration before the path
  580. urlsToTry.push({
  581. url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin),
  582. type: 'oidc'
  583. });
  584. // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path
  585. urlsToTry.push({
  586. url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin),
  587. type: 'oidc'
  588. });
  589. return urlsToTry;
  590. }
  591. /**
  592. * Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata
  593. * and OpenID Connect Discovery 1.0 specifications.
  594. *
  595. * This function implements a fallback strategy for authorization server discovery:
  596. * 1. Attempts RFC 8414 OAuth metadata discovery first
  597. * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery
  598. *
  599. * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's
  600. * protected resource metadata, or the MCP server's URL if the
  601. * metadata was not found.
  602. * @param options - Configuration options
  603. * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
  604. * @param options.protocolVersion - MCP protocol version to use, defaults to LATEST_PROTOCOL_VERSION
  605. * @returns Promise resolving to authorization server metadata, or undefined if discovery fails
  606. */
  607. export async function discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION } = {}) {
  608. const headers = {
  609. 'MCP-Protocol-Version': protocolVersion,
  610. Accept: 'application/json'
  611. };
  612. // Get the list of URLs to try
  613. const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
  614. // Try each URL in order
  615. for (const { url: endpointUrl, type } of urlsToTry) {
  616. const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn);
  617. if (!response) {
  618. /**
  619. * CORS error occurred - don't throw as the endpoint may not allow CORS,
  620. * continue trying other possible endpoints
  621. */
  622. continue;
  623. }
  624. if (!response.ok) {
  625. await response.body?.cancel();
  626. // Continue looking for any 4xx response code.
  627. if (response.status >= 400 && response.status < 500) {
  628. continue; // Try next URL
  629. }
  630. throw new Error(`HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`);
  631. }
  632. // Parse and validate based on type
  633. if (type === 'oauth') {
  634. return OAuthMetadataSchema.parse(await response.json());
  635. }
  636. else {
  637. return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
  638. }
  639. }
  640. return undefined;
  641. }
  642. /**
  643. * Discovers the authorization server for an MCP server following
  644. * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
  645. * Resource Metadata), with fallback to treating the server URL as the
  646. * authorization server.
  647. *
  648. * This function combines two discovery steps into one call:
  649. * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
  650. * authorization server URL (RFC 9728).
  651. * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
  652. *
  653. * Use this when you need the authorization server metadata for operations outside the
  654. * {@linkcode auth} orchestrator, such as token refresh or token revocation.
  655. *
  656. * @param serverUrl - The MCP resource server URL
  657. * @param opts - Optional configuration
  658. * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
  659. * @param opts.fetchFn - Custom fetch function for HTTP requests
  660. * @returns Authorization server URL, metadata, and resource metadata (if available)
  661. */
  662. export async function discoverOAuthServerInfo(serverUrl, opts) {
  663. let resourceMetadata;
  664. let authorizationServerUrl;
  665. try {
  666. resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.fetchFn);
  667. if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
  668. authorizationServerUrl = resourceMetadata.authorization_servers[0];
  669. }
  670. }
  671. catch {
  672. // RFC 9728 not supported -- fall back to treating the server URL as the authorization server
  673. }
  674. // If we don't get a valid authorization server from protected resource metadata,
  675. // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
  676. if (!authorizationServerUrl) {
  677. authorizationServerUrl = String(new URL('/', serverUrl));
  678. }
  679. const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn });
  680. return {
  681. authorizationServerUrl,
  682. authorizationServerMetadata,
  683. resourceMetadata
  684. };
  685. }
  686. /**
  687. * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
  688. */
  689. export async function startAuthorization(authorizationServerUrl, { metadata, clientInformation, redirectUrl, scope, state, resource }) {
  690. let authorizationUrl;
  691. if (metadata) {
  692. authorizationUrl = new URL(metadata.authorization_endpoint);
  693. if (!metadata.response_types_supported.includes(AUTHORIZATION_CODE_RESPONSE_TYPE)) {
  694. throw new Error(`Incompatible auth server: does not support response type ${AUTHORIZATION_CODE_RESPONSE_TYPE}`);
  695. }
  696. if (metadata.code_challenge_methods_supported &&
  697. !metadata.code_challenge_methods_supported.includes(AUTHORIZATION_CODE_CHALLENGE_METHOD)) {
  698. throw new Error(`Incompatible auth server: does not support code challenge method ${AUTHORIZATION_CODE_CHALLENGE_METHOD}`);
  699. }
  700. }
  701. else {
  702. authorizationUrl = new URL('/authorize', authorizationServerUrl);
  703. }
  704. // Generate PKCE challenge
  705. const challenge = await pkceChallenge();
  706. const codeVerifier = challenge.code_verifier;
  707. const codeChallenge = challenge.code_challenge;
  708. authorizationUrl.searchParams.set('response_type', AUTHORIZATION_CODE_RESPONSE_TYPE);
  709. authorizationUrl.searchParams.set('client_id', clientInformation.client_id);
  710. authorizationUrl.searchParams.set('code_challenge', codeChallenge);
  711. authorizationUrl.searchParams.set('code_challenge_method', AUTHORIZATION_CODE_CHALLENGE_METHOD);
  712. authorizationUrl.searchParams.set('redirect_uri', String(redirectUrl));
  713. if (state) {
  714. authorizationUrl.searchParams.set('state', state);
  715. }
  716. if (scope) {
  717. authorizationUrl.searchParams.set('scope', scope);
  718. }
  719. if (scope?.includes('offline_access')) {
  720. // if the request includes the OIDC-only "offline_access" scope,
  721. // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
  722. // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
  723. authorizationUrl.searchParams.append('prompt', 'consent');
  724. }
  725. if (resource) {
  726. authorizationUrl.searchParams.set('resource', resource.href);
  727. }
  728. return { authorizationUrl, codeVerifier };
  729. }
  730. /**
  731. * Prepares token request parameters for an authorization code exchange.
  732. *
  733. * This is the default implementation used by fetchToken when the provider
  734. * doesn't implement prepareTokenRequest.
  735. *
  736. * @param authorizationCode - The authorization code received from the authorization endpoint
  737. * @param codeVerifier - The PKCE code verifier
  738. * @param redirectUri - The redirect URI used in the authorization request
  739. * @returns URLSearchParams for the authorization_code grant
  740. */
  741. export function prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri) {
  742. return new URLSearchParams({
  743. grant_type: 'authorization_code',
  744. code: authorizationCode,
  745. code_verifier: codeVerifier,
  746. redirect_uri: String(redirectUri)
  747. });
  748. }
  749. /**
  750. * Internal helper to execute a token request with the given parameters.
  751. * Used by exchangeAuthorization, refreshAuthorization, and fetchToken.
  752. */
  753. async function executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, clientInformation, addClientAuthentication, resource, fetchFn }) {
  754. const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
  755. const headers = new Headers({
  756. 'Content-Type': 'application/x-www-form-urlencoded',
  757. Accept: 'application/json'
  758. });
  759. if (resource) {
  760. tokenRequestParams.set('resource', resource.href);
  761. }
  762. if (addClientAuthentication) {
  763. await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata);
  764. }
  765. else if (clientInformation) {
  766. const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
  767. const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
  768. applyClientAuthentication(authMethod, clientInformation, headers, tokenRequestParams);
  769. }
  770. const response = await (fetchFn ?? fetch)(tokenUrl, {
  771. method: 'POST',
  772. headers,
  773. body: tokenRequestParams
  774. });
  775. if (!response.ok) {
  776. throw await parseErrorResponse(response);
  777. }
  778. return OAuthTokensSchema.parse(await response.json());
  779. }
  780. /**
  781. * Exchanges an authorization code for an access token with the given server.
  782. *
  783. * Supports multiple client authentication methods as specified in OAuth 2.1:
  784. * - Automatically selects the best authentication method based on server support
  785. * - Falls back to appropriate defaults when server metadata is unavailable
  786. *
  787. * @param authorizationServerUrl - The authorization server's base URL
  788. * @param options - Configuration object containing client info, auth code, etc.
  789. * @returns Promise resolving to OAuth tokens
  790. * @throws {Error} When token exchange fails or authentication is invalid
  791. */
  792. export async function exchangeAuthorization(authorizationServerUrl, { metadata, clientInformation, authorizationCode, codeVerifier, redirectUri, resource, addClientAuthentication, fetchFn }) {
  793. const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri);
  794. return executeTokenRequest(authorizationServerUrl, {
  795. metadata,
  796. tokenRequestParams,
  797. clientInformation,
  798. addClientAuthentication,
  799. resource,
  800. fetchFn
  801. });
  802. }
  803. /**
  804. * Exchange a refresh token for an updated access token.
  805. *
  806. * Supports multiple client authentication methods as specified in OAuth 2.1:
  807. * - Automatically selects the best authentication method based on server support
  808. * - Preserves the original refresh token if a new one is not returned
  809. *
  810. * @param authorizationServerUrl - The authorization server's base URL
  811. * @param options - Configuration object containing client info, refresh token, etc.
  812. * @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced)
  813. * @throws {Error} When token refresh fails or authentication is invalid
  814. */
  815. export async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) {
  816. const tokenRequestParams = new URLSearchParams({
  817. grant_type: 'refresh_token',
  818. refresh_token: refreshToken
  819. });
  820. const tokens = await executeTokenRequest(authorizationServerUrl, {
  821. metadata,
  822. tokenRequestParams,
  823. clientInformation,
  824. addClientAuthentication,
  825. resource,
  826. fetchFn
  827. });
  828. // Preserve original refresh token if server didn't return a new one
  829. return { refresh_token: refreshToken, ...tokens };
  830. }
  831. /**
  832. * Unified token fetching that works with any grant type via provider.prepareTokenRequest().
  833. *
  834. * This function provides a single entry point for obtaining tokens regardless of the
  835. * OAuth grant type. The provider's prepareTokenRequest() method determines which grant
  836. * to use and supplies the grant-specific parameters.
  837. *
  838. * @param provider - OAuth client provider that implements prepareTokenRequest()
  839. * @param authorizationServerUrl - The authorization server's base URL
  840. * @param options - Configuration for the token request
  841. * @returns Promise resolving to OAuth tokens
  842. * @throws {Error} When provider doesn't implement prepareTokenRequest or token fetch fails
  843. *
  844. * @example
  845. * // Provider for client_credentials:
  846. * class MyProvider implements OAuthClientProvider {
  847. * prepareTokenRequest(scope) {
  848. * const params = new URLSearchParams({ grant_type: 'client_credentials' });
  849. * if (scope) params.set('scope', scope);
  850. * return params;
  851. * }
  852. * // ... other methods
  853. * }
  854. *
  855. * const tokens = await fetchToken(provider, authServerUrl, { metadata });
  856. */
  857. export async function fetchToken(provider, authorizationServerUrl, { metadata, resource, authorizationCode, fetchFn } = {}) {
  858. const scope = provider.clientMetadata.scope;
  859. // Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
  860. let tokenRequestParams;
  861. if (provider.prepareTokenRequest) {
  862. tokenRequestParams = await provider.prepareTokenRequest(scope);
  863. }
  864. // Default to authorization_code grant if no custom prepareTokenRequest
  865. if (!tokenRequestParams) {
  866. if (!authorizationCode) {
  867. throw new Error('Either provider.prepareTokenRequest() or authorizationCode is required');
  868. }
  869. if (!provider.redirectUrl) {
  870. throw new Error('redirectUrl is required for authorization_code flow');
  871. }
  872. const codeVerifier = await provider.codeVerifier();
  873. tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl);
  874. }
  875. const clientInformation = await provider.clientInformation();
  876. return executeTokenRequest(authorizationServerUrl, {
  877. metadata,
  878. tokenRequestParams,
  879. clientInformation: clientInformation ?? undefined,
  880. addClientAuthentication: provider.addClientAuthentication,
  881. resource,
  882. fetchFn
  883. });
  884. }
  885. /**
  886. * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
  887. *
  888. * If `scope` is provided, it overrides `clientMetadata.scope` in the registration
  889. * request body. This allows callers to apply the Scope Selection Strategy (SEP-835)
  890. * consistently across both DCR and the subsequent authorization request.
  891. */
  892. export async function registerClient(authorizationServerUrl, { metadata, clientMetadata, scope, fetchFn }) {
  893. let registrationUrl;
  894. if (metadata) {
  895. if (!metadata.registration_endpoint) {
  896. throw new Error('Incompatible auth server: does not support dynamic client registration');
  897. }
  898. registrationUrl = new URL(metadata.registration_endpoint);
  899. }
  900. else {
  901. registrationUrl = new URL('/register', authorizationServerUrl);
  902. }
  903. const response = await (fetchFn ?? fetch)(registrationUrl, {
  904. method: 'POST',
  905. headers: {
  906. 'Content-Type': 'application/json'
  907. },
  908. body: JSON.stringify({
  909. ...clientMetadata,
  910. ...(scope !== undefined ? { scope } : {})
  911. })
  912. });
  913. if (!response.ok) {
  914. throw await parseErrorResponse(response);
  915. }
  916. return OAuthClientInformationFullSchema.parse(await response.json());
  917. }
  918. //# sourceMappingURL=auth.js.map