567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
// 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<OAuthTokenExchangeResponse> {
|
|
const requestBody: Record<string, string | number> = {
|
|
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<OAuthTokens> {
|
|
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<AccountInfo> = {}
|
|
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<void> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
// 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<boolean> {
|
|
// 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 }
|
|
})
|
|
}
|