/ cli / handlers / auth.ts
auth.ts
  1  /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handler intentionally exits */
  2  
  3  import {
  4    clearAuthRelatedCaches,
  5    performLogout,
  6  } from '../../commands/logout/logout.js'
  7  import {
  8    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  9    logEvent,
 10  } from '../../services/analytics/index.js'
 11  import { getSSLErrorHint } from '../../services/api/errorUtils.js'
 12  import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js'
 13  import {
 14    createAndStoreApiKey,
 15    fetchAndStoreUserRoles,
 16    refreshOAuthToken,
 17    shouldUseClaudeAIAuth,
 18    storeOAuthAccountInfo,
 19  } from '../../services/oauth/client.js'
 20  import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'
 21  import { OAuthService } from '../../services/oauth/index.js'
 22  import type { OAuthTokens } from '../../services/oauth/types.js'
 23  import {
 24    clearOAuthTokenCache,
 25    getAnthropicApiKeyWithSource,
 26    getAuthTokenSource,
 27    getOauthAccountInfo,
 28    getSubscriptionType,
 29    isUsing3PServices,
 30    saveOAuthTokensIfNeeded,
 31    validateForceLoginOrg,
 32  } from '../../utils/auth.js'
 33  import { saveGlobalConfig } from '../../utils/config.js'
 34  import { logForDebugging } from '../../utils/debug.js'
 35  import { isRunningOnHomespace } from '../../utils/envUtils.js'
 36  import { errorMessage } from '../../utils/errors.js'
 37  import { logError } from '../../utils/log.js'
 38  import { getAPIProvider } from '../../utils/model/providers.js'
 39  import { getInitialSettings } from '../../utils/settings/settings.js'
 40  import { jsonStringify } from '../../utils/slowOperations.js'
 41  import {
 42    buildAccountProperties,
 43    buildAPIProviderProperties,
 44  } from '../../utils/status.js'
 45  
 46  /**
 47   * Shared post-token-acquisition logic. Saves tokens, fetches profile/roles,
 48   * and sets up the local auth state.
 49   */
 50  export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
 51    // Clear old state before saving new credentials
 52    await performLogout({ clearOnboarding: false })
 53  
 54    // Reuse pre-fetched profile if available, otherwise fetch fresh
 55    const profile =
 56      tokens.profile ?? (await getOauthProfileFromOauthToken(tokens.accessToken))
 57    if (profile) {
 58      storeOAuthAccountInfo({
 59        accountUuid: profile.account.uuid,
 60        emailAddress: profile.account.email,
 61        organizationUuid: profile.organization.uuid,
 62        displayName: profile.account.display_name || undefined,
 63        hasExtraUsageEnabled:
 64          profile.organization.has_extra_usage_enabled ?? undefined,
 65        billingType: profile.organization.billing_type ?? undefined,
 66        subscriptionCreatedAt:
 67          profile.organization.subscription_created_at ?? undefined,
 68        accountCreatedAt: profile.account.created_at,
 69      })
 70    } else if (tokens.tokenAccount) {
 71      // Fallback to token exchange account data when profile endpoint fails
 72      storeOAuthAccountInfo({
 73        accountUuid: tokens.tokenAccount.uuid,
 74        emailAddress: tokens.tokenAccount.emailAddress,
 75        organizationUuid: tokens.tokenAccount.organizationUuid,
 76      })
 77    }
 78  
 79    const storageResult = saveOAuthTokensIfNeeded(tokens)
 80    clearOAuthTokenCache()
 81  
 82    if (storageResult.warning) {
 83      logEvent('tengu_oauth_storage_warning', {
 84        warning:
 85          storageResult.warning as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 86      })
 87    }
 88  
 89    // Roles and first-token-date may fail for limited-scope tokens (e.g.
 90    // inference-only from setup-token). They're not required for core auth.
 91    await fetchAndStoreUserRoles(tokens.accessToken).catch(err =>
 92      logForDebugging(String(err), { level: 'error' }),
 93    )
 94  
 95    if (shouldUseClaudeAIAuth(tokens.scopes)) {
 96      await fetchAndStoreClaudeCodeFirstTokenDate().catch(err =>
 97        logForDebugging(String(err), { level: 'error' }),
 98      )
 99    } else {
100      // API key creation is critical for Console users β€” let it throw.
101      const apiKey = await createAndStoreApiKey(tokens.accessToken)
102      if (!apiKey) {
103        throw new Error(
104          'Unable to create API key. The server accepted the request but did not return a key.',
105        )
106      }
107    }
108  
109    await clearAuthRelatedCaches()
110  }
111  
112  export async function authLogin({
113    email,
114    sso,
115    console: useConsole,
116    claudeai,
117  }: {
118    email?: string
119    sso?: boolean
120    console?: boolean
121    claudeai?: boolean
122  }): Promise<void> {
123    if (useConsole && claudeai) {
124      process.stderr.write(
125        'Error: --console and --claudeai cannot be used together.\n',
126      )
127      process.exit(1)
128    }
129  
130    const settings = getInitialSettings()
131    // forceLoginMethod is a hard constraint (enterprise setting) β€” matches ConsoleOAuthFlow behavior.
132    // Without it, --console selects Console; --claudeai (or no flag) selects claude.ai.
133    const loginWithClaudeAi = settings.forceLoginMethod
134      ? settings.forceLoginMethod === 'claudeai'
135      : !useConsole
136    const orgUUID = settings.forceLoginOrgUUID
137  
138    // Fast path: if a refresh token is provided via env var, skip the browser
139    // OAuth flow and exchange it directly for tokens.
140    const envRefreshToken = process.env.CLAUDE_CODE_OAUTH_REFRESH_TOKEN
141    if (envRefreshToken) {
142      const envScopes = process.env.CLAUDE_CODE_OAUTH_SCOPES
143      if (!envScopes) {
144        process.stderr.write(
145          'CLAUDE_CODE_OAUTH_SCOPES is required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN.\n' +
146            'Set it to the space-separated scopes the refresh token was issued with\n' +
147            '(e.g. "user:inference" or "user:profile user:inference user:sessions:claude_code user:mcp_servers").\n',
148        )
149        process.exit(1)
150      }
151  
152      const scopes = envScopes.split(/\s+/).filter(Boolean)
153  
154      try {
155        logEvent('tengu_login_from_refresh_token', {})
156  
157        const tokens = await refreshOAuthToken(envRefreshToken, { scopes })
158        await installOAuthTokens(tokens)
159  
160        const orgResult = await validateForceLoginOrg()
161        if (!orgResult.valid) {
162          process.stderr.write(orgResult.message + '\n')
163          process.exit(1)
164        }
165  
166        // Mark onboarding complete β€” interactive paths handle this via
167        // the Onboarding component, but the env var path skips it.
168        saveGlobalConfig(current => {
169          if (current.hasCompletedOnboarding) return current
170          return { ...current, hasCompletedOnboarding: true }
171        })
172  
173        logEvent('tengu_oauth_success', {
174          loginWithClaudeAi: shouldUseClaudeAIAuth(tokens.scopes),
175        })
176        process.stdout.write('Login successful.\n')
177        process.exit(0)
178      } catch (err) {
179        logError(err)
180        const sslHint = getSSLErrorHint(err)
181        process.stderr.write(
182          `Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
183        )
184        process.exit(1)
185      }
186    }
187  
188    const resolvedLoginMethod = sso ? 'sso' : undefined
189  
190    const oauthService = new OAuthService()
191  
192    try {
193      logEvent('tengu_oauth_flow_start', { loginWithClaudeAi })
194  
195      const result = await oauthService.startOAuthFlow(
196        async url => {
197          process.stdout.write('Opening browser to sign in…\n')
198          process.stdout.write(`If the browser didn't open, visit: ${url}\n`)
199        },
200        {
201          loginWithClaudeAi,
202          loginHint: email,
203          loginMethod: resolvedLoginMethod,
204          orgUUID,
205        },
206      )
207  
208      await installOAuthTokens(result)
209  
210      const orgResult = await validateForceLoginOrg()
211      if (!orgResult.valid) {
212        process.stderr.write(orgResult.message + '\n')
213        process.exit(1)
214      }
215  
216      logEvent('tengu_oauth_success', { loginWithClaudeAi })
217  
218      process.stdout.write('Login successful.\n')
219      process.exit(0)
220    } catch (err) {
221      logError(err)
222      const sslHint = getSSLErrorHint(err)
223      process.stderr.write(
224        `Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
225      )
226      process.exit(1)
227    } finally {
228      oauthService.cleanup()
229    }
230  }
231  
232  export async function authStatus(opts: {
233    json?: boolean
234    text?: boolean
235  }): Promise<void> {
236    const { source: authTokenSource, hasToken } = getAuthTokenSource()
237    const { source: apiKeySource } = getAnthropicApiKeyWithSource()
238    const hasApiKeyEnvVar =
239      !!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()
240    const oauthAccount = getOauthAccountInfo()
241    const subscriptionType = getSubscriptionType()
242    const using3P = isUsing3PServices()
243    const loggedIn =
244      hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
245  
246    // Determine auth method
247    let authMethod: string = 'none'
248    if (using3P) {
249      authMethod = 'third_party'
250    } else if (authTokenSource === 'claude.ai') {
251      authMethod = 'claude.ai'
252    } else if (authTokenSource === 'apiKeyHelper') {
253      authMethod = 'api_key_helper'
254    } else if (authTokenSource !== 'none') {
255      authMethod = 'oauth_token'
256    } else if (apiKeySource === 'ANTHROPIC_API_KEY' || hasApiKeyEnvVar) {
257      authMethod = 'api_key'
258    } else if (apiKeySource === '/login managed key') {
259      authMethod = 'claude.ai'
260    }
261  
262    if (opts.text) {
263      const properties = [
264        ...buildAccountProperties(),
265        ...buildAPIProviderProperties(),
266      ]
267      let hasAuthProperty = false
268      for (const prop of properties) {
269        const value =
270          typeof prop.value === 'string'
271            ? prop.value
272            : Array.isArray(prop.value)
273              ? prop.value.join(', ')
274              : null
275        if (value === null || value === 'none') {
276          continue
277        }
278        hasAuthProperty = true
279        if (prop.label) {
280          process.stdout.write(`${prop.label}: ${value}\n`)
281        } else {
282          process.stdout.write(`${value}\n`)
283        }
284      }
285      if (!hasAuthProperty && hasApiKeyEnvVar) {
286        process.stdout.write('API key: ANTHROPIC_API_KEY\n')
287      }
288      if (!loggedIn) {
289        process.stdout.write(
290          'Not logged in. Run claude auth login to authenticate.\n',
291        )
292      }
293    } else {
294      const apiProvider = getAPIProvider()
295      const resolvedApiKeySource =
296        apiKeySource !== 'none'
297          ? apiKeySource
298          : hasApiKeyEnvVar
299            ? 'ANTHROPIC_API_KEY'
300            : null
301      const output: Record<string, string | boolean | null> = {
302        loggedIn,
303        authMethod,
304        apiProvider,
305      }
306      if (resolvedApiKeySource) {
307        output.apiKeySource = resolvedApiKeySource
308      }
309      if (authMethod === 'claude.ai') {
310        output.email = oauthAccount?.emailAddress ?? null
311        output.orgId = oauthAccount?.organizationUuid ?? null
312        output.orgName = oauthAccount?.organizationName ?? null
313        output.subscriptionType = subscriptionType ?? null
314      }
315  
316      process.stdout.write(jsonStringify(output, null, 2) + '\n')
317    }
318    process.exit(loggedIn ? 0 : 1)
319  }
320  
321  export async function authLogout(): Promise<void> {
322    try {
323      await performLogout({ clearOnboarding: false })
324    } catch {
325      process.stderr.write('Failed to log out.\n')
326      process.exit(1)
327    }
328    process.stdout.write('Successfully logged out from your Anthropic account.\n')
329    process.exit(0)
330  }