mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
Prevent desktop OAuth sessions from drifting out of sync
Desktop auth was reporting stale local token files as logged in, could inherit a parent OAuth token after logout, and kept polling even when browser launch failed. This change validates stored tokens through the refresh path, clears inherited OAuth env before spawning the CLI, fetches subscription metadata on initial login, and moves polling start until after the browser opens. Regression tests cover the server and desktop store paths. Constraint: Desktop CLI must bypass macOS Keychain by injecting env OAuth when available Rejected: Keep status endpoint file-based only | reports expired sessions as logged in Confidence: high Scope-risk: moderate Directive: Official desktop auth state must be derived from ensureFreshTokens rather than oauth.json presence alone Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/haha-oauth-service.test.ts src/server/__tests__/haha-oauth-api.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run test Not-tested: Manual end-to-end desktop OAuth flow against the live provider
This commit is contained in:
parent
4b4567bb80
commit
d83efc1b04
@ -11,8 +11,16 @@ import { useTranslation } from '../../i18n'
|
|||||||
|
|
||||||
export function ClaudeOfficialLogin() {
|
export function ClaudeOfficialLogin() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const { status, isLoading, error, fetchStatus, login, logout, stopPolling } =
|
const {
|
||||||
useHahaOAuthStore()
|
status,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchStatus,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
} = useHahaOAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus()
|
fetchStatus()
|
||||||
@ -24,6 +32,7 @@ export function ClaudeOfficialLogin() {
|
|||||||
const { authorizeUrl } = await login()
|
const { authorizeUrl } = await login()
|
||||||
try {
|
try {
|
||||||
await shellOpen(authorizeUrl)
|
await shellOpen(authorizeUrl)
|
||||||
|
startPolling()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ClaudeOfficialLogin] shellOpen failed:', err)
|
console.error('[ClaudeOfficialLogin] shellOpen failed:', err)
|
||||||
useHahaOAuthStore.setState({
|
useHahaOAuthStore.setState({
|
||||||
|
|||||||
77
desktop/src/stores/hahaOAuthStore.test.ts
Normal file
77
desktop/src/stores/hahaOAuthStore.test.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({
|
||||||
|
startMock: vi.fn(),
|
||||||
|
statusMock: vi.fn(),
|
||||||
|
logoutMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../api/hahaOAuth', () => ({
|
||||||
|
hahaOAuthApi: {
|
||||||
|
start: startMock,
|
||||||
|
status: statusMock,
|
||||||
|
logout: logoutMock,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useHahaOAuthStore } from './hahaOAuthStore'
|
||||||
|
|
||||||
|
const initialState = useHahaOAuthStore.getState()
|
||||||
|
|
||||||
|
describe('hahaOAuthStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
startMock.mockReset()
|
||||||
|
statusMock.mockReset()
|
||||||
|
logoutMock.mockReset()
|
||||||
|
useHahaOAuthStore.setState({
|
||||||
|
...initialState,
|
||||||
|
status: null,
|
||||||
|
isPolling: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useHahaOAuthStore.getState().stopPolling()
|
||||||
|
useHahaOAuthStore.setState(initialState)
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('login does not start polling until the browser launch succeeds', async () => {
|
||||||
|
startMock.mockResolvedValue({
|
||||||
|
authorizeUrl: 'http://localhost:3456/api/haha-oauth/callback',
|
||||||
|
state: 'state-123',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await useHahaOAuthStore.getState().login()
|
||||||
|
|
||||||
|
expect(result.authorizeUrl).toContain('/api/haha-oauth/callback')
|
||||||
|
expect(useHahaOAuthStore.getState().isPolling).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startPolling stops after the status becomes logged in', async () => {
|
||||||
|
statusMock
|
||||||
|
.mockResolvedValueOnce({ loggedIn: false })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
loggedIn: true,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
scopes: ['user:inference'],
|
||||||
|
subscriptionType: 'max',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHahaOAuthStore.getState().startPolling()
|
||||||
|
expect(useHahaOAuthStore.getState().isPolling).toBe(true)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000)
|
||||||
|
expect(useHahaOAuthStore.getState().isPolling).toBe(true)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000)
|
||||||
|
expect(useHahaOAuthStore.getState().status).toMatchObject({
|
||||||
|
loggedIn: true,
|
||||||
|
subscriptionType: 'max',
|
||||||
|
})
|
||||||
|
expect(useHahaOAuthStore.getState().isPolling).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -41,7 +41,6 @@ export const useHahaOAuthStore = create<HahaOAuthState>((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const res = await hahaOAuthApi.start()
|
const res = await hahaOAuthApi.start()
|
||||||
set({ isLoading: false })
|
set({ isLoading: false })
|
||||||
get().startPolling()
|
|
||||||
return { authorizeUrl: res.authorizeUrl }
|
return { authorizeUrl: res.authorizeUrl }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@ -11,6 +11,7 @@ describe('ConversationService', () => {
|
|||||||
let originalBaseUrl: string | undefined
|
let originalBaseUrl: string | undefined
|
||||||
let originalModel: string | undefined
|
let originalModel: string | undefined
|
||||||
let originalEntrypoint: string | undefined
|
let originalEntrypoint: string | undefined
|
||||||
|
let originalOAuthToken: string | undefined
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
||||||
@ -19,11 +20,13 @@ describe('ConversationService', () => {
|
|||||||
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
||||||
originalModel = process.env.ANTHROPIC_MODEL
|
originalModel = process.env.ANTHROPIC_MODEL
|
||||||
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
|
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
||||||
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
|
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
|
||||||
process.env.ANTHROPIC_MODEL = 'test-model'
|
process.env.ANTHROPIC_MODEL = 'test-model'
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'inherited-parent-oauth-token'
|
||||||
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
|
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
|
||||||
// buildChildEnv injects it or not without interference from the shell env.
|
// buildChildEnv injects it or not without interference from the shell env.
|
||||||
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
@ -45,6 +48,9 @@ describe('ConversationService', () => {
|
|||||||
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
|
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint
|
else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint
|
||||||
|
|
||||||
|
if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -124,6 +130,22 @@ describe('ConversationService', () => {
|
|||||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => {
|
||||||
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
||||||
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(ccHahaDir, 'settings.json'),
|
||||||
|
JSON.stringify({ env: {} }),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new ConversationService() as any
|
||||||
|
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
|
||||||
|
|
||||||
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
|
||||||
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
||||||
const service = new ConversationService() as any
|
const service = new ConversationService() as any
|
||||||
const args = service.resolveCliArgs(['--print'])
|
const args = service.resolveCliArgs(['--print'])
|
||||||
|
|||||||
@ -104,6 +104,25 @@ describe('GET /api/haha-oauth/status', () => {
|
|||||||
expect(JSON.stringify(data)).not.toContain('sk-ant-oat01')
|
expect(JSON.stringify(data)).not.toContain('sk-ant-oat01')
|
||||||
expect(JSON.stringify(data)).not.toContain('sk-ant-ort01')
|
expect(JSON.stringify(data)).not.toContain('sk-ant-ort01')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('returns loggedIn=false when stored token is expired and refresh fails', async () => {
|
||||||
|
await hahaOAuthService.saveTokens({
|
||||||
|
accessToken: 'expired-token',
|
||||||
|
refreshToken: 'revoked-refresh-token',
|
||||||
|
expiresAt: Date.now() - 1_000,
|
||||||
|
scopes: ['user:inference'],
|
||||||
|
subscriptionType: 'max',
|
||||||
|
})
|
||||||
|
hahaOAuthService.setRefreshFn(async () => {
|
||||||
|
throw new Error('refresh revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { req, url, segments } = buildReq('GET', '/api/haha-oauth/status')
|
||||||
|
const res = await handleHahaOAuthApi(req, url, segments)
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ loggedIn: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DELETE /api/haha-oauth', () => {
|
describe('DELETE /api/haha-oauth', () => {
|
||||||
|
|||||||
@ -101,6 +101,24 @@ describe('HahaOAuthService — session management', () => {
|
|||||||
expect(service.consumeSession(session.state)).not.toBeNull()
|
expect(service.consumeSession(session.state)).not.toBeNull()
|
||||||
expect(service.getSession(session.state)).toBeNull()
|
expect(service.getSession(session.state)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('completeSession stores subscription type fetched from profile info', async () => {
|
||||||
|
const session = service.startSession({ serverPort: 54321 })
|
||||||
|
;(service as any).exchangeWithCustomCallback = async () => ({
|
||||||
|
access_token: 'fresh-access-token',
|
||||||
|
refresh_token: 'fresh-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'user:inference',
|
||||||
|
})
|
||||||
|
service.setFetchProfileFn(async () => ({
|
||||||
|
subscriptionType: 'team',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tokens = await service.completeSession('authorization-code', session.state)
|
||||||
|
|
||||||
|
expect(tokens.subscriptionType).toBe('team')
|
||||||
|
expect((await service.loadTokens())?.subscriptionType).toBe('team')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('HahaOAuthService — ensureFreshAccessToken', () => {
|
describe('HahaOAuthService — ensureFreshAccessToken', () => {
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export async function handleHahaOAuthApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((action === undefined || action === 'status') && req.method === 'GET') {
|
if ((action === undefined || action === 'status') && req.method === 'GET') {
|
||||||
const tokens = await hahaOAuthService.loadTokens()
|
const tokens = await hahaOAuthService.ensureFreshTokens()
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
return Response.json({ loggedIn: false })
|
return Response.json({ loggedIn: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -490,6 +490,7 @@ export class ConversationService {
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const cleanEnv = { ...process.env }
|
const cleanEnv = { ...process.env }
|
||||||
|
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
if (this.shouldStripInheritedProviderEnv()) {
|
if (this.shouldStripInheritedProviderEnv()) {
|
||||||
for (const key of PROVIDER_ENV_KEYS) {
|
for (const key of PROVIDER_ENV_KEYS) {
|
||||||
delete cleanEnv[key]
|
delete cleanEnv[key]
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
} from '../../services/oauth/crypto.js'
|
} from '../../services/oauth/crypto.js'
|
||||||
import {
|
import {
|
||||||
buildAuthUrl,
|
buildAuthUrl,
|
||||||
|
fetchProfileInfo,
|
||||||
refreshOAuthToken,
|
refreshOAuthToken,
|
||||||
isOAuthTokenExpired,
|
isOAuthTokenExpired,
|
||||||
parseScopes,
|
parseScopes,
|
||||||
@ -47,17 +48,25 @@ export type OAuthSession = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RefreshFn = (refreshToken: string, opts?: { scopes?: string[] }) => Promise<OAuthTokens>
|
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 SESSION_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
export class HahaOAuthService {
|
export class HahaOAuthService {
|
||||||
private sessions = new Map<string, OAuthSession>()
|
private sessions = new Map<string, OAuthSession>()
|
||||||
private refreshFn: RefreshFn = refreshOAuthToken
|
private refreshFn: RefreshFn = refreshOAuthToken
|
||||||
|
private fetchProfileFn: FetchProfileFn = fetchProfileInfo
|
||||||
|
|
||||||
setRefreshFn(fn: RefreshFn): void {
|
setRefreshFn(fn: RefreshFn): void {
|
||||||
this.refreshFn = fn
|
this.refreshFn = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFetchProfileFn(fn: FetchProfileFn): void {
|
||||||
|
this.fetchProfileFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
private getOAuthFilePath(): string {
|
private getOAuthFilePath(): string {
|
||||||
const configDir =
|
const configDir =
|
||||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||||
@ -166,13 +175,14 @@ export class HahaOAuthService {
|
|||||||
session.codeVerifier,
|
session.codeVerifier,
|
||||||
session.serverPort,
|
session.serverPort,
|
||||||
)
|
)
|
||||||
|
const profile = await this.fetchProfileFn(response.access_token)
|
||||||
|
|
||||||
const tokens: StoredOAuthTokens = {
|
const tokens: StoredOAuthTokens = {
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token ?? null,
|
refreshToken: response.refresh_token ?? null,
|
||||||
expiresAt: Date.now() + response.expires_in * 1000,
|
expiresAt: Date.now() + response.expires_in * 1000,
|
||||||
scopes: parseScopes(response.scope),
|
scopes: parseScopes(response.scope),
|
||||||
subscriptionType: null,
|
subscriptionType: profile.subscriptionType,
|
||||||
}
|
}
|
||||||
await this.saveTokens(tokens)
|
await this.saveTokens(tokens)
|
||||||
return tokens
|
return tokens
|
||||||
@ -214,13 +224,13 @@ export class HahaOAuthService {
|
|||||||
return (await res.json()) as OAuthTokenExchangeResponse
|
return (await res.json()) as OAuthTokenExchangeResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureFreshAccessToken(): Promise<string | null> {
|
async ensureFreshTokens(): Promise<StoredOAuthTokens | null> {
|
||||||
const tokens = await this.loadTokens()
|
const tokens = await this.loadTokens()
|
||||||
if (!tokens) return null
|
if (!tokens) return null
|
||||||
|
|
||||||
if (tokens.expiresAt === null) return tokens.accessToken
|
if (tokens.expiresAt === null) return tokens
|
||||||
|
|
||||||
if (!isOAuthTokenExpired(tokens.expiresAt)) return tokens.accessToken
|
if (!isOAuthTokenExpired(tokens.expiresAt)) return tokens
|
||||||
|
|
||||||
if (!tokens.refreshToken) return null
|
if (!tokens.refreshToken) return null
|
||||||
|
|
||||||
@ -236,7 +246,7 @@ export class HahaOAuthService {
|
|||||||
subscriptionType: refreshed.subscriptionType ?? tokens.subscriptionType,
|
subscriptionType: refreshed.subscriptionType ?? tokens.subscriptionType,
|
||||||
}
|
}
|
||||||
await this.saveTokens(updated)
|
await this.saveTokens(updated)
|
||||||
return updated.accessToken
|
return updated
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'[HahaOAuthService] token refresh failed:',
|
'[HahaOAuthService] token refresh failed:',
|
||||||
@ -245,6 +255,11 @@ export class HahaOAuthService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureFreshAccessToken(): Promise<string | null> {
|
||||||
|
const tokens = await this.ensureFreshTokens()
|
||||||
|
return tokens?.accessToken ?? null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hahaOAuthService = new HahaOAuthService()
|
export const hahaOAuthService = new HahaOAuthService()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user