init claude-code
This commit is contained in:
@@ -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