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