source dump of claude code
at main 2465 lines 89 kB view raw
1import { 2 discoverAuthorizationServerMetadata, 3 discoverOAuthServerInfo, 4 type OAuthClientProvider, 5 type OAuthDiscoveryState, 6 auth as sdkAuth, 7 refreshAuthorization as sdkRefreshAuthorization, 8} from '@modelcontextprotocol/sdk/client/auth.js' 9import { 10 InvalidGrantError, 11 OAuthError, 12 ServerError, 13 TemporarilyUnavailableError, 14 TooManyRequestsError, 15} from '@modelcontextprotocol/sdk/server/auth/errors.js' 16import { 17 type AuthorizationServerMetadata, 18 type OAuthClientInformation, 19 type OAuthClientInformationFull, 20 type OAuthClientMetadata, 21 OAuthErrorResponseSchema, 22 OAuthMetadataSchema, 23 type OAuthTokens, 24 OAuthTokensSchema, 25} from '@modelcontextprotocol/sdk/shared/auth.js' 26import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' 27import axios from 'axios' 28import { createHash, randomBytes, randomUUID } from 'crypto' 29import { mkdir } from 'fs/promises' 30import { createServer, type Server } from 'http' 31import { join } from 'path' 32import { parse } from 'url' 33import xss from 'xss' 34import { MCP_CLIENT_METADATA_URL } from '../../constants/oauth.js' 35import { openBrowser } from '../../utils/browser.js' 36import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 37import { errorMessage, getErrnoCode } from '../../utils/errors.js' 38import * as lockfile from '../../utils/lockfile.js' 39import { logMCPDebug } from '../../utils/log.js' 40import { getPlatform } from '../../utils/platform.js' 41import { getSecureStorage } from '../../utils/secureStorage/index.js' 42import { clearKeychainCache } from '../../utils/secureStorage/macOsKeychainHelpers.js' 43import type { SecureStorageData } from '../../utils/secureStorage/types.js' 44import { sleep } from '../../utils/sleep.js' 45import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 46import { logEvent } from '../analytics/index.js' 47import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../analytics/metadata.js' 48import { buildRedirectUri, findAvailablePort } from './oauthPort.js' 49import type { McpHTTPServerConfig, McpSSEServerConfig } from './types.js' 50import { getLoggingSafeMcpBaseUrl } from './utils.js' 51import { performCrossAppAccess, XaaTokenExchangeError } from './xaa.js' 52import { 53 acquireIdpIdToken, 54 clearIdpIdToken, 55 discoverOidc, 56 getCachedIdpIdToken, 57 getIdpClientSecret, 58 getXaaIdpSettings, 59 isXaaEnabled, 60} from './xaaIdpLogin.js' 61 62/** 63 * Timeout for individual OAuth requests (metadata discovery, token refresh, etc.) 64 */ 65const AUTH_REQUEST_TIMEOUT_MS = 30000 66 67/** 68 * Failure reasons for the `tengu_mcp_oauth_refresh_failure` event. Values 69 * are emitted to analytics — keep them stable (do not rename; add new ones). 70 */ 71type MCPRefreshFailureReason = 72 | 'metadata_discovery_failed' 73 | 'no_client_info' 74 | 'no_tokens_returned' 75 | 'invalid_grant' 76 | 'transient_retries_exhausted' 77 | 'request_failed' 78 79/** 80 * Failure reasons for the `tengu_mcp_oauth_flow_error` event. Values are 81 * emitted to analytics for attribution in BigQuery. Keep stable (do not 82 * rename; add new ones). 83 */ 84type MCPOAuthFlowErrorReason = 85 | 'cancelled' 86 | 'timeout' 87 | 'provider_denied' 88 | 'state_mismatch' 89 | 'port_unavailable' 90 | 'sdk_auth_failed' 91 | 'token_exchange_failed' 92 | 'unknown' 93 94const MAX_LOCK_RETRIES = 5 95 96/** 97 * OAuth query parameters that should be redacted from logs. 98 * These contain sensitive values that could enable CSRF or session fixation attacks. 99 */ 100const SENSITIVE_OAUTH_PARAMS = [ 101 'state', 102 'nonce', 103 'code_challenge', 104 'code_verifier', 105 'code', 106] 107 108/** 109 * Redacts sensitive OAuth query parameters from a URL for safe logging. 110 * Prevents exposure of state, nonce, code_challenge, code_verifier, and authorization codes. 111 */ 112function redactSensitiveUrlParams(url: string): string { 113 try { 114 const parsedUrl = new URL(url) 115 for (const param of SENSITIVE_OAUTH_PARAMS) { 116 if (parsedUrl.searchParams.has(param)) { 117 parsedUrl.searchParams.set(param, '[REDACTED]') 118 } 119 } 120 return parsedUrl.toString() 121 } catch { 122 // Return as-is if not a valid URL 123 return url 124 } 125} 126 127/** 128 * Some OAuth servers (notably Slack) return HTTP 200 for all responses, 129 * signaling errors via the JSON body instead. The SDK's executeTokenRequest 130 * only calls parseErrorResponse when !response.ok, so a 200 with 131 * {"error":"invalid_grant"} gets fed to OAuthTokensSchema.parse() and 132 * surfaces as a ZodError — which the refresh retry/invalidation logic 133 * treats as opaque request_failed instead of invalid_grant. 134 * 135 * This wrapper peeks at 2xx POST response bodies and rewrites ones that 136 * match OAuthErrorResponseSchema (but not OAuthTokensSchema) to a 400 137 * Response, so the SDK's normal error-class mapping applies. The same 138 * fetchFn is also used for DCR POSTs, but DCR success responses have no 139 * {error: string} field so they don't match the rewrite condition. 140 * 141 * Slack uses non-standard error codes (invalid_refresh_token observed live 142 * at oauth.v2.user.access; expired_refresh_token/token_expired per Slack's 143 * token rotation docs) where RFC 6749 specifies invalid_grant. We normalize 144 * those so OAUTH_ERRORS['invalid_grant'] → InvalidGrantError matches and 145 * token invalidation fires correctly. 146 */ 147const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([ 148 'invalid_refresh_token', 149 'expired_refresh_token', 150 'token_expired', 151]) 152 153/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins -- 154 * Response has been stable in Node since 18; the rule flags it as 155 * experimental-until-21 which is incorrect. Pattern matches existing 156 * createAuthFetch suppressions in this file. */ 157export async function normalizeOAuthErrorBody( 158 response: Response, 159): Promise<Response> { 160 if (!response.ok) { 161 return response 162 } 163 const text = await response.text() 164 let parsed: unknown 165 try { 166 parsed = jsonParse(text) 167 } catch { 168 return new Response(text, response) 169 } 170 if (OAuthTokensSchema.safeParse(parsed).success) { 171 return new Response(text, response) 172 } 173 const result = OAuthErrorResponseSchema.safeParse(parsed) 174 if (!result.success) { 175 return new Response(text, response) 176 } 177 const normalized = NONSTANDARD_INVALID_GRANT_ALIASES.has(result.data.error) 178 ? { 179 error: 'invalid_grant', 180 error_description: 181 result.data.error_description ?? 182 `Server returned non-standard error code: ${result.data.error}`, 183 } 184 : result.data 185 return new Response(jsonStringify(normalized), { 186 status: 400, 187 statusText: 'Bad Request', 188 headers: response.headers, 189 }) 190} 191/* eslint-enable eslint-plugin-n/no-unsupported-features/node-builtins */ 192 193/** 194 * Creates a fetch function with a fresh 30-second timeout for each OAuth request. 195 * Used by ClaudeAuthProvider for metadata discovery and token refresh. 196 * Prevents stale timeout signals from affecting auth operations. 197 */ 198function createAuthFetch(): FetchLike { 199 return async (url: string | URL, init?: RequestInit) => { 200 const timeoutSignal = AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS) 201 const isPost = init?.method?.toUpperCase() === 'POST' 202 203 // No existing signal - just use timeout 204 if (!init?.signal) { 205 // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins 206 const response = await fetch(url, { ...init, signal: timeoutSignal }) 207 return isPost ? normalizeOAuthErrorBody(response) : response 208 } 209 210 // Combine signals: abort when either fires 211 const controller = new AbortController() 212 const abort = () => controller.abort() 213 214 init.signal.addEventListener('abort', abort) 215 timeoutSignal.addEventListener('abort', abort) 216 217 // Cleanup to prevent event listener leaks after fetch completes 218 const cleanup = () => { 219 init.signal?.removeEventListener('abort', abort) 220 timeoutSignal.removeEventListener('abort', abort) 221 } 222 223 if (init.signal.aborted) { 224 controller.abort() 225 } 226 227 try { 228 // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins 229 const response = await fetch(url, { ...init, signal: controller.signal }) 230 cleanup() 231 return isPost ? normalizeOAuthErrorBody(response) : response 232 } catch (error) { 233 cleanup() 234 throw error 235 } 236 } 237} 238 239/** 240 * Fetches authorization server metadata, using a configured metadata URL if available, 241 * otherwise performing RFC 9728 → RFC 8414 discovery via the SDK. 242 * 243 * Discovery order when no configured URL: 244 * 1. RFC 9728: probe /.well-known/oauth-protected-resource on the MCP server, 245 * read authorization_servers[0], then RFC 8414 against that URL. 246 * 2. Fallback: RFC 8414 directly against the MCP server URL (path-aware). Covers 247 * legacy servers that co-host auth metadata at /.well-known/oauth-authorization-server/{path} 248 * without implementing RFC 9728. The SDK's own fallback strips the path, so this 249 * preserves the pre-existing path-aware probe for backward compatibility. 250 * 251 * Note: configuredMetadataUrl is user-controlled via .mcp.json. Project-scoped MCP 252 * servers require user approval before connecting (same trust level as the MCP server 253 * URL itself). The HTTPS requirement here is defense-in-depth beyond schema validation 254 * — RFC 8414 mandates OAuth metadata retrieval over TLS. 255 */ 256async function fetchAuthServerMetadata( 257 serverName: string, 258 serverUrl: string, 259 configuredMetadataUrl: string | undefined, 260 fetchFn?: FetchLike, 261 resourceMetadataUrl?: URL, 262): Promise<Awaited<ReturnType<typeof discoverAuthorizationServerMetadata>>> { 263 if (configuredMetadataUrl) { 264 if (!configuredMetadataUrl.startsWith('https://')) { 265 throw new Error( 266 `authServerMetadataUrl must use https:// (got: ${configuredMetadataUrl})`, 267 ) 268 } 269 const authFetch = fetchFn ?? createAuthFetch() 270 const response = await authFetch(configuredMetadataUrl, { 271 headers: { Accept: 'application/json' }, 272 }) 273 if (response.ok) { 274 return OAuthMetadataSchema.parse(await response.json()) 275 } 276 throw new Error( 277 `HTTP ${response.status} fetching configured auth server metadata from ${configuredMetadataUrl}`, 278 ) 279 } 280 281 try { 282 const { authorizationServerMetadata } = await discoverOAuthServerInfo( 283 serverUrl, 284 { 285 ...(fetchFn && { fetchFn }), 286 ...(resourceMetadataUrl && { resourceMetadataUrl }), 287 }, 288 ) 289 if (authorizationServerMetadata) { 290 return authorizationServerMetadata 291 } 292 } catch (err) { 293 // Any error from the RFC 9728 → RFC 8414 chain (5xx from the root or 294 // resolved-AS probe, schema parse failure, network error) — fall through 295 // to the legacy path-aware retry. 296 logMCPDebug( 297 serverName, 298 `RFC 9728 discovery failed, falling back: ${errorMessage(err)}`, 299 ) 300 } 301 302 // Fallback only when the URL has a path component; for root URLs the SDK's 303 // own fallback already probed the same endpoints. 304 const url = new URL(serverUrl) 305 if (url.pathname === '/') { 306 return undefined 307 } 308 return discoverAuthorizationServerMetadata(url, { 309 ...(fetchFn && { fetchFn }), 310 }) 311} 312 313export class AuthenticationCancelledError extends Error { 314 constructor() { 315 super('Authentication was cancelled') 316 this.name = 'AuthenticationCancelledError' 317 } 318} 319 320/** 321 * Generates a unique key for server credentials based on both name and config hash 322 * This prevents credentials from being reused across different servers 323 * with the same name or different configurations 324 */ 325export function getServerKey( 326 serverName: string, 327 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 328): string { 329 const configJson = jsonStringify({ 330 type: serverConfig.type, 331 url: serverConfig.url, 332 headers: serverConfig.headers || {}, 333 }) 334 335 const hash = createHash('sha256') 336 .update(configJson) 337 .digest('hex') 338 .substring(0, 16) 339 340 return `${serverName}|${hash}` 341} 342 343/** 344 * True when we have probed this server before (OAuth discovery state is 345 * stored) but hold no credentials to try. A connection attempt in this 346 * state is guaranteed to 401 — the only way out is the user running 347 * /mcp to authenticate. 348 */ 349export function hasMcpDiscoveryButNoToken( 350 serverName: string, 351 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 352): boolean { 353 // XAA servers can silently re-auth via cached id_token even without an 354 // access/refresh token — tokens() fires the xaaRefresh path. Skipping the 355 // connection here would make that auto-auth branch unreachable after 356 // invalidateCredentials('tokens') clears the stored tokens. 357 if (isXaaEnabled() && serverConfig.oauth?.xaa) { 358 return false 359 } 360 const serverKey = getServerKey(serverName, serverConfig) 361 const entry = getSecureStorage().read()?.mcpOAuth?.[serverKey] 362 return entry !== undefined && !entry.accessToken && !entry.refreshToken 363} 364 365/** 366 * Revokes a single token on the OAuth server. 367 * 368 * Per RFC 7009, public clients (like Claude Code) should authenticate by including 369 * client_id in the request body, NOT via an Authorization header. The Bearer token 370 * in an Authorization header is meant for resource owner authentication, not client 371 * authentication. 372 * 373 * However, the MCP spec doesn't explicitly define token revocation behavior, so some 374 * servers may not be RFC 7009 compliant. As defensive programming, we: 375 * 1. First try the RFC 7009 compliant approach (client_id in body, no Authorization header) 376 * 2. If we get a 401, retry with Bearer auth as a fallback for non-compliant servers 377 * 378 * This fallback should rarely be needed - most servers either accept the compliant 379 * approach or ignore unexpected headers. 380 */ 381async function revokeToken({ 382 serverName, 383 endpoint, 384 token, 385 tokenTypeHint, 386 clientId, 387 clientSecret, 388 accessToken, 389 authMethod = 'client_secret_basic', 390}: { 391 serverName: string 392 endpoint: string 393 token: string 394 tokenTypeHint: 'access_token' | 'refresh_token' 395 clientId?: string 396 clientSecret?: string 397 accessToken?: string 398 authMethod?: 'client_secret_basic' | 'client_secret_post' 399}): Promise<void> { 400 const params = new URLSearchParams() 401 params.set('token', token) 402 params.set('token_type_hint', tokenTypeHint) 403 404 const headers: Record<string, string> = { 405 'Content-Type': 'application/x-www-form-urlencoded', 406 } 407 408 // RFC 7009 §2.1 requires client auth per RFC 6749 §2.3. XAA always uses a 409 // confidential client at the AS — strict ASes (Okta/Stytch) reject public- 410 // client revocation of confidential-client tokens. 411 if (clientId && clientSecret) { 412 if (authMethod === 'client_secret_post') { 413 params.set('client_id', clientId) 414 params.set('client_secret', clientSecret) 415 } else { 416 const basic = Buffer.from( 417 `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`, 418 ).toString('base64') 419 headers.Authorization = `Basic ${basic}` 420 } 421 } else if (clientId) { 422 params.set('client_id', clientId) 423 } else { 424 logMCPDebug( 425 serverName, 426 `No client_id available for ${tokenTypeHint} revocation - server may reject`, 427 ) 428 } 429 430 try { 431 await axios.post(endpoint, params, { headers }) 432 logMCPDebug(serverName, `Successfully revoked ${tokenTypeHint}`) 433 } catch (error: unknown) { 434 // Fallback for non-RFC-7009-compliant servers that require Bearer auth 435 if ( 436 axios.isAxiosError(error) && 437 error.response?.status === 401 && 438 accessToken 439 ) { 440 logMCPDebug( 441 serverName, 442 `Got 401, retrying ${tokenTypeHint} revocation with Bearer auth`, 443 ) 444 // RFC 6749 §2.3.1: must not send more than one auth method. The retry 445 // switches to Bearer — clear any client creds from the body. 446 params.delete('client_id') 447 params.delete('client_secret') 448 await axios.post(endpoint, params, { 449 headers: { ...headers, Authorization: `Bearer ${accessToken}` }, 450 }) 451 logMCPDebug( 452 serverName, 453 `Successfully revoked ${tokenTypeHint} with Bearer auth`, 454 ) 455 } else { 456 throw error 457 } 458 } 459} 460 461/** 462 * Revokes tokens on the OAuth server if a revocation endpoint is available. 463 * Per RFC 7009, we revoke the refresh token first (the long-lived credential), 464 * then the access token. Revoking the refresh token prevents generation of new 465 * access tokens and many servers implicitly invalidate associated access tokens. 466 */ 467export async function revokeServerTokens( 468 serverName: string, 469 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 470 { preserveStepUpState = false }: { preserveStepUpState?: boolean } = {}, 471): Promise<void> { 472 const storage = getSecureStorage() 473 const existingData = storage.read() 474 if (!existingData?.mcpOAuth) return 475 476 const serverKey = getServerKey(serverName, serverConfig) 477 const tokenData = existingData.mcpOAuth[serverKey] 478 479 // Attempt server-side revocation if there are tokens to revoke (best-effort) 480 if (tokenData?.accessToken || tokenData?.refreshToken) { 481 try { 482 // For XAA (and any PRM-discovered auth), the AS is at a different host 483 // than the MCP URL — use the persisted discoveryState if we have it. 484 const asUrl = 485 tokenData.discoveryState?.authorizationServerUrl ?? serverConfig.url 486 const metadata = await fetchAuthServerMetadata( 487 serverName, 488 asUrl, 489 serverConfig.oauth?.authServerMetadataUrl, 490 ) 491 492 if (!metadata) { 493 logMCPDebug(serverName, 'No OAuth metadata found') 494 } else { 495 const revocationEndpoint = 496 'revocation_endpoint' in metadata 497 ? metadata.revocation_endpoint 498 : null 499 if (!revocationEndpoint) { 500 logMCPDebug(serverName, 'Server does not support token revocation') 501 } else { 502 const revocationEndpointStr = String(revocationEndpoint) 503 // RFC 7009 defines revocation_endpoint_auth_methods_supported 504 // separately from the token endpoint's list; prefer it if present. 505 const authMethods = 506 ('revocation_endpoint_auth_methods_supported' in metadata 507 ? metadata.revocation_endpoint_auth_methods_supported 508 : undefined) ?? 509 ('token_endpoint_auth_methods_supported' in metadata 510 ? metadata.token_endpoint_auth_methods_supported 511 : undefined) 512 const authMethod: 'client_secret_basic' | 'client_secret_post' = 513 authMethods && 514 !authMethods.includes('client_secret_basic') && 515 authMethods.includes('client_secret_post') 516 ? 'client_secret_post' 517 : 'client_secret_basic' 518 logMCPDebug( 519 serverName, 520 `Revoking tokens via ${revocationEndpointStr} (${authMethod})`, 521 ) 522 523 // Revoke refresh token first (more important - prevents future access token generation) 524 if (tokenData.refreshToken) { 525 try { 526 await revokeToken({ 527 serverName, 528 endpoint: revocationEndpointStr, 529 token: tokenData.refreshToken, 530 tokenTypeHint: 'refresh_token', 531 clientId: tokenData.clientId, 532 clientSecret: tokenData.clientSecret, 533 accessToken: tokenData.accessToken, 534 authMethod, 535 }) 536 } catch (error: unknown) { 537 // Log but continue 538 logMCPDebug( 539 serverName, 540 `Failed to revoke refresh token: ${errorMessage(error)}`, 541 ) 542 } 543 } 544 545 // Then revoke access token (may already be invalidated by refresh token revocation) 546 if (tokenData.accessToken) { 547 try { 548 await revokeToken({ 549 serverName, 550 endpoint: revocationEndpointStr, 551 token: tokenData.accessToken, 552 tokenTypeHint: 'access_token', 553 clientId: tokenData.clientId, 554 clientSecret: tokenData.clientSecret, 555 accessToken: tokenData.accessToken, 556 authMethod, 557 }) 558 } catch (error: unknown) { 559 logMCPDebug( 560 serverName, 561 `Failed to revoke access token: ${errorMessage(error)}`, 562 ) 563 } 564 } 565 } 566 } 567 } catch (error: unknown) { 568 // Log error but don't throw - revocation is best-effort 569 logMCPDebug(serverName, `Failed to revoke tokens: ${errorMessage(error)}`) 570 } 571 } else { 572 logMCPDebug(serverName, 'No tokens to revoke') 573 } 574 575 // Always clear local tokens, regardless of server-side revocation result. 576 clearServerTokensFromLocalStorage(serverName, serverConfig) 577 578 // When re-authenticating, preserve step-up auth state (scope + discovery) 579 // so the next performMCPOAuthFlow can use cached scope instead of 580 // re-probing. For "Clear Auth" (default), wipe everything. 581 if ( 582 preserveStepUpState && 583 tokenData && 584 (tokenData.stepUpScope || tokenData.discoveryState) 585 ) { 586 const freshData = storage.read() || {} 587 const updatedData: SecureStorageData = { 588 ...freshData, 589 mcpOAuth: { 590 ...freshData.mcpOAuth, 591 [serverKey]: { 592 ...freshData.mcpOAuth?.[serverKey], 593 serverName, 594 serverUrl: serverConfig.url, 595 accessToken: freshData.mcpOAuth?.[serverKey]?.accessToken ?? '', 596 expiresAt: freshData.mcpOAuth?.[serverKey]?.expiresAt ?? 0, 597 ...(tokenData.stepUpScope 598 ? { stepUpScope: tokenData.stepUpScope } 599 : {}), 600 ...(tokenData.discoveryState 601 ? { 602 // Strip legacy bulky metadata fields here too so users with 603 // existing overflowed blobs recover on next re-auth (#30337). 604 discoveryState: { 605 authorizationServerUrl: 606 tokenData.discoveryState.authorizationServerUrl, 607 resourceMetadataUrl: 608 tokenData.discoveryState.resourceMetadataUrl, 609 }, 610 } 611 : {}), 612 }, 613 }, 614 } 615 storage.update(updatedData) 616 logMCPDebug(serverName, 'Preserved step-up auth state across revocation') 617 } 618} 619 620export function clearServerTokensFromLocalStorage( 621 serverName: string, 622 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 623): void { 624 const storage = getSecureStorage() 625 const existingData = storage.read() 626 if (!existingData?.mcpOAuth) return 627 628 const serverKey = getServerKey(serverName, serverConfig) 629 if (existingData.mcpOAuth[serverKey]) { 630 delete existingData.mcpOAuth[serverKey] 631 storage.update(existingData) 632 logMCPDebug(serverName, 'Cleared stored tokens') 633 } 634} 635 636type WWWAuthenticateParams = { 637 scope?: string 638 resourceMetadataUrl?: URL 639} 640 641type XaaFailureStage = 642 | 'idp_login' 643 | 'discovery' 644 | 'token_exchange' 645 | 'jwt_bearer' 646 647/** 648 * XAA (Cross-App Access) auth. 649 * 650 * One IdP browser login is reused across all XAA-configured MCP servers: 651 * 1. Acquire an id_token from the IdP (cached in keychain by issuer; if 652 * missing/expired, runs a standard OIDC authorization_code+PKCE flow 653 * — this is the one browser pop) 654 * 2. Run the RFC 8693 + RFC 7523 exchange (no browser) 655 * 3. Save tokens to the same keychain slot as normal OAuth 656 * 657 * IdP connection details come from settings.xaaIdp (configured once via 658 * `claude mcp xaa setup`). Per-server config is just `oauth.xaa: true` 659 * plus the AS clientId/clientSecret. 660 * 661 * No silent fallback: if `oauth.xaa` is set, XAA is the only path. 662 * All errors are actionable — they tell the user what to run. 663 */ 664async function performMCPXaaAuth( 665 serverName: string, 666 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 667 onAuthorizationUrl: (url: string) => void, 668 abortSignal?: AbortSignal, 669 skipBrowserOpen?: boolean, 670): Promise<void> { 671 if (!serverConfig.oauth?.xaa) { 672 throw new Error('XAA: oauth.xaa must be set') // guarded by caller 673 } 674 675 // IdP config comes from user-level settings, not per-server. 676 const idp = getXaaIdpSettings() 677 if (!idp) { 678 throw new Error( 679 "XAA: no IdP connection configured. Run 'claude mcp xaa setup --issuer <url> --client-id <id> --client-secret' to configure.", 680 ) 681 } 682 683 const clientId = serverConfig.oauth?.clientId 684 if (!clientId) { 685 throw new Error( 686 `XAA: server '${serverName}' needs an AS client_id. Re-add with --client-id.`, 687 ) 688 } 689 690 const clientConfig = getMcpClientConfig(serverName, serverConfig) 691 const clientSecret = clientConfig?.clientSecret 692 if (!clientSecret) { 693 // Diagnostic context for serverKey mismatch debugging. Only computed 694 // on the error path so there's no perf cost on success. 695 const wantedKey = getServerKey(serverName, serverConfig) 696 const haveKeys = Object.keys( 697 getSecureStorage().read()?.mcpOAuthClientConfig ?? {}, 698 ) 699 const headersForLogging = Object.fromEntries( 700 Object.entries(serverConfig.headers ?? {}).map(([k, v]) => 701 k.toLowerCase() === 'authorization' ? [k, '[REDACTED]'] : [k, v], 702 ), 703 ) 704 logMCPDebug( 705 serverName, 706 `XAA: secret lookup miss. wanted=${wantedKey} have=[${haveKeys.join(', ')}] configHeaders=${jsonStringify(headersForLogging)}`, 707 ) 708 throw new Error( 709 `XAA: AS client secret not found for '${serverName}'. Re-add with --client-secret.`, 710 ) 711 } 712 713 logMCPDebug(serverName, 'XAA: starting cross-app access flow') 714 715 // IdP client secret lives in a separate keychain slot (keyed by IdP issuer), 716 // NOT the AS secret — different trust domain. Optional: if absent, PKCE-only. 717 const idpClientSecret = getIdpClientSecret(idp.issuer) 718 719 // Acquire id_token (cached or via one OIDC browser pop at the IdP). 720 // Peek the cache first so we can report idTokenCacheHit in analytics before 721 // acquireIdpIdToken potentially writes a fresh one. 722 const idTokenCacheHit = getCachedIdpIdToken(idp.issuer) !== undefined 723 724 let failureStage: XaaFailureStage = 'idp_login' 725 try { 726 let idToken 727 try { 728 idToken = await acquireIdpIdToken({ 729 idpIssuer: idp.issuer, 730 idpClientId: idp.clientId, 731 idpClientSecret, 732 callbackPort: idp.callbackPort, 733 onAuthorizationUrl, 734 skipBrowserOpen, 735 abortSignal, 736 }) 737 } catch (e) { 738 if (abortSignal?.aborted) throw new AuthenticationCancelledError() 739 throw e 740 } 741 742 // Discover the IdP's token endpoint for the RFC 8693 exchange. 743 failureStage = 'discovery' 744 const oidc = await discoverOidc(idp.issuer) 745 746 // Run the exchange. performCrossAppAccess throws XaaTokenExchangeError 747 // for the IdP leg and "jwt-bearer grant failed" for the AS leg. 748 failureStage = 'token_exchange' 749 let tokens 750 try { 751 tokens = await performCrossAppAccess( 752 serverConfig.url, 753 { 754 clientId, 755 clientSecret, 756 idpClientId: idp.clientId, 757 idpClientSecret, 758 idpIdToken: idToken, 759 idpTokenEndpoint: oidc.token_endpoint, 760 }, 761 serverName, 762 abortSignal, 763 ) 764 } catch (e) { 765 if (abortSignal?.aborted) throw new AuthenticationCancelledError() 766 const msg = errorMessage(e) 767 // If the IdP says the id_token is bad, drop it from the cache so the 768 // next attempt does a fresh IdP login. XaaTokenExchangeError carries 769 // shouldClearIdToken so we key off OAuth semantics (4xx / invalid body 770 // → clear; 5xx IdP outage → preserve) rather than substring matching. 771 if (e instanceof XaaTokenExchangeError) { 772 if (e.shouldClearIdToken) { 773 clearIdpIdToken(idp.issuer) 774 logMCPDebug( 775 serverName, 776 'XAA: cleared cached id_token after token-exchange failure', 777 ) 778 } 779 } else if ( 780 msg.includes('PRM discovery failed') || 781 msg.includes('AS metadata discovery failed') || 782 msg.includes('no authorization server supports jwt-bearer') 783 ) { 784 // performCrossAppAccess runs PRM + AS discovery before the actual 785 // exchange — don't attribute their failures to 'token_exchange'. 786 failureStage = 'discovery' 787 } else if (msg.includes('jwt-bearer')) { 788 failureStage = 'jwt_bearer' 789 } 790 throw e 791 } 792 793 // Save tokens via the same storage path as normal OAuth. We write directly 794 // (instead of ClaudeAuthProvider.saveTokens) to avoid instantiating the 795 // whole provider just to write the same keys. 796 const storage = getSecureStorage() 797 const existingData = storage.read() || {} 798 const serverKey = getServerKey(serverName, serverConfig) 799 const prev = existingData.mcpOAuth?.[serverKey] 800 storage.update({ 801 ...existingData, 802 mcpOAuth: { 803 ...existingData.mcpOAuth, 804 [serverKey]: { 805 ...prev, 806 serverName, 807 serverUrl: serverConfig.url, 808 accessToken: tokens.access_token, 809 // AS may omit refresh_token on jwt-bearer — preserve any existing one 810 refreshToken: tokens.refresh_token ?? prev?.refreshToken, 811 expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 812 scope: tokens.scope, 813 clientId, 814 clientSecret, 815 // Persist the AS URL so _doRefresh and revokeServerTokens can locate 816 // the token/revocation endpoints when MCP URL ≠ AS URL (the common 817 // XAA topology). 818 discoveryState: { 819 authorizationServerUrl: tokens.authorizationServerUrl, 820 }, 821 }, 822 }, 823 }) 824 825 logMCPDebug(serverName, 'XAA: tokens saved') 826 logEvent('tengu_mcp_oauth_flow_success', { 827 authMethod: 828 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 829 idTokenCacheHit, 830 }) 831 } catch (e) { 832 // User-initiated cancel (Esc during IdP browser pop) isn't a failure. 833 if (e instanceof AuthenticationCancelledError) { 834 throw e 835 } 836 logEvent('tengu_mcp_oauth_flow_failure', { 837 authMethod: 838 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 839 xaaFailureStage: 840 failureStage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 841 idTokenCacheHit, 842 }) 843 throw e 844 } 845} 846 847export async function performMCPOAuthFlow( 848 serverName: string, 849 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 850 onAuthorizationUrl: (url: string) => void, 851 abortSignal?: AbortSignal, 852 options?: { 853 skipBrowserOpen?: boolean 854 onWaitingForCallback?: (submit: (callbackUrl: string) => void) => void 855 }, 856): Promise<void> { 857 // XAA (SEP-990): if configured, bypass the per-server consent dance. 858 // If the IdP id_token isn't cached, this pops the browser once at the IdP 859 // (shared across all XAA servers for that issuer). Subsequent servers hit 860 // the cache and are silent. Tokens land in the same keychain slot, so the 861 // rest of CC's transport wiring (ClaudeAuthProvider.tokens() in client.ts) 862 // works unchanged. 863 // 864 // No silent fallback: if `oauth.xaa` is set, XAA is the only path. We 865 // never fall through to the consent flow — that would be surprising (the 866 // user explicitly asked for XAA) and security-relevant (consent flow may 867 // have a different trust/scope posture than the org's IdP policy). 868 // 869 // Servers with `oauth.xaa` but CLAUDE_CODE_ENABLE_XAA unset hard-fail with 870 // actionable copy rather than silently degrade to consent. 871 if (serverConfig.oauth?.xaa) { 872 if (!isXaaEnabled()) { 873 throw new Error( 874 `XAA is not enabled (set CLAUDE_CODE_ENABLE_XAA=1). Remove 'oauth.xaa' from server '${serverName}' to use the standard consent flow.`, 875 ) 876 } 877 logEvent('tengu_mcp_oauth_flow_start', { 878 isOAuthFlow: true, 879 authMethod: 880 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 881 transportType: 882 serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 883 ...(getLoggingSafeMcpBaseUrl(serverConfig) 884 ? { 885 mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 886 serverConfig, 887 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 888 } 889 : {}), 890 }) 891 // performMCPXaaAuth logs its own success/failure events (with 892 // idTokenCacheHit + xaaFailureStage). 893 await performMCPXaaAuth( 894 serverName, 895 serverConfig, 896 onAuthorizationUrl, 897 abortSignal, 898 options?.skipBrowserOpen, 899 ) 900 return 901 } 902 903 // Check for cached step-up scope and resource metadata URL before clearing 904 // tokens. The transport-attached auth provider persists scope when it receives 905 // a step-up 401, so we can use it here instead of making an extra probe request. 906 const storage = getSecureStorage() 907 const serverKey = getServerKey(serverName, serverConfig) 908 const cachedEntry = storage.read()?.mcpOAuth?.[serverKey] 909 const cachedStepUpScope = cachedEntry?.stepUpScope 910 const cachedResourceMetadataUrl = 911 cachedEntry?.discoveryState?.resourceMetadataUrl 912 913 // Clear any existing stored credentials to ensure fresh client registration. 914 // Note: this deletes the entire entry (including discoveryState/stepUpScope), 915 // but we already read the cached values above. 916 clearServerTokensFromLocalStorage(serverName, serverConfig) 917 918 // Use cached step-up scope and resource metadata URL if available. 919 // The transport-attached auth provider caches these when it receives a 920 // step-up 401, so we don't need to probe the server again. 921 let resourceMetadataUrl: URL | undefined 922 if (cachedResourceMetadataUrl) { 923 try { 924 resourceMetadataUrl = new URL(cachedResourceMetadataUrl) 925 } catch { 926 logMCPDebug( 927 serverName, 928 `Invalid cached resourceMetadataUrl: ${cachedResourceMetadataUrl}`, 929 ) 930 } 931 } 932 const wwwAuthParams: WWWAuthenticateParams = { 933 scope: cachedStepUpScope, 934 resourceMetadataUrl, 935 } 936 937 const flowAttemptId = randomUUID() 938 939 logEvent('tengu_mcp_oauth_flow_start', { 940 flowAttemptId: 941 flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 942 isOAuthFlow: true, 943 transportType: 944 serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 945 ...(getLoggingSafeMcpBaseUrl(serverConfig) 946 ? { 947 mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 948 serverConfig, 949 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 950 } 951 : {}), 952 }) 953 954 // Track whether we reached the token-exchange phase so the catch block can 955 // attribute the failure reason correctly. 956 let authorizationCodeObtained = false 957 958 try { 959 // Use configured callback port for pre-configured OAuth, otherwise find an available port 960 const configuredCallbackPort = serverConfig.oauth?.callbackPort 961 const port = configuredCallbackPort ?? (await findAvailablePort()) 962 const redirectUri = buildRedirectUri(port) 963 logMCPDebug( 964 serverName, 965 `Using redirect port: ${port}${configuredCallbackPort ? ' (from config)' : ''}`, 966 ) 967 968 const provider = new ClaudeAuthProvider( 969 serverName, 970 serverConfig, 971 redirectUri, 972 true, 973 onAuthorizationUrl, 974 options?.skipBrowserOpen, 975 ) 976 977 // Fetch and store OAuth metadata for scope information 978 try { 979 const metadata = await fetchAuthServerMetadata( 980 serverName, 981 serverConfig.url, 982 serverConfig.oauth?.authServerMetadataUrl, 983 undefined, 984 wwwAuthParams.resourceMetadataUrl, 985 ) 986 if (metadata) { 987 // Store metadata in provider for scope information 988 provider.setMetadata(metadata) 989 logMCPDebug( 990 serverName, 991 `Fetched OAuth metadata with scope: ${getScopeFromMetadata(metadata) || 'NONE'}`, 992 ) 993 } 994 } catch (error) { 995 logMCPDebug( 996 serverName, 997 `Failed to fetch OAuth metadata: ${errorMessage(error)}`, 998 ) 999 } 1000 1001 // Get the OAuth state from the provider for validation 1002 const oauthState = await provider.state() 1003 1004 // Store the server, timeout, and abort listener references for cleanup 1005 let server: Server | null = null 1006 let timeoutId: NodeJS.Timeout | null = null 1007 let abortHandler: (() => void) | null = null 1008 1009 const cleanup = () => { 1010 if (server) { 1011 server.removeAllListeners() 1012 // Defensive: removeAllListeners() strips the error handler, so swallow any late error during close 1013 server.on('error', () => {}) 1014 server.close() 1015 server = null 1016 } 1017 if (timeoutId) { 1018 clearTimeout(timeoutId) 1019 timeoutId = null 1020 } 1021 if (abortSignal && abortHandler) { 1022 abortSignal.removeEventListener('abort', abortHandler) 1023 abortHandler = null 1024 } 1025 logMCPDebug(serverName, `MCP OAuth server cleaned up`) 1026 } 1027 1028 // Setup a server to receive the callback 1029 const authorizationCode = await new Promise<string>((resolve, reject) => { 1030 let resolved = false 1031 const resolveOnce = (code: string) => { 1032 if (resolved) return 1033 resolved = true 1034 resolve(code) 1035 } 1036 const rejectOnce = (error: Error) => { 1037 if (resolved) return 1038 resolved = true 1039 reject(error) 1040 } 1041 1042 if (abortSignal) { 1043 abortHandler = () => { 1044 cleanup() 1045 rejectOnce(new AuthenticationCancelledError()) 1046 } 1047 if (abortSignal.aborted) { 1048 abortHandler() 1049 return 1050 } 1051 abortSignal.addEventListener('abort', abortHandler) 1052 } 1053 1054 // Allow manual callback URL paste for remote/browser-based environments 1055 // where localhost is not reachable from the user's browser. 1056 if (options?.onWaitingForCallback) { 1057 options.onWaitingForCallback((callbackUrl: string) => { 1058 try { 1059 const parsed = new URL(callbackUrl) 1060 const code = parsed.searchParams.get('code') 1061 const state = parsed.searchParams.get('state') 1062 const error = parsed.searchParams.get('error') 1063 1064 if (error) { 1065 const errorDescription = 1066 parsed.searchParams.get('error_description') || '' 1067 cleanup() 1068 rejectOnce( 1069 new Error(`OAuth error: ${error} - ${errorDescription}`), 1070 ) 1071 return 1072 } 1073 1074 if (!code) { 1075 // Not a valid callback URL, ignore so the user can try again 1076 return 1077 } 1078 1079 if (state !== oauthState) { 1080 cleanup() 1081 rejectOnce( 1082 new Error('OAuth state mismatch - possible CSRF attack'), 1083 ) 1084 return 1085 } 1086 1087 logMCPDebug( 1088 serverName, 1089 `Received auth code via manual callback URL`, 1090 ) 1091 cleanup() 1092 resolveOnce(code) 1093 } catch { 1094 // Invalid URL, ignore so the user can try again 1095 } 1096 }) 1097 } 1098 1099 server = createServer((req, res) => { 1100 const parsedUrl = parse(req.url || '', true) 1101 1102 if (parsedUrl.pathname === '/callback') { 1103 const code = parsedUrl.query.code as string 1104 const state = parsedUrl.query.state as string 1105 const error = parsedUrl.query.error 1106 const errorDescription = parsedUrl.query.error_description as string 1107 const errorUri = parsedUrl.query.error_uri as string 1108 1109 // Validate OAuth state to prevent CSRF attacks 1110 if (!error && state !== oauthState) { 1111 res.writeHead(400, { 'Content-Type': 'text/html' }) 1112 res.end( 1113 `<h1>Authentication Error</h1><p>Invalid state parameter. Please try again.</p><p>You can close this window.</p>`, 1114 ) 1115 cleanup() 1116 rejectOnce(new Error('OAuth state mismatch - possible CSRF attack')) 1117 return 1118 } 1119 1120 if (error) { 1121 res.writeHead(200, { 'Content-Type': 'text/html' }) 1122 // Sanitize error messages to prevent XSS 1123 const sanitizedError = xss(String(error)) 1124 const sanitizedErrorDescription = errorDescription 1125 ? xss(String(errorDescription)) 1126 : '' 1127 res.end( 1128 `<h1>Authentication Error</h1><p>${sanitizedError}: ${sanitizedErrorDescription}</p><p>You can close this window.</p>`, 1129 ) 1130 cleanup() 1131 let errorMessage = `OAuth error: ${error}` 1132 if (errorDescription) { 1133 errorMessage += ` - ${errorDescription}` 1134 } 1135 if (errorUri) { 1136 errorMessage += ` (See: ${errorUri})` 1137 } 1138 rejectOnce(new Error(errorMessage)) 1139 return 1140 } 1141 1142 if (code) { 1143 res.writeHead(200, { 'Content-Type': 'text/html' }) 1144 res.end( 1145 `<h1>Authentication Successful</h1><p>You can close this window. Return to Claude Code.</p>`, 1146 ) 1147 cleanup() 1148 resolveOnce(code) 1149 } 1150 } 1151 }) 1152 1153 server.on('error', (err: NodeJS.ErrnoException) => { 1154 cleanup() 1155 if (err.code === 'EADDRINUSE') { 1156 const findCmd = 1157 getPlatform() === 'windows' 1158 ? `netstat -ano | findstr :${port}` 1159 : `lsof -ti:${port} -sTCP:LISTEN` 1160 rejectOnce( 1161 new Error( 1162 `OAuth callback port ${port} is already in use — another process may be holding it. ` + 1163 `Run \`${findCmd}\` to find it.`, 1164 ), 1165 ) 1166 } else { 1167 rejectOnce(new Error(`OAuth callback server failed: ${err.message}`)) 1168 } 1169 }) 1170 1171 server.listen(port, '127.0.0.1', async () => { 1172 try { 1173 logMCPDebug(serverName, `Starting SDK auth`) 1174 logMCPDebug(serverName, `Server URL: ${serverConfig.url}`) 1175 1176 // First call to start the auth flow - should redirect 1177 // Pass the scope and resource_metadata from WWW-Authenticate header if available 1178 const result = await sdkAuth(provider, { 1179 serverUrl: serverConfig.url, 1180 scope: wwwAuthParams.scope, 1181 resourceMetadataUrl: wwwAuthParams.resourceMetadataUrl, 1182 }) 1183 logMCPDebug(serverName, `Initial auth result: ${result}`) 1184 1185 if (result !== 'REDIRECT') { 1186 logMCPDebug( 1187 serverName, 1188 `Unexpected auth result, expected REDIRECT: ${result}`, 1189 ) 1190 } 1191 } catch (error) { 1192 logMCPDebug(serverName, `SDK auth error: ${error}`) 1193 cleanup() 1194 rejectOnce(new Error(`SDK auth failed: ${errorMessage(error)}`)) 1195 } 1196 }) 1197 1198 // Don't let the callback server or timeout pin the event loop — if the UI 1199 // component unmounts without aborting (e.g. parent intercepts Esc), we'd 1200 // rather let the process exit than stay alive for 5 minutes holding the 1201 // port. The abortSignal is the intended lifecycle management. 1202 server.unref() 1203 1204 timeoutId = setTimeout( 1205 (cleanup, rejectOnce) => { 1206 cleanup() 1207 rejectOnce(new Error('Authentication timeout')) 1208 }, 1209 5 * 60 * 1000, // 5 minutes 1210 cleanup, 1211 rejectOnce, 1212 ) 1213 timeoutId.unref() 1214 }) 1215 1216 authorizationCodeObtained = true 1217 1218 // Now complete the auth flow with the received code 1219 logMCPDebug(serverName, `Completing auth flow with authorization code`) 1220 const result = await sdkAuth(provider, { 1221 serverUrl: serverConfig.url, 1222 authorizationCode, 1223 resourceMetadataUrl: wwwAuthParams.resourceMetadataUrl, 1224 }) 1225 1226 logMCPDebug(serverName, `Auth result: ${result}`) 1227 1228 if (result === 'AUTHORIZED') { 1229 // Debug: Check if tokens were properly saved 1230 const savedTokens = await provider.tokens() 1231 logMCPDebug( 1232 serverName, 1233 `Tokens after auth: ${savedTokens ? 'Present' : 'Missing'}`, 1234 ) 1235 if (savedTokens) { 1236 logMCPDebug( 1237 serverName, 1238 `Token access_token length: ${savedTokens.access_token?.length}`, 1239 ) 1240 logMCPDebug(serverName, `Token expires_in: ${savedTokens.expires_in}`) 1241 } 1242 1243 logEvent('tengu_mcp_oauth_flow_success', { 1244 flowAttemptId: 1245 flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1246 transportType: 1247 serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1248 ...(getLoggingSafeMcpBaseUrl(serverConfig) 1249 ? { 1250 mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 1251 serverConfig, 1252 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1253 } 1254 : {}), 1255 }) 1256 } else { 1257 throw new Error('Unexpected auth result: ' + result) 1258 } 1259 } catch (error) { 1260 logMCPDebug(serverName, `Error during auth completion: ${error}`) 1261 1262 // Determine failure reason for attribution telemetry. The try block covers 1263 // port acquisition, the callback server, the redirect flow, and token 1264 // exchange. Map known failure paths to stable reason codes. 1265 let reason: MCPOAuthFlowErrorReason = 'unknown' 1266 let oauthErrorCode: string | undefined 1267 let httpStatus: number | undefined 1268 1269 if (error instanceof AuthenticationCancelledError) { 1270 reason = 'cancelled' 1271 } else if (authorizationCodeObtained) { 1272 reason = 'token_exchange_failed' 1273 } else { 1274 const msg = errorMessage(error) 1275 if (msg.includes('Authentication timeout')) { 1276 reason = 'timeout' 1277 } else if (msg.includes('OAuth state mismatch')) { 1278 reason = 'state_mismatch' 1279 } else if (msg.includes('OAuth error:')) { 1280 reason = 'provider_denied' 1281 } else if ( 1282 msg.includes('already in use') || 1283 msg.includes('EADDRINUSE') || 1284 msg.includes('callback server failed') || 1285 msg.includes('No available port') 1286 ) { 1287 reason = 'port_unavailable' 1288 } else if (msg.includes('SDK auth failed')) { 1289 reason = 'sdk_auth_failed' 1290 } 1291 } 1292 1293 // sdkAuth uses native fetch and throws OAuthError subclasses (InvalidGrantError, 1294 // ServerError, InvalidClientError, etc.) via parseErrorResponse. Extract the 1295 // OAuth error code directly from the SDK error instance. 1296 if (error instanceof OAuthError) { 1297 oauthErrorCode = error.errorCode 1298 // SDK does not attach HTTP status as a property, but the fallback ServerError 1299 // embeds it in the message as "HTTP {status}:" when the response body was 1300 // unparseable. Best-effort extraction. 1301 const statusMatch = error.message.match(/^HTTP (\d{3}):/) 1302 if (statusMatch) { 1303 httpStatus = Number(statusMatch[1]) 1304 } 1305 // If client not found, clear the stored client ID and suggest retry 1306 if ( 1307 error.errorCode === 'invalid_client' && 1308 error.message.includes('Client not found') 1309 ) { 1310 const storage = getSecureStorage() 1311 const existingData = storage.read() || {} 1312 const serverKey = getServerKey(serverName, serverConfig) 1313 if (existingData.mcpOAuth?.[serverKey]) { 1314 delete existingData.mcpOAuth[serverKey].clientId 1315 delete existingData.mcpOAuth[serverKey].clientSecret 1316 storage.update(existingData) 1317 } 1318 } 1319 } 1320 1321 logEvent('tengu_mcp_oauth_flow_error', { 1322 flowAttemptId: 1323 flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1324 reason: 1325 reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1326 error_code: 1327 oauthErrorCode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1328 http_status: 1329 httpStatus?.toString() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1330 transportType: 1331 serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1332 ...(getLoggingSafeMcpBaseUrl(serverConfig) 1333 ? { 1334 mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 1335 serverConfig, 1336 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1337 } 1338 : {}), 1339 }) 1340 throw error 1341 } 1342} 1343 1344/** 1345 * Wraps fetch to detect 403 insufficient_scope responses and mark step-up 1346 * pending on the provider BEFORE the SDK's 403 handler calls auth(). Without 1347 * this, the SDK's authInternal sees refresh_token → refreshes (uselessly, since 1348 * RFC 6749 §6 forbids scope elevation via refresh) → returns 'AUTHORIZED' → 1349 * retry → 403 again → aborts with "Server returned 403 after trying upscoping", 1350 * never reaching redirectToAuthorization where step-up scope is persisted. 1351 * With this flag set, tokens() omits refresh_token so the SDK falls through 1352 * to the PKCE flow. See github.com/anthropics/claude-code/issues/28258. 1353 */ 1354export function wrapFetchWithStepUpDetection( 1355 baseFetch: FetchLike, 1356 provider: ClaudeAuthProvider, 1357): FetchLike { 1358 return async (url, init) => { 1359 const response = await baseFetch(url, init) 1360 if (response.status === 403) { 1361 const wwwAuth = response.headers.get('WWW-Authenticate') 1362 if (wwwAuth?.includes('insufficient_scope')) { 1363 // Match both quoted and unquoted values (RFC 6750 §3 allows either). 1364 // Same pattern as the SDK's extractFieldFromWwwAuth. 1365 const match = wwwAuth.match(/scope=(?:"([^"]+)"|([^\s,]+))/) 1366 const scope = match?.[1] ?? match?.[2] 1367 if (scope) { 1368 provider.markStepUpPending(scope) 1369 } 1370 } 1371 } 1372 return response 1373 } 1374} 1375 1376export class ClaudeAuthProvider implements OAuthClientProvider { 1377 private serverName: string 1378 private serverConfig: McpSSEServerConfig | McpHTTPServerConfig 1379 private redirectUri: string 1380 private handleRedirection: boolean 1381 private _codeVerifier?: string 1382 private _authorizationUrl?: string 1383 private _state?: string 1384 private _scopes?: string 1385 private _metadata?: Awaited< 1386 ReturnType<typeof discoverAuthorizationServerMetadata> 1387 > 1388 private _refreshInProgress?: Promise<OAuthTokens | undefined> 1389 private _pendingStepUpScope?: string 1390 private onAuthorizationUrlCallback?: (url: string) => void 1391 private skipBrowserOpen: boolean 1392 1393 constructor( 1394 serverName: string, 1395 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 1396 redirectUri: string = buildRedirectUri(), 1397 handleRedirection = false, 1398 onAuthorizationUrl?: (url: string) => void, 1399 skipBrowserOpen?: boolean, 1400 ) { 1401 this.serverName = serverName 1402 this.serverConfig = serverConfig 1403 this.redirectUri = redirectUri 1404 this.handleRedirection = handleRedirection 1405 this.onAuthorizationUrlCallback = onAuthorizationUrl 1406 this.skipBrowserOpen = skipBrowserOpen ?? false 1407 } 1408 1409 get redirectUrl(): string { 1410 return this.redirectUri 1411 } 1412 1413 get authorizationUrl(): string | undefined { 1414 return this._authorizationUrl 1415 } 1416 1417 get clientMetadata(): OAuthClientMetadata { 1418 const metadata: OAuthClientMetadata = { 1419 client_name: `Claude Code (${this.serverName})`, 1420 redirect_uris: [this.redirectUri], 1421 grant_types: ['authorization_code', 'refresh_token'], 1422 response_types: ['code'], 1423 token_endpoint_auth_method: 'none', // Public client 1424 } 1425 1426 // Include scope from metadata if available 1427 const metadataScope = getScopeFromMetadata(this._metadata) 1428 if (metadataScope) { 1429 metadata.scope = metadataScope 1430 logMCPDebug( 1431 this.serverName, 1432 `Using scope from metadata: ${metadata.scope}`, 1433 ) 1434 } 1435 1436 return metadata 1437 } 1438 1439 /** 1440 * CIMD (SEP-991): URL-based client_id. When the auth server advertises 1441 * client_id_metadata_document_supported: true, the SDK uses this URL as the 1442 * client_id instead of performing Dynamic Client Registration. 1443 * Override via MCP_OAUTH_CLIENT_METADATA_URL env var (e.g. for testing, FedStart). 1444 */ 1445 get clientMetadataUrl(): string | undefined { 1446 const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL 1447 if (override) { 1448 logMCPDebug(this.serverName, `Using CIMD URL from env: ${override}`) 1449 return override 1450 } 1451 return MCP_CLIENT_METADATA_URL 1452 } 1453 1454 setMetadata( 1455 metadata: Awaited<ReturnType<typeof discoverAuthorizationServerMetadata>>, 1456 ): void { 1457 this._metadata = metadata 1458 } 1459 1460 /** 1461 * Called by the fetch wrapper when a 403 insufficient_scope response is 1462 * detected. Setting this causes tokens() to omit refresh_token, forcing 1463 * the SDK's authInternal to skip its (useless) refresh path and fall through 1464 * to startAuthorization → redirectToAuthorization → step-up persistence. 1465 * RFC 6749 §6 forbids scope elevation via refresh, so refreshing would just 1466 * return the same-scoped token and the retry would 403 again. 1467 */ 1468 markStepUpPending(scope: string): void { 1469 this._pendingStepUpScope = scope 1470 logMCPDebug(this.serverName, `Marked step-up pending: ${scope}`) 1471 } 1472 1473 async state(): Promise<string> { 1474 // Generate state if not already generated for this instance 1475 if (!this._state) { 1476 this._state = randomBytes(32).toString('base64url') 1477 logMCPDebug(this.serverName, 'Generated new OAuth state') 1478 } 1479 return this._state 1480 } 1481 1482 async clientInformation(): Promise<OAuthClientInformation | undefined> { 1483 const storage = getSecureStorage() 1484 const data = storage.read() 1485 const serverKey = getServerKey(this.serverName, this.serverConfig) 1486 1487 // Check session credentials first (from DCR or previous auth) 1488 const storedInfo = data?.mcpOAuth?.[serverKey] 1489 if (storedInfo?.clientId) { 1490 logMCPDebug(this.serverName, `Found client info`) 1491 return { 1492 client_id: storedInfo.clientId, 1493 client_secret: storedInfo.clientSecret, 1494 } 1495 } 1496 1497 // Fallback: pre-configured client ID from server config 1498 const configClientId = this.serverConfig.oauth?.clientId 1499 if (configClientId) { 1500 const clientConfig = data?.mcpOAuthClientConfig?.[serverKey] 1501 logMCPDebug(this.serverName, `Using pre-configured client ID`) 1502 return { 1503 client_id: configClientId, 1504 client_secret: clientConfig?.clientSecret, 1505 } 1506 } 1507 1508 // If we don't have stored client info, return undefined to trigger registration 1509 logMCPDebug(this.serverName, `No client info found`) 1510 return undefined 1511 } 1512 1513 async saveClientInformation( 1514 clientInformation: OAuthClientInformationFull, 1515 ): Promise<void> { 1516 const storage = getSecureStorage() 1517 const existingData = storage.read() || {} 1518 const serverKey = getServerKey(this.serverName, this.serverConfig) 1519 1520 const updatedData: SecureStorageData = { 1521 ...existingData, 1522 mcpOAuth: { 1523 ...existingData.mcpOAuth, 1524 [serverKey]: { 1525 ...existingData.mcpOAuth?.[serverKey], 1526 serverName: this.serverName, 1527 serverUrl: this.serverConfig.url, 1528 clientId: clientInformation.client_id, 1529 clientSecret: clientInformation.client_secret, 1530 // Provide default values for required fields if not present 1531 accessToken: existingData.mcpOAuth?.[serverKey]?.accessToken || '', 1532 expiresAt: existingData.mcpOAuth?.[serverKey]?.expiresAt || 0, 1533 }, 1534 }, 1535 } 1536 1537 storage.update(updatedData) 1538 } 1539 1540 async tokens(): Promise<OAuthTokens | undefined> { 1541 // Cross-process token changes (another CC instance refreshed or invalidated) 1542 // are picked up via the keychain cache TTL (see macOsKeychainStorage.ts). 1543 // In-process writes already invalidate the cache via storage.update(). 1544 // We do NOT clearKeychainCache() here — tokens() is called by the MCP SDK's 1545 // _commonHeaders on every request, and forcing a cache miss would trigger 1546 // a blocking spawnSync(`security find-generic-password`) 30-40x/sec. 1547 // See CPU profile: spawnSync was 7.2% of total CPU after PR #19436. 1548 const storage = getSecureStorage() 1549 const data = await storage.readAsync() 1550 const serverKey = getServerKey(this.serverName, this.serverConfig) 1551 1552 const tokenData = data?.mcpOAuth?.[serverKey] 1553 1554 // XAA: a cached id_token plays the same UX role as a refresh_token — run 1555 // the silent exchange to get a fresh access_token without a browser. The 1556 // id_token does expire (we re-acquire via `xaa login` when it does); the 1557 // point is that while it's valid, re-auth is zero-interaction. 1558 // 1559 // Only fire when we don't have a refresh_token. If the AS returned one, 1560 // the normal refresh path (below) is cheaper — 1 request vs the 4-request 1561 // XAA chain. If that refresh is revoked, refreshAuthorization() clears it 1562 // (invalidateCredentials('tokens')), and the next tokens() falls through 1563 // to here. 1564 // 1565 // Fires on: 1566 // - never authed (!tokenData) → first connect, auto-auth 1567 // - SDK partial write {accessToken:''} → stale from past session 1568 // - expired/expiring, no refresh_token → proactive XAA re-auth 1569 // 1570 // No special-casing of {accessToken:'', expiresAt:0}. Yes, SDK auth() 1571 // writes that mid-flow (saveClientInformation defaults). But with this 1572 // auto-auth branch, the *first* tokens() call — before auth() writes 1573 // anything — fires xaaRefresh. If id_token is cached, SDK short-circuits 1574 // there and never reaches the write. If id_token isn't cached, xaaRefresh 1575 // returns undefined in ~1 keychain read, auth() proceeds, writes the 1576 // marker, calls tokens() again, xaaRefresh fails again identically. 1577 // Harmless redundancy, not a wasted exchange. And guarding on `!==''` 1578 // permanently bricks auto-auth when a *prior* session left that marker 1579 // in keychain — real bug seen with xaa.dev. 1580 // 1581 // xaaRefresh() internally short-circuits to undefined when the id_token 1582 // isn't cached (or settings.xaaIdp is gone) → we fall through to the 1583 // existing needs-auth path → user runs `xaa login`. 1584 // 1585 if ( 1586 isXaaEnabled() && 1587 this.serverConfig.oauth?.xaa && 1588 !tokenData?.refreshToken && 1589 (!tokenData?.accessToken || 1590 (tokenData.expiresAt - Date.now()) / 1000 <= 300) 1591 ) { 1592 if (!this._refreshInProgress) { 1593 logMCPDebug( 1594 this.serverName, 1595 tokenData 1596 ? `XAA: access_token expiring, attempting silent exchange` 1597 : `XAA: no access_token yet, attempting silent exchange`, 1598 ) 1599 this._refreshInProgress = this.xaaRefresh().finally(() => { 1600 this._refreshInProgress = undefined 1601 }) 1602 } 1603 try { 1604 const refreshed = await this._refreshInProgress 1605 if (refreshed) return refreshed 1606 } catch (e) { 1607 logMCPDebug( 1608 this.serverName, 1609 `XAA silent exchange failed: ${errorMessage(e)}`, 1610 ) 1611 } 1612 // Fall through. Either id_token isn't cached (xaaRefresh returned 1613 // undefined) or the exchange errored. Normal path below handles both: 1614 // !tokenData → undefined → 401 → needs-auth; expired → undefined → same. 1615 } 1616 1617 if (!tokenData) { 1618 logMCPDebug(this.serverName, `No token data found`) 1619 return undefined 1620 } 1621 1622 // Check if token is expired 1623 const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 1624 1625 // Step-up check: if a 403 insufficient_scope was detected and the current 1626 // token doesn't have the requested scope, omit refresh_token below so the 1627 // SDK skips refresh and falls through to the PKCE flow. 1628 const currentScopes = tokenData.scope?.split(' ') ?? [] 1629 const needsStepUp = 1630 this._pendingStepUpScope !== undefined && 1631 this._pendingStepUpScope.split(' ').some(s => !currentScopes.includes(s)) 1632 if (needsStepUp) { 1633 logMCPDebug( 1634 this.serverName, 1635 `Step-up pending (${this._pendingStepUpScope}), omitting refresh_token`, 1636 ) 1637 } 1638 1639 // If token is expired and we don't have a refresh token, return undefined 1640 if (expiresIn <= 0 && !tokenData.refreshToken) { 1641 logMCPDebug(this.serverName, `Token expired without refresh token`) 1642 return undefined 1643 } 1644 1645 // If token is expired or about to expire (within 5 minutes) and we have a refresh token, refresh it proactively. 1646 // This proactive refresh is a UX improvement - it avoids the latency of a failed request followed by token refresh. 1647 // While MCP servers should return 401 for expired tokens (which triggers SDK-level refresh), proactively refreshing 1648 // before expiry provides a smoother user experience. 1649 // Skip when step-up is pending — refreshing can't elevate scope (RFC 6749 §6). 1650 if (expiresIn <= 300 && tokenData.refreshToken && !needsStepUp) { 1651 // Reuse existing refresh promise if one is in progress to prevent concurrent refreshes 1652 if (!this._refreshInProgress) { 1653 logMCPDebug( 1654 this.serverName, 1655 `Token expires in ${Math.floor(expiresIn)}s, attempting proactive refresh`, 1656 ) 1657 this._refreshInProgress = this.refreshAuthorization( 1658 tokenData.refreshToken, 1659 ).finally(() => { 1660 this._refreshInProgress = undefined 1661 }) 1662 } else { 1663 logMCPDebug( 1664 this.serverName, 1665 `Token refresh already in progress, reusing existing promise`, 1666 ) 1667 } 1668 1669 try { 1670 const refreshed = await this._refreshInProgress 1671 if (refreshed) { 1672 logMCPDebug(this.serverName, `Token refreshed successfully`) 1673 return refreshed 1674 } 1675 logMCPDebug( 1676 this.serverName, 1677 `Token refresh failed, returning current tokens`, 1678 ) 1679 } catch (error) { 1680 logMCPDebug( 1681 this.serverName, 1682 `Token refresh error: ${errorMessage(error)}`, 1683 ) 1684 } 1685 } 1686 1687 // Return current tokens (may be expired if refresh failed or not needed yet) 1688 const tokens = { 1689 access_token: tokenData.accessToken, 1690 refresh_token: needsStepUp ? undefined : tokenData.refreshToken, 1691 expires_in: expiresIn, 1692 scope: tokenData.scope, 1693 token_type: 'Bearer', 1694 } 1695 1696 logMCPDebug(this.serverName, `Returning tokens`) 1697 logMCPDebug(this.serverName, `Token length: ${tokens.access_token?.length}`) 1698 logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`) 1699 logMCPDebug(this.serverName, `Expires in: ${Math.floor(expiresIn)}s`) 1700 1701 return tokens 1702 } 1703 1704 async saveTokens(tokens: OAuthTokens): Promise<void> { 1705 this._pendingStepUpScope = undefined 1706 const storage = getSecureStorage() 1707 const existingData = storage.read() || {} 1708 const serverKey = getServerKey(this.serverName, this.serverConfig) 1709 1710 logMCPDebug(this.serverName, `Saving tokens`) 1711 logMCPDebug(this.serverName, `Token expires in: ${tokens.expires_in}`) 1712 logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`) 1713 1714 const updatedData: SecureStorageData = { 1715 ...existingData, 1716 mcpOAuth: { 1717 ...existingData.mcpOAuth, 1718 [serverKey]: { 1719 ...existingData.mcpOAuth?.[serverKey], 1720 serverName: this.serverName, 1721 serverUrl: this.serverConfig.url, 1722 accessToken: tokens.access_token, 1723 refreshToken: tokens.refresh_token, 1724 expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 1725 scope: tokens.scope, 1726 }, 1727 }, 1728 } 1729 1730 storage.update(updatedData) 1731 } 1732 1733 /** 1734 * XAA silent refresh: cached id_token → Layer-2 exchange → new access_token. 1735 * No browser. 1736 * 1737 * Returns undefined if the id_token is gone from cache — caller treats this 1738 * as needs-interactive-reauth (transport will 401, CC surfaces it). 1739 * 1740 * On exchange failure, clears the id_token cache so the next interactive 1741 * auth does a fresh IdP login (the cached id_token is likely stale/revoked). 1742 * 1743 * TODO(xaa-ga): add cross-process lockfile before GA. `_refreshInProgress` 1744 * only dedupes within one process — two CC instances with expiring tokens 1745 * both fire the full 4-request XAA chain and race on storage.update(). 1746 * Unlike inc-4829 the id_token is not single-use so both access_tokens 1747 * stay valid (wasted round-trips + keychain write race, not brickage), 1748 * but this is the shape CLAUDE.md flags under "Token/auth caching across 1749 * process boundaries". Mirror refreshAuthorization()'s lockfile pattern. 1750 */ 1751 private async xaaRefresh(): Promise<OAuthTokens | undefined> { 1752 const idp = getXaaIdpSettings() 1753 if (!idp) return undefined // config was removed mid-session 1754 1755 const idToken = getCachedIdpIdToken(idp.issuer) 1756 if (!idToken) { 1757 logMCPDebug( 1758 this.serverName, 1759 'XAA: id_token not cached, needs interactive re-auth', 1760 ) 1761 return undefined 1762 } 1763 1764 const clientId = this.serverConfig.oauth?.clientId 1765 const clientConfig = getMcpClientConfig(this.serverName, this.serverConfig) 1766 if (!clientId || !clientConfig?.clientSecret) { 1767 logMCPDebug( 1768 this.serverName, 1769 'XAA: missing clientId or clientSecret in config — skipping silent refresh', 1770 ) 1771 return undefined // shouldn't happen if `mcp add` was correct 1772 } 1773 1774 const idpClientSecret = getIdpClientSecret(idp.issuer) 1775 1776 // Discover IdP token endpoint. Could cache (fetchCache.ts already 1777 // caches /.well-known/ requests), but OIDC metadata is cheap + idempotent. 1778 // xaaRefresh is the silent tokens() path — soft-fail to undefined so the 1779 // caller falls through to needs-authentication instead of throwing mid-connect. 1780 let oidc 1781 try { 1782 oidc = await discoverOidc(idp.issuer) 1783 } catch (e) { 1784 logMCPDebug( 1785 this.serverName, 1786 `XAA: OIDC discovery failed in silent refresh: ${errorMessage(e)}`, 1787 ) 1788 return undefined 1789 } 1790 1791 try { 1792 const tokens = await performCrossAppAccess( 1793 this.serverConfig.url, 1794 { 1795 clientId, 1796 clientSecret: clientConfig.clientSecret, 1797 idpClientId: idp.clientId, 1798 idpClientSecret, 1799 idpIdToken: idToken, 1800 idpTokenEndpoint: oidc.token_endpoint, 1801 }, 1802 this.serverName, 1803 ) 1804 // Write directly (not via saveTokens) so clientId + clientSecret land in 1805 // storage even when this is the first write for serverKey. saveTokens 1806 // only spreads existing data; if no prior performMCPXaaAuth ran, 1807 // revokeServerTokens would later read tokenData.clientId as undefined 1808 // and send a client_id-less RFC 7009 request that strict ASes reject. 1809 const storage = getSecureStorage() 1810 const existingData = storage.read() || {} 1811 const serverKey = getServerKey(this.serverName, this.serverConfig) 1812 const prev = existingData.mcpOAuth?.[serverKey] 1813 storage.update({ 1814 ...existingData, 1815 mcpOAuth: { 1816 ...existingData.mcpOAuth, 1817 [serverKey]: { 1818 ...prev, 1819 serverName: this.serverName, 1820 serverUrl: this.serverConfig.url, 1821 accessToken: tokens.access_token, 1822 refreshToken: tokens.refresh_token ?? prev?.refreshToken, 1823 expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 1824 scope: tokens.scope, 1825 clientId, 1826 clientSecret: clientConfig.clientSecret, 1827 discoveryState: { 1828 authorizationServerUrl: tokens.authorizationServerUrl, 1829 }, 1830 }, 1831 }, 1832 }) 1833 return { 1834 access_token: tokens.access_token, 1835 token_type: 'Bearer', 1836 expires_in: tokens.expires_in, 1837 scope: tokens.scope, 1838 refresh_token: tokens.refresh_token, 1839 } 1840 } catch (e) { 1841 if (e instanceof XaaTokenExchangeError && e.shouldClearIdToken) { 1842 clearIdpIdToken(idp.issuer) 1843 logMCPDebug( 1844 this.serverName, 1845 'XAA: cleared id_token after exchange failure', 1846 ) 1847 } 1848 throw e 1849 } 1850 } 1851 1852 async redirectToAuthorization(authorizationUrl: URL): Promise<void> { 1853 // Store the authorization URL 1854 this._authorizationUrl = authorizationUrl.toString() 1855 1856 // Extract and store scopes from the authorization URL for later use in token exchange 1857 const scopes = authorizationUrl.searchParams.get('scope') 1858 logMCPDebug( 1859 this.serverName, 1860 `Authorization URL: ${redactSensitiveUrlParams(authorizationUrl.toString())}`, 1861 ) 1862 logMCPDebug(this.serverName, `Scopes in URL: ${scopes || 'NOT FOUND'}`) 1863 1864 if (scopes) { 1865 this._scopes = scopes 1866 logMCPDebug( 1867 this.serverName, 1868 `Captured scopes from authorization URL: ${scopes}`, 1869 ) 1870 } else { 1871 // If no scope in URL, try to get it from metadata 1872 const metadataScope = getScopeFromMetadata(this._metadata) 1873 if (metadataScope) { 1874 this._scopes = metadataScope 1875 logMCPDebug( 1876 this.serverName, 1877 `Using scopes from metadata: ${metadataScope}`, 1878 ) 1879 } else { 1880 logMCPDebug(this.serverName, `No scopes available from URL or metadata`) 1881 } 1882 } 1883 1884 // Persist scope for step-up auth: only when the transport-attached provider 1885 // (handleRedirection=false) receives a step-up 401. The SDK calls auth() 1886 // which calls redirectToAuthorization with the new scope. We persist it 1887 // so the next performMCPOAuthFlow can use it without an extra probe request. 1888 // Guard with !handleRedirection to avoid persisting during normal auth flows 1889 // (where the scope may come from metadata scopes_supported rather than a 401). 1890 if (this._scopes && !this.handleRedirection) { 1891 const storage = getSecureStorage() 1892 const existingData = storage.read() || {} 1893 const serverKey = getServerKey(this.serverName, this.serverConfig) 1894 const existing = existingData.mcpOAuth?.[serverKey] 1895 if (existing) { 1896 existing.stepUpScope = this._scopes 1897 storage.update(existingData) 1898 logMCPDebug(this.serverName, `Persisted step-up scope: ${this._scopes}`) 1899 } 1900 } 1901 1902 if (!this.handleRedirection) { 1903 logMCPDebug( 1904 this.serverName, 1905 `Redirection handling is disabled, skipping redirect`, 1906 ) 1907 return 1908 } 1909 1910 // Validate URL scheme for security 1911 const urlString = authorizationUrl.toString() 1912 if (!urlString.startsWith('http://') && !urlString.startsWith('https://')) { 1913 throw new Error( 1914 'Invalid authorization URL: must use http:// or https:// scheme', 1915 ) 1916 } 1917 1918 logMCPDebug(this.serverName, `Redirecting to authorization URL`) 1919 const redactedUrl = redactSensitiveUrlParams(urlString) 1920 logMCPDebug(this.serverName, `Authorization URL: ${redactedUrl}`) 1921 1922 // Notify the UI about the authorization URL BEFORE opening the browser, 1923 // so users can see the URL as a fallback if the browser fails to open 1924 if (this.onAuthorizationUrlCallback) { 1925 this.onAuthorizationUrlCallback(urlString) 1926 } 1927 1928 if (!this.skipBrowserOpen) { 1929 logMCPDebug(this.serverName, `Opening authorization URL: ${redactedUrl}`) 1930 1931 const success = await openBrowser(urlString) 1932 if (!success) { 1933 logMCPDebug( 1934 this.serverName, 1935 `Browser didn't open automatically. URL is shown in UI.`, 1936 ) 1937 } 1938 } else { 1939 logMCPDebug( 1940 this.serverName, 1941 `Skipping browser open (skipBrowserOpen=true). URL: ${redactedUrl}`, 1942 ) 1943 } 1944 } 1945 1946 async saveCodeVerifier(codeVerifier: string): Promise<void> { 1947 logMCPDebug(this.serverName, `Saving code verifier`) 1948 this._codeVerifier = codeVerifier 1949 } 1950 1951 async codeVerifier(): Promise<string> { 1952 if (!this._codeVerifier) { 1953 logMCPDebug(this.serverName, `No code verifier saved`) 1954 throw new Error('No code verifier saved') 1955 } 1956 logMCPDebug(this.serverName, `Returning code verifier`) 1957 return this._codeVerifier 1958 } 1959 1960 async invalidateCredentials( 1961 scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', 1962 ): Promise<void> { 1963 const storage = getSecureStorage() 1964 const existingData = storage.read() 1965 if (!existingData?.mcpOAuth) return 1966 1967 const serverKey = getServerKey(this.serverName, this.serverConfig) 1968 const tokenData = existingData.mcpOAuth[serverKey] 1969 if (!tokenData) return 1970 1971 switch (scope) { 1972 case 'all': 1973 delete existingData.mcpOAuth[serverKey] 1974 break 1975 case 'client': 1976 tokenData.clientId = undefined 1977 tokenData.clientSecret = undefined 1978 break 1979 case 'tokens': 1980 tokenData.accessToken = '' 1981 tokenData.refreshToken = undefined 1982 tokenData.expiresAt = 0 1983 break 1984 case 'verifier': 1985 this._codeVerifier = undefined 1986 return 1987 case 'discovery': 1988 tokenData.discoveryState = undefined 1989 tokenData.stepUpScope = undefined 1990 break 1991 } 1992 1993 storage.update(existingData) 1994 logMCPDebug(this.serverName, `Invalidated credentials (scope: ${scope})`) 1995 } 1996 1997 async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> { 1998 const storage = getSecureStorage() 1999 const existingData = storage.read() || {} 2000 const serverKey = getServerKey(this.serverName, this.serverConfig) 2001 2002 logMCPDebug( 2003 this.serverName, 2004 `Saving discovery state (authServer: ${state.authorizationServerUrl})`, 2005 ) 2006 2007 // Persist only the URLs, NOT the full metadata blobs. 2008 // authorizationServerMetadata alone is ~1.5-2KB per MCP server (every 2009 // grant type, PKCE method, endpoint the IdP supports). On macOS the 2010 // keychain write goes through `security -i` which has a 4096-byte stdin 2011 // line limit — with hex encoding that's ~2013 bytes of JSON total. Two 2012 // OAuth MCP servers persisting full metadata overflows it, corrupting 2013 // the credential store (#30337). The SDK re-fetches missing metadata 2014 // with one HTTP GET on the next auth — see node_modules/.../auth.js 2015 // `cachedState.authorizationServerMetadata ?? await discover...`. 2016 const updatedData: SecureStorageData = { 2017 ...existingData, 2018 mcpOAuth: { 2019 ...existingData.mcpOAuth, 2020 [serverKey]: { 2021 ...existingData.mcpOAuth?.[serverKey], 2022 serverName: this.serverName, 2023 serverUrl: this.serverConfig.url, 2024 accessToken: existingData.mcpOAuth?.[serverKey]?.accessToken || '', 2025 expiresAt: existingData.mcpOAuth?.[serverKey]?.expiresAt || 0, 2026 discoveryState: { 2027 authorizationServerUrl: state.authorizationServerUrl, 2028 resourceMetadataUrl: state.resourceMetadataUrl, 2029 }, 2030 }, 2031 }, 2032 } 2033 2034 storage.update(updatedData) 2035 } 2036 2037 async discoveryState(): Promise<OAuthDiscoveryState | undefined> { 2038 const storage = getSecureStorage() 2039 const data = storage.read() 2040 const serverKey = getServerKey(this.serverName, this.serverConfig) 2041 2042 const cached = data?.mcpOAuth?.[serverKey]?.discoveryState 2043 if (cached?.authorizationServerUrl) { 2044 logMCPDebug( 2045 this.serverName, 2046 `Returning cached discovery state (authServer: ${cached.authorizationServerUrl})`, 2047 ) 2048 2049 return { 2050 authorizationServerUrl: cached.authorizationServerUrl, 2051 resourceMetadataUrl: cached.resourceMetadataUrl, 2052 resourceMetadata: 2053 cached.resourceMetadata as OAuthDiscoveryState['resourceMetadata'], 2054 authorizationServerMetadata: 2055 cached.authorizationServerMetadata as OAuthDiscoveryState['authorizationServerMetadata'], 2056 } 2057 } 2058 2059 // Check config hint for direct metadata URL 2060 const metadataUrl = this.serverConfig.oauth?.authServerMetadataUrl 2061 if (metadataUrl) { 2062 logMCPDebug( 2063 this.serverName, 2064 `Fetching metadata from configured URL: ${metadataUrl}`, 2065 ) 2066 try { 2067 const metadata = await fetchAuthServerMetadata( 2068 this.serverName, 2069 this.serverConfig.url, 2070 metadataUrl, 2071 ) 2072 if (metadata) { 2073 return { 2074 authorizationServerUrl: metadata.issuer, 2075 authorizationServerMetadata: 2076 metadata as OAuthDiscoveryState['authorizationServerMetadata'], 2077 } 2078 } 2079 } catch (error) { 2080 logMCPDebug( 2081 this.serverName, 2082 `Failed to fetch from configured metadata URL: ${errorMessage(error)}`, 2083 ) 2084 } 2085 } 2086 2087 return undefined 2088 } 2089 2090 async refreshAuthorization( 2091 refreshToken: string, 2092 ): Promise<OAuthTokens | undefined> { 2093 const serverKey = getServerKey(this.serverName, this.serverConfig) 2094 const claudeDir = getClaudeConfigHomeDir() 2095 await mkdir(claudeDir, { recursive: true }) 2096 const sanitizedKey = serverKey.replace(/[^a-zA-Z0-9]/g, '_') 2097 const lockfilePath = join(claudeDir, `mcp-refresh-${sanitizedKey}.lock`) 2098 2099 let release: (() => Promise<void>) | undefined 2100 for (let retry = 0; retry < MAX_LOCK_RETRIES; retry++) { 2101 try { 2102 logMCPDebug( 2103 this.serverName, 2104 `Acquiring refresh lock (attempt ${retry + 1})`, 2105 ) 2106 release = await lockfile.lock(lockfilePath, { 2107 realpath: false, 2108 onCompromised: () => { 2109 logMCPDebug(this.serverName, `Refresh lock was compromised`) 2110 }, 2111 }) 2112 logMCPDebug(this.serverName, `Acquired refresh lock`) 2113 break 2114 } catch (e: unknown) { 2115 const code = getErrnoCode(e) 2116 if (code === 'ELOCKED') { 2117 logMCPDebug( 2118 this.serverName, 2119 `Refresh lock held by another process, waiting (attempt ${retry + 1}/${MAX_LOCK_RETRIES})`, 2120 ) 2121 await sleep(1000 + Math.random() * 1000) 2122 continue 2123 } 2124 logMCPDebug( 2125 this.serverName, 2126 `Failed to acquire refresh lock: ${code}, proceeding without lock`, 2127 ) 2128 break 2129 } 2130 } 2131 if (!release) { 2132 logMCPDebug( 2133 this.serverName, 2134 `Could not acquire refresh lock after ${MAX_LOCK_RETRIES} retries, proceeding without lock`, 2135 ) 2136 } 2137 2138 try { 2139 // Re-read tokens after acquiring lock — another process may have refreshed 2140 clearKeychainCache() 2141 const storage = getSecureStorage() 2142 const data = storage.read() 2143 const tokenData = data?.mcpOAuth?.[serverKey] 2144 if (tokenData) { 2145 const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 2146 if (expiresIn > 300) { 2147 logMCPDebug( 2148 this.serverName, 2149 `Another process already refreshed tokens (expires in ${Math.floor(expiresIn)}s)`, 2150 ) 2151 return { 2152 access_token: tokenData.accessToken, 2153 refresh_token: tokenData.refreshToken, 2154 expires_in: expiresIn, 2155 scope: tokenData.scope, 2156 token_type: 'Bearer', 2157 } 2158 } 2159 // Use the freshest refresh token from storage 2160 if (tokenData.refreshToken) { 2161 refreshToken = tokenData.refreshToken 2162 } 2163 } 2164 return await this._doRefresh(refreshToken) 2165 } finally { 2166 if (release) { 2167 try { 2168 await release() 2169 logMCPDebug(this.serverName, `Released refresh lock`) 2170 } catch { 2171 logMCPDebug(this.serverName, `Failed to release refresh lock`) 2172 } 2173 } 2174 } 2175 } 2176 2177 private async _doRefresh( 2178 refreshToken: string, 2179 ): Promise<OAuthTokens | undefined> { 2180 const MAX_ATTEMPTS = 3 2181 2182 const mcpServerBaseUrl = getLoggingSafeMcpBaseUrl(this.serverConfig) 2183 const emitRefreshEvent = ( 2184 outcome: 'success' | 'failure', 2185 reason?: MCPRefreshFailureReason, 2186 ): void => { 2187 logEvent( 2188 outcome === 'success' 2189 ? 'tengu_mcp_oauth_refresh_success' 2190 : 'tengu_mcp_oauth_refresh_failure', 2191 { 2192 transportType: this.serverConfig 2193 .type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2194 ...(mcpServerBaseUrl 2195 ? { 2196 mcpServerBaseUrl: 2197 mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2198 } 2199 : {}), 2200 ...(reason 2201 ? { 2202 reason: 2203 reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2204 } 2205 : {}), 2206 }, 2207 ) 2208 } 2209 2210 for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { 2211 try { 2212 logMCPDebug(this.serverName, `Starting token refresh`) 2213 const authFetch = createAuthFetch() 2214 2215 // Reuse cached metadata from the initial OAuth flow if available, 2216 // since metadata (token endpoint URL, etc.) is static per auth server. 2217 // Priority: 2218 // 1. In-memory cache (same-session refreshes) 2219 // 2. Persisted discovery state from initial auth (cross-session) — 2220 // avoids re-running RFC 9728 discovery on every refresh. 2221 // 3. Full RFC 9728 → RFC 8414 re-discovery via fetchAuthServerMetadata. 2222 let metadata = this._metadata 2223 if (!metadata) { 2224 const cached = await this.discoveryState() 2225 if (cached?.authorizationServerMetadata) { 2226 logMCPDebug( 2227 this.serverName, 2228 `Using persisted auth server metadata for refresh`, 2229 ) 2230 metadata = cached.authorizationServerMetadata 2231 } else if (cached?.authorizationServerUrl) { 2232 logMCPDebug( 2233 this.serverName, 2234 `Re-discovering metadata from persisted auth server URL: ${cached.authorizationServerUrl}`, 2235 ) 2236 metadata = await discoverAuthorizationServerMetadata( 2237 cached.authorizationServerUrl, 2238 { fetchFn: authFetch }, 2239 ) 2240 } 2241 } 2242 if (!metadata) { 2243 metadata = await fetchAuthServerMetadata( 2244 this.serverName, 2245 this.serverConfig.url, 2246 this.serverConfig.oauth?.authServerMetadataUrl, 2247 authFetch, 2248 ) 2249 } 2250 if (!metadata) { 2251 logMCPDebug(this.serverName, `Failed to discover OAuth metadata`) 2252 emitRefreshEvent('failure', 'metadata_discovery_failed') 2253 return undefined 2254 } 2255 // Cache for future refreshes 2256 this._metadata = metadata 2257 2258 const clientInfo = await this.clientInformation() 2259 if (!clientInfo) { 2260 logMCPDebug(this.serverName, `No client information available`) 2261 emitRefreshEvent('failure', 'no_client_info') 2262 return undefined 2263 } 2264 2265 const newTokens = await sdkRefreshAuthorization( 2266 new URL(this.serverConfig.url), 2267 { 2268 metadata, 2269 clientInformation: clientInfo, 2270 refreshToken, 2271 resource: new URL(this.serverConfig.url), 2272 fetchFn: authFetch, 2273 }, 2274 ) 2275 2276 if (newTokens) { 2277 logMCPDebug(this.serverName, `Token refresh successful`) 2278 await this.saveTokens(newTokens) 2279 emitRefreshEvent('success') 2280 return newTokens 2281 } 2282 2283 logMCPDebug(this.serverName, `Token refresh returned no tokens`) 2284 emitRefreshEvent('failure', 'no_tokens_returned') 2285 return undefined 2286 } catch (error) { 2287 // Invalid grant means the refresh token itself is invalid/revoked/expired. 2288 // But another process may have already refreshed successfully — check first. 2289 if (error instanceof InvalidGrantError) { 2290 logMCPDebug( 2291 this.serverName, 2292 `Token refresh failed with invalid_grant: ${error.message}`, 2293 ) 2294 clearKeychainCache() 2295 const storage = getSecureStorage() 2296 const data = storage.read() 2297 const serverKey = getServerKey(this.serverName, this.serverConfig) 2298 const tokenData = data?.mcpOAuth?.[serverKey] 2299 if (tokenData) { 2300 const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 2301 if (expiresIn > 300) { 2302 logMCPDebug( 2303 this.serverName, 2304 `Another process refreshed tokens, using those`, 2305 ) 2306 // Not emitted as success: this process did not perform a 2307 // refresh, and the winning process already emitted its own 2308 // success event. Emitting here would double-count. 2309 return { 2310 access_token: tokenData.accessToken, 2311 refresh_token: tokenData.refreshToken, 2312 expires_in: expiresIn, 2313 scope: tokenData.scope, 2314 token_type: 'Bearer', 2315 } 2316 } 2317 } 2318 logMCPDebug( 2319 this.serverName, 2320 `No valid tokens in storage, clearing stored tokens`, 2321 ) 2322 await this.invalidateCredentials('tokens') 2323 emitRefreshEvent('failure', 'invalid_grant') 2324 return undefined 2325 } 2326 2327 // Retry on timeouts or transient server errors 2328 const isTimeoutError = 2329 error instanceof Error && 2330 /timeout|timed out|etimedout|econnreset/i.test(error.message) 2331 const isTransientServerError = 2332 error instanceof ServerError || 2333 error instanceof TemporarilyUnavailableError || 2334 error instanceof TooManyRequestsError 2335 const isRetryable = isTimeoutError || isTransientServerError 2336 2337 if (!isRetryable || attempt >= MAX_ATTEMPTS) { 2338 logMCPDebug( 2339 this.serverName, 2340 `Token refresh failed: ${errorMessage(error)}`, 2341 ) 2342 emitRefreshEvent( 2343 'failure', 2344 isRetryable ? 'transient_retries_exhausted' : 'request_failed', 2345 ) 2346 return undefined 2347 } 2348 2349 const delayMs = 1000 * Math.pow(2, attempt - 1) // 1s, 2s, 4s 2350 logMCPDebug( 2351 this.serverName, 2352 `Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, 2353 ) 2354 await sleep(delayMs) 2355 } 2356 } 2357 2358 return undefined 2359 } 2360} 2361 2362export async function readClientSecret(): Promise<string> { 2363 const envSecret = process.env.MCP_CLIENT_SECRET 2364 if (envSecret) { 2365 return envSecret 2366 } 2367 2368 if (!process.stdin.isTTY) { 2369 throw new Error( 2370 'No TTY available to prompt for client secret. Set MCP_CLIENT_SECRET env var instead.', 2371 ) 2372 } 2373 2374 return new Promise((resolve, reject) => { 2375 process.stderr.write('Enter OAuth client secret: ') 2376 process.stdin.setRawMode?.(true) 2377 let secret = '' 2378 const onData = (ch: Buffer) => { 2379 const c = ch.toString() 2380 if (c === '\n' || c === '\r') { 2381 process.stdin.setRawMode?.(false) 2382 process.stdin.removeListener('data', onData) 2383 process.stderr.write('\n') 2384 resolve(secret) 2385 } else if (c === '\u0003') { 2386 process.stdin.setRawMode?.(false) 2387 process.stdin.removeListener('data', onData) 2388 reject(new Error('Cancelled')) 2389 } else if (c === '\u007F' || c === '\b') { 2390 secret = secret.slice(0, -1) 2391 } else { 2392 secret += c 2393 } 2394 } 2395 process.stdin.on('data', onData) 2396 }) 2397} 2398 2399export function saveMcpClientSecret( 2400 serverName: string, 2401 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 2402 clientSecret: string, 2403): void { 2404 const storage = getSecureStorage() 2405 const existingData = storage.read() || {} 2406 const serverKey = getServerKey(serverName, serverConfig) 2407 storage.update({ 2408 ...existingData, 2409 mcpOAuthClientConfig: { 2410 ...existingData.mcpOAuthClientConfig, 2411 [serverKey]: { clientSecret }, 2412 }, 2413 }) 2414} 2415 2416export function clearMcpClientConfig( 2417 serverName: string, 2418 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 2419): void { 2420 const storage = getSecureStorage() 2421 const existingData = storage.read() 2422 if (!existingData?.mcpOAuthClientConfig) return 2423 const serverKey = getServerKey(serverName, serverConfig) 2424 if (existingData.mcpOAuthClientConfig[serverKey]) { 2425 delete existingData.mcpOAuthClientConfig[serverKey] 2426 storage.update(existingData) 2427 } 2428} 2429 2430export function getMcpClientConfig( 2431 serverName: string, 2432 serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 2433): { clientSecret?: string } | undefined { 2434 const storage = getSecureStorage() 2435 const data = storage.read() 2436 const serverKey = getServerKey(serverName, serverConfig) 2437 return data?.mcpOAuthClientConfig?.[serverKey] 2438} 2439 2440/** 2441 * Safely extracts scope information from AuthorizationServerMetadata. 2442 * The metadata can be either OAuthMetadata or OpenIdProviderDiscoveryMetadata, 2443 * and different providers use different fields for scope information. 2444 */ 2445function getScopeFromMetadata( 2446 metadata: AuthorizationServerMetadata | undefined, 2447): string | undefined { 2448 if (!metadata) return undefined 2449 // Try 'scope' first (non-standard but used by some providers) 2450 if ('scope' in metadata && typeof metadata.scope === 'string') { 2451 return metadata.scope 2452 } 2453 // Try 'default_scope' (non-standard but used by some providers) 2454 if ( 2455 'default_scope' in metadata && 2456 typeof metadata.default_scope === 'string' 2457 ) { 2458 return metadata.default_scope 2459 } 2460 // Fall back to scopes_supported (standard OAuth 2.0 field) 2461 if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) { 2462 return metadata.scopes_supported.join(' ') 2463 } 2464 return undefined 2465}