init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+211
View File
@@ -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()
}
}
}
+566
View File
@@ -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 }
})
}
+23
View File
@@ -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))
}
+53
View File
@@ -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)
}
}
+198
View File
@@ -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
}
}