/ commands / mcp / xaaIdpCommand.ts
xaaIdpCommand.ts
  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   */
  8  import type { Command } from '@commander-js/extra-typings'
  9  import { cliError, cliOk } from '../../cli/exit.js'
 10  import {
 11    acquireIdpIdToken,
 12    clearIdpClientSecret,
 13    clearIdpIdToken,
 14    getCachedIdpIdToken,
 15    getIdpClientSecret,
 16    getXaaIdpSettings,
 17    issuerKey,
 18    saveIdpClientSecret,
 19    saveIdpIdTokenFromJwt,
 20  } from '../../services/mcp/xaaIdpLogin.js'
 21  import { errorMessage } from '../../utils/errors.js'
 22  import { updateSettingsForSource } from '../../utils/settings/settings.js'
 23  
 24  export 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  }