xaaIdpCommand.ts 10 KB

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