diff --git a/src/server/__tests__/haha-oauth-service.test.ts b/src/server/__tests__/haha-oauth-service.test.ts new file mode 100644 index 00000000..8ce99b57 --- /dev/null +++ b/src/server/__tests__/haha-oauth-service.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for HahaOAuthService — haha 自管 OAuth 的核心 service 层。 + */ + +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { + HahaOAuthService, + type StoredOAuthTokens, +} from '../services/hahaOAuthService.js' + +let tmpDir: string +let originalConfigDir: string | undefined +let service: HahaOAuthService + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-oauth-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir + service = new HahaOAuthService() +} + +async function teardown() { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +describe('HahaOAuthService — file storage', () => { + beforeEach(setup) + afterEach(teardown) + + test('loadTokens returns null when file does not exist', async () => { + expect(await service.loadTokens()).toBeNull() + }) + + test('saveTokens writes file with 0600 permissions', async () => { + const tokens: StoredOAuthTokens = { + accessToken: 'sk-ant-oat01-xxx', + refreshToken: 'sk-ant-ort01-xxx', + expiresAt: Date.now() + 3600_000, + scopes: ['user:inference', 'user:profile'], + subscriptionType: 'max', + } + await service.saveTokens(tokens) + + const oauthPath = path.join(tmpDir, 'cc-haha', 'oauth.json') + const stat = await fs.stat(oauthPath) + expect(stat.mode & 0o777).toBe(0o600) + + const loaded = await service.loadTokens() + expect(loaded).toEqual(tokens) + }) + + test('deleteTokens removes file', async () => { + await service.saveTokens({ + accessToken: 'a', + refreshToken: null, + expiresAt: null, + scopes: [], + subscriptionType: null, + }) + await service.deleteTokens() + expect(await service.loadTokens()).toBeNull() + }) +}) + +describe('HahaOAuthService — session management', () => { + beforeEach(setup) + afterEach(teardown) + + test('startSession creates session with PKCE + state', () => { + const session = service.startSession({ serverPort: 54321 }) + expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(session.authorizeUrl).toContain('code_challenge_method=S256') + expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`) + expect(session.authorizeUrl).toContain('redirect_uri=') + expect(session.authorizeUrl).toContain(encodeURIComponent( + 'http://localhost:54321/api/haha-oauth/callback', + )) + }) + + test('getSession returns stored session by state', () => { + const session = service.startSession({ serverPort: 54321 }) + const found = service.getSession(session.state) + expect(found?.codeVerifier).toBe(session.codeVerifier) + }) + + test('getSession returns null for unknown state', () => { + expect(service.getSession('unknown-state')).toBeNull() + }) + + test('consumeSession removes session after fetch', () => { + const session = service.startSession({ serverPort: 54321 }) + expect(service.consumeSession(session.state)).not.toBeNull() + expect(service.getSession(session.state)).toBeNull() + }) +}) + +describe('HahaOAuthService — ensureFreshAccessToken', () => { + beforeEach(setup) + afterEach(teardown) + + test('returns null when no token file exists', async () => { + expect(await service.ensureFreshAccessToken()).toBeNull() + }) + + test('returns token unchanged if not expired', async () => { + const tokens: StoredOAuthTokens = { + accessToken: 'still-valid', + refreshToken: 'refresh-xxx', + expiresAt: Date.now() + 30 * 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + } + await service.saveTokens(tokens) + + expect(await service.ensureFreshAccessToken()).toBe('still-valid') + }) + + test('refreshes token when expired (within 5-min buffer)', async () => { + const oldTokens: StoredOAuthTokens = { + accessToken: 'expired', + refreshToken: 'refresh-xxx', + expiresAt: Date.now() + 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + } + await service.saveTokens(oldTokens) + + service.setRefreshFn(async () => ({ + accessToken: 'new-fresh-token', + refreshToken: 'new-refresh-xxx', + expiresAt: Date.now() + 3600_000, + scopes: ['user:inference'], + subscriptionType: 'max', + rateLimitTier: null, + })) + + const fresh = await service.ensureFreshAccessToken() + expect(fresh).toBe('new-fresh-token') + + const loaded = await service.loadTokens() + expect(loaded?.accessToken).toBe('new-fresh-token') + }) + + test('returns null when refresh fails', async () => { + await service.saveTokens({ + accessToken: 'expired', + refreshToken: 'bad-refresh', + expiresAt: Date.now() + 60_000, + scopes: ['user:inference'], + subscriptionType: null, + }) + service.setRefreshFn(async () => { + throw new Error('401 Unauthorized') + }) + + expect(await service.ensureFreshAccessToken()).toBeNull() + }) +}) diff --git a/src/server/services/hahaOAuthService.ts b/src/server/services/hahaOAuthService.ts new file mode 100644 index 00000000..79020794 --- /dev/null +++ b/src/server/services/hahaOAuthService.ts @@ -0,0 +1,249 @@ +/** + * 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, + exchangeCodeForTokens, + refreshOAuthToken, + isOAuthTokenExpired, + parseScopes, +} from '../../services/oauth/client.js' +import type { + OAuthTokens, + OAuthTokenExchangeResponse, + SubscriptionType, +} from '../../services/oauth/types.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 +type ExchangeFn = ( + code: string, + state: string, + verifier: string, + port: number, +) => Promise + +const SESSION_TTL_MS = 5 * 60 * 1000 + +export class HahaOAuthService { + private sessions = new Map() + private refreshFn: RefreshFn = refreshOAuthToken + private exchangeFn: ExchangeFn = (code, state, verifier, port) => + exchangeCodeForTokens(code, state, verifier, port, false) + + setRefreshFn(fn: RefreshFn): void { + this.refreshFn = fn + } + + setExchangeFn(fn: ExchangeFn): void { + this.exchangeFn = 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 { + 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 { + const filePath = this.getOAuthFilePath() + await fs.mkdir(path.dirname(filePath), { recursive: true }) + 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 { + 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 baseUrl = buildAuthUrl({ + codeChallenge, + state, + port: serverPort, + isManual: false, + loginWithClaudeAi: true, + }) + const authorizeUrl = this.rewriteCallbackPath(baseUrl, serverPort) + + const session: OAuthSession = { + state, + codeVerifier, + authorizeUrl, + serverPort, + createdAt: Date.now(), + } + this.sessions.set(state, session) + return session + } + + private rewriteCallbackPath(url: string, port: number): string { + const u = new URL(url) + u.searchParams.set( + 'redirect_uri', + `http://localhost:${port}/api/haha-oauth/callback`, + ) + return u.toString() + } + + 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 { + 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 tokens: StoredOAuthTokens = { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? null, + expiresAt: Date.now() + response.expires_in * 1000, + scopes: parseScopes(response.scope), + subscriptionType: null, + } + await this.saveTokens(tokens) + return tokens + } + + private async exchangeWithCustomCallback( + code: string, + state: string, + verifier: string, + port: number, + ): Promise { + const { getOauthConfig } = await import('../../constants/oauth.js') + const requestBody = { + grant_type: 'authorization_code', + code, + redirect_uri: `http://localhost:${port}/api/haha-oauth/callback`, + client_id: getOauthConfig().CLIENT_ID, + code_verifier: verifier, + state, + } + + const res = await fetch(getOauthConfig().TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + if (!res.ok) { + throw new Error( + `Token exchange failed (${res.status}): ${await res.text()}`, + ) + } + return (await res.json()) as OAuthTokenExchangeResponse + } + + async ensureFreshAccessToken(): Promise { + const tokens = await this.loadTokens() + if (!tokens) return null + + if (tokens.expiresAt === null) return tokens.accessToken + + if (!isOAuthTokenExpired(tokens.expiresAt)) return tokens.accessToken + + 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.accessToken + } catch { + return null + } + } +} + +export const hahaOAuthService = new HahaOAuthService()