File size: 7,347 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | /**
* 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()
|