/ services / mcp / auth.ts
auth.ts
   1  import {
   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'
   9  import {
  10    InvalidGrantError,
  11    OAuthError,
  12    ServerError,
  13    TemporarilyUnavailableError,
  14    TooManyRequestsError,
  15  } from '@modelcontextprotocol/sdk/server/auth/errors.js'
  16  import {
  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'
  26  import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
  27  import axios from 'axios'
  28  import { createHash, randomBytes, randomUUID } from 'crypto'
  29  import { mkdir } from 'fs/promises'
  30  import { createServer, type Server } from 'http'
  31  import { join } from 'path'
  32  import { parse } from 'url'
  33  import xss from 'xss'
  34  import { MCP_CLIENT_METADATA_URL } from '../../constants/oauth.js'
  35  import { openBrowser } from '../../utils/browser.js'
  36  import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
  37  import { errorMessage, getErrnoCode } from '../../utils/errors.js'
  38  import * as lockfile from '../../utils/lockfile.js'
  39  import { logMCPDebug } from '../../utils/log.js'
  40  import { getPlatform } from '../../utils/platform.js'
  41  import { getSecureStorage } from '../../utils/secureStorage/index.js'
  42  import { clearKeychainCache } from '../../utils/secureStorage/macOsKeychainHelpers.js'
  43  import type { SecureStorageData } from '../../utils/secureStorage/types.js'
  44  import { sleep } from '../../utils/sleep.js'
  45  import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
  46  import { logEvent } from '../analytics/index.js'
  47  import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../analytics/metadata.js'
  48  import { buildRedirectUri, findAvailablePort } from './oauthPort.js'
  49  import type { McpHTTPServerConfig, McpSSEServerConfig } from './types.js'
  50  import { getLoggingSafeMcpBaseUrl } from './utils.js'
  51  import { performCrossAppAccess, XaaTokenExchangeError } from './xaa.js'
  52  import {
  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   */
  65  const 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   */
  71  type 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   */
  84  type 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  
  94  const 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   */
 100  const 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   */
 112  function 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   */
 147  const 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. */
 157  export 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   */
 198  function 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   */
 256  async 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  
 313  export 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   */
 325  export 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   */
 349  export 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   */
 381  async 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   */
 467  export 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  
 620  export 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  
 636  type WWWAuthenticateParams = {
 637    scope?: string
 638    resourceMetadataUrl?: URL
 639  }
 640  
 641  type 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   */
 664  async 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  
 847  export 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   */
1354  export 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  
1376  export 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  
2362  export 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  
2399  export 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  
2416  export 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  
2430  export 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   */
2445  function 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  }