// OAuth client for handling authentication flows with Claude services import axios from 'axios' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { ALL_OAUTH_SCOPES, CLAUDE_AI_INFERENCE_SCOPE, CLAUDE_AI_OAUTH_SCOPES, getOauthConfig, } from '../../constants/oauth.js' import { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens, hasProfileScope, isClaudeAISubscriber, saveApiKey, } from '../../utils/auth.js' import type { AccountInfo } from '../../utils/config.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { logForDebugging } from '../../utils/debug.js' import { getOauthProfileFromOauthToken } from './getOauthProfile.js' import type { BillingType, OAuthProfileResponse, OAuthTokenExchangeResponse, OAuthTokens, RateLimitTier, SubscriptionType, UserRolesResponse, } from './types.js' /** * Check if the user has Claude.ai authentication scope * @private Only call this if you're OAuth / auth related code! */ export function shouldUseClaudeAIAuth(scopes: string[] | undefined): boolean { return Boolean(scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE)) } export function parseScopes(scopeString?: string): string[] { return scopeString?.split(' ').filter(Boolean) ?? [] } export function buildAuthUrl({ codeChallenge, state, port, isManual, loginWithClaudeAi, inferenceOnly, orgUUID, loginHint, loginMethod, }: { codeChallenge: string state: string port: number isManual: boolean loginWithClaudeAi?: boolean inferenceOnly?: boolean orgUUID?: string loginHint?: string loginMethod?: string }): string { const authUrlBase = loginWithClaudeAi ? getOauthConfig().CLAUDE_AI_AUTHORIZE_URL : getOauthConfig().CONSOLE_AUTHORIZE_URL const authUrl = new URL(authUrlBase) authUrl.searchParams.append('code', 'true') // this tells the login page to show Claude Max upsell authUrl.searchParams.append('client_id', getOauthConfig().CLIENT_ID) authUrl.searchParams.append('response_type', 'code') authUrl.searchParams.append( 'redirect_uri', isManual ? getOauthConfig().MANUAL_REDIRECT_URL : `http://localhost:${port}/callback`, ) const scopesToUse = inferenceOnly ? [CLAUDE_AI_INFERENCE_SCOPE] // Long-lived inference-only tokens : ALL_OAUTH_SCOPES authUrl.searchParams.append('scope', scopesToUse.join(' ')) authUrl.searchParams.append('code_challenge', codeChallenge) authUrl.searchParams.append('code_challenge_method', 'S256') authUrl.searchParams.append('state', state) // Add orgUUID as URL param if provided if (orgUUID) { authUrl.searchParams.append('orgUUID', orgUUID) } // Pre-populate email on the login form (standard OIDC parameter) if (loginHint) { authUrl.searchParams.append('login_hint', loginHint) } // Request a specific login method (e.g. 'sso', 'magic_link', 'google') if (loginMethod) { authUrl.searchParams.append('login_method', loginMethod) } return authUrl.toString() } export async function exchangeCodeForTokens( authorizationCode: string, state: string, codeVerifier: string, port: number, useManualRedirect: boolean = false, expiresIn?: number, ): Promise { const requestBody: Record = { grant_type: 'authorization_code', code: authorizationCode, redirect_uri: useManualRedirect ? getOauthConfig().MANUAL_REDIRECT_URL : `http://localhost:${port}/callback`, client_id: getOauthConfig().CLIENT_ID, code_verifier: codeVerifier, state, } if (expiresIn !== undefined) { requestBody.expires_in = expiresIn } const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 15000, }) if (response.status !== 200) { throw new Error( response.status === 401 ? 'Authentication failed: Invalid authorization code' : `Token exchange failed (${response.status}): ${response.statusText}`, ) } logEvent('tengu_oauth_token_exchange_success', {}) return response.data } export async function refreshOAuthToken( refreshToken: string, { scopes: requestedScopes }: { scopes?: string[] } = {}, ): Promise { const requestBody = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: getOauthConfig().CLIENT_ID, // Request specific scopes, defaulting to the full Claude AI set. The // backend's refresh-token grant allows scope expansion beyond what the // initial authorize granted (see ALLOWED_SCOPE_EXPANSIONS), so this is // safe even for tokens issued before scopes were added to the app's // registered oauth_scope. scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES ).join(' '), } try { const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 15000, }) if (response.status !== 200) { throw new Error(`Token refresh failed: ${response.statusText}`) } const data = response.data as OAuthTokenExchangeResponse const { access_token: accessToken, refresh_token: newRefreshToken = refreshToken, expires_in: expiresIn, } = data const expiresAt = Date.now() + expiresIn * 1000 const scopes = parseScopes(data.scope) logEvent('tengu_oauth_token_refresh_success', {}) // Skip the extra /api/oauth/profile round-trip when we already have both // the global-config profile fields AND the secure-storage subscription data. // Routine refreshes satisfy both, so we cut ~7M req/day fleet-wide. // // Checking secure storage (not just config) matters for the // CLAUDE_CODE_OAUTH_REFRESH_TOKEN re-login path: installOAuthTokens runs // performLogout() AFTER we return, wiping secure storage. If we returned // null for subscriptionType here, saveOAuthTokensIfNeeded would persist // null ?? (wiped) ?? null = null, and every future refresh would see the // config guard fields satisfied and skip again, permanently losing the // subscription type for paying users. By passing through existing values, // the re-login path writes cached ?? wiped ?? null = cached; and if secure // storage was already empty we fall through to the fetch. const config = getGlobalConfig() const existing = getClaudeAIOAuthTokens() const haveProfileAlready = config.oauthAccount?.billingType !== undefined && config.oauthAccount?.accountCreatedAt !== undefined && config.oauthAccount?.subscriptionCreatedAt !== undefined && existing?.subscriptionType != null && existing?.rateLimitTier != null const profileInfo = haveProfileAlready ? null : await fetchProfileInfo(accessToken) // Update the stored properties if they have changed if (profileInfo && config.oauthAccount) { const updates: Partial = {} if (profileInfo.displayName !== undefined) { updates.displayName = profileInfo.displayName } if (typeof profileInfo.hasExtraUsageEnabled === 'boolean') { updates.hasExtraUsageEnabled = profileInfo.hasExtraUsageEnabled } if (profileInfo.billingType !== null) { updates.billingType = profileInfo.billingType } if (profileInfo.accountCreatedAt !== undefined) { updates.accountCreatedAt = profileInfo.accountCreatedAt } if (profileInfo.subscriptionCreatedAt !== undefined) { updates.subscriptionCreatedAt = profileInfo.subscriptionCreatedAt } if (Object.keys(updates).length > 0) { saveGlobalConfig(current => ({ ...current, oauthAccount: current.oauthAccount ? { ...current.oauthAccount, ...updates } : current.oauthAccount, })) } } return { accessToken, refreshToken: newRefreshToken, expiresAt, scopes, subscriptionType: profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null, rateLimitTier: profileInfo?.rateLimitTier ?? existing?.rateLimitTier ?? null, profile: profileInfo?.rawProfile, tokenAccount: data.account ? { uuid: data.account.uuid, emailAddress: data.account.email_address, organizationUuid: data.organization?.uuid, } : undefined, } } catch (error) { const responseBody = axios.isAxiosError(error) && error.response?.data ? JSON.stringify(error.response.data) : undefined logEvent('tengu_oauth_token_refresh_failure', { error: (error as Error) .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(responseBody && { responseBody: responseBody as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }) throw error } } export async function fetchAndStoreUserRoles( accessToken: string, ): Promise { const response = await axios.get(getOauthConfig().ROLES_URL, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (response.status !== 200) { throw new Error(`Failed to fetch user roles: ${response.statusText}`) } const data = response.data as UserRolesResponse const config = getGlobalConfig() if (!config.oauthAccount) { throw new Error('OAuth account information not found in config') } saveGlobalConfig(current => ({ ...current, oauthAccount: current.oauthAccount ? { ...current.oauthAccount, organizationRole: data.organization_role, workspaceRole: data.workspace_role, organizationName: data.organization_name, } : current.oauthAccount, })) logEvent('tengu_oauth_roles_stored', { org_role: data.organization_role as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } export async function createAndStoreApiKey( accessToken: string, ): Promise { try { const response = await axios.post(getOauthConfig().API_KEY_URL, null, { headers: { Authorization: `Bearer ${accessToken}` }, }) const apiKey = response.data?.raw_key if (apiKey) { await saveApiKey(apiKey) logEvent('tengu_oauth_api_key', { status: 'success' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, statusCode: response.status, }) return apiKey } return null } catch (error) { logEvent('tengu_oauth_api_key', { status: 'failure' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, error: (error instanceof Error ? error.message : String( error, )) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw error } } export function isOAuthTokenExpired(expiresAt: number | null): boolean { if (expiresAt === null) { return false } const bufferTime = 5 * 60 * 1000 const now = Date.now() const expiresWithBuffer = now + bufferTime return expiresWithBuffer >= expiresAt } export async function fetchProfileInfo(accessToken: string): Promise<{ subscriptionType: SubscriptionType | null displayName?: string rateLimitTier: RateLimitTier | null hasExtraUsageEnabled: boolean | null billingType: BillingType | null accountCreatedAt?: string subscriptionCreatedAt?: string rawProfile?: OAuthProfileResponse }> { const profile = await getOauthProfileFromOauthToken(accessToken) const orgType = profile?.organization?.organization_type // Reuse the logic from fetchSubscriptionType let subscriptionType: SubscriptionType | null = null switch (orgType) { case 'claude_max': subscriptionType = 'max' break case 'claude_pro': subscriptionType = 'pro' break case 'claude_enterprise': subscriptionType = 'enterprise' break case 'claude_team': subscriptionType = 'team' break default: // Return null for unknown organization types subscriptionType = null break } const result: { subscriptionType: SubscriptionType | null displayName?: string rateLimitTier: RateLimitTier | null hasExtraUsageEnabled: boolean | null billingType: BillingType | null accountCreatedAt?: string subscriptionCreatedAt?: string } = { subscriptionType, rateLimitTier: profile?.organization?.rate_limit_tier ?? null, hasExtraUsageEnabled: profile?.organization?.has_extra_usage_enabled ?? null, billingType: profile?.organization?.billing_type ?? null, } if (profile?.account?.display_name) { result.displayName = profile.account.display_name } if (profile?.account?.created_at) { result.accountCreatedAt = profile.account.created_at } if (profile?.organization?.subscription_created_at) { result.subscriptionCreatedAt = profile.organization.subscription_created_at } logEvent('tengu_oauth_profile_fetch_success', {}) return { ...result, rawProfile: profile } } /** * Gets the organization UUID from the OAuth access token * @returns The organization UUID or null if not authenticated */ export async function getOrganizationUUID(): Promise { // Check global config first to avoid unnecessary API call const globalConfig = getGlobalConfig() const orgUUID = globalConfig.oauthAccount?.organizationUuid if (orgUUID) { return orgUUID } // Fall back to fetching from profile (requires user:profile scope) const accessToken = getClaudeAIOAuthTokens()?.accessToken if (accessToken === undefined || !hasProfileScope()) { return null } const profile = await getOauthProfileFromOauthToken(accessToken) const profileOrgUUID = profile?.organization?.uuid if (!profileOrgUUID) { return null } return profileOrgUUID } /** * Populate the OAuth account info if it has not already been cached in config. * @returns Whether or not the oauth account info was populated. */ export async function populateOAuthAccountInfoIfNeeded(): Promise { // Check env vars first (synchronous, no network call needed). // SDK callers like Cowork can provide account info directly, which also // eliminates the race condition where early telemetry events lack account info. // NB: If/when adding additional SDK-relevant functionality requiring _other_ OAuth account properties, // please reach out to #proj-cowork so the team can add additional env var fallbacks. const envAccountUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID const envUserEmail = process.env.CLAUDE_CODE_USER_EMAIL const envOrganizationUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID const hasEnvVars = Boolean( envAccountUuid && envUserEmail && envOrganizationUuid, ) if (envAccountUuid && envUserEmail && envOrganizationUuid) { if (!getGlobalConfig().oauthAccount) { storeOAuthAccountInfo({ accountUuid: envAccountUuid, emailAddress: envUserEmail, organizationUuid: envOrganizationUuid, }) } } // Wait for any in-flight token refresh to complete first, since // refreshOAuthToken already fetches and stores profile info await checkAndRefreshOAuthTokenIfNeeded() const config = getGlobalConfig() if ( (config.oauthAccount && config.oauthAccount.billingType !== undefined && config.oauthAccount.accountCreatedAt !== undefined && config.oauthAccount.subscriptionCreatedAt !== undefined) || !isClaudeAISubscriber() || !hasProfileScope() ) { return false } const tokens = getClaudeAIOAuthTokens() if (tokens?.accessToken) { const profile = await getOauthProfileFromOauthToken(tokens.accessToken) if (profile) { if (hasEnvVars) { logForDebugging( 'OAuth profile fetch succeeded, overriding env var account info', { level: 'info' }, ) } storeOAuthAccountInfo({ accountUuid: profile.account.uuid, emailAddress: profile.account.email, organizationUuid: profile.organization.uuid, displayName: profile.account.display_name || undefined, hasExtraUsageEnabled: profile.organization.has_extra_usage_enabled ?? false, billingType: profile.organization.billing_type ?? undefined, accountCreatedAt: profile.account.created_at, subscriptionCreatedAt: profile.organization.subscription_created_at ?? undefined, }) return true } } return false } export function storeOAuthAccountInfo({ accountUuid, emailAddress, organizationUuid, displayName, hasExtraUsageEnabled, billingType, accountCreatedAt, subscriptionCreatedAt, }: { accountUuid: string emailAddress: string organizationUuid: string | undefined displayName?: string hasExtraUsageEnabled?: boolean billingType?: BillingType accountCreatedAt?: string subscriptionCreatedAt?: string }): void { const accountInfo: AccountInfo = { accountUuid, emailAddress, organizationUuid, hasExtraUsageEnabled, billingType, accountCreatedAt, subscriptionCreatedAt, } if (displayName) { accountInfo.displayName = displayName } saveGlobalConfig(current => { // For oauthAccount we need to compare content since it's an object if ( current.oauthAccount?.accountUuid === accountInfo.accountUuid && current.oauthAccount?.emailAddress === accountInfo.emailAddress && current.oauthAccount?.organizationUuid === accountInfo.organizationUuid && current.oauthAccount?.displayName === accountInfo.displayName && current.oauthAccount?.hasExtraUsageEnabled === accountInfo.hasExtraUsageEnabled && current.oauthAccount?.billingType === accountInfo.billingType && current.oauthAccount?.accountCreatedAt === accountInfo.accountCreatedAt && current.oauthAccount?.subscriptionCreatedAt === accountInfo.subscriptionCreatedAt ) { return current } return { ...current, oauthAccount: accountInfo } }) }