source dump of claude code
at main 266 lines 10 kB view raw
1/** 2 * `claude mcp xaa` — manage the XAA (SEP-990) IdP connection. 3 * 4 * The IdP connection is user-level: configure once, all XAA-enabled MCP 5 * servers reuse it. Lives in settings.xaaIdp (non-secret) + a keychain slot 6 * keyed by issuer (secret). Separate trust domain from per-server AS secrets. 7 */ 8import type { Command } from '@commander-js/extra-typings' 9import { cliError, cliOk } from '../../cli/exit.js' 10import { 11 acquireIdpIdToken, 12 clearIdpClientSecret, 13 clearIdpIdToken, 14 getCachedIdpIdToken, 15 getIdpClientSecret, 16 getXaaIdpSettings, 17 issuerKey, 18 saveIdpClientSecret, 19 saveIdpIdTokenFromJwt, 20} from '../../services/mcp/xaaIdpLogin.js' 21import { errorMessage } from '../../utils/errors.js' 22import { updateSettingsForSource } from '../../utils/settings/settings.js' 23 24export function registerMcpXaaIdpCommand(mcp: Command): void { 25 const xaaIdp = mcp 26 .command('xaa') 27 .description('Manage the XAA (SEP-990) IdP connection') 28 29 xaaIdp 30 .command('setup') 31 .description( 32 'Configure the IdP connection (one-time setup for all XAA-enabled servers)', 33 ) 34 .requiredOption('--issuer <url>', 'IdP issuer URL (OIDC discovery)') 35 .requiredOption('--client-id <id>', "Claude Code's client_id at the IdP") 36 .option( 37 '--client-secret', 38 'Read IdP client secret from MCP_XAA_IDP_CLIENT_SECRET env var', 39 ) 40 .option( 41 '--callback-port <port>', 42 'Fixed loopback callback port (only if IdP does not honor RFC 8252 port-any matching)', 43 ) 44 .action(options => { 45 // Validate everything BEFORE any writes. An exit(1) mid-write leaves 46 // settings configured but keychain missing — confusing state. 47 // updateSettingsForSource doesn't schema-check on write; a non-URL 48 // issuer lands on disk and then poisons the whole userSettings source 49 // on next launch (SettingsSchema .url() fails → parseSettingsFile 50 // returns { settings: null }, dropping everything, not just xaaIdp). 51 let issuerUrl: URL 52 try { 53 issuerUrl = new URL(options.issuer) 54 } catch { 55 return cliError( 56 `Error: --issuer must be a valid URL (got "${options.issuer}")`, 57 ) 58 } 59 // OIDC discovery + token exchange run against this host. Allow http:// 60 // only for loopback (conformance harness mock IdP); anything else leaks 61 // the client secret and authorization code over plaintext. 62 if ( 63 issuerUrl.protocol !== 'https:' && 64 !( 65 issuerUrl.protocol === 'http:' && 66 (issuerUrl.hostname === 'localhost' || 67 issuerUrl.hostname === '127.0.0.1' || 68 issuerUrl.hostname === '[::1]') 69 ) 70 ) { 71 return cliError( 72 `Error: --issuer must use https:// (got "${issuerUrl.protocol}//${issuerUrl.host}")`, 73 ) 74 } 75 const callbackPort = options.callbackPort 76 ? parseInt(options.callbackPort, 10) 77 : undefined 78 // callbackPort <= 0 fails Zod's .positive() on next launch — same 79 // settings-poisoning failure mode as the issuer check above. 80 if ( 81 callbackPort !== undefined && 82 (!Number.isInteger(callbackPort) || callbackPort <= 0) 83 ) { 84 return cliError('Error: --callback-port must be a positive integer') 85 } 86 const secret = options.clientSecret 87 ? process.env.MCP_XAA_IDP_CLIENT_SECRET 88 : undefined 89 if (options.clientSecret && !secret) { 90 return cliError( 91 'Error: --client-secret requires MCP_XAA_IDP_CLIENT_SECRET env var', 92 ) 93 } 94 95 // Read old config now (before settings overwrite) so we can clear stale 96 // keychain slots after a successful write. `clear` can't do this after 97 // the fact — it reads the *current* settings.xaaIdp, which by then is 98 // the new one. 99 const old = getXaaIdpSettings() 100 const oldIssuer = old?.issuer 101 const oldClientId = old?.clientId 102 103 // callbackPort MUST be present (even as undefined) — mergeWith deep-merges 104 // and only deletes on explicit `undefined`, not on absent key. A conditional 105 // spread would leak a prior fixed port into a new IdP's config. 106 const { error } = updateSettingsForSource('userSettings', { 107 xaaIdp: { 108 issuer: options.issuer, 109 clientId: options.clientId, 110 callbackPort, 111 }, 112 }) 113 if (error) { 114 return cliError(`Error writing settings: ${error.message}`) 115 } 116 117 // Clear stale keychain slots only after settings write succeeded — 118 // otherwise a write failure leaves settings pointing at oldIssuer with 119 // its secret already gone. Compare via issuerKey(): trailing-slash or 120 // host-case differences normalize to the same keychain slot. 121 if (oldIssuer) { 122 if (issuerKey(oldIssuer) !== issuerKey(options.issuer)) { 123 clearIdpIdToken(oldIssuer) 124 clearIdpClientSecret(oldIssuer) 125 } else if (oldClientId !== options.clientId) { 126 // Same issuer slot but different OAuth client registration — the 127 // cached id_token's aud claim and the stored secret are both for the 128 // old client. `xaa login` would send {new clientId, old secret} and 129 // fail with opaque `invalid_client`; downstream SEP-990 exchange 130 // would fail aud validation. Keep both when clientId is unchanged: 131 // re-setup without --client-secret means "tweak port, keep secret". 132 clearIdpIdToken(oldIssuer) 133 clearIdpClientSecret(oldIssuer) 134 } 135 } 136 137 if (secret) { 138 const { success, warning } = saveIdpClientSecret(options.issuer, secret) 139 if (!success) { 140 return cliError( 141 `Error: settings written but keychain save failed${warning ? `${warning}` : ''}. ` + 142 `Re-run with --client-secret once keychain is available.`, 143 ) 144 } 145 } 146 147 cliOk(`XAA IdP connection configured for ${options.issuer}`) 148 }) 149 150 xaaIdp 151 .command('login') 152 .description( 153 'Cache an IdP id_token so XAA-enabled MCP servers authenticate ' + 154 'silently. Default: run the OIDC browser login. With --id-token: ' + 155 'write a pre-obtained JWT directly (used by conformance/e2e tests ' + 156 'where the mock IdP does not serve /authorize).', 157 ) 158 .option( 159 '--force', 160 'Ignore any cached id_token and re-login (useful after IdP-side revocation)', 161 ) 162 // TODO(paulc): read the JWT from stdin instead of argv to keep it out of 163 // shell history. Fine for conformance (docker exec uses argv directly, 164 // no shell parser), but a real user would want `echo $TOKEN | ... --stdin`. 165 .option( 166 '--id-token <jwt>', 167 'Write this pre-obtained id_token directly to cache, skipping the OIDC browser login', 168 ) 169 .action(async options => { 170 const idp = getXaaIdpSettings() 171 if (!idp) { 172 return cliError( 173 "Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.", 174 ) 175 } 176 177 // Direct-inject path: skip cache check, skip OIDC. Writing IS the 178 // operation. Issuer comes from settings (single source of truth), not 179 // a separate flag — one less thing to desync. 180 if (options.idToken) { 181 const expiresAt = saveIdpIdTokenFromJwt(idp.issuer, options.idToken) 182 return cliOk( 183 `id_token cached for ${idp.issuer} (expires ${new Date(expiresAt).toISOString()})`, 184 ) 185 } 186 187 if (options.force) { 188 clearIdpIdToken(idp.issuer) 189 } 190 191 const wasCached = getCachedIdpIdToken(idp.issuer) !== undefined 192 if (wasCached) { 193 return cliOk( 194 `Already logged in to ${idp.issuer} (cached id_token still valid). Use --force to re-login.`, 195 ) 196 } 197 198 process.stdout.write(`Opening browser for IdP login at ${idp.issuer}\n`) 199 try { 200 await acquireIdpIdToken({ 201 idpIssuer: idp.issuer, 202 idpClientId: idp.clientId, 203 idpClientSecret: getIdpClientSecret(idp.issuer), 204 callbackPort: idp.callbackPort, 205 onAuthorizationUrl: url => { 206 process.stdout.write( 207 `If the browser did not open, visit:\n ${url}\n`, 208 ) 209 }, 210 }) 211 cliOk( 212 `Logged in. MCP servers with --xaa will now authenticate silently.`, 213 ) 214 } catch (e) { 215 cliError(`IdP login failed: ${errorMessage(e)}`) 216 } 217 }) 218 219 xaaIdp 220 .command('show') 221 .description('Show the current IdP connection config') 222 .action(() => { 223 const idp = getXaaIdpSettings() 224 if (!idp) { 225 return cliOk('No XAA IdP connection configured.') 226 } 227 const hasSecret = getIdpClientSecret(idp.issuer) !== undefined 228 const hasIdToken = getCachedIdpIdToken(idp.issuer) !== undefined 229 process.stdout.write(`Issuer: ${idp.issuer}\n`) 230 process.stdout.write(`Client ID: ${idp.clientId}\n`) 231 if (idp.callbackPort !== undefined) { 232 process.stdout.write(`Callback port: ${idp.callbackPort}\n`) 233 } 234 process.stdout.write( 235 `Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`, 236 ) 237 process.stdout.write( 238 `Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`, 239 ) 240 cliOk() 241 }) 242 243 xaaIdp 244 .command('clear') 245 .description('Clear the IdP connection config and cached id_token') 246 .action(() => { 247 // Read issuer first so we can clear the right keychain slots. 248 const idp = getXaaIdpSettings() 249 // updateSettingsForSource uses mergeWith: set to undefined (not delete) 250 // to signal key removal. 251 const { error } = updateSettingsForSource('userSettings', { 252 xaaIdp: undefined, 253 }) 254 if (error) { 255 return cliError(`Error writing settings: ${error.message}`) 256 } 257 // Clear keychain only after settings write succeeded — otherwise a 258 // write failure leaves settings pointing at the IdP with its secrets 259 // already gone (same pattern as `setup`'s old-issuer cleanup). 260 if (idp) { 261 clearIdpIdToken(idp.issuer) 262 clearIdpClientSecret(idp.issuer) 263 } 264 cliOk('XAA IdP connection cleared') 265 }) 266}