xaaIdpLogin.ts
1 /** 2 * XAA IdP Login — acquires an OIDC id_token from an enterprise IdP via the 3 * standard authorization_code + PKCE flow, then caches it by IdP issuer. 4 * 5 * This is the "one browser pop" in the XAA value prop: one IdP login → N silent 6 * MCP server auths. The id_token is cached in the keychain and reused until expiry. 7 */ 8 9 import { 10 exchangeAuthorization, 11 startAuthorization, 12 } from '@modelcontextprotocol/sdk/client/auth.js' 13 import { 14 type OAuthClientInformation, 15 type OpenIdProviderDiscoveryMetadata, 16 OpenIdProviderDiscoveryMetadataSchema, 17 } from '@modelcontextprotocol/sdk/shared/auth.js' 18 import { randomBytes } from 'crypto' 19 import { createServer, type Server } from 'http' 20 import { parse } from 'url' 21 import xss from 'xss' 22 import { openBrowser } from '../../utils/browser.js' 23 import { isEnvTruthy } from '../../utils/envUtils.js' 24 import { toError } from '../../utils/errors.js' 25 import { logMCPDebug } from '../../utils/log.js' 26 import { getPlatform } from '../../utils/platform.js' 27 import { getSecureStorage } from '../../utils/secureStorage/index.js' 28 import { getInitialSettings } from '../../utils/settings/settings.js' 29 import { jsonParse } from '../../utils/slowOperations.js' 30 import { buildRedirectUri, findAvailablePort } from './oauthPort.js' 31 32 export function isXaaEnabled(): boolean { 33 return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA) 34 } 35 36 export type XaaIdpSettings = { 37 issuer: string 38 clientId: string 39 callbackPort?: number 40 } 41 42 /** 43 * Typed accessor for settings.xaaIdp. The field is env-gated in SettingsSchema 44 * so it doesn't surface in SDK types/docs — which means the inferred settings 45 * type doesn't have it at compile time. This is the one cast. 46 */ 47 export function getXaaIdpSettings(): XaaIdpSettings | undefined { 48 return (getInitialSettings() as { xaaIdp?: XaaIdpSettings }).xaaIdp 49 } 50 51 const IDP_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 52 const IDP_REQUEST_TIMEOUT_MS = 30000 53 const ID_TOKEN_EXPIRY_BUFFER_S = 60 54 55 export type IdpLoginOptions = { 56 idpIssuer: string 57 idpClientId: string 58 /** 59 * Optional IdP client secret for confidential clients. Auth method 60 * (client_secret_post, client_secret_basic, none) is chosen per IdP 61 * metadata. Omit for public clients (PKCE only). 62 */ 63 idpClientSecret?: string 64 /** 65 * Fixed callback port. If omitted, a random port is chosen. 66 * Use this when the IdP client is pre-registered with a specific loopback 67 * redirect URI (RFC 8252 §7.3 says IdPs SHOULD accept any port for 68 * http://localhost, but many don't). 69 */ 70 callbackPort?: number 71 /** Called with the authorization URL before (or instead of) opening the browser */ 72 onAuthorizationUrl?: (url: string) => void 73 /** If true, don't auto-open the browser — just call onAuthorizationUrl */ 74 skipBrowserOpen?: boolean 75 abortSignal?: AbortSignal 76 } 77 78 /** 79 * Normalize an IdP issuer URL for use as a cache key: strip trailing slashes, 80 * lowercase host. Issuers from config and from OIDC discovery may differ 81 * cosmetically but should hit the same cache slot. Exported so the setup 82 * command can compare issuers using the same normalization as keychain ops. 83 */ 84 export function issuerKey(issuer: string): string { 85 try { 86 const u = new URL(issuer) 87 u.pathname = u.pathname.replace(/\/+$/, '') 88 u.host = u.host.toLowerCase() 89 return u.toString() 90 } catch { 91 return issuer.replace(/\/+$/, '') 92 } 93 } 94 95 /** 96 * Read a cached id_token for the given IdP issuer from secure storage. 97 * Returns undefined if missing or within ID_TOKEN_EXPIRY_BUFFER_S of expiring. 98 */ 99 export function getCachedIdpIdToken(idpIssuer: string): string | undefined { 100 const storage = getSecureStorage() 101 const data = storage.read() 102 const entry = data?.mcpXaaIdp?.[issuerKey(idpIssuer)] 103 if (!entry) return undefined 104 const remainingMs = entry.expiresAt - Date.now() 105 if (remainingMs <= ID_TOKEN_EXPIRY_BUFFER_S * 1000) return undefined 106 return entry.idToken 107 } 108 109 function saveIdpIdToken( 110 idpIssuer: string, 111 idToken: string, 112 expiresAt: number, 113 ): void { 114 const storage = getSecureStorage() 115 const existing = storage.read() || {} 116 storage.update({ 117 ...existing, 118 mcpXaaIdp: { 119 ...existing.mcpXaaIdp, 120 [issuerKey(idpIssuer)]: { idToken, expiresAt }, 121 }, 122 }) 123 } 124 125 /** 126 * Save an externally-obtained id_token into the XAA cache — the exact slot 127 * getCachedIdpIdToken/acquireIdpIdToken read from. Used by conformance testing 128 * where the mock IdP hands us a pre-signed token but doesn't serve /authorize. 129 * 130 * Parses the JWT's exp claim for cache TTL (same as acquireIdpIdToken). 131 * Returns the expiresAt it computed so the caller can report it. 132 */ 133 export function saveIdpIdTokenFromJwt( 134 idpIssuer: string, 135 idToken: string, 136 ): number { 137 const expFromJwt = jwtExp(idToken) 138 const expiresAt = expFromJwt ? expFromJwt * 1000 : Date.now() + 3600 * 1000 139 saveIdpIdToken(idpIssuer, idToken, expiresAt) 140 return expiresAt 141 } 142 143 export function clearIdpIdToken(idpIssuer: string): void { 144 const storage = getSecureStorage() 145 const existing = storage.read() 146 const key = issuerKey(idpIssuer) 147 if (!existing?.mcpXaaIdp?.[key]) return 148 delete existing.mcpXaaIdp[key] 149 storage.update(existing) 150 } 151 152 /** 153 * Save an IdP client secret to secure storage, keyed by IdP issuer. 154 * Separate from MCP server AS secrets — different trust domain. 155 * Returns the storage update result so callers can surface keychain 156 * failures (locked keychain, `security` nonzero exit) instead of 157 * silently dropping the secret and failing later with invalid_client. 158 */ 159 export function saveIdpClientSecret( 160 idpIssuer: string, 161 clientSecret: string, 162 ): { success: boolean; warning?: string } { 163 const storage = getSecureStorage() 164 const existing = storage.read() || {} 165 return storage.update({ 166 ...existing, 167 mcpXaaIdpConfig: { 168 ...existing.mcpXaaIdpConfig, 169 [issuerKey(idpIssuer)]: { clientSecret }, 170 }, 171 }) 172 } 173 174 /** 175 * Read the IdP client secret for the given issuer from secure storage. 176 */ 177 export function getIdpClientSecret(idpIssuer: string): string | undefined { 178 const storage = getSecureStorage() 179 const data = storage.read() 180 return data?.mcpXaaIdpConfig?.[issuerKey(idpIssuer)]?.clientSecret 181 } 182 183 /** 184 * Remove the IdP client secret for the given issuer from secure storage. 185 * Used by `claude mcp xaa clear`. 186 */ 187 export function clearIdpClientSecret(idpIssuer: string): void { 188 const storage = getSecureStorage() 189 const existing = storage.read() 190 const key = issuerKey(idpIssuer) 191 if (!existing?.mcpXaaIdpConfig?.[key]) return 192 delete existing.mcpXaaIdpConfig[key] 193 storage.update(existing) 194 } 195 196 // OIDC Discovery §4.1 says `{issuer}/.well-known/openid-configuration` — path 197 // APPEND, not replace. `new URL('/.well-known/...', issuer)` with a leading 198 // slash is a WHATWG absolute-path reference and drops the issuer's pathname, 199 // breaking Azure AD (`login.microsoftonline.com/{tenant}/v2.0`), Okta custom 200 // auth servers, and Keycloak realms. Trailing-slash base + relative path is 201 // the fix. Exported because auth.ts needs the same discovery. 202 export async function discoverOidc( 203 idpIssuer: string, 204 ): Promise<OpenIdProviderDiscoveryMetadata> { 205 const base = idpIssuer.endsWith('/') ? idpIssuer : idpIssuer + '/' 206 const url = new URL('.well-known/openid-configuration', base) 207 // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins 208 const res = await fetch(url, { 209 headers: { Accept: 'application/json' }, 210 signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS), 211 }) 212 if (!res.ok) { 213 throw new Error( 214 `XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`, 215 ) 216 } 217 // Captive portals and proxy auth pages return 200 with HTML. res.json() 218 // throws a raw SyntaxError before safeParse can give a useful message. 219 let body: unknown 220 try { 221 body = await res.json() 222 } catch { 223 throw new Error( 224 `XAA IdP: OIDC discovery returned non-JSON at ${url} (captive portal or proxy?)`, 225 ) 226 } 227 const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body) 228 if (!parsed.success) { 229 throw new Error(`XAA IdP: invalid OIDC metadata: ${parsed.error.message}`) 230 } 231 if (new URL(parsed.data.token_endpoint).protocol !== 'https:') { 232 throw new Error( 233 `XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`, 234 ) 235 } 236 return parsed.data 237 } 238 239 /** 240 * Decode the exp claim from a JWT without verifying its signature. 241 * Returns undefined if parsing fails or exp is absent. Used only to 242 * derive a cache TTL. 243 * 244 * Why no signature/iss/aud/nonce validation: per SEP-990, this id_token 245 * is the RFC 8693 subject_token in a token-exchange at the IdP's own 246 * token endpoint. The IdP validates its own token there. An attacker who 247 * can mint a token that fools the IdP has no need to fool us first; an 248 * attacker who can't, hands us garbage and gets a 401 from the IdP. The 249 * --id-token injection seam is likewise safe: bad input → rejected later, 250 * no privesc. Client-side verification would add code and no security. 251 */ 252 function jwtExp(jwt: string): number | undefined { 253 const parts = jwt.split('.') 254 if (parts.length !== 3) return undefined 255 try { 256 const payload = jsonParse( 257 Buffer.from(parts[1]!, 'base64url').toString('utf-8'), 258 ) as { exp?: number } 259 return typeof payload.exp === 'number' ? payload.exp : undefined 260 } catch { 261 return undefined 262 } 263 } 264 265 /** 266 * Wait for the OAuth authorization code on a local callback server. 267 * Returns the code once /callback is hit with a matching state. 268 * 269 * `onListening` fires after the socket is actually bound — use it to defer 270 * browser-open so EADDRINUSE surfaces before a spurious tab pops open. 271 */ 272 function waitForCallback( 273 port: number, 274 expectedState: string, 275 abortSignal: AbortSignal | undefined, 276 onListening: () => void, 277 ): Promise<string> { 278 let server: Server | null = null 279 let timeoutId: NodeJS.Timeout | null = null 280 let abortHandler: (() => void) | null = null 281 const cleanup = () => { 282 server?.removeAllListeners() 283 // Defensive: removeAllListeners() strips the error handler, so swallow any late error during close 284 server?.on('error', () => {}) 285 server?.close() 286 server = null 287 if (timeoutId) { 288 clearTimeout(timeoutId) 289 timeoutId = null 290 } 291 if (abortSignal && abortHandler) { 292 abortSignal.removeEventListener('abort', abortHandler) 293 abortHandler = null 294 } 295 } 296 return new Promise<string>((resolve, reject) => { 297 let resolved = false 298 const resolveOnce = (v: string) => { 299 if (resolved) return 300 resolved = true 301 cleanup() 302 resolve(v) 303 } 304 const rejectOnce = (e: Error) => { 305 if (resolved) return 306 resolved = true 307 cleanup() 308 reject(e) 309 } 310 311 if (abortSignal) { 312 abortHandler = () => rejectOnce(new Error('XAA IdP: login cancelled')) 313 if (abortSignal.aborted) { 314 abortHandler() 315 return 316 } 317 abortSignal.addEventListener('abort', abortHandler, { once: true }) 318 } 319 320 server = createServer((req, res) => { 321 const parsed = parse(req.url || '', true) 322 if (parsed.pathname !== '/callback') { 323 res.writeHead(404) 324 res.end() 325 return 326 } 327 const code = parsed.query.code as string | undefined 328 const state = parsed.query.state as string | undefined 329 const err = parsed.query.error as string | undefined 330 331 if (err) { 332 const desc = parsed.query.error_description as string | undefined 333 const safeErr = xss(err) 334 const safeDesc = desc ? xss(desc) : '' 335 res.writeHead(400, { 'Content-Type': 'text/html' }) 336 res.end( 337 `<html><body><h3>IdP login failed</h3><p>${safeErr}</p><p>${safeDesc}</p></body></html>`, 338 ) 339 rejectOnce(new Error(`XAA IdP: ${err}${desc ? ` — ${desc}` : ''}`)) 340 return 341 } 342 343 if (state !== expectedState) { 344 res.writeHead(400, { 'Content-Type': 'text/html' }) 345 res.end('<html><body><h3>State mismatch</h3></body></html>') 346 rejectOnce(new Error('XAA IdP: state mismatch (possible CSRF)')) 347 return 348 } 349 350 if (!code) { 351 res.writeHead(400, { 'Content-Type': 'text/html' }) 352 res.end('<html><body><h3>Missing code</h3></body></html>') 353 rejectOnce(new Error('XAA IdP: callback missing code')) 354 return 355 } 356 357 res.writeHead(200, { 'Content-Type': 'text/html' }) 358 res.end( 359 '<html><body><h3>IdP login complete — you can close this window.</h3></body></html>', 360 ) 361 resolveOnce(code) 362 }) 363 364 server.on('error', (err: NodeJS.ErrnoException) => { 365 if (err.code === 'EADDRINUSE') { 366 const findCmd = 367 getPlatform() === 'windows' 368 ? `netstat -ano | findstr :${port}` 369 : `lsof -ti:${port} -sTCP:LISTEN` 370 rejectOnce( 371 new Error( 372 `XAA IdP: callback port ${port} is already in use. Run \`${findCmd}\` to find the holder.`, 373 ), 374 ) 375 } else { 376 rejectOnce(new Error(`XAA IdP: callback server failed: ${err.message}`)) 377 } 378 }) 379 380 server.listen(port, '127.0.0.1', () => { 381 try { 382 onListening() 383 } catch (e) { 384 rejectOnce(toError(e)) 385 } 386 }) 387 server.unref() 388 timeoutId = setTimeout( 389 rej => rej(new Error('XAA IdP: login timed out')), 390 IDP_LOGIN_TIMEOUT_MS, 391 rejectOnce, 392 ) 393 timeoutId.unref() 394 }) 395 } 396 397 /** 398 * Acquire an id_token from the IdP: return cached if valid, otherwise run 399 * the full OIDC authorization_code + PKCE flow (one browser pop). 400 */ 401 export async function acquireIdpIdToken( 402 opts: IdpLoginOptions, 403 ): Promise<string> { 404 const { idpIssuer, idpClientId } = opts 405 406 const cached = getCachedIdpIdToken(idpIssuer) 407 if (cached) { 408 logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`) 409 return cached 410 } 411 412 logMCPDebug('xaa', `No cached id_token for ${idpIssuer}; starting OIDC login`) 413 414 const metadata = await discoverOidc(idpIssuer) 415 const port = opts.callbackPort ?? (await findAvailablePort()) 416 const redirectUri = buildRedirectUri(port) 417 const state = randomBytes(32).toString('base64url') 418 const clientInformation: OAuthClientInformation = { 419 client_id: idpClientId, 420 ...(opts.idpClientSecret ? { client_secret: opts.idpClientSecret } : {}), 421 } 422 423 const { authorizationUrl, codeVerifier } = await startAuthorization( 424 idpIssuer, 425 { 426 metadata, 427 clientInformation, 428 redirectUrl: redirectUri, 429 scope: 'openid', 430 state, 431 }, 432 ) 433 434 // Open the browser only after the socket is actually bound — listen() is 435 // async, and on the fixed-callbackPort path EADDRINUSE otherwise surfaces 436 // after a spurious tab has already popped. Mirrors the auth.ts pattern of 437 // wrapping sdkAuth inside server.listen's callback. 438 const authorizationCode = await waitForCallback( 439 port, 440 state, 441 opts.abortSignal, 442 () => { 443 if (opts.onAuthorizationUrl) { 444 opts.onAuthorizationUrl(authorizationUrl.toString()) 445 } 446 if (!opts.skipBrowserOpen) { 447 logMCPDebug('xaa', `Opening browser to IdP authorization endpoint`) 448 void openBrowser(authorizationUrl.toString()) 449 } 450 }, 451 ) 452 453 const tokens = await exchangeAuthorization(idpIssuer, { 454 metadata, 455 clientInformation, 456 authorizationCode, 457 codeVerifier, 458 redirectUri, 459 fetchFn: (url, init) => 460 // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins 461 fetch(url, { 462 ...init, 463 signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS), 464 }), 465 }) 466 if (!tokens.id_token) { 467 throw new Error( 468 'XAA IdP: token response missing id_token (check scope=openid)', 469 ) 470 } 471 472 // Prefer the id_token's own exp claim; fall back to expires_in. 473 // expires_in is for the access_token and may differ from the id_token 474 // lifetime. If neither is present, default to 1h. 475 const expFromJwt = jwtExp(tokens.id_token) 476 const expiresAt = expFromJwt 477 ? expFromJwt * 1000 478 : Date.now() + (tokens.expires_in ?? 3600) * 1000 479 480 saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt) 481 logMCPDebug( 482 'xaa', 483 `Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`, 484 ) 485 486 return tokens.id_token 487 }