π File detail
services/oauth/index.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes OAuthService β mainly types, interfaces, or factory objects. Dependencies touch src. It composes internal code from utils, auth-code-listener, client, crypto, and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
OAuthService
π External import roots
Package roots from from "β¦" (relative paths omitted).
src
π₯οΈ Source preview
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
}
}