source dump of claude code
at main 210 lines 7.8 kB view raw
1import axios from 'axios' 2import memoize from 'lodash-es/memoize.js' 3import { hostname } from 'os' 4import { getOauthConfig } from '../constants/oauth.js' 5import { 6 checkGate_CACHED_OR_BLOCKING, 7 getFeatureValue_CACHED_MAY_BE_STALE, 8} from '../services/analytics/growthbook.js' 9import { logForDebugging } from '../utils/debug.js' 10import { errorMessage } from '../utils/errors.js' 11import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' 12import { getSecureStorage } from '../utils/secureStorage/index.js' 13import { 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 33const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement' 34 35function 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. 45const 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 54export function getTrustedDeviceToken(): string | undefined { 55 if (!isGateEnabled()) { 56 return undefined 57 } 58 return readStoredToken() 59} 60 61export 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 */ 72export 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 */ 98export 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}