/ src / services / oauth.ts
oauth.ts
  1  import * as crypto from 'crypto'
  2  import * as http from 'http'
  3  import { IncomingMessage, ServerResponse } from 'http'
  4  import * as url from 'url'
  5  
  6  import { OAUTH_CONFIG } from '../constants/oauth.js'
  7  import { openBrowser } from '../utils/browser.js'
  8  import { logEvent } from '../services/statsig.js'
  9  import { logError } from '../utils/log.js'
 10  import { resetAnthropicClient } from './claude.js'
 11  import {
 12    AccountInfo,
 13    getGlobalConfig,
 14    saveGlobalConfig,
 15    normalizeApiKeyForConfig,
 16  } from '../utils/config.js'
 17  
 18  // Base64URL encoding function (RFC 4648)
 19  function base64URLEncode(buffer: Buffer): string {
 20    return buffer
 21      .toString('base64')
 22      .replace(/\+/g, '-')
 23      .replace(/\//g, '_')
 24      .replace(/=/g, '')
 25  }
 26  
 27  function generateCodeVerifier(): string {
 28    return base64URLEncode(crypto.randomBytes(32))
 29  }
 30  
 31  async function generateCodeChallenge(verifier: string): Promise<string> {
 32    const encoder = new TextEncoder()
 33    const data = encoder.encode(verifier)
 34    const digest = await crypto.subtle.digest('SHA-256', data)
 35    return base64URLEncode(Buffer.from(digest))
 36  }
 37  
 38  type OAuthTokenExchangeResponse = {
 39    access_token: string
 40    account?: {
 41      uuid: string
 42      email_address: string
 43    }
 44    organization?: {
 45      uuid: string
 46      name: string
 47    }
 48  }
 49  
 50  export type OAuthResult = {
 51    accessToken: string
 52  }
 53  
 54  export class OAuthService {
 55    private server: http.Server | null = null
 56    private codeVerifier: string
 57    private expectedState: string | null = null
 58    private pendingCodePromise: {
 59      resolve: (result: {
 60        authorizationCode: string
 61        useManualRedirect: boolean
 62      }) => void
 63      reject: (err: Error) => void
 64    } | null = null
 65  
 66    constructor() {
 67      this.codeVerifier = generateCodeVerifier()
 68    }
 69  
 70    private generateAuthUrls(
 71      codeChallenge: string,
 72      state: string,
 73    ): { autoUrl: string; manualUrl: string } {
 74      function makeUrl(isManual: boolean): string {
 75        const authUrl = new URL(OAUTH_CONFIG.AUTHORIZE_URL)
 76        authUrl.searchParams.append('client_id', OAUTH_CONFIG.CLIENT_ID)
 77        authUrl.searchParams.append('response_type', 'code')
 78        authUrl.searchParams.append(
 79          'redirect_uri',
 80          isManual
 81            ? OAUTH_CONFIG.MANUAL_REDIRECT_URL
 82            : `http://localhost:${OAUTH_CONFIG.REDIRECT_PORT}/callback`,
 83        )
 84        authUrl.searchParams.append('scope', OAUTH_CONFIG.SCOPES.join(' '))
 85        authUrl.searchParams.append('code_challenge', codeChallenge)
 86        authUrl.searchParams.append('code_challenge_method', 'S256')
 87        authUrl.searchParams.append('state', state)
 88        return authUrl.toString()
 89      }
 90  
 91      return {
 92        autoUrl: makeUrl(false),
 93        manualUrl: makeUrl(true),
 94      }
 95    }
 96  
 97    async startOAuthFlow(
 98      authURLHandler: (url: string) => Promise<void>,
 99    ): Promise<OAuthResult> {
100      const codeChallenge = await generateCodeChallenge(this.codeVerifier)
101      const state = base64URLEncode(crypto.randomBytes(32))
102      this.expectedState = state
103      const { autoUrl, manualUrl } = this.generateAuthUrls(codeChallenge, state)
104  
105      const onReady = async () => {
106        await authURLHandler(manualUrl)
107        await openBrowser(autoUrl)
108      }
109  
110      const { authorizationCode, useManualRedirect } = await new Promise<{
111        authorizationCode: string
112        useManualRedirect: boolean
113      }>((resolve, reject) => {
114        this.pendingCodePromise = { resolve, reject }
115        this.startLocalServer(state, onReady)
116      })
117  
118      // Exchange code for tokens
119      const {
120        access_token: accessToken,
121        account,
122        organization,
123      } = await this.exchangeCodeForTokens(
124        authorizationCode,
125        state,
126        useManualRedirect,
127      )
128  
129      // Store account info
130      if (account) {
131        const accountInfo: AccountInfo = {
132          accountUuid: account.uuid,
133          emailAddress: account.email_address,
134          organizationUuid: organization?.uuid,
135        }
136        const config = getGlobalConfig()
137        config.oauthAccount = accountInfo
138        saveGlobalConfig(config)
139      }
140  
141      return { accessToken }
142    }
143  
144    private startLocalServer(state: string, onReady?: () => void): void {
145      if (this.server) {
146        this.closeServer()
147      }
148      this.server = http.createServer(
149        (req: IncomingMessage, res: ServerResponse) => {
150          const parsedUrl = url.parse(req.url || '', true)
151  
152          if (parsedUrl.pathname === '/callback') {
153            const authorizationCode = parsedUrl.query.code as string
154            const returnedState = parsedUrl.query.state as string
155  
156            if (!authorizationCode) {
157              res.writeHead(400)
158              res.end('Authorization code not found')
159              if (this.pendingCodePromise) {
160                this.pendingCodePromise.reject(
161                  new Error('No authorization code received'),
162                )
163              }
164              return
165            }
166  
167            if (returnedState !== state) {
168              res.writeHead(400)
169              res.end('Invalid state parameter')
170              if (this.pendingCodePromise) {
171                this.pendingCodePromise.reject(
172                  new Error('Invalid state parameter'), // Possible CSRF attack
173                )
174              }
175              return
176            }
177  
178            res.writeHead(302, {
179              Location: OAUTH_CONFIG.SUCCESS_URL,
180            })
181            res.end()
182  
183            // Track which path the user is taking (automatic browser redirect)
184            logEvent('tengu_oauth_automatic_redirect', {})
185  
186            this.processCallback({
187              authorizationCode,
188              state,
189              useManualRedirect: false,
190            })
191          } else {
192            res.writeHead(404)
193            res.end()
194          }
195        },
196      )
197  
198      this.server.listen(OAUTH_CONFIG.REDIRECT_PORT, async () => {
199        onReady?.()
200      })
201  
202      this.server.on('error', (err: Error) => {
203        const portError = err as NodeJS.ErrnoException
204        if (portError.code === 'EADDRINUSE') {
205          const error = new Error(
206            `Port ${OAUTH_CONFIG.REDIRECT_PORT} is already in use. Please ensure no other applications are using this port.`,
207          )
208          logError(error)
209          this.closeServer()
210          if (this.pendingCodePromise) {
211            this.pendingCodePromise.reject(error)
212          }
213          return
214        } else {
215          logError(err)
216          this.closeServer()
217          if (this.pendingCodePromise) {
218            this.pendingCodePromise.reject(err)
219          }
220          return
221        }
222      })
223    }
224  
225    private async exchangeCodeForTokens(
226      authorizationCode: string,
227      state: string,
228      useManualRedirect: boolean = false,
229    ): Promise<OAuthTokenExchangeResponse> {
230      const requestBody = {
231        grant_type: 'authorization_code',
232        code: authorizationCode,
233        redirect_uri: useManualRedirect
234          ? OAUTH_CONFIG.MANUAL_REDIRECT_URL
235          : `http://localhost:${OAUTH_CONFIG.REDIRECT_PORT}/callback`,
236        client_id: OAUTH_CONFIG.CLIENT_ID,
237        code_verifier: this.codeVerifier,
238        state,
239      }
240  
241      const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
242        method: 'POST',
243        headers: {
244          'Content-Type': 'application/json',
245        },
246        body: JSON.stringify(requestBody),
247      })
248  
249      if (!response.ok) {
250        throw new Error(`Token exchange failed: ${response.statusText}`)
251      }
252  
253      const data = await response.json()
254      return data
255    }
256  
257    processCallback({
258      authorizationCode,
259      state,
260      useManualRedirect,
261    }: {
262      authorizationCode: string
263      state: string
264      useManualRedirect: boolean
265    }): void {
266      this.closeServer()
267  
268      if (state !== this.expectedState) {
269        if (this.pendingCodePromise) {
270          this.pendingCodePromise.reject(
271            new Error('Invalid state parameter'), // Possible CSRF attack
272          )
273          this.pendingCodePromise = null
274        }
275        return
276      }
277  
278      if (this.pendingCodePromise) {
279        this.pendingCodePromise.resolve({ authorizationCode, useManualRedirect })
280        this.pendingCodePromise = null
281      }
282    }
283  
284    private closeServer(): void {
285      if (this.server) {
286        this.server.close()
287        this.server = null
288      }
289    }
290  }
291  
292  export async function createAndStoreApiKey(
293    accessToken: string,
294  ): Promise<string | null> {
295    try {
296      // Call create_api_key endpoint
297      const createApiKeyResp = await fetch(OAUTH_CONFIG.API_KEY_URL, {
298        method: 'POST',
299        headers: { Authorization: `Bearer ${accessToken}` },
300      })
301  
302      let apiKeyData
303      let errorText = ''
304  
305      try {
306        apiKeyData = await createApiKeyResp.json()
307      } catch (_e) {
308        // If response is not valid JSON, get as text for error logging
309        errorText = await createApiKeyResp.text()
310      }
311  
312      logEvent('tengu_oauth_api_key', {
313        status: createApiKeyResp.ok ? 'success' : 'failure',
314        statusCode: createApiKeyResp.status.toString(),
315        error: createApiKeyResp.ok ? '' : errorText || JSON.stringify(apiKeyData),
316      })
317  
318      if (createApiKeyResp.ok && apiKeyData && apiKeyData.raw_key) {
319        const apiKey = apiKeyData.raw_key
320  
321        // Store in global config
322        const config = getGlobalConfig()
323  
324        // Store as primary API key
325        config.primaryApiKey = apiKey
326  
327        // Add to approved list
328        if (!config.customApiKeyResponses) {
329          config.customApiKeyResponses = { approved: [], rejected: [] }
330        }
331        if (!config.customApiKeyResponses.approved) {
332          config.customApiKeyResponses.approved = []
333        }
334  
335        const normalizedKey = normalizeApiKeyForConfig(apiKey)
336        if (!config.customApiKeyResponses.approved.includes(normalizedKey)) {
337          config.customApiKeyResponses.approved.push(normalizedKey)
338        }
339  
340        // Save config
341        saveGlobalConfig(config)
342  
343        // Reset the Anthropic client to force creation with new API key
344        resetAnthropicClient()
345  
346        return apiKey
347      }
348  
349      return null
350    } catch (error) {
351      logEvent('tengu_oauth_api_key', {
352        status: 'failure',
353        statusCode: 'exception',
354        error: error instanceof Error ? error.message : String(error),
355      })
356      throw error
357    }
358  }