/ services / oauth / client.ts
client.ts
  1  // OAuth client for handling authentication flows with Claude services
  2  import axios from 'axios'
  3  import {
  4    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  5    logEvent,
  6  } from 'src/services/analytics/index.js'
  7  import {
  8    ALL_OAUTH_SCOPES,
  9    CLAUDE_AI_INFERENCE_SCOPE,
 10    CLAUDE_AI_OAUTH_SCOPES,
 11    getOauthConfig,
 12  } from '../../constants/oauth.js'
 13  import {
 14    checkAndRefreshOAuthTokenIfNeeded,
 15    getClaudeAIOAuthTokens,
 16    hasProfileScope,
 17    isClaudeAISubscriber,
 18    saveApiKey,
 19  } from '../../utils/auth.js'
 20  import type { AccountInfo } from '../../utils/config.js'
 21  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
 22  import { logForDebugging } from '../../utils/debug.js'
 23  import { getOauthProfileFromOauthToken } from './getOauthProfile.js'
 24  import 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   */
 38  export function shouldUseClaudeAIAuth(scopes: string[] | undefined): boolean {
 39    return Boolean(scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE))
 40  }
 41  
 42  export function parseScopes(scopeString?: string): string[] {
 43    return scopeString?.split(' ').filter(Boolean) ?? []
 44  }
 45  
 46  export 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  
107  export 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  
146  export 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  
276  export 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  
311  export 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  
344  export 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  
355  export 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   */
426  export 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   */
451  export 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  
517  export 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  }