init claude-code
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
import { createServer, type Server } from 'http'
|
||||
import type { AddressInfo } from 'net'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { shouldUseClaudeAIAuth } from './client.js'
|
||||
|
||||
/**
|
||||
* Temporary localhost HTTP server that listens for OAuth authorization code redirects.
|
||||
*
|
||||
* When the user authorizes in their browser, the OAuth provider redirects to:
|
||||
* http://localhost:[port]/callback?code=AUTH_CODE&state=STATE
|
||||
*
|
||||
* This server captures that redirect and extracts the auth code.
|
||||
* Note: This is NOT an OAuth server - it's just a redirect capture mechanism.
|
||||
*/
|
||||
export class AuthCodeListener {
|
||||
private localServer: Server
|
||||
private port: number = 0
|
||||
private promiseResolver: ((authorizationCode: string) => void) | null = null
|
||||
private promiseRejecter: ((error: Error) => void) | null = null
|
||||
private expectedState: string | null = null // State parameter for CSRF protection
|
||||
private pendingResponse: ServerResponse | null = null // Response object for final redirect
|
||||
private callbackPath: string // Configurable callback path
|
||||
|
||||
constructor(callbackPath: string = '/callback') {
|
||||
this.localServer = createServer()
|
||||
this.callbackPath = callbackPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening on an OS-assigned port and returns the port number.
|
||||
* This avoids race conditions by keeping the server open until it's used.
|
||||
* @param port Optional specific port to use. If not provided, uses OS-assigned port.
|
||||
*/
|
||||
async start(port?: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.localServer.once('error', err => {
|
||||
reject(
|
||||
new Error(`Failed to start OAuth callback server: ${err.message}`),
|
||||
)
|
||||
})
|
||||
|
||||
// Listen on specified port or 0 to let the OS assign an available port
|
||||
this.localServer.listen(port ?? 0, 'localhost', () => {
|
||||
const address = this.localServer.address() as AddressInfo
|
||||
this.port = address.port
|
||||
resolve(this.port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
hasPendingResponse(): boolean {
|
||||
return this.pendingResponse !== null
|
||||
}
|
||||
|
||||
async waitForAuthorization(
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.promiseResolver = resolve
|
||||
this.promiseRejecter = reject
|
||||
this.expectedState = state
|
||||
this.startLocalListener(onReady)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the OAuth flow by redirecting the user's browser to a success page.
|
||||
* Different success pages are shown based on the granted scopes.
|
||||
* @param scopes The OAuth scopes that were granted
|
||||
* @param customHandler Optional custom handler to serve response instead of redirecting
|
||||
*/
|
||||
handleSuccessRedirect(
|
||||
scopes: string[],
|
||||
customHandler?: (res: ServerResponse, scopes: string[]) => void,
|
||||
): void {
|
||||
if (!this.pendingResponse) return
|
||||
|
||||
// If custom handler provided, use it instead of default redirect
|
||||
if (customHandler) {
|
||||
customHandler(this.pendingResponse, scopes)
|
||||
this.pendingResponse = null
|
||||
logEvent('tengu_oauth_automatic_redirect', { custom_handler: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Default behavior: Choose success page based on granted permissions
|
||||
const successUrl = shouldUseClaudeAIAuth(scopes)
|
||||
? getOauthConfig().CLAUDEAI_SUCCESS_URL
|
||||
: getOauthConfig().CONSOLE_SUCCESS_URL
|
||||
|
||||
// Send browser to success page
|
||||
this.pendingResponse.writeHead(302, { Location: successUrl })
|
||||
this.pendingResponse.end()
|
||||
this.pendingResponse = null
|
||||
|
||||
logEvent('tengu_oauth_automatic_redirect', {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles error case by sending a redirect to the appropriate success page with an error indicator,
|
||||
* ensuring the browser flow is completed properly.
|
||||
*/
|
||||
handleErrorRedirect(): void {
|
||||
if (!this.pendingResponse) return
|
||||
|
||||
// TODO: swap to a different url once we have an error page
|
||||
const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL
|
||||
|
||||
// Send browser to error page
|
||||
this.pendingResponse.writeHead(302, { Location: errorUrl })
|
||||
this.pendingResponse.end()
|
||||
this.pendingResponse = null
|
||||
|
||||
logEvent('tengu_oauth_automatic_redirect_error', {})
|
||||
}
|
||||
|
||||
private startLocalListener(onReady: () => Promise<void>): void {
|
||||
// Server is already created and listening, just set up handlers
|
||||
this.localServer.on('request', this.handleRedirect.bind(this))
|
||||
this.localServer.on('error', this.handleError.bind(this))
|
||||
|
||||
// Server is already listening, so we can call onReady immediately
|
||||
void onReady()
|
||||
}
|
||||
|
||||
private handleRedirect(req: IncomingMessage, res: ServerResponse): void {
|
||||
const parsedUrl = new URL(
|
||||
req.url || '',
|
||||
`http://${req.headers.host || 'localhost'}`,
|
||||
)
|
||||
|
||||
if (parsedUrl.pathname !== this.callbackPath) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const authCode = parsedUrl.searchParams.get('code') ?? undefined
|
||||
const state = parsedUrl.searchParams.get('state') ?? undefined
|
||||
|
||||
this.validateAndRespond(authCode, state, res)
|
||||
}
|
||||
|
||||
private validateAndRespond(
|
||||
authCode: string | undefined,
|
||||
state: string | undefined,
|
||||
res: ServerResponse,
|
||||
): void {
|
||||
if (!authCode) {
|
||||
res.writeHead(400)
|
||||
res.end('Authorization code not found')
|
||||
this.reject(new Error('No authorization code received'))
|
||||
return
|
||||
}
|
||||
|
||||
if (state !== this.expectedState) {
|
||||
res.writeHead(400)
|
||||
res.end('Invalid state parameter')
|
||||
this.reject(new Error('Invalid state parameter'))
|
||||
return
|
||||
}
|
||||
|
||||
// Store the response for later redirect
|
||||
this.pendingResponse = res
|
||||
|
||||
this.resolve(authCode)
|
||||
}
|
||||
|
||||
private handleError(err: Error): void {
|
||||
logError(err)
|
||||
this.close()
|
||||
this.reject(err)
|
||||
}
|
||||
|
||||
private resolve(authorizationCode: string): void {
|
||||
if (this.promiseResolver) {
|
||||
this.promiseResolver(authorizationCode)
|
||||
this.promiseResolver = null
|
||||
this.promiseRejecter = null
|
||||
}
|
||||
}
|
||||
|
||||
private reject(error: Error): void {
|
||||
if (this.promiseRejecter) {
|
||||
this.promiseRejecter(error)
|
||||
this.promiseResolver = null
|
||||
this.promiseRejecter = null
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// If we have a pending response, send a redirect before closing
|
||||
if (this.pendingResponse) {
|
||||
this.handleErrorRedirect()
|
||||
}
|
||||
|
||||
if (this.localServer) {
|
||||
// Remove all listeners to prevent memory leaks
|
||||
this.localServer.removeAllListeners()
|
||||
this.localServer.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// 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 }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
function base64URLEncode(buffer: Buffer): string {
|
||||
return buffer
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
return base64URLEncode(randomBytes(32))
|
||||
}
|
||||
|
||||
export function generateCodeChallenge(verifier: string): string {
|
||||
const hash = createHash('sha256')
|
||||
hash.update(verifier)
|
||||
return base64URLEncode(hash.digest())
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
return base64URLEncode(randomBytes(32))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js'
|
||||
import type { OAuthProfileResponse } from 'src/services/oauth/types.js'
|
||||
import { getAnthropicApiKey } from 'src/utils/auth.js'
|
||||
import { getGlobalConfig } from 'src/utils/config.js'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
export async function getOauthProfileFromApiKey(): Promise<
|
||||
OAuthProfileResponse | undefined
|
||||
> {
|
||||
// Assumes interactive session
|
||||
const config = getGlobalConfig()
|
||||
const accountUuid = config.oauthAccount?.accountUuid
|
||||
const apiKey = getAnthropicApiKey()
|
||||
|
||||
// Need both account UUID and API key to check
|
||||
if (!accountUuid || !apiKey) {
|
||||
return
|
||||
}
|
||||
const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_cli_profile`
|
||||
try {
|
||||
const response = await axios.get<OAuthProfileResponse>(endpoint, {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-beta': OAUTH_BETA_HEADER,
|
||||
},
|
||||
params: {
|
||||
account_uuid: accountUuid,
|
||||
},
|
||||
timeout: 10000,
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOauthProfileFromOauthToken(
|
||||
accessToken: string,
|
||||
): Promise<OAuthProfileResponse | undefined> {
|
||||
const endpoint = `${getOauthConfig().BASE_API_URL}/api/oauth/profile`
|
||||
try {
|
||||
const response = await axios.get<OAuthProfileResponse>(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { AuthCodeListener } from './auth-code-listener.js'
|
||||
import * as client from './client.js'
|
||||
import * as crypto from './crypto.js'
|
||||
import type {
|
||||
OAuthProfileResponse,
|
||||
OAuthTokenExchangeResponse,
|
||||
OAuthTokens,
|
||||
RateLimitTier,
|
||||
SubscriptionType,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* OAuth service that handles the OAuth 2.0 authorization code flow with PKCE.
|
||||
*
|
||||
* Supports two ways to get authorization codes:
|
||||
* 1. Automatic: Opens browser, redirects to localhost where we capture the code
|
||||
* 2. Manual: User manually copies and pastes the code (used in non-browser environments)
|
||||
*/
|
||||
export class OAuthService {
|
||||
private codeVerifier: string
|
||||
private authCodeListener: AuthCodeListener | null = null
|
||||
private port: number | null = null
|
||||
private manualAuthCodeResolver: ((authorizationCode: string) => void) | null =
|
||||
null
|
||||
|
||||
constructor() {
|
||||
this.codeVerifier = crypto.generateCodeVerifier()
|
||||
}
|
||||
|
||||
async startOAuthFlow(
|
||||
authURLHandler: (url: string, automaticUrl?: string) => Promise<void>,
|
||||
options?: {
|
||||
loginWithClaudeAi?: boolean
|
||||
inferenceOnly?: boolean
|
||||
expiresIn?: number
|
||||
orgUUID?: string
|
||||
loginHint?: string
|
||||
loginMethod?: string
|
||||
/**
|
||||
* Don't call openBrowser(). Caller takes both URLs via authURLHandler
|
||||
* and decides how/where to open them. Used by the SDK control protocol
|
||||
* (claude_authenticate) where the SDK client owns the user's display,
|
||||
* not this process.
|
||||
*/
|
||||
skipBrowserOpen?: boolean
|
||||
},
|
||||
): Promise<OAuthTokens> {
|
||||
// Create OAuth callback listener and start it
|
||||
this.authCodeListener = new AuthCodeListener()
|
||||
this.port = await this.authCodeListener.start()
|
||||
|
||||
// Generate PKCE values and state
|
||||
const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier)
|
||||
const state = crypto.generateState()
|
||||
|
||||
// Build auth URLs for both automatic and manual flows
|
||||
const opts = {
|
||||
codeChallenge,
|
||||
state,
|
||||
port: this.port,
|
||||
loginWithClaudeAi: options?.loginWithClaudeAi,
|
||||
inferenceOnly: options?.inferenceOnly,
|
||||
orgUUID: options?.orgUUID,
|
||||
loginHint: options?.loginHint,
|
||||
loginMethod: options?.loginMethod,
|
||||
}
|
||||
const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true })
|
||||
const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false })
|
||||
|
||||
// Wait for either automatic or manual auth code
|
||||
const authorizationCode = await this.waitForAuthorizationCode(
|
||||
state,
|
||||
async () => {
|
||||
if (options?.skipBrowserOpen) {
|
||||
// Hand both URLs to the caller. The automatic one still works
|
||||
// if the caller opens it on the same host (localhost listener
|
||||
// is running); the manual one works from anywhere.
|
||||
await authURLHandler(manualFlowUrl, automaticFlowUrl)
|
||||
} else {
|
||||
await authURLHandler(manualFlowUrl) // Show manual option to user
|
||||
await openBrowser(automaticFlowUrl) // Try automatic flow
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Check if the automatic flow is still active (has a pending response)
|
||||
const isAutomaticFlow = this.authCodeListener?.hasPendingResponse() ?? false
|
||||
logEvent('tengu_oauth_auth_code_received', { automatic: isAutomaticFlow })
|
||||
|
||||
try {
|
||||
// Exchange authorization code for tokens
|
||||
const tokenResponse = await client.exchangeCodeForTokens(
|
||||
authorizationCode,
|
||||
state,
|
||||
this.codeVerifier,
|
||||
this.port!,
|
||||
!isAutomaticFlow, // Pass isManual=true if it's NOT automatic flow
|
||||
options?.expiresIn,
|
||||
)
|
||||
|
||||
// Fetch profile info (subscription type and rate limit tier) for the
|
||||
// returned OAuthTokens. Logout and account storage are handled by the
|
||||
// caller (installOAuthTokens in auth.ts).
|
||||
const profileInfo = await client.fetchProfileInfo(
|
||||
tokenResponse.access_token,
|
||||
)
|
||||
|
||||
// Handle success redirect for automatic flow
|
||||
if (isAutomaticFlow) {
|
||||
const scopes = client.parseScopes(tokenResponse.scope)
|
||||
this.authCodeListener?.handleSuccessRedirect(scopes)
|
||||
}
|
||||
|
||||
return this.formatTokens(
|
||||
tokenResponse,
|
||||
profileInfo.subscriptionType,
|
||||
profileInfo.rateLimitTier,
|
||||
profileInfo.rawProfile,
|
||||
)
|
||||
} catch (error) {
|
||||
// If we have a pending response, send an error redirect before closing
|
||||
if (isAutomaticFlow) {
|
||||
this.authCodeListener?.handleErrorRedirect()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// Always cleanup
|
||||
this.authCodeListener?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForAuthorizationCode(
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up manual auth code resolver
|
||||
this.manualAuthCodeResolver = resolve
|
||||
|
||||
// Start automatic flow
|
||||
this.authCodeListener
|
||||
?.waitForAuthorization(state, onReady)
|
||||
.then(authorizationCode => {
|
||||
this.manualAuthCodeResolver = null
|
||||
resolve(authorizationCode)
|
||||
})
|
||||
.catch(error => {
|
||||
this.manualAuthCodeResolver = null
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Handle manual flow callback when user pastes the auth code
|
||||
handleManualAuthCodeInput(params: {
|
||||
authorizationCode: string
|
||||
state: string
|
||||
}): void {
|
||||
if (this.manualAuthCodeResolver) {
|
||||
this.manualAuthCodeResolver(params.authorizationCode)
|
||||
this.manualAuthCodeResolver = null
|
||||
// Close the auth code listener since manual input was used
|
||||
this.authCodeListener?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private formatTokens(
|
||||
response: OAuthTokenExchangeResponse,
|
||||
subscriptionType: SubscriptionType | null,
|
||||
rateLimitTier: RateLimitTier | null,
|
||||
profile?: OAuthProfileResponse,
|
||||
): OAuthTokens {
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: Date.now() + response.expires_in * 1000,
|
||||
scopes: client.parseScopes(response.scope),
|
||||
subscriptionType,
|
||||
rateLimitTier,
|
||||
profile,
|
||||
tokenAccount: response.account
|
||||
? {
|
||||
uuid: response.account.uuid,
|
||||
emailAddress: response.account.email_address,
|
||||
organizationUuid: response.organization?.uuid,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any resources (like the local server)
|
||||
cleanup(): void {
|
||||
this.authCodeListener?.close()
|
||||
this.manualAuthCodeResolver = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user