cc-haha/src/server/services/hahaOAuthService.ts
程序员阿江(Relakkes) 9d29d8e32c Keep desktop replies stable across turns and fix OAuth callback routing
The desktop chat store was dropping the previous assistant draft when a new user turn began because streaming text was cleared before it was flushed into message history. At the same time, the desktop OAuth flow was using an unregistered /api/haha-oauth/callback redirect URI, which caused provider authorization failures. This change flushes visible assistant drafts before starting a new turn, restores the OAuth redirect to the registered localhost callback, and adds a root callback handler while keeping the legacy API callback path compatible.

Constraint: The OAuth provider only accepts the registered localhost /callback redirect URI
Rejected: Keep the desktop-specific /api/haha-oauth/callback path | provider rejects unsupported redirect URIs
Rejected: Rely on message_complete to persist visible assistant text | next user turn can begin before that event arrives
Confidence: high
Scope-risk: moderate
Directive: Any UI-visible assistant draft must be flushed into messages before a new user turn resets streaming state
Tested: bun test src/server/__tests__/haha-oauth-service.test.ts src/server/__tests__/haha-oauth-api.test.ts src/server/__tests__/conversation-service.test.ts
Tested: cd desktop && bun run test src/stores/chatStore.test.ts
Tested: cd desktop && bun run lint
Not-tested: Manual end-to-end desktop OAuth login after reinstall
2026-04-18 17:01:53 +08:00

257 lines
7.2 KiB
TypeScript

/**
* HahaOAuthService — 桌面端自管 Claude OAuth token
*
* 为什么存在: macOS Keychain ACL 在 .app 被打上 quarantine 属性后
* 对无 UI sidecar 静默拒绝,导致 CLI 读不到 OAuth token → 403。
* 这个 service 把 token 存到 haha 自己的目录,并通过 env 注入给 CLI。
*
* 复用 src/services/oauth/{crypto,client}.ts 里的 PKCE + token exchange 逻辑,
* 不复制粘贴 —— 保证跟 CLI 走同一套协议实现。
*/
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
generateCodeVerifier,
generateCodeChallenge,
generateState,
} from '../../services/oauth/crypto.js'
import {
buildAuthUrl,
fetchProfileInfo,
refreshOAuthToken,
isOAuthTokenExpired,
parseScopes,
} from '../../services/oauth/client.js'
import type {
OAuthTokens,
OAuthTokenExchangeResponse,
SubscriptionType,
} from '../../services/oauth/types.js'
import { getOauthConfig } from '../../constants/oauth.js'
export type StoredOAuthTokens = {
accessToken: string
refreshToken: string | null
expiresAt: number | null
scopes: string[]
subscriptionType: SubscriptionType | null
}
export type OAuthSession = {
state: string
codeVerifier: string
authorizeUrl: string
serverPort: number
createdAt: number
}
type RefreshFn = (refreshToken: string, opts?: { scopes?: string[] }) => Promise<OAuthTokens>
type FetchProfileFn = (
accessToken: string,
) => Promise<{ subscriptionType: SubscriptionType | null }>
const SESSION_TTL_MS = 5 * 60 * 1000
const OAUTH_CALLBACK_PATH = '/callback'
export class HahaOAuthService {
private sessions = new Map<string, OAuthSession>()
private refreshFn: RefreshFn = refreshOAuthToken
private fetchProfileFn: FetchProfileFn = fetchProfileInfo
setRefreshFn(fn: RefreshFn): void {
this.refreshFn = fn
}
setFetchProfileFn(fn: FetchProfileFn): void {
this.fetchProfileFn = fn
}
private getOAuthFilePath(): string {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'cc-haha', 'oauth.json')
}
async loadTokens(): Promise<StoredOAuthTokens | null> {
try {
const raw = await fs.readFile(this.getOAuthFilePath(), 'utf-8')
return JSON.parse(raw) as StoredOAuthTokens
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
throw err
}
}
async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
const filePath = this.getOAuthFilePath()
await fs.mkdir(path.dirname(filePath), { recursive: true })
// 写临时文件再 rename,防止写到一半被其他读者读到残缺 JSON。
// 单进程 desktop 下 pid 后缀足够隔离。
const tmp = `${filePath}.tmp.${process.pid}`
await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 })
await fs.rename(tmp, filePath)
}
async deleteTokens(): Promise<void> {
try {
await fs.unlink(this.getOAuthFilePath())
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
startSession({ serverPort }: { serverPort: number }): OAuthSession {
this.pruneExpiredSessions()
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
const authorizeUrl = buildAuthUrl({
codeChallenge,
state,
port: serverPort,
isManual: false,
loginWithClaudeAi: true,
})
const session: OAuthSession = {
state,
codeVerifier,
authorizeUrl,
serverPort,
createdAt: Date.now(),
}
this.sessions.set(state, session)
return session
}
getSession(state: string): OAuthSession | null {
const s = this.sessions.get(state)
if (!s) return null
if (Date.now() - s.createdAt > SESSION_TTL_MS) {
this.sessions.delete(state)
return null
}
return s
}
consumeSession(state: string): OAuthSession | null {
const s = this.getSession(state)
if (s) this.sessions.delete(state)
return s
}
private pruneExpiredSessions(): void {
const now = Date.now()
for (const [state, s] of this.sessions.entries()) {
if (now - s.createdAt > SESSION_TTL_MS) this.sessions.delete(state)
}
}
async completeSession(
authorizationCode: string,
state: string,
): Promise<StoredOAuthTokens> {
const session = this.consumeSession(state)
if (!session) {
throw new Error('OAuth session not found or expired')
}
const response = await this.exchangeWithCustomCallback(
authorizationCode,
state,
session.codeVerifier,
session.serverPort,
)
const profile = await this.fetchProfileFn(response.access_token)
const tokens: StoredOAuthTokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token ?? null,
expiresAt: Date.now() + response.expires_in * 1000,
scopes: parseScopes(response.scope),
subscriptionType: profile.subscriptionType,
}
await this.saveTokens(tokens)
return tokens
}
private async exchangeWithCustomCallback(
code: string,
state: string,
verifier: string,
port: number,
): Promise<OAuthTokenExchangeResponse> {
const requestBody = {
grant_type: 'authorization_code',
code,
redirect_uri: `http://localhost:${port}${OAUTH_CALLBACK_PATH}`,
client_id: getOauthConfig().CLIENT_ID,
code_verifier: verifier,
state,
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15_000)
let res: Response
try {
res = await fetch(getOauthConfig().TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
})
} finally {
clearTimeout(timeoutId)
}
if (!res.ok) {
throw new Error(
`Token exchange failed (${res.status}): ${await res.text()}`,
)
}
return (await res.json()) as OAuthTokenExchangeResponse
}
async ensureFreshTokens(): Promise<StoredOAuthTokens | null> {
const tokens = await this.loadTokens()
if (!tokens) return null
if (tokens.expiresAt === null) return tokens
if (!isOAuthTokenExpired(tokens.expiresAt)) return tokens
if (!tokens.refreshToken) return null
try {
const refreshed = await this.refreshFn(tokens.refreshToken, {
scopes: tokens.scopes,
})
const updated: StoredOAuthTokens = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken ?? tokens.refreshToken,
expiresAt: refreshed.expiresAt,
scopes: refreshed.scopes,
subscriptionType: refreshed.subscriptionType ?? tokens.subscriptionType,
}
await this.saveTokens(updated)
return updated
} catch (err) {
console.error(
'[HahaOAuthService] token refresh failed:',
err instanceof Error ? err.message : err,
)
return null
}
}
async ensureFreshAccessToken(): Promise<string | null> {
const tokens = await this.ensureFreshTokens()
return tokens?.accessToken ?? null
}
}
export const hahaOAuthService = new HahaOAuthService()