531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
/**
|
|
* 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]"`)
|
|
}
|
|
|
|
function summarizeXaaPayload(raw: unknown): string {
|
|
if (typeof raw === 'string') {
|
|
return `text(${raw.length} chars)`
|
|
}
|
|
if (Array.isArray(raw)) {
|
|
return `array(${raw.length})`
|
|
}
|
|
if (raw && typeof raw === 'object') {
|
|
return jsonStringify({
|
|
payloadType: 'object',
|
|
keys: Object.keys(raw as Record<string, unknown>)
|
|
.sort()
|
|
.slice(0, 10),
|
|
})
|
|
}
|
|
return typeof raw
|
|
}
|
|
|
|
// ─── 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.name : typeof 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')
|
|
}
|
|
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')
|
|
}
|
|
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
|
|
throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
|
|
}
|
|
// 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')
|
|
}
|
|
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 = summarizeXaaPayload(redactTokens(await res.text()))
|
|
// 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 response (captive portal?)',
|
|
false,
|
|
)
|
|
}
|
|
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
|
|
if (!exchangeParsed.success) {
|
|
throw new XaaTokenExchangeError(
|
|
`XAA: token exchange response did not match expected shape: ${summarizeXaaPayload(
|
|
redactTokens(rawExchange),
|
|
)}`,
|
|
true,
|
|
)
|
|
}
|
|
const result = exchangeParsed.data
|
|
if (!result.access_token) {
|
|
throw new XaaTokenExchangeError(
|
|
`XAA: token exchange response missing access_token: ${summarizeXaaPayload(
|
|
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 = summarizeXaaPayload(redactTokens(await res.text()))
|
|
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 response (captive portal?)',
|
|
)
|
|
}
|
|
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
|
|
if (!tokensParsed.success) {
|
|
throw new Error(
|
|
`XAA: jwt-bearer response did not match expected shape: ${summarizeXaaPayload(
|
|
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 protected resource metadata')
|
|
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
|
|
logMCPDebug(
|
|
serverName,
|
|
`XAA: discovered protected resource metadata ${jsonStringify({
|
|
hasResource: Boolean(prm.resource),
|
|
authorizationServerCount: prm.authorization_servers.length,
|
|
})}`,
|
|
)
|
|
|
|
// 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(
|
|
`authorization server discovery failed (${e instanceof Error ? e.name : typeof e})`,
|
|
)
|
|
continue
|
|
}
|
|
if (
|
|
candidate.grant_types_supported &&
|
|
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
|
|
) {
|
|
asErrors.push('authorization server does not advertise jwt-bearer grant')
|
|
continue
|
|
}
|
|
asMeta = candidate
|
|
break
|
|
}
|
|
if (!asMeta) {
|
|
throw new Error(
|
|
`XAA: no authorization server supports jwt-bearer (${asErrors.length} candidates tried)`,
|
|
)
|
|
}
|
|
// 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: selected authorization server (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 }
|
|
}
|