cherry-studio/src/main/services/AnthropicService.ts
Pleasure1234 c940e0bc92
feat: add Anthropic OAuth settings UI and logic (#8905)
* feat: add Anthropic OAuth settings UI and logic

Introduces AnthropicSettings component for managing Anthropic OAuth authentication in provider settings. Adds Anthropic OAuth logic in a new anthropicOAuth.ts file, including PKCE flow, token exchange, and credential management stubs. Integrates AnthropicSettings into ProviderSetting to enable UI for login, logout, and code entry.

* feat: add Anthropic OAuth authentication support

Introduces OAuth authentication for Anthropic provider, including UI changes for selecting authentication method and handling authorization code input. Updates i18n files with new Anthropic OAuth-related strings in multiple languages and adds the 'authType' property to the Provider type.

* fix: oauth

* refactor: Anthropic OAuth to main process service

Moved Anthropic OAuth logic from renderer to main process as a singleton service. Updated IPC channels and preload API to support Anthropic OAuth actions. Refactored AnthropicSettings component to use new IPC-based API for authentication flow.

* fix: add 'authenticating' translation and update AnthropicSettings

Added the 'authenticating' key to Anthropic provider translations across multiple languages. Updated AnthropicSettings.tsx to remove the unused 'authenticating_detail' description and set the modal to be centered.

* fix: add reference

* Update AnthropicAPIClient.ts

* fix: update credentials path and improve OAuth handling in AnthropicAPIClient

* feat: add support for Anthropic OAuth provider handling in ProviderSetting

* feat: enhance OAuth authentication messages in multiple languages

* feat: add support for Anthropic provider with OAuth authentication and system message handling for new aisdk provider

* fix: update credential path and use net.fetch for OAuth token requests

* fix: setting page ui

---------

Co-authored-by: Vaayne <liu.vaayne@gmail.com>
2025-09-05 17:43:20 +08:00

227 lines
6.6 KiB
TypeScript

/**
* Reference:
* This code is adapted from https://github.com/ThinkInAIXYZ/deepchat
* Original file: src/main/presenter/anthropicOAuth.ts
*/
import path from 'node:path'
import { loggerService } from '@logger'
import { getConfigDir } from '@main/utils/file'
import * as crypto from 'crypto'
import { net, shell } from 'electron'
import { promises } from 'fs'
import { dirname } from 'path'
const logger = loggerService.withContext('AnthropicOAuth')
// Constants
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
const CREDS_PATH = path.join(getConfigDir(), 'oauth', 'anthropic.json')
// Types
interface Credentials {
access_token: string
refresh_token: string
expires_at: number
}
interface PKCEPair {
verifier: string
challenge: string
}
class AnthropicService extends Error {
private currentPKCE: PKCEPair | null = null
// 1. Generate PKCE pair
private generatePKCE(): PKCEPair {
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
return { verifier, challenge }
}
// 2. Get OAuth authorization URL
private getAuthorizationURL(pkce: PKCEPair): string {
const url = new URL('https://claude.ai/oauth/authorize')
url.searchParams.set('code', 'true')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', 'https://console.anthropic.com/oauth/code/callback')
url.searchParams.set('scope', 'org:create_api_key user:profile user:inference')
url.searchParams.set('code_challenge', pkce.challenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', pkce.verifier)
return url.toString()
}
// 3. Exchange authorization code for tokens
private async exchangeCodeForTokens(code: string, verifier: string): Promise<Credentials> {
// Handle both legacy format (code#state) and new format (pure code)
const authCode = code.includes('#') ? code.split('#')[0] : code
const state = code.includes('#') ? code.split('#')[1] : verifier
const response = await net.fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: authCode,
state: state,
grant_type: 'authorization_code',
client_id: CLIENT_ID,
redirect_uri: 'https://console.anthropic.com/oauth/code/callback',
code_verifier: verifier
})
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`)
}
const data = await response.json()
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000
}
}
// 4. Refresh access token
private async refreshAccessToken(refreshToken: string): Promise<Credentials> {
const response = await net.fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID
})
})
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.statusText}`)
}
const data = await response.json()
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000
}
}
// 5. Save credentials
private async saveCredentials(creds: Credentials): Promise<void> {
await promises.mkdir(dirname(CREDS_PATH), { recursive: true })
await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2))
await promises.chmod(CREDS_PATH, 0o600) // Read/write for owner only
}
// 6. Load credentials
private async loadCredentials(): Promise<Credentials | null> {
try {
const data = await promises.readFile(CREDS_PATH, 'utf-8')
return JSON.parse(data)
} catch {
return null
}
}
// 7. Get valid access token (refresh if needed)
public async getValidAccessToken(): Promise<string | null> {
const creds = await this.loadCredentials()
if (!creds) return null
// If token is still valid, return it
if (creds.expires_at > Date.now() + 60000) {
// 1 minute buffer
return creds.access_token
}
// Otherwise, refresh it
try {
const newCreds = await this.refreshAccessToken(creds.refresh_token)
await this.saveCredentials(newCreds)
return newCreds.access_token
} catch {
return null
}
}
// 8. Start OAuth flow with external browser
public async startOAuthFlow(): Promise<string> {
// Try to get existing valid token
const existingToken = await this.getValidAccessToken()
if (existingToken) return existingToken
// Generate PKCE pair and store it for later use
this.currentPKCE = this.generatePKCE()
// Build authorization URL
const authUrl = this.getAuthorizationURL(this.currentPKCE)
logger.debug(authUrl)
// Open URL in external browser
await shell.openExternal(authUrl)
// Return the URL for UI to show (optional)
return authUrl
}
// 9. Complete OAuth flow with manual code input
public async completeOAuthWithCode(code: string): Promise<string> {
if (!this.currentPKCE) {
throw new Error('OAuth flow not started. Please call startOAuthFlow first.')
}
try {
// Exchange code for tokens using stored PKCE verifier
const credentials = await this.exchangeCodeForTokens(code, this.currentPKCE.verifier)
await this.saveCredentials(credentials)
// Clear stored PKCE after successful exchange
this.currentPKCE = null
return credentials.access_token
} catch (error) {
logger.error('OAuth code exchange failed:', error as Error)
// Clear PKCE on error
this.currentPKCE = null
throw error
}
}
// 10. Cancel current OAuth flow
public cancelOAuthFlow(): void {
if (this.currentPKCE) {
logger.info('Cancelling OAuth flow')
this.currentPKCE = null
}
}
// 11. Clear stored credentials
public async clearCredentials(): Promise<void> {
try {
await promises.unlink(CREDS_PATH)
logger.info('Credentials cleared')
} catch (error) {
// File doesn't exist, which is fine
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
}
// 12. Check if credentials exist
public async hasCredentials(): Promise<boolean> {
const creds = await this.loadCredentials()
return creds !== null
}
}
export default new AnthropicService()