Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
10 kB · 266 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267/** * `claude mcp xaa` — manage the XAA (SEP-990) IdP connection. * * The IdP connection is user-level: configure once, all XAA-enabled MCP * servers reuse it. Lives in settings.xaaIdp (non-secret) + a keychain slot * keyed by issuer (secret). Separate trust domain from per-server AS secrets. */import type { Command } from '@commander-js/extra-typings'import { cliError, cliOk } from '../../cli/exit.js'import { acquireIdpIdToken, clearIdpClientSecret, clearIdpIdToken, getCachedIdpIdToken, getIdpClientSecret, getXaaIdpSettings, issuerKey, saveIdpClientSecret, saveIdpIdTokenFromJwt,} from '../../services/mcp/xaaIdpLogin.js'import { errorMessage } from '../../utils/errors.js'import { updateSettingsForSource } from '../../utils/settings/settings.js'
export function registerMcpXaaIdpCommand(mcp: Command): void { const xaaIdp = mcp .command('xaa') .description('Manage the XAA (SEP-990) IdP connection')
xaaIdp .command('setup') .description( 'Configure the IdP connection (one-time setup for all XAA-enabled servers)', ) .requiredOption('--issuer <url>', 'IdP issuer URL (OIDC discovery)') .requiredOption('--client-id <id>', "Claude Code's client_id at the IdP") .option( '--client-secret', 'Read IdP client secret from MCP_XAA_IDP_CLIENT_SECRET env var', ) .option( '--callback-port <port>', 'Fixed loopback callback port (only if IdP does not honor RFC 8252 port-any matching)', ) .action(options => { // Validate everything BEFORE any writes. An exit(1) mid-write leaves // settings configured but keychain missing — confusing state. // updateSettingsForSource doesn't schema-check on write; a non-URL // issuer lands on disk and then poisons the whole userSettings source // on next launch (SettingsSchema .url() fails → parseSettingsFile // returns { settings: null }, dropping everything, not just xaaIdp). let issuerUrl: URL try { issuerUrl = new URL(options.issuer) } catch { return cliError( `Error: --issuer must be a valid URL (got "${options.issuer}")`, ) } // OIDC discovery + token exchange run against this host. Allow http:// // only for loopback (conformance harness mock IdP); anything else leaks // the client secret and authorization code over plaintext. if ( issuerUrl.protocol !== 'https:' && !( issuerUrl.protocol === 'http:' && (issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1' || issuerUrl.hostname === '[::1]') ) ) { return cliError( `Error: --issuer must use https:// (got "${issuerUrl.protocol}//${issuerUrl.host}")`, ) } const callbackPort = options.callbackPort ? parseInt(options.callbackPort, 10) : undefined // callbackPort <= 0 fails Zod's .positive() on next launch — same // settings-poisoning failure mode as the issuer check above. if ( callbackPort !== undefined && (!Number.isInteger(callbackPort) || callbackPort <= 0) ) { return cliError('Error: --callback-port must be a positive integer') } const secret = options.clientSecret ? process.env.MCP_XAA_IDP_CLIENT_SECRET : undefined if (options.clientSecret && !secret) { return cliError( 'Error: --client-secret requires MCP_XAA_IDP_CLIENT_SECRET env var', ) }
// Read old config now (before settings overwrite) so we can clear stale // keychain slots after a successful write. `clear` can't do this after // the fact — it reads the *current* settings.xaaIdp, which by then is // the new one. const old = getXaaIdpSettings() const oldIssuer = old?.issuer const oldClientId = old?.clientId
// callbackPort MUST be present (even as undefined) — mergeWith deep-merges // and only deletes on explicit `undefined`, not on absent key. A conditional // spread would leak a prior fixed port into a new IdP's config. const { error } = updateSettingsForSource('userSettings', { xaaIdp: { issuer: options.issuer, clientId: options.clientId, callbackPort, }, }) if (error) { return cliError(`Error writing settings: ${error.message}`) }
// Clear stale keychain slots only after settings write succeeded — // otherwise a write failure leaves settings pointing at oldIssuer with // its secret already gone. Compare via issuerKey(): trailing-slash or // host-case differences normalize to the same keychain slot. if (oldIssuer) { if (issuerKey(oldIssuer) !== issuerKey(options.issuer)) { clearIdpIdToken(oldIssuer) clearIdpClientSecret(oldIssuer) } else if (oldClientId !== options.clientId) { // Same issuer slot but different OAuth client registration — the // cached id_token's aud claim and the stored secret are both for the // old client. `xaa login` would send {new clientId, old secret} and // fail with opaque `invalid_client`; downstream SEP-990 exchange // would fail aud validation. Keep both when clientId is unchanged: // re-setup without --client-secret means "tweak port, keep secret". clearIdpIdToken(oldIssuer) clearIdpClientSecret(oldIssuer) } }
if (secret) { const { success, warning } = saveIdpClientSecret(options.issuer, secret) if (!success) { return cliError( `Error: settings written but keychain save failed${warning ? ` — ${warning}` : ''}. ` + `Re-run with --client-secret once keychain is available.`, ) } }
cliOk(`XAA IdP connection configured for ${options.issuer}`) })
xaaIdp .command('login') .description( 'Cache an IdP id_token so XAA-enabled MCP servers authenticate ' + 'silently. Default: run the OIDC browser login. With --id-token: ' + 'write a pre-obtained JWT directly (used by conformance/e2e tests ' + 'where the mock IdP does not serve /authorize).', ) .option( '--force', 'Ignore any cached id_token and re-login (useful after IdP-side revocation)', ) // TODO(paulc): read the JWT from stdin instead of argv to keep it out of // shell history. Fine for conformance (docker exec uses argv directly, // no shell parser), but a real user would want `echo $TOKEN | ... --stdin`. .option( '--id-token <jwt>', 'Write this pre-obtained id_token directly to cache, skipping the OIDC browser login', ) .action(async options => { const idp = getXaaIdpSettings() if (!idp) { return cliError( "Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.", ) }
// Direct-inject path: skip cache check, skip OIDC. Writing IS the // operation. Issuer comes from settings (single source of truth), not // a separate flag — one less thing to desync. if (options.idToken) { const expiresAt = saveIdpIdTokenFromJwt(idp.issuer, options.idToken) return cliOk( `id_token cached for ${idp.issuer} (expires ${new Date(expiresAt).toISOString()})`, ) }
if (options.force) { clearIdpIdToken(idp.issuer) }
const wasCached = getCachedIdpIdToken(idp.issuer) !== undefined if (wasCached) { return cliOk( `Already logged in to ${idp.issuer} (cached id_token still valid). Use --force to re-login.`, ) }
process.stdout.write(`Opening browser for IdP login at ${idp.issuer}…\n`) try { await acquireIdpIdToken({ idpIssuer: idp.issuer, idpClientId: idp.clientId, idpClientSecret: getIdpClientSecret(idp.issuer), callbackPort: idp.callbackPort, onAuthorizationUrl: url => { process.stdout.write( `If the browser did not open, visit:\n ${url}\n`, ) }, }) cliOk( `Logged in. MCP servers with --xaa will now authenticate silently.`, ) } catch (e) { cliError(`IdP login failed: ${errorMessage(e)}`) } })
xaaIdp .command('show') .description('Show the current IdP connection config') .action(() => { const idp = getXaaIdpSettings() if (!idp) { return cliOk('No XAA IdP connection configured.') } const hasSecret = getIdpClientSecret(idp.issuer) !== undefined const hasIdToken = getCachedIdpIdToken(idp.issuer) !== undefined process.stdout.write(`Issuer: ${idp.issuer}\n`) process.stdout.write(`Client ID: ${idp.clientId}\n`) if (idp.callbackPort !== undefined) { process.stdout.write(`Callback port: ${idp.callbackPort}\n`) } process.stdout.write( `Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`, ) process.stdout.write( `Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`, ) cliOk() })
xaaIdp .command('clear') .description('Clear the IdP connection config and cached id_token') .action(() => { // Read issuer first so we can clear the right keychain slots. const idp = getXaaIdpSettings() // updateSettingsForSource uses mergeWith: set to undefined (not delete) // to signal key removal. const { error } = updateSettingsForSource('userSettings', { xaaIdp: undefined, }) if (error) { return cliError(`Error writing settings: ${error.message}`) } // Clear keychain only after settings write succeeded — otherwise a // write failure leaves settings pointing at the IdP with its secrets // already gone (same pattern as `setup`'s old-issuer cleanup). if (idp) { clearIdpIdToken(idp.issuer) clearIdpClientSecret(idp.issuer) } cliOk('XAA IdP connection cleared') })}