mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
25bd594dcf
commit
b0ca586190
167
src/server/__tests__/haha-oauth-service.test.ts
Normal file
167
src/server/__tests__/haha-oauth-service.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
249
src/server/services/hahaOAuthService.ts
Normal file
249
src/server/services/hahaOAuthService.ts
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user