/ services / mcp / xaaIdpLogin.ts
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  }