Skip to content

Commit 66a91c7

Browse files
authored
fix(oauth): send the Claude Code CLI identity upstream and confirm official login risk (#1526)
* feat(desktop): confirm account ban risk before official OAuth login Claude, ChatGPT and Grok sign-in now open a confirmation dialog first. It states the account-ban risk of using OAuth through a third-party app, graded per provider (Claude high, Grok medium, ChatGPT relatively low). Cancelling starts nothing; confirming runs the existing flow. * fix(oauth): send the Claude Code CLI identity on official OAuth requests Upstream requests made with the Claude subscription token are now the CLI's own, not the desktop host's: - wire entrypoint is always cli (User-Agent, cc_entrypoint, MCP User-Agent); CLAUDE_CODE_ENTRYPOINT=claude-desktop stays an internal switch - compatibility version 2.1.281 -> 2.1.288, shared by User-Agent, cc_version, fingerprint and claude-code/<version> - authorization-code exchange reuses the CLI axios call (axios pinned to 1.15.2); profile, roles and create_api_key send claude-code/<version> - metadata.user_id.account_uuid is injected by the desktop server as CLAUDE_CODE_ACCOUNT_UUID (cached, bounded wait, backoff, skipped without user:profile) - @anthropic-ai/sdk 0.80.0 -> 0.128.0, no source change required - auto-answer no longer posts to api.anthropic.com with the subscription token from the server; the question stays with the user
1 parent 92c7ca4 commit 66a91c7

35 files changed

Lines changed: 1004 additions & 130 deletions

‎bun.lock‎

Lines changed: 16 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ describe('ChatGPTOfficialLogin', () => {
9595
await act(async () => {
9696
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
9797
})
98+
await act(async () => {
99+
fireEvent.click(screen.getByRole('button', { name: 'I understand, continue' }))
100+
})
98101

99102
expect(hostOpen).toHaveBeenCalledWith(authorizeUrl)
100103
expect(shellOpenMock).not.toHaveBeenCalled()
@@ -139,6 +142,9 @@ describe('ChatGPTOfficialLogin', () => {
139142
await act(async () => {
140143
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
141144
})
145+
await act(async () => {
146+
fireEvent.click(screen.getByRole('button', { name: 'I understand, continue' }))
147+
})
142148

143149
await act(async () => {
144150
fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' }))
@@ -149,4 +155,38 @@ describe('ChatGPTOfficialLogin', () => {
149155
expect(screen.getByText(/Unable to copy authorization link/)).toBeInTheDocument()
150156
expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument()
151157
})
158+
159+
it('asks for risk confirmation before starting login and skips it on cancel', async () => {
160+
const hostOpen = vi.fn().mockResolvedValue(undefined)
161+
window.desktopHost = {
162+
...browserHost,
163+
kind: 'electron',
164+
isDesktop: true,
165+
capabilities: { ...browserHost.capabilities, shell: true },
166+
shell: { ...browserHost.shell, open: hostOpen },
167+
}
168+
statusMock.mockResolvedValue({ loggedIn: false })
169+
startMock.mockResolvedValue({
170+
authorizeUrl: 'https://chatgpt.com/oauth/authorize?state=openai-state',
171+
state: 'openai-state',
172+
})
173+
174+
render(<ChatGPTOfficialLogin />)
175+
await screen.findByRole('button', { name: 'Sign in with ChatGPT' })
176+
await act(async () => {
177+
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
178+
})
179+
180+
expect(screen.getByRole('dialog')).toBeInTheDocument()
181+
expect(screen.getByText('Relatively low')).toBeInTheDocument()
182+
expect(startMock).not.toHaveBeenCalled()
183+
184+
await act(async () => {
185+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
186+
})
187+
188+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
189+
expect(startMock).not.toHaveBeenCalled()
190+
expect(hostOpen).not.toHaveBeenCalled()
191+
})
152192
})

‎desktop/src/components/settings/ChatGPTOfficialLogin.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
77
import { useTranslation } from '../../i18n'
88
import { copyTextToClipboard } from '@/lib/clipboard'
99
import { getDesktopHost } from '../../lib/desktopHost'
10+
import { OfficialLoginRiskDialog } from './OfficialLoginRiskDialog'
1011

1112
export function ChatGPTOfficialLogin() {
1213
const t = useTranslation()
1314
const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState<string | null>(null)
15+
const [riskDialogOpen, setRiskDialogOpen] = useState(false)
1416
const {
1517
status,
1618
isLoading,
@@ -127,7 +129,7 @@ export function ChatGPTOfficialLogin() {
127129
</div>
128130
<Button
129131
size="base"
130-
onClick={handleLogin}
132+
onClick={() => setRiskDialogOpen(true)}
131133
disabled={isLoading}
132134
icon={<LogIn size={14} strokeWidth={1.75} aria-hidden="true" />}
133135
className="self-start"
@@ -142,6 +144,15 @@ export function ChatGPTOfficialLogin() {
142144
</div>
143145
)}
144146
{manualAuthorizeButton}
147+
<OfficialLoginRiskDialog
148+
open={riskDialogOpen}
149+
provider="chatgpt"
150+
onClose={() => setRiskDialogOpen(false)}
151+
onConfirm={() => {
152+
setRiskDialogOpen(false)
153+
void handleLogin()
154+
}}
155+
/>
145156
</div>
146157
)
147158
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3+
import '@testing-library/jest-dom'
4+
5+
const { logoutMock, startMock, statusMock } = vi.hoisted(() => ({
6+
logoutMock: vi.fn(),
7+
startMock: vi.fn(),
8+
statusMock: vi.fn(),
9+
}))
10+
11+
vi.mock('../../api/hahaOAuth', () => ({
12+
hahaOAuthApi: {
13+
start: startMock,
14+
status: statusMock,
15+
logout: logoutMock,
16+
},
17+
}))
18+
19+
import { ClaudeOfficialLogin } from './ClaudeOfficialLogin'
20+
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
21+
import { useSettingsStore } from '../../stores/settingsStore'
22+
import { browserHost } from '../../lib/desktopHost/browserHost'
23+
24+
const initialOAuthState = useHahaOAuthStore.getState()
25+
const authorizeUrl = 'https://claude.ai/oauth/authorize?state=claude-state'
26+
27+
describe('ClaudeOfficialLogin', () => {
28+
beforeEach(() => {
29+
statusMock.mockReset()
30+
startMock.mockReset()
31+
logoutMock.mockReset()
32+
statusMock.mockResolvedValue({ loggedIn: false })
33+
startMock.mockResolvedValue({ authorizeUrl, state: 'claude-state' })
34+
useSettingsStore.setState({ locale: 'en' })
35+
useHahaOAuthStore.setState({
36+
...initialOAuthState,
37+
status: null,
38+
isPolling: false,
39+
isLoading: false,
40+
error: null,
41+
})
42+
})
43+
44+
afterEach(() => {
45+
act(() => {
46+
useHahaOAuthStore.getState().stopPolling()
47+
useHahaOAuthStore.setState(initialOAuthState)
48+
})
49+
Reflect.deleteProperty(window, 'desktopHost')
50+
cleanup()
51+
vi.restoreAllMocks()
52+
})
53+
54+
function installHost(open: ReturnType<typeof vi.fn>) {
55+
window.desktopHost = {
56+
...browserHost,
57+
kind: 'electron',
58+
isDesktop: true,
59+
capabilities: { ...browserHost.capabilities, shell: true },
60+
shell: { ...browserHost.shell, open },
61+
}
62+
}
63+
64+
it('asks for risk confirmation before starting the OAuth flow', async () => {
65+
const open = vi.fn().mockResolvedValue(undefined)
66+
installHost(open)
67+
68+
render(<ClaudeOfficialLogin />)
69+
await screen.findByRole('button', { name: 'Sign in to Claude' })
70+
await act(async () => {
71+
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Claude' }))
72+
})
73+
74+
// Clicking login only opens the dialog; nothing may reach the server or browser yet.
75+
expect(screen.getByRole('dialog')).toBeInTheDocument()
76+
expect(screen.getByText('High')).toBeInTheDocument()
77+
expect(startMock).not.toHaveBeenCalled()
78+
expect(open).not.toHaveBeenCalled()
79+
80+
await act(async () => {
81+
fireEvent.click(screen.getByRole('button', { name: 'I understand, continue' }))
82+
})
83+
84+
expect(startMock).toHaveBeenCalledTimes(1)
85+
expect(open).toHaveBeenCalledWith(authorizeUrl)
86+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
87+
expect(useHahaOAuthStore.getState().isPolling).toBe(true)
88+
})
89+
90+
it('does not start the OAuth flow when the risk dialog is cancelled', async () => {
91+
const open = vi.fn().mockResolvedValue(undefined)
92+
installHost(open)
93+
94+
render(<ClaudeOfficialLogin />)
95+
await screen.findByRole('button', { name: 'Sign in to Claude' })
96+
await act(async () => {
97+
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Claude' }))
98+
})
99+
await act(async () => {
100+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
101+
})
102+
103+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
104+
expect(startMock).not.toHaveBeenCalled()
105+
expect(open).not.toHaveBeenCalled()
106+
expect(useHahaOAuthStore.getState().isPolling).toBe(false)
107+
expect(screen.getByRole('button', { name: 'Sign in to Claude' })).toBeInTheDocument()
108+
})
109+
})

‎desktop/src/components/settings/ClaudeOfficialLogin.tsx‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
// 点击 Login 调 desktop host shell.open 打开浏览器走 OAuth flow;浏览器回 callback
55
// 到 haha server 后,store 的 polling 自动刷新 UI 展示"已登录"。
66

7-
import { useEffect } from 'react'
7+
import { useEffect, useState } from 'react'
88
import { CircleCheck } from 'lucide-react'
99
import { Button } from '@/components/ui/Button'
1010
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
1111
import { useTranslation } from '../../i18n'
1212
import { getDesktopHost } from '../../lib/desktopHost'
13+
import { OfficialLoginRiskDialog } from './OfficialLoginRiskDialog'
1314

1415
export function ClaudeOfficialLogin() {
1516
const t = useTranslation()
17+
const [riskDialogOpen, setRiskDialogOpen] = useState(false)
1618
const {
1719
status,
1820
isLoading,
@@ -85,7 +87,12 @@ export function ClaudeOfficialLogin() {
8587
<div className="text-[13px] leading-5 text-[var(--color-text-secondary)]">
8688
{t('settings.claudeOfficialLogin.intro')}
8789
</div>
88-
<Button size="base" onClick={handleLogin} disabled={isLoading} className="self-start">
90+
<Button
91+
size="base"
92+
onClick={() => setRiskDialogOpen(true)}
93+
disabled={isLoading}
94+
className="self-start"
95+
>
8996
{isLoading
9097
? t('settings.claudeOfficialLogin.loginStarting')
9198
: t('settings.claudeOfficialLogin.loginButton')}
@@ -95,6 +102,15 @@ export function ClaudeOfficialLogin() {
95102
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
96103
</div>
97104
)}
105+
<OfficialLoginRiskDialog
106+
open={riskDialogOpen}
107+
provider="claude"
108+
onClose={() => setRiskDialogOpen(false)}
109+
onConfirm={() => {
110+
setRiskDialogOpen(false)
111+
void handleLogin()
112+
}}
113+
/>
98114
</div>
99115
)
100116
}

0 commit comments

Comments
 (0)