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 }