feat(desktop): add HahaOAuthService for self-managed Claude OAuth

新增 service 层管理桌面端自己的 OAuth token 生命周期:
- PKCE + state 生成(复用 oauth/crypto.ts)
- Token 存 ~/.claude/cc-haha/oauth.json, 权限 0600
- In-memory session map (5min TTL)
- ensureFreshAccessToken: 自动 refresh (5min buffer)

目的: 绕开 macOS Keychain ACL 对未公证 .app 的静默拒绝,
让 DMG 装的 .app 也能正常走官方 Claude OAuth。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-17 19:38:31 +08:00
parent 25bd594dcf
commit b0ca586190
2 changed files with 416 additions and 0 deletions

View File

@ -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()
})
})

View File

@ -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<OAuthTokens>
type ExchangeFn = (
code: string,
state: string,
verifier: string,
port: number,
) => Promise<OAuthTokenExchangeResponse>
const SESSION_TTL_MS = 5 * 60 * 1000
export class HahaOAuthService {
private sessions = new Map<string, OAuthSession>()
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<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 })
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 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<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 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<OAuthTokenExchangeResponse> {
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<string | null> {
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()