trustedDevice.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import axios from 'axios'
  2. import memoize from 'lodash-es/memoize.js'
  3. import { hostname } from 'os'
  4. import { getOauthConfig } from '../constants/oauth.js'
  5. import {
  6. checkGate_CACHED_OR_BLOCKING,
  7. getFeatureValue_CACHED_MAY_BE_STALE,
  8. } from '../services/analytics/growthbook.js'
  9. import { logForDebugging } from '../utils/debug.js'
  10. import { errorMessage } from '../utils/errors.js'
  11. import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
  12. import { getSecureStorage } from '../utils/secureStorage/index.js'
  13. import { jsonStringify } from '../utils/slowOperations.js'
  14. /**
  15. * Trusted device token source for bridge (remote-control) sessions.
  16. *
  17. * Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
  18. * The server gates ConnectBridgeWorker on its own flag
  19. * (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
  20. * flag controls whether the CLI sends X-Trusted-Device-Token at all.
  21. * Two flags so rollout can be staged: flip CLI-side first (headers
  22. * start flowing, server still no-ops), then flip server-side.
  23. *
  24. * Enrollment (POST /auth/trusted_devices) is gated server-side by
  25. * account_session.created_at < 10min, so it must happen during /login.
  26. * Token is persistent (90d rolling expiry) and stored in keychain.
  27. *
  28. * See anthropics/anthropic#274559 (spec), #310375 (B1b tenant RPCs),
  29. * #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
  30. */
  31. const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
  32. function isGateEnabled(): boolean {
  33. return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
  34. }
  35. // Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
  36. // bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
  37. // Cache cleared after enrollment (below) and on logout (clearAuthRelatedCaches).
  38. //
  39. // Only the storage read is memoized — the GrowthBook gate is checked live so
  40. // that a gate flip after GrowthBook refresh takes effect without a restart.
  41. const readStoredToken = memoize((): string | undefined => {
  42. // Env var takes precedence for testing/canary.
  43. const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
  44. if (envToken) {
  45. return envToken
  46. }
  47. return getSecureStorage().read()?.trustedDeviceToken
  48. })
  49. export function getTrustedDeviceToken(): string | undefined {
  50. if (!isGateEnabled()) {
  51. return undefined
  52. }
  53. return readStoredToken()
  54. }
  55. export function clearTrustedDeviceTokenCache(): void {
  56. readStoredToken.cache?.clear?.()
  57. }
  58. /**
  59. * Clear the stored trusted device token from secure storage and the memo cache.
  60. * Called before enrollTrustedDevice() during /login so a stale token from the
  61. * previous account isn't sent as X-Trusted-Device-Token while enrollment is
  62. * in-flight (enrollTrustedDevice is async — bridge API calls between login and
  63. * enrollment completion would otherwise still read the old cached token).
  64. */
  65. export function clearTrustedDeviceToken(): void {
  66. if (!isGateEnabled()) {
  67. return
  68. }
  69. const secureStorage = getSecureStorage()
  70. try {
  71. const data = secureStorage.read()
  72. if (data?.trustedDeviceToken) {
  73. delete data.trustedDeviceToken
  74. secureStorage.update(data)
  75. }
  76. } catch {
  77. // Best-effort — don't block login if storage is inaccessible
  78. }
  79. readStoredToken.cache?.clear?.()
  80. }
  81. /**
  82. * Enroll this device via POST /auth/trusted_devices and persist the token
  83. * to keychain. Best-effort — logs and returns on failure so callers
  84. * (post-login hooks) don't block the login flow.
  85. *
  86. * The server gates enrollment on account_session.created_at < 10min, so
  87. * this must be called immediately after a fresh /login. Calling it later
  88. * (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
  89. */
  90. export async function enrollTrustedDevice(): Promise<void> {
  91. try {
  92. // checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
  93. // (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
  94. // reading the gate, so we get the post-refresh value.
  95. if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
  96. logForDebugging(
  97. `[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
  98. )
  99. return
  100. }
  101. // If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
  102. // skip enrollment — the env var takes precedence in readStoredToken() so
  103. // any enrolled token would be shadowed and never used.
  104. if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
  105. logForDebugging(
  106. '[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
  107. )
  108. return
  109. }
  110. // Lazy require — utils/auth.ts transitively pulls ~1300 modules
  111. // (config → file → permissions → sessionStorage → commands). Daemon callers
  112. // of getTrustedDeviceToken() don't need this; only /login does.
  113. /* eslint-disable @typescript-eslint/no-require-imports */
  114. const { getClaudeAIOAuthTokens } =
  115. require('../utils/auth.js') as typeof import('../utils/auth.js')
  116. /* eslint-enable @typescript-eslint/no-require-imports */
  117. const accessToken = getClaudeAIOAuthTokens()?.accessToken
  118. if (!accessToken) {
  119. logForDebugging('[trusted-device] No OAuth token, skipping enrollment')
  120. return
  121. }
  122. // Always re-enroll on /login — the existing token may belong to a
  123. // different account (account-switch without /logout). Skipping enrollment
  124. // would send the old account's token on the new account's bridge calls.
  125. const secureStorage = getSecureStorage()
  126. if (isEssentialTrafficOnly()) {
  127. logForDebugging(
  128. '[trusted-device] Essential traffic only, skipping enrollment',
  129. )
  130. return
  131. }
  132. const baseUrl = getOauthConfig().BASE_API_URL
  133. let response
  134. try {
  135. response = await axios.post<{
  136. device_token?: string
  137. device_id?: string
  138. }>(
  139. `${baseUrl}/api/auth/trusted_devices`,
  140. { display_name: `Claude Code on ${hostname()} · ${process.platform}` },
  141. {
  142. headers: {
  143. Authorization: `Bearer ${accessToken}`,
  144. 'Content-Type': 'application/json',
  145. },
  146. timeout: 10_000,
  147. validateStatus: s => s < 500,
  148. },
  149. )
  150. } catch (err: unknown) {
  151. logForDebugging(
  152. `[trusted-device] Enrollment request failed: ${errorMessage(err)}`,
  153. )
  154. return
  155. }
  156. if (response.status !== 200 && response.status !== 201) {
  157. logForDebugging(
  158. `[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`,
  159. )
  160. return
  161. }
  162. const token = response.data?.device_token
  163. if (!token || typeof token !== 'string') {
  164. logForDebugging(
  165. '[trusted-device] Enrollment response missing device_token field',
  166. )
  167. return
  168. }
  169. try {
  170. const storageData = secureStorage.read()
  171. if (!storageData) {
  172. logForDebugging(
  173. '[trusted-device] Cannot read storage, skipping token persist',
  174. )
  175. return
  176. }
  177. storageData.trustedDeviceToken = token
  178. const result = secureStorage.update(storageData)
  179. if (!result.success) {
  180. logForDebugging(
  181. `[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`,
  182. )
  183. return
  184. }
  185. readStoredToken.cache?.clear?.()
  186. logForDebugging(
  187. `[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`,
  188. )
  189. } catch (err: unknown) {
  190. logForDebugging(
  191. `[trusted-device] Storage write failed: ${errorMessage(err)}`,
  192. )
  193. }
  194. } catch (err: unknown) {
  195. logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`)
  196. }
  197. }