/ services / mcp / xaa.ts
xaa.ts
  1  /**
  2   * Cross-App Access (XAA) / Enterprise Managed Authorization (SEP-990)
  3   *
  4   * Obtains an MCP access token WITHOUT a browser consent screen by chaining:
  5   *   1. RFC 8693 Token Exchange at the IdP: id_token → ID-JAG
  6   *   2. RFC 7523 JWT Bearer Grant at the AS: ID-JAG → access_token
  7   *
  8   * Spec refs:
  9   *   - ID-JAG (IETF draft): https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/
 10   *   - MCP ext-auth (SEP-990): https://github.com/modelcontextprotocol/ext-auth
 11   *   - RFC 8693 (Token Exchange), RFC 7523 (JWT Bearer), RFC 9728 (PRM)
 12   *
 13   * Reference impl: ~/code/mcp/conformance/examples/clients/typescript/everything-client.ts:375-522
 14   *
 15   * Structure: four Layer-2 ops (aligned with TS SDK PR #1593's Layer-2 shapes so
 16   * a future SDK swap is mechanical) + one Layer-3 orchestrator that composes them.
 17   */
 18  
 19  import {
 20    discoverAuthorizationServerMetadata,
 21    discoverOAuthProtectedResourceMetadata,
 22  } from '@modelcontextprotocol/sdk/client/auth.js'
 23  import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
 24  import { z } from 'zod/v4'
 25  import { lazySchema } from '../../utils/lazySchema.js'
 26  import { logMCPDebug } from '../../utils/log.js'
 27  import { jsonStringify } from '../../utils/slowOperations.js'
 28  
 29  const XAA_REQUEST_TIMEOUT_MS = 30000
 30  
 31  const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
 32  const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
 33  const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'
 34  const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'
 35  
 36  /**
 37   * Creates a fetch wrapper that enforces the XAA request timeout and optionally
 38   * composes a caller-provided abort signal. Using AbortSignal.any ensures the
 39   * user's cancel (e.g. Esc in the auth menu) actually aborts in-flight requests
 40   * rather than being clobbered by the timeout signal.
 41   */
 42  function makeXaaFetch(abortSignal?: AbortSignal): FetchLike {
 43    return (url, init) => {
 44      const timeout = AbortSignal.timeout(XAA_REQUEST_TIMEOUT_MS)
 45      const signal = abortSignal
 46        ? // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
 47          AbortSignal.any([timeout, abortSignal])
 48        : timeout
 49      // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
 50      return fetch(url, { ...init, signal })
 51    }
 52  }
 53  
 54  const defaultFetch = makeXaaFetch()
 55  
 56  /**
 57   * RFC 8414 §3.3 / RFC 9728 §3.3 identifier comparison. Roundtrip through URL
 58   * to apply RFC 3986 §6.2.2 syntax-based normalization (lowercases scheme+host,
 59   * drops default port), then strip trailing slash.
 60   */
 61  function normalizeUrl(url: string): string {
 62    try {
 63      return new URL(url).href.replace(/\/$/, '')
 64    } catch {
 65      return url.replace(/\/$/, '')
 66    }
 67  }
 68  
 69  /**
 70   * Thrown by requestJwtAuthorizationGrant when the IdP token-exchange leg
 71   * fails. Carries `shouldClearIdToken` so callers can decide whether to drop
 72   * the cached id_token based on OAuth error semantics (not substring matching):
 73   *   - 4xx / invalid_grant / invalid_token → id_token is bad, clear it
 74   *   - 5xx → IdP is down, id_token may still be valid, keep it
 75   *   - 200 with structurally-invalid body → protocol violation, clear it
 76   */
 77  export class XaaTokenExchangeError extends Error {
 78    readonly shouldClearIdToken: boolean
 79    constructor(message: string, shouldClearIdToken: boolean) {
 80      super(message)
 81      this.name = 'XaaTokenExchangeError'
 82      this.shouldClearIdToken = shouldClearIdToken
 83    }
 84  }
 85  
 86  // Matches quoted values for known token-bearing keys regardless of nesting
 87  // depth. Works on both parsed-then-stringified bodies AND raw text() error
 88  // bodies from !res.ok paths — a misbehaving AS that echoes the request's
 89  // subject_token/assertion/client_secret in a 4xx error envelope must not leak
 90  // into debug logs.
 91  const SENSITIVE_TOKEN_RE =
 92    /"(access_token|refresh_token|id_token|assertion|subject_token|client_secret)"\s*:\s*"[^"]*"/g
 93  
 94  function redactTokens(raw: unknown): string {
 95    const s = typeof raw === 'string' ? raw : jsonStringify(raw)
 96    return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
 97  }
 98  
 99  // ─── Zod Schemas ────────────────────────────────────────────────────────────
100  
101  const TokenExchangeResponseSchema = lazySchema(() =>
102    z.object({
103      access_token: z.string().optional(),
104      issued_token_type: z.string().optional(),
105      // z.coerce tolerates IdPs that send expires_in as a string (common in
106      // PHP-backed IdPs) — technically non-conformant JSON but widespread.
107      expires_in: z.coerce.number().optional(),
108      scope: z.string().optional(),
109    }),
110  )
111  
112  const JwtBearerResponseSchema = lazySchema(() =>
113    z.object({
114      access_token: z.string().min(1),
115      // Many ASes omit token_type since Bearer is the only value anyone uses
116      // (RFC 6750). Don't reject a valid access_token over a missing label.
117      token_type: z.string().default('Bearer'),
118      expires_in: z.coerce.number().optional(),
119      scope: z.string().optional(),
120      refresh_token: z.string().optional(),
121    }),
122  )
123  
124  // ─── Layer 2: Discovery ─────────────────────────────────────────────────────
125  
126  export type ProtectedResourceMetadata = {
127    resource: string
128    authorization_servers: string[]
129  }
130  
131  /**
132   * RFC 9728 PRM discovery via SDK, plus RFC 9728 §3.3 resource-mismatch
133   * validation (mix-up protection — TODO: upstream to SDK).
134   */
135  export async function discoverProtectedResource(
136    serverUrl: string,
137    opts?: { fetchFn?: FetchLike },
138  ): Promise<ProtectedResourceMetadata> {
139    let prm
140    try {
141      prm = await discoverOAuthProtectedResourceMetadata(
142        serverUrl,
143        undefined,
144        opts?.fetchFn ?? defaultFetch,
145      )
146    } catch (e) {
147      throw new Error(
148        `XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`,
149      )
150    }
151    if (!prm.resource || !prm.authorization_servers?.[0]) {
152      throw new Error(
153        'XAA: PRM discovery failed: PRM missing resource or authorization_servers',
154      )
155    }
156    if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
157      throw new Error(
158        `XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
159      )
160    }
161    return {
162      resource: prm.resource,
163      authorization_servers: prm.authorization_servers,
164    }
165  }
166  
167  export type AuthorizationServerMetadata = {
168    issuer: string
169    token_endpoint: string
170    grant_types_supported?: string[]
171    token_endpoint_auth_methods_supported?: string[]
172  }
173  
174  /**
175   * AS metadata discovery via SDK (RFC 8414 + OIDC fallback), plus RFC 8414
176   * §3.3 issuer-mismatch validation (mix-up protection — TODO: upstream to SDK).
177   */
178  export async function discoverAuthorizationServer(
179    asUrl: string,
180    opts?: { fetchFn?: FetchLike },
181  ): Promise<AuthorizationServerMetadata> {
182    const meta = await discoverAuthorizationServerMetadata(asUrl, {
183      fetchFn: opts?.fetchFn ?? defaultFetch,
184    })
185    if (!meta?.issuer || !meta.token_endpoint) {
186      throw new Error(
187        `XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
188      )
189    }
190    if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
191      throw new Error(
192        `XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
193      )
194    }
195    // RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
196    // that self-consistently reports an http:// issuer would pass the mismatch
197    // check above, then we'd POST id_token + client_secret over plaintext.
198    if (new URL(meta.token_endpoint).protocol !== 'https:') {
199      throw new Error(
200        `XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
201      )
202    }
203    return {
204      issuer: meta.issuer,
205      token_endpoint: meta.token_endpoint,
206      grant_types_supported: meta.grant_types_supported,
207      token_endpoint_auth_methods_supported:
208        meta.token_endpoint_auth_methods_supported,
209    }
210  }
211  
212  // ─── Layer 2: Exchange ──────────────────────────────────────────────────────
213  
214  export type JwtAuthGrantResult = {
215    /** The ID-JAG (Identity Assertion Authorization Grant) */
216    jwtAuthGrant: string
217    expiresIn?: number
218    scope?: string
219  }
220  
221  /**
222   * RFC 8693 Token Exchange at the IdP: id_token → ID-JAG.
223   * Validates `issued_token_type` is `urn:ietf:params:oauth:token-type:id-jag`.
224   *
225   * `clientSecret` is optional — sent via `client_secret_post` if present.
226   * Some IdPs register the client as confidential even when they advertise
227   * `token_endpoint_auth_method: "none"`.
228   *
229   * TODO(xaa-ga): consult `token_endpoint_auth_methods_supported` from IdP
230   * OIDC metadata and support `client_secret_basic`, mirroring the AS-side
231   * selection in `performCrossAppAccess`. All major IdPs accept POST today.
232   */
233  export async function requestJwtAuthorizationGrant(opts: {
234    tokenEndpoint: string
235    audience: string
236    resource: string
237    idToken: string
238    clientId: string
239    clientSecret?: string
240    scope?: string
241    fetchFn?: FetchLike
242  }): Promise<JwtAuthGrantResult> {
243    const fetchFn = opts.fetchFn ?? defaultFetch
244    const params = new URLSearchParams({
245      grant_type: TOKEN_EXCHANGE_GRANT,
246      requested_token_type: ID_JAG_TOKEN_TYPE,
247      audience: opts.audience,
248      resource: opts.resource,
249      subject_token: opts.idToken,
250      subject_token_type: ID_TOKEN_TYPE,
251      client_id: opts.clientId,
252    })
253    if (opts.clientSecret) {
254      params.set('client_secret', opts.clientSecret)
255    }
256    if (opts.scope) {
257      params.set('scope', opts.scope)
258    }
259  
260    const res = await fetchFn(opts.tokenEndpoint, {
261      method: 'POST',
262      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
263      body: params,
264    })
265    if (!res.ok) {
266      const body = redactTokens(await res.text()).slice(0, 200)
267      // 4xx → id_token rejected (invalid_grant etc.), clear cache.
268      // 5xx → IdP outage, id_token may still be valid, preserve it.
269      const shouldClear = res.status < 500
270      throw new XaaTokenExchangeError(
271        `XAA: token exchange failed: HTTP ${res.status}: ${body}`,
272        shouldClear,
273      )
274    }
275    let rawExchange: unknown
276    try {
277      rawExchange = await res.json()
278    } catch {
279      // Transient network condition (captive portal, proxy) — don't clear id_token.
280      throw new XaaTokenExchangeError(
281        `XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
282        false,
283      )
284    }
285    const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
286    if (!exchangeParsed.success) {
287      throw new XaaTokenExchangeError(
288        `XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`,
289        true,
290      )
291    }
292    const result = exchangeParsed.data
293    if (!result.access_token) {
294      throw new XaaTokenExchangeError(
295        `XAA: token exchange response missing access_token: ${redactTokens(result)}`,
296        true,
297      )
298    }
299    if (result.issued_token_type !== ID_JAG_TOKEN_TYPE) {
300      throw new XaaTokenExchangeError(
301        `XAA: token exchange returned unexpected issued_token_type: ${result.issued_token_type}`,
302        true,
303      )
304    }
305    return {
306      jwtAuthGrant: result.access_token,
307      expiresIn: result.expires_in,
308      scope: result.scope,
309    }
310  }
311  
312  export type XaaTokenResult = {
313    access_token: string
314    token_type: string
315    expires_in?: number
316    scope?: string
317    refresh_token?: string
318  }
319  
320  export type XaaResult = XaaTokenResult & {
321    /**
322     * The AS issuer URL discovered via PRM. Callers must persist this as
323     * `discoveryState.authorizationServerUrl` so that refresh (auth.ts _doRefresh)
324     * and revocation (revokeServerTokens) can locate the token/revocation
325     * endpoints — the MCP URL is not the AS URL in typical XAA setups.
326     */
327    authorizationServerUrl: string
328  }
329  
330  /**
331   * RFC 7523 JWT Bearer Grant at the AS: ID-JAG → access_token.
332   *
333   * `authMethod` defaults to `client_secret_basic` (Base64 header, not body
334   * params) — the SEP-990 conformance test requires this. Only set
335   * `client_secret_post` if the AS explicitly requires it.
336   */
337  export async function exchangeJwtAuthGrant(opts: {
338    tokenEndpoint: string
339    assertion: string
340    clientId: string
341    clientSecret: string
342    authMethod?: 'client_secret_basic' | 'client_secret_post'
343    scope?: string
344    fetchFn?: FetchLike
345  }): Promise<XaaTokenResult> {
346    const fetchFn = opts.fetchFn ?? defaultFetch
347    const authMethod = opts.authMethod ?? 'client_secret_basic'
348  
349    const params = new URLSearchParams({
350      grant_type: JWT_BEARER_GRANT,
351      assertion: opts.assertion,
352    })
353    if (opts.scope) {
354      params.set('scope', opts.scope)
355    }
356  
357    const headers: Record<string, string> = {
358      'Content-Type': 'application/x-www-form-urlencoded',
359    }
360    if (authMethod === 'client_secret_basic') {
361      const basicAuth = Buffer.from(
362        `${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret)}`,
363      ).toString('base64')
364      headers.Authorization = `Basic ${basicAuth}`
365    } else {
366      params.set('client_id', opts.clientId)
367      params.set('client_secret', opts.clientSecret)
368    }
369  
370    const res = await fetchFn(opts.tokenEndpoint, {
371      method: 'POST',
372      headers,
373      body: params,
374    })
375    if (!res.ok) {
376      const body = redactTokens(await res.text()).slice(0, 200)
377      throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
378    }
379    let rawTokens: unknown
380    try {
381      rawTokens = await res.json()
382    } catch {
383      throw new Error(
384        `XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
385      )
386    }
387    const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
388    if (!tokensParsed.success) {
389      throw new Error(
390        `XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`,
391      )
392    }
393    return tokensParsed.data
394  }
395  
396  // ─── Layer 3: Orchestrator ──────────────────────────────────────────────────
397  
398  /**
399   * Config needed to run the full XAA orchestrator.
400   * Mirrors the conformance test context shape (see ClientConformanceContextSchema).
401   */
402  export type XaaConfig = {
403    /** Client ID registered at the MCP server's authorization server */
404    clientId: string
405    /** Client secret for the MCP server's authorization server */
406    clientSecret: string
407    /** Client ID registered at the IdP (for the token-exchange request) */
408    idpClientId: string
409    /** Optional IdP client secret (client_secret_post) — some IdPs require it */
410    idpClientSecret?: string
411    /** The user's OIDC id_token from the IdP login */
412    idpIdToken: string
413    /** IdP token endpoint (where to send the RFC 8693 token-exchange) */
414    idpTokenEndpoint: string
415  }
416  
417  /**
418   * Full XAA flow: PRM → AS metadata → token-exchange → jwt-bearer → access_token.
419   * Thin composition of the four Layer-2 ops. Used by performMCPXaaAuth,
420   * ClaudeAuthProvider.xaaRefresh, and the try-xaa*.ts debug scripts.
421   *
422   * @param serverUrl The MCP server URL (e.g. `https://mcp.example.com/mcp`)
423   * @param config IdP + AS credentials
424   * @param serverName Server name for debug logging
425   */
426  export async function performCrossAppAccess(
427    serverUrl: string,
428    config: XaaConfig,
429    serverName = 'xaa',
430    abortSignal?: AbortSignal,
431  ): Promise<XaaResult> {
432    const fetchFn = makeXaaFetch(abortSignal)
433  
434    logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`)
435    const prm = await discoverProtectedResource(serverUrl, { fetchFn })
436    logMCPDebug(
437      serverName,
438      `XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`,
439    )
440  
441    // Try each advertised AS in order. grant_types_supported is OPTIONAL per
442    // RFC 8414 §2 — only skip if the AS explicitly advertises a list that omits
443    // jwt-bearer. If absent, let the token endpoint decide.
444    let asMeta: AuthorizationServerMetadata | undefined
445    const asErrors: string[] = []
446    for (const asUrl of prm.authorization_servers) {
447      let candidate: AuthorizationServerMetadata
448      try {
449        candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
450      } catch (e) {
451        if (abortSignal?.aborted) throw e
452        asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`)
453        continue
454      }
455      if (
456        candidate.grant_types_supported &&
457        !candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
458      ) {
459        asErrors.push(
460          `${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
461        )
462        continue
463      }
464      asMeta = candidate
465      break
466    }
467    if (!asMeta) {
468      throw new Error(
469        `XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`,
470      )
471    }
472    // Pick auth method from what the AS advertises. We handle
473    // client_secret_basic and client_secret_post; if the AS only supports post,
474    // honor that, else default to basic (SEP-990 conformance expectation).
475    const authMethods = asMeta.token_endpoint_auth_methods_supported
476    const authMethod: 'client_secret_basic' | 'client_secret_post' =
477      authMethods &&
478      !authMethods.includes('client_secret_basic') &&
479      authMethods.includes('client_secret_post')
480        ? 'client_secret_post'
481        : 'client_secret_basic'
482    logMCPDebug(
483      serverName,
484      `XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`,
485    )
486  
487    logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`)
488    const jag = await requestJwtAuthorizationGrant({
489      tokenEndpoint: config.idpTokenEndpoint,
490      audience: asMeta.issuer,
491      resource: prm.resource,
492      idToken: config.idpIdToken,
493      clientId: config.idpClientId,
494      clientSecret: config.idpClientSecret,
495      fetchFn,
496    })
497    logMCPDebug(serverName, `XAA: ID-JAG obtained`)
498  
499    logMCPDebug(serverName, `XAA: exchanging ID-JAG for access_token at AS`)
500    const tokens = await exchangeJwtAuthGrant({
501      tokenEndpoint: asMeta.token_endpoint,
502      assertion: jag.jwtAuthGrant,
503      clientId: config.clientId,
504      clientSecret: config.clientSecret,
505      authMethod,
506      fetchFn,
507    })
508    logMCPDebug(serverName, `XAA: access_token obtained`)
509  
510    return { ...tokens, authorizationServerUrl: asMeta.issuer }
511  }