Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
18 kB · 511 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512/** * Cross-App Access (XAA) / Enterprise Managed Authorization (SEP-990) * * Obtains an MCP access token WITHOUT a browser consent screen by chaining: * 1. RFC 8693 Token Exchange at the IdP: id_token → ID-JAG * 2. RFC 7523 JWT Bearer Grant at the AS: ID-JAG → access_token * * Spec refs: * - ID-JAG (IETF draft): https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/ * - MCP ext-auth (SEP-990): https://github.com/modelcontextprotocol/ext-auth * - RFC 8693 (Token Exchange), RFC 7523 (JWT Bearer), RFC 9728 (PRM) * * Reference impl: ~/code/mcp/conformance/examples/clients/typescript/everything-client.ts:375-522 * * Structure: four Layer-2 ops (aligned with TS SDK PR #1593's Layer-2 shapes so * a future SDK swap is mechanical) + one Layer-3 orchestrator that composes them. */
import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata,} from '@modelcontextprotocol/sdk/client/auth.js'import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'import { z } from 'zod/v4'import { lazySchema } from '../../utils/lazySchema.js'import { logMCPDebug } from '../../utils/log.js'import { jsonStringify } from '../../utils/slowOperations.js'
const XAA_REQUEST_TIMEOUT_MS = 30000
const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'
/** * Creates a fetch wrapper that enforces the XAA request timeout and optionally * composes a caller-provided abort signal. Using AbortSignal.any ensures the * user's cancel (e.g. Esc in the auth menu) actually aborts in-flight requests * rather than being clobbered by the timeout signal. */function makeXaaFetch(abortSignal?: AbortSignal): FetchLike { return (url, init) => { const timeout = AbortSignal.timeout(XAA_REQUEST_TIMEOUT_MS) const signal = abortSignal ? // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins AbortSignal.any([timeout, abortSignal]) : timeout // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins return fetch(url, { ...init, signal }) }}
const defaultFetch = makeXaaFetch()
/** * RFC 8414 §3.3 / RFC 9728 §3.3 identifier comparison. Roundtrip through URL * to apply RFC 3986 §6.2.2 syntax-based normalization (lowercases scheme+host, * drops default port), then strip trailing slash. */function normalizeUrl(url: string): string { try { return new URL(url).href.replace(/\/$/, '') } catch { return url.replace(/\/$/, '') }}
/** * Thrown by requestJwtAuthorizationGrant when the IdP token-exchange leg * fails. Carries `shouldClearIdToken` so callers can decide whether to drop * the cached id_token based on OAuth error semantics (not substring matching): * - 4xx / invalid_grant / invalid_token → id_token is bad, clear it * - 5xx → IdP is down, id_token may still be valid, keep it * - 200 with structurally-invalid body → protocol violation, clear it */export class XaaTokenExchangeError extends Error { readonly shouldClearIdToken: boolean constructor(message: string, shouldClearIdToken: boolean) { super(message) this.name = 'XaaTokenExchangeError' this.shouldClearIdToken = shouldClearIdToken }}
// Matches quoted values for known token-bearing keys regardless of nesting// depth. Works on both parsed-then-stringified bodies AND raw text() error// bodies from !res.ok paths — a misbehaving AS that echoes the request's// subject_token/assertion/client_secret in a 4xx error envelope must not leak// into debug logs.const SENSITIVE_TOKEN_RE = /"(access_token|refresh_token|id_token|assertion|subject_token|client_secret)"\s*:\s*"[^"]*"/g
function redactTokens(raw: unknown): string { const s = typeof raw === 'string' ? raw : jsonStringify(raw) return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)}
// ─── Zod Schemas ────────────────────────────────────────────────────────────
const TokenExchangeResponseSchema = lazySchema(() => z.object({ access_token: z.string().optional(), issued_token_type: z.string().optional(), // z.coerce tolerates IdPs that send expires_in as a string (common in // PHP-backed IdPs) — technically non-conformant JSON but widespread. expires_in: z.coerce.number().optional(), scope: z.string().optional(), }),)
const JwtBearerResponseSchema = lazySchema(() => z.object({ access_token: z.string().min(1), // Many ASes omit token_type since Bearer is the only value anyone uses // (RFC 6750). Don't reject a valid access_token over a missing label. token_type: z.string().default('Bearer'), expires_in: z.coerce.number().optional(), scope: z.string().optional(), refresh_token: z.string().optional(), }),)
// ─── Layer 2: Discovery ─────────────────────────────────────────────────────
export type ProtectedResourceMetadata = { resource: string authorization_servers: string[]}
/** * RFC 9728 PRM discovery via SDK, plus RFC 9728 §3.3 resource-mismatch * validation (mix-up protection — TODO: upstream to SDK). */export async function discoverProtectedResource( serverUrl: string, opts?: { fetchFn?: FetchLike },): Promise<ProtectedResourceMetadata> { let prm try { prm = await discoverOAuthProtectedResourceMetadata( serverUrl, undefined, opts?.fetchFn ?? defaultFetch, ) } catch (e) { throw new Error( `XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`, ) } if (!prm.resource || !prm.authorization_servers?.[0]) { throw new Error( 'XAA: PRM discovery failed: PRM missing resource or authorization_servers', ) } if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) { throw new Error( `XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`, ) } return { resource: prm.resource, authorization_servers: prm.authorization_servers, }}
export type AuthorizationServerMetadata = { issuer: string token_endpoint: string grant_types_supported?: string[] token_endpoint_auth_methods_supported?: string[]}
/** * AS metadata discovery via SDK (RFC 8414 + OIDC fallback), plus RFC 8414 * §3.3 issuer-mismatch validation (mix-up protection — TODO: upstream to SDK). */export async function discoverAuthorizationServer( asUrl: string, opts?: { fetchFn?: FetchLike },): Promise<AuthorizationServerMetadata> { const meta = await discoverAuthorizationServerMetadata(asUrl, { fetchFn: opts?.fetchFn ?? defaultFetch, }) if (!meta?.issuer || !meta.token_endpoint) { throw new Error( `XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`, ) } if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) { throw new Error( `XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`, ) } // RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS // that self-consistently reports an http:// issuer would pass the mismatch // check above, then we'd POST id_token + client_secret over plaintext. if (new URL(meta.token_endpoint).protocol !== 'https:') { throw new Error( `XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`, ) } return { issuer: meta.issuer, token_endpoint: meta.token_endpoint, grant_types_supported: meta.grant_types_supported, token_endpoint_auth_methods_supported: meta.token_endpoint_auth_methods_supported, }}
// ─── Layer 2: Exchange ──────────────────────────────────────────────────────
export type JwtAuthGrantResult = { /** The ID-JAG (Identity Assertion Authorization Grant) */ jwtAuthGrant: string expiresIn?: number scope?: string}
/** * RFC 8693 Token Exchange at the IdP: id_token → ID-JAG. * Validates `issued_token_type` is `urn:ietf:params:oauth:token-type:id-jag`. * * `clientSecret` is optional — sent via `client_secret_post` if present. * Some IdPs register the client as confidential even when they advertise * `token_endpoint_auth_method: "none"`. * * TODO(xaa-ga): consult `token_endpoint_auth_methods_supported` from IdP * OIDC metadata and support `client_secret_basic`, mirroring the AS-side * selection in `performCrossAppAccess`. All major IdPs accept POST today. */export async function requestJwtAuthorizationGrant(opts: { tokenEndpoint: string audience: string resource: string idToken: string clientId: string clientSecret?: string scope?: string fetchFn?: FetchLike}): Promise<JwtAuthGrantResult> { const fetchFn = opts.fetchFn ?? defaultFetch const params = new URLSearchParams({ grant_type: TOKEN_EXCHANGE_GRANT, requested_token_type: ID_JAG_TOKEN_TYPE, audience: opts.audience, resource: opts.resource, subject_token: opts.idToken, subject_token_type: ID_TOKEN_TYPE, client_id: opts.clientId, }) if (opts.clientSecret) { params.set('client_secret', opts.clientSecret) } if (opts.scope) { params.set('scope', opts.scope) }
const res = await fetchFn(opts.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }) if (!res.ok) { const body = redactTokens(await res.text()).slice(0, 200) // 4xx → id_token rejected (invalid_grant etc.), clear cache. // 5xx → IdP outage, id_token may still be valid, preserve it. const shouldClear = res.status < 500 throw new XaaTokenExchangeError( `XAA: token exchange failed: HTTP ${res.status}: ${body}`, shouldClear, ) } let rawExchange: unknown try { rawExchange = await res.json() } catch { // Transient network condition (captive portal, proxy) — don't clear id_token. throw new XaaTokenExchangeError( `XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, false, ) } const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange) if (!exchangeParsed.success) { throw new XaaTokenExchangeError( `XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`, true, ) } const result = exchangeParsed.data if (!result.access_token) { throw new XaaTokenExchangeError( `XAA: token exchange response missing access_token: ${redactTokens(result)}`, true, ) } if (result.issued_token_type !== ID_JAG_TOKEN_TYPE) { throw new XaaTokenExchangeError( `XAA: token exchange returned unexpected issued_token_type: ${result.issued_token_type}`, true, ) } return { jwtAuthGrant: result.access_token, expiresIn: result.expires_in, scope: result.scope, }}
export type XaaTokenResult = { access_token: string token_type: string expires_in?: number scope?: string refresh_token?: string}
export type XaaResult = XaaTokenResult & { /** * The AS issuer URL discovered via PRM. Callers must persist this as * `discoveryState.authorizationServerUrl` so that refresh (auth.ts _doRefresh) * and revocation (revokeServerTokens) can locate the token/revocation * endpoints — the MCP URL is not the AS URL in typical XAA setups. */ authorizationServerUrl: string}
/** * RFC 7523 JWT Bearer Grant at the AS: ID-JAG → access_token. * * `authMethod` defaults to `client_secret_basic` (Base64 header, not body * params) — the SEP-990 conformance test requires this. Only set * `client_secret_post` if the AS explicitly requires it. */export async function exchangeJwtAuthGrant(opts: { tokenEndpoint: string assertion: string clientId: string clientSecret: string authMethod?: 'client_secret_basic' | 'client_secret_post' scope?: string fetchFn?: FetchLike}): Promise<XaaTokenResult> { const fetchFn = opts.fetchFn ?? defaultFetch const authMethod = opts.authMethod ?? 'client_secret_basic'
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion: opts.assertion, }) if (opts.scope) { params.set('scope', opts.scope) }
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', } if (authMethod === 'client_secret_basic') { const basicAuth = Buffer.from( `${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret)}`, ).toString('base64') headers.Authorization = `Basic ${basicAuth}` } else { params.set('client_id', opts.clientId) params.set('client_secret', opts.clientSecret) }
const res = await fetchFn(opts.tokenEndpoint, { method: 'POST', headers, body: params, }) if (!res.ok) { const body = redactTokens(await res.text()).slice(0, 200) throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`) } let rawTokens: unknown try { rawTokens = await res.json() } catch { throw new Error( `XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, ) } const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens) if (!tokensParsed.success) { throw new Error( `XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`, ) } return tokensParsed.data}
// ─── Layer 3: Orchestrator ──────────────────────────────────────────────────
/** * Config needed to run the full XAA orchestrator. * Mirrors the conformance test context shape (see ClientConformanceContextSchema). */export type XaaConfig = { /** Client ID registered at the MCP server's authorization server */ clientId: string /** Client secret for the MCP server's authorization server */ clientSecret: string /** Client ID registered at the IdP (for the token-exchange request) */ idpClientId: string /** Optional IdP client secret (client_secret_post) — some IdPs require it */ idpClientSecret?: string /** The user's OIDC id_token from the IdP login */ idpIdToken: string /** IdP token endpoint (where to send the RFC 8693 token-exchange) */ idpTokenEndpoint: string}
/** * Full XAA flow: PRM → AS metadata → token-exchange → jwt-bearer → access_token. * Thin composition of the four Layer-2 ops. Used by performMCPXaaAuth, * ClaudeAuthProvider.xaaRefresh, and the try-xaa*.ts debug scripts. * * @param serverUrl The MCP server URL (e.g. `https://mcp.example.com/mcp`) * @param config IdP + AS credentials * @param serverName Server name for debug logging */export async function performCrossAppAccess( serverUrl: string, config: XaaConfig, serverName = 'xaa', abortSignal?: AbortSignal,): Promise<XaaResult> { const fetchFn = makeXaaFetch(abortSignal)
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`) const prm = await discoverProtectedResource(serverUrl, { fetchFn }) logMCPDebug( serverName, `XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`, )
// Try each advertised AS in order. grant_types_supported is OPTIONAL per // RFC 8414 §2 — only skip if the AS explicitly advertises a list that omits // jwt-bearer. If absent, let the token endpoint decide. let asMeta: AuthorizationServerMetadata | undefined const asErrors: string[] = [] for (const asUrl of prm.authorization_servers) { let candidate: AuthorizationServerMetadata try { candidate = await discoverAuthorizationServer(asUrl, { fetchFn }) } catch (e) { if (abortSignal?.aborted) throw e asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`) continue } if ( candidate.grant_types_supported && !candidate.grant_types_supported.includes(JWT_BEARER_GRANT) ) { asErrors.push( `${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`, ) continue } asMeta = candidate break } if (!asMeta) { throw new Error( `XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`, ) } // Pick auth method from what the AS advertises. We handle // client_secret_basic and client_secret_post; if the AS only supports post, // honor that, else default to basic (SEP-990 conformance expectation). const authMethods = asMeta.token_endpoint_auth_methods_supported const authMethod: 'client_secret_basic' | 'client_secret_post' = authMethods && !authMethods.includes('client_secret_basic') && authMethods.includes('client_secret_post') ? 'client_secret_post' : 'client_secret_basic' logMCPDebug( serverName, `XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`, )
logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`) const jag = await requestJwtAuthorizationGrant({ tokenEndpoint: config.idpTokenEndpoint, audience: asMeta.issuer, resource: prm.resource, idToken: config.idpIdToken, clientId: config.idpClientId, clientSecret: config.idpClientSecret, fetchFn, }) logMCPDebug(serverName, `XAA: ID-JAG obtained`)
logMCPDebug(serverName, `XAA: exchanging ID-JAG for access_token at AS`) const tokens = await exchangeJwtAuthGrant({ tokenEndpoint: asMeta.token_endpoint, assertion: jag.jwtAuthGrant, clientId: config.clientId, clientSecret: config.clientSecret, authMethod, fetchFn, }) logMCPDebug(serverName, `XAA: access_token obtained`)
return { ...tokens, authorizationServerUrl: asMeta.issuer }}