the claude code sourcemaps leaked march 31
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 566 lines 18 kB view raw
1// OAuth client for handling authentication flows with Claude services 2import axios from 'axios' 3import { 4 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5 logEvent, 6} from 'src/services/analytics/index.js' 7import { 8 ALL_OAUTH_SCOPES, 9 CLAUDE_AI_INFERENCE_SCOPE, 10 CLAUDE_AI_OAUTH_SCOPES, 11 getOauthConfig, 12} from '../../constants/oauth.js' 13import { 14 checkAndRefreshOAuthTokenIfNeeded, 15 getClaudeAIOAuthTokens, 16 hasProfileScope, 17 isClaudeAISubscriber, 18 saveApiKey, 19} from '../../utils/auth.js' 20import type { AccountInfo } from '../../utils/config.js' 21import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 22import { logForDebugging } from '../../utils/debug.js' 23import { getOauthProfileFromOauthToken } from './getOauthProfile.js' 24import type { 25 BillingType, 26 OAuthProfileResponse, 27 OAuthTokenExchangeResponse, 28 OAuthTokens, 29 RateLimitTier, 30 SubscriptionType, 31 UserRolesResponse, 32} from './types.js' 33 34/** 35 * Check if the user has Claude.ai authentication scope 36 * @private Only call this if you're OAuth / auth related code! 37 */ 38export function shouldUseClaudeAIAuth(scopes: string[] | undefined): boolean { 39 return Boolean(scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE)) 40} 41 42export function parseScopes(scopeString?: string): string[] { 43 return scopeString?.split(' ').filter(Boolean) ?? [] 44} 45 46export function buildAuthUrl({ 47 codeChallenge, 48 state, 49 port, 50 isManual, 51 loginWithClaudeAi, 52 inferenceOnly, 53 orgUUID, 54 loginHint, 55 loginMethod, 56}: { 57 codeChallenge: string 58 state: string 59 port: number 60 isManual: boolean 61 loginWithClaudeAi?: boolean 62 inferenceOnly?: boolean 63 orgUUID?: string 64 loginHint?: string 65 loginMethod?: string 66}): string { 67 const authUrlBase = loginWithClaudeAi 68 ? getOauthConfig().CLAUDE_AI_AUTHORIZE_URL 69 : getOauthConfig().CONSOLE_AUTHORIZE_URL 70 71 const authUrl = new URL(authUrlBase) 72 authUrl.searchParams.append('code', 'true') // this tells the login page to show Claude Max upsell 73 authUrl.searchParams.append('client_id', getOauthConfig().CLIENT_ID) 74 authUrl.searchParams.append('response_type', 'code') 75 authUrl.searchParams.append( 76 'redirect_uri', 77 isManual 78 ? getOauthConfig().MANUAL_REDIRECT_URL 79 : `http://localhost:${port}/callback`, 80 ) 81 const scopesToUse = inferenceOnly 82 ? [CLAUDE_AI_INFERENCE_SCOPE] // Long-lived inference-only tokens 83 : ALL_OAUTH_SCOPES 84 authUrl.searchParams.append('scope', scopesToUse.join(' ')) 85 authUrl.searchParams.append('code_challenge', codeChallenge) 86 authUrl.searchParams.append('code_challenge_method', 'S256') 87 authUrl.searchParams.append('state', state) 88 89 // Add orgUUID as URL param if provided 90 if (orgUUID) { 91 authUrl.searchParams.append('orgUUID', orgUUID) 92 } 93 94 // Pre-populate email on the login form (standard OIDC parameter) 95 if (loginHint) { 96 authUrl.searchParams.append('login_hint', loginHint) 97 } 98 99 // Request a specific login method (e.g. 'sso', 'magic_link', 'google') 100 if (loginMethod) { 101 authUrl.searchParams.append('login_method', loginMethod) 102 } 103 104 return authUrl.toString() 105} 106 107export async function exchangeCodeForTokens( 108 authorizationCode: string, 109 state: string, 110 codeVerifier: string, 111 port: number, 112 useManualRedirect: boolean = false, 113 expiresIn?: number, 114): Promise<OAuthTokenExchangeResponse> { 115 const requestBody: Record<string, string | number> = { 116 grant_type: 'authorization_code', 117 code: authorizationCode, 118 redirect_uri: useManualRedirect 119 ? getOauthConfig().MANUAL_REDIRECT_URL 120 : `http://localhost:${port}/callback`, 121 client_id: getOauthConfig().CLIENT_ID, 122 code_verifier: codeVerifier, 123 state, 124 } 125 126 if (expiresIn !== undefined) { 127 requestBody.expires_in = expiresIn 128 } 129 130 const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { 131 headers: { 'Content-Type': 'application/json' }, 132 timeout: 15000, 133 }) 134 135 if (response.status !== 200) { 136 throw new Error( 137 response.status === 401 138 ? 'Authentication failed: Invalid authorization code' 139 : `Token exchange failed (${response.status}): ${response.statusText}`, 140 ) 141 } 142 logEvent('tengu_oauth_token_exchange_success', {}) 143 return response.data 144} 145 146export async function refreshOAuthToken( 147 refreshToken: string, 148 { scopes: requestedScopes }: { scopes?: string[] } = {}, 149): Promise<OAuthTokens> { 150 const requestBody = { 151 grant_type: 'refresh_token', 152 refresh_token: refreshToken, 153 client_id: getOauthConfig().CLIENT_ID, 154 // Request specific scopes, defaulting to the full Claude AI set. The 155 // backend's refresh-token grant allows scope expansion beyond what the 156 // initial authorize granted (see ALLOWED_SCOPE_EXPANSIONS), so this is 157 // safe even for tokens issued before scopes were added to the app's 158 // registered oauth_scope. 159 scope: (requestedScopes?.length 160 ? requestedScopes 161 : CLAUDE_AI_OAUTH_SCOPES 162 ).join(' '), 163 } 164 165 try { 166 const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { 167 headers: { 'Content-Type': 'application/json' }, 168 timeout: 15000, 169 }) 170 171 if (response.status !== 200) { 172 throw new Error(`Token refresh failed: ${response.statusText}`) 173 } 174 175 const data = response.data as OAuthTokenExchangeResponse 176 const { 177 access_token: accessToken, 178 refresh_token: newRefreshToken = refreshToken, 179 expires_in: expiresIn, 180 } = data 181 182 const expiresAt = Date.now() + expiresIn * 1000 183 const scopes = parseScopes(data.scope) 184 185 logEvent('tengu_oauth_token_refresh_success', {}) 186 187 // Skip the extra /api/oauth/profile round-trip when we already have both 188 // the global-config profile fields AND the secure-storage subscription data. 189 // Routine refreshes satisfy both, so we cut ~7M req/day fleet-wide. 190 // 191 // Checking secure storage (not just config) matters for the 192 // CLAUDE_CODE_OAUTH_REFRESH_TOKEN re-login path: installOAuthTokens runs 193 // performLogout() AFTER we return, wiping secure storage. If we returned 194 // null for subscriptionType here, saveOAuthTokensIfNeeded would persist 195 // null ?? (wiped) ?? null = null, and every future refresh would see the 196 // config guard fields satisfied and skip again, permanently losing the 197 // subscription type for paying users. By passing through existing values, 198 // the re-login path writes cached ?? wiped ?? null = cached; and if secure 199 // storage was already empty we fall through to the fetch. 200 const config = getGlobalConfig() 201 const existing = getClaudeAIOAuthTokens() 202 const haveProfileAlready = 203 config.oauthAccount?.billingType !== undefined && 204 config.oauthAccount?.accountCreatedAt !== undefined && 205 config.oauthAccount?.subscriptionCreatedAt !== undefined && 206 existing?.subscriptionType != null && 207 existing?.rateLimitTier != null 208 209 const profileInfo = haveProfileAlready 210 ? null 211 : await fetchProfileInfo(accessToken) 212 213 // Update the stored properties if they have changed 214 if (profileInfo && config.oauthAccount) { 215 const updates: Partial<AccountInfo> = {} 216 if (profileInfo.displayName !== undefined) { 217 updates.displayName = profileInfo.displayName 218 } 219 if (typeof profileInfo.hasExtraUsageEnabled === 'boolean') { 220 updates.hasExtraUsageEnabled = profileInfo.hasExtraUsageEnabled 221 } 222 if (profileInfo.billingType !== null) { 223 updates.billingType = profileInfo.billingType 224 } 225 if (profileInfo.accountCreatedAt !== undefined) { 226 updates.accountCreatedAt = profileInfo.accountCreatedAt 227 } 228 if (profileInfo.subscriptionCreatedAt !== undefined) { 229 updates.subscriptionCreatedAt = profileInfo.subscriptionCreatedAt 230 } 231 if (Object.keys(updates).length > 0) { 232 saveGlobalConfig(current => ({ 233 ...current, 234 oauthAccount: current.oauthAccount 235 ? { ...current.oauthAccount, ...updates } 236 : current.oauthAccount, 237 })) 238 } 239 } 240 241 return { 242 accessToken, 243 refreshToken: newRefreshToken, 244 expiresAt, 245 scopes, 246 subscriptionType: 247 profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null, 248 rateLimitTier: 249 profileInfo?.rateLimitTier ?? existing?.rateLimitTier ?? null, 250 profile: profileInfo?.rawProfile, 251 tokenAccount: data.account 252 ? { 253 uuid: data.account.uuid, 254 emailAddress: data.account.email_address, 255 organizationUuid: data.organization?.uuid, 256 } 257 : undefined, 258 } 259 } catch (error) { 260 const responseBody = 261 axios.isAxiosError(error) && error.response?.data 262 ? JSON.stringify(error.response.data) 263 : undefined 264 logEvent('tengu_oauth_token_refresh_failure', { 265 error: (error as Error) 266 .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 267 ...(responseBody && { 268 responseBody: 269 responseBody as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 270 }), 271 }) 272 throw error 273 } 274} 275 276export async function fetchAndStoreUserRoles( 277 accessToken: string, 278): Promise<void> { 279 const response = await axios.get(getOauthConfig().ROLES_URL, { 280 headers: { Authorization: `Bearer ${accessToken}` }, 281 }) 282 283 if (response.status !== 200) { 284 throw new Error(`Failed to fetch user roles: ${response.statusText}`) 285 } 286 const data = response.data as UserRolesResponse 287 const config = getGlobalConfig() 288 289 if (!config.oauthAccount) { 290 throw new Error('OAuth account information not found in config') 291 } 292 293 saveGlobalConfig(current => ({ 294 ...current, 295 oauthAccount: current.oauthAccount 296 ? { 297 ...current.oauthAccount, 298 organizationRole: data.organization_role, 299 workspaceRole: data.workspace_role, 300 organizationName: data.organization_name, 301 } 302 : current.oauthAccount, 303 })) 304 305 logEvent('tengu_oauth_roles_stored', { 306 org_role: 307 data.organization_role as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 308 }) 309} 310 311export async function createAndStoreApiKey( 312 accessToken: string, 313): Promise<string | null> { 314 try { 315 const response = await axios.post(getOauthConfig().API_KEY_URL, null, { 316 headers: { Authorization: `Bearer ${accessToken}` }, 317 }) 318 319 const apiKey = response.data?.raw_key 320 if (apiKey) { 321 await saveApiKey(apiKey) 322 logEvent('tengu_oauth_api_key', { 323 status: 324 'success' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 325 statusCode: response.status, 326 }) 327 return apiKey 328 } 329 return null 330 } catch (error) { 331 logEvent('tengu_oauth_api_key', { 332 status: 333 'failure' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 334 error: (error instanceof Error 335 ? error.message 336 : String( 337 error, 338 )) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 339 }) 340 throw error 341 } 342} 343 344export function isOAuthTokenExpired(expiresAt: number | null): boolean { 345 if (expiresAt === null) { 346 return false 347 } 348 349 const bufferTime = 5 * 60 * 1000 350 const now = Date.now() 351 const expiresWithBuffer = now + bufferTime 352 return expiresWithBuffer >= expiresAt 353} 354 355export async function fetchProfileInfo(accessToken: string): Promise<{ 356 subscriptionType: SubscriptionType | null 357 displayName?: string 358 rateLimitTier: RateLimitTier | null 359 hasExtraUsageEnabled: boolean | null 360 billingType: BillingType | null 361 accountCreatedAt?: string 362 subscriptionCreatedAt?: string 363 rawProfile?: OAuthProfileResponse 364}> { 365 const profile = await getOauthProfileFromOauthToken(accessToken) 366 const orgType = profile?.organization?.organization_type 367 368 // Reuse the logic from fetchSubscriptionType 369 let subscriptionType: SubscriptionType | null = null 370 switch (orgType) { 371 case 'claude_max': 372 subscriptionType = 'max' 373 break 374 case 'claude_pro': 375 subscriptionType = 'pro' 376 break 377 case 'claude_enterprise': 378 subscriptionType = 'enterprise' 379 break 380 case 'claude_team': 381 subscriptionType = 'team' 382 break 383 default: 384 // Return null for unknown organization types 385 subscriptionType = null 386 break 387 } 388 389 const result: { 390 subscriptionType: SubscriptionType | null 391 displayName?: string 392 rateLimitTier: RateLimitTier | null 393 hasExtraUsageEnabled: boolean | null 394 billingType: BillingType | null 395 accountCreatedAt?: string 396 subscriptionCreatedAt?: string 397 } = { 398 subscriptionType, 399 rateLimitTier: profile?.organization?.rate_limit_tier ?? null, 400 hasExtraUsageEnabled: 401 profile?.organization?.has_extra_usage_enabled ?? null, 402 billingType: profile?.organization?.billing_type ?? null, 403 } 404 405 if (profile?.account?.display_name) { 406 result.displayName = profile.account.display_name 407 } 408 409 if (profile?.account?.created_at) { 410 result.accountCreatedAt = profile.account.created_at 411 } 412 413 if (profile?.organization?.subscription_created_at) { 414 result.subscriptionCreatedAt = profile.organization.subscription_created_at 415 } 416 417 logEvent('tengu_oauth_profile_fetch_success', {}) 418 419 return { ...result, rawProfile: profile } 420} 421 422/** 423 * Gets the organization UUID from the OAuth access token 424 * @returns The organization UUID or null if not authenticated 425 */ 426export async function getOrganizationUUID(): Promise<string | null> { 427 // Check global config first to avoid unnecessary API call 428 const globalConfig = getGlobalConfig() 429 const orgUUID = globalConfig.oauthAccount?.organizationUuid 430 if (orgUUID) { 431 return orgUUID 432 } 433 434 // Fall back to fetching from profile (requires user:profile scope) 435 const accessToken = getClaudeAIOAuthTokens()?.accessToken 436 if (accessToken === undefined || !hasProfileScope()) { 437 return null 438 } 439 const profile = await getOauthProfileFromOauthToken(accessToken) 440 const profileOrgUUID = profile?.organization?.uuid 441 if (!profileOrgUUID) { 442 return null 443 } 444 return profileOrgUUID 445} 446 447/** 448 * Populate the OAuth account info if it has not already been cached in config. 449 * @returns Whether or not the oauth account info was populated. 450 */ 451export async function populateOAuthAccountInfoIfNeeded(): Promise<boolean> { 452 // Check env vars first (synchronous, no network call needed). 453 // SDK callers like Cowork can provide account info directly, which also 454 // eliminates the race condition where early telemetry events lack account info. 455 // NB: If/when adding additional SDK-relevant functionality requiring _other_ OAuth account properties, 456 // please reach out to #proj-cowork so the team can add additional env var fallbacks. 457 const envAccountUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID 458 const envUserEmail = process.env.CLAUDE_CODE_USER_EMAIL 459 const envOrganizationUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID 460 const hasEnvVars = Boolean( 461 envAccountUuid && envUserEmail && envOrganizationUuid, 462 ) 463 if (envAccountUuid && envUserEmail && envOrganizationUuid) { 464 if (!getGlobalConfig().oauthAccount) { 465 storeOAuthAccountInfo({ 466 accountUuid: envAccountUuid, 467 emailAddress: envUserEmail, 468 organizationUuid: envOrganizationUuid, 469 }) 470 } 471 } 472 473 // Wait for any in-flight token refresh to complete first, since 474 // refreshOAuthToken already fetches and stores profile info 475 await checkAndRefreshOAuthTokenIfNeeded() 476 477 const config = getGlobalConfig() 478 if ( 479 (config.oauthAccount && 480 config.oauthAccount.billingType !== undefined && 481 config.oauthAccount.accountCreatedAt !== undefined && 482 config.oauthAccount.subscriptionCreatedAt !== undefined) || 483 !isClaudeAISubscriber() || 484 !hasProfileScope() 485 ) { 486 return false 487 } 488 489 const tokens = getClaudeAIOAuthTokens() 490 if (tokens?.accessToken) { 491 const profile = await getOauthProfileFromOauthToken(tokens.accessToken) 492 if (profile) { 493 if (hasEnvVars) { 494 logForDebugging( 495 'OAuth profile fetch succeeded, overriding env var account info', 496 { level: 'info' }, 497 ) 498 } 499 storeOAuthAccountInfo({ 500 accountUuid: profile.account.uuid, 501 emailAddress: profile.account.email, 502 organizationUuid: profile.organization.uuid, 503 displayName: profile.account.display_name || undefined, 504 hasExtraUsageEnabled: 505 profile.organization.has_extra_usage_enabled ?? false, 506 billingType: profile.organization.billing_type ?? undefined, 507 accountCreatedAt: profile.account.created_at, 508 subscriptionCreatedAt: 509 profile.organization.subscription_created_at ?? undefined, 510 }) 511 return true 512 } 513 } 514 return false 515} 516 517export function storeOAuthAccountInfo({ 518 accountUuid, 519 emailAddress, 520 organizationUuid, 521 displayName, 522 hasExtraUsageEnabled, 523 billingType, 524 accountCreatedAt, 525 subscriptionCreatedAt, 526}: { 527 accountUuid: string 528 emailAddress: string 529 organizationUuid: string | undefined 530 displayName?: string 531 hasExtraUsageEnabled?: boolean 532 billingType?: BillingType 533 accountCreatedAt?: string 534 subscriptionCreatedAt?: string 535}): void { 536 const accountInfo: AccountInfo = { 537 accountUuid, 538 emailAddress, 539 organizationUuid, 540 hasExtraUsageEnabled, 541 billingType, 542 accountCreatedAt, 543 subscriptionCreatedAt, 544 } 545 if (displayName) { 546 accountInfo.displayName = displayName 547 } 548 saveGlobalConfig(current => { 549 // For oauthAccount we need to compare content since it's an object 550 if ( 551 current.oauthAccount?.accountUuid === accountInfo.accountUuid && 552 current.oauthAccount?.emailAddress === accountInfo.emailAddress && 553 current.oauthAccount?.organizationUuid === accountInfo.organizationUuid && 554 current.oauthAccount?.displayName === accountInfo.displayName && 555 current.oauthAccount?.hasExtraUsageEnabled === 556 accountInfo.hasExtraUsageEnabled && 557 current.oauthAccount?.billingType === accountInfo.billingType && 558 current.oauthAccount?.accountCreatedAt === accountInfo.accountCreatedAt && 559 current.oauthAccount?.subscriptionCreatedAt === 560 accountInfo.subscriptionCreatedAt 561 ) { 562 return current 563 } 564 return { ...current, oauthAccount: accountInfo } 565 }) 566}