Skip to content

Commit 94ddc3a

Browse files
committed
feat: Codex device code authorization flow
- Device code endpoints: /api/accounts/deviceauth/usercode + /token - User visits auth.openai.com/codex/device and enters code - Poll every N seconds until authorized, then exchange for access token - Tauri commands: codex_device_start, codex_device_poll - UI: two auth options - browser OAuth or device code - Device code displayed with copy-friendly styling - 15 minute timeout on polling
1 parent fd3035e commit 94ddc3a

6 files changed

Lines changed: 271 additions & 2 deletions

File tree

src-tauri/src/ai/codex.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ const CALLBACK_PATH: &str = "/auth/callback";
1414
const SCOPE: &str = "openid profile email offline_access";
1515
const API_BASE: &str = "https://api.openai.com";
1616

17+
/// Device Code Flow 端点
18+
const DEVICE_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
19+
const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
20+
const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
21+
const DEVICE_VERIFY_URL: &str = "https://auth.openai.com/codex/device";
22+
const DEVICE_TIMEOUT_SECS: u64 = 900; // 15 分钟
23+
1724
/// Codex OAuth Token
1825
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1926
pub struct CodexToken {
@@ -22,6 +29,15 @@ pub struct CodexToken {
2229
pub expires_at: Option<u64>,
2330
}
2431

32+
/// Device Code Flow 响应
33+
#[derive(Debug, Clone, Serialize, Deserialize)]
34+
pub struct DeviceCodeResponse {
35+
pub device_auth_id: String,
36+
pub user_code: String,
37+
pub verification_uri: String,
38+
pub interval: u64,
39+
}
40+
2541
/// Codex Provider - 通过 ChatGPT Plus/Pro 订阅的 OAuth 授权使用 OpenAI 模型
2642
pub struct CodexProvider {
2743
client: Client,
@@ -155,6 +171,115 @@ impl CodexProvider {
155171
Ok(token)
156172
}
157173

174+
/// Device Code Flow: 请求设备码
175+
pub fn start_device_code_login(&self) -> Result<DeviceCodeResponse, AIError> {
176+
let body = serde_json::json!({ "client_id": CLIENT_ID });
177+
178+
let resp = reqwest::blocking::Client::new()
179+
.post(DEVICE_CODE_URL)
180+
.json(&body)
181+
.send()
182+
.map_err(|e| AIError::Network(e.into()))?;
183+
184+
if !resp.status().is_success() {
185+
let text = resp.text().unwrap_or_default();
186+
return Err(AIError::ApiError(format!("请求设备码失败: {}", text)));
187+
}
188+
189+
let data: serde_json::Value = resp.json()
190+
.map_err(|e| AIError::Network(e.into()))?;
191+
192+
Ok(DeviceCodeResponse {
193+
device_auth_id: data["device_auth_id"].as_str().unwrap_or("").to_string(),
194+
user_code: data["user_code"].as_str()
195+
.or(data["usercode"].as_str())
196+
.unwrap_or("").to_string(),
197+
verification_uri: DEVICE_VERIFY_URL.to_string(),
198+
interval: data["interval"].as_u64()
199+
.or(data["interval"].as_str().and_then(|s| s.parse().ok()))
200+
.unwrap_or(5),
201+
})
202+
}
203+
204+
/// Device Code Flow: 轮询等待用户授权并换取 token
205+
pub fn poll_device_code(&self, device_auth_id: &str, user_code: &str, interval: u64) -> Result<CodexToken, AIError> {
206+
let client = reqwest::blocking::Client::new();
207+
let start = std::time::Instant::now();
208+
209+
loop {
210+
if start.elapsed().as_secs() > DEVICE_TIMEOUT_SECS {
211+
return Err(AIError::ApiError("设备码授权超时(15分钟)".into()));
212+
}
213+
214+
std::thread::sleep(std::time::Duration::from_secs(interval));
215+
216+
let resp = client
217+
.post(DEVICE_TOKEN_URL)
218+
.json(&serde_json::json!({
219+
"device_auth_id": device_auth_id,
220+
"user_code": user_code,
221+
}))
222+
.send()
223+
.map_err(|e| AIError::Network(e.into()))?;
224+
225+
let status = resp.status();
226+
if status.is_success() {
227+
let data: serde_json::Value = resp.json()
228+
.map_err(|e| AIError::Network(e.into()))?;
229+
230+
let auth_code = data["authorization_code"].as_str()
231+
.ok_or_else(|| AIError::ApiError("响应中无 authorization_code".into()))?;
232+
let code_verifier = data["code_verifier"].as_str()
233+
.ok_or_else(|| AIError::ApiError("响应中无 code_verifier".into()))?;
234+
235+
// 用 authorization_code 换 access_token
236+
return self.exchange_device_token(auth_code, code_verifier);
237+
}
238+
239+
// 403/404 = 用户尚未授权,继续轮询
240+
if status.as_u16() == 403 || status.as_u16() == 404 {
241+
tracing::debug!("设备码授权等待中...");
242+
continue;
243+
}
244+
245+
let text = resp.text().unwrap_or_default();
246+
return Err(AIError::ApiError(format!("轮询失败 ({}): {}", status, text)));
247+
}
248+
}
249+
250+
fn exchange_device_token(&self, code: &str, verifier: &str) -> Result<CodexToken, AIError> {
251+
let body = format!(
252+
"grant_type=authorization_code&client_id={}&code={}&code_verifier={}&redirect_uri={}",
253+
CLIENT_ID,
254+
urlencoding::encode(code),
255+
urlencoding::encode(verifier),
256+
urlencoding::encode(DEVICE_REDIRECT_URI),
257+
);
258+
259+
let resp = reqwest::blocking::Client::new()
260+
.post(TOKEN_URL)
261+
.header("Content-Type", "application/x-www-form-urlencoded")
262+
.body(body)
263+
.send()
264+
.map_err(|e| AIError::Network(e.into()))?;
265+
266+
let data: serde_json::Value = resp.json()
267+
.map_err(|e| AIError::Network(e.into()))?;
268+
269+
let access_token = data["access_token"].as_str()
270+
.ok_or_else(|| AIError::ApiError("token 响应中无 access_token".into()))?
271+
.to_string();
272+
273+
let refresh_token = data["refresh_token"].as_str().map(|s| s.to_string());
274+
let expires_in = data["expires_in"].as_u64().unwrap_or(3600);
275+
let expires_at = std::time::SystemTime::now()
276+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + expires_in;
277+
278+
let token = CodexToken { access_token, refresh_token, expires_at: Some(expires_at) };
279+
self.set_token(token.clone());
280+
Ok(token)
281+
}
282+
158283
fn exchange_code_blocking(
159284
&self,
160285
code: &str,

src-tauri/src/commands.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,41 @@ pub fn codex_is_logged_in(router: State<'_, AIRouter>) -> bool {
117117
router.has_provider("codex")
118118
}
119119

120+
#[tauri::command]
121+
pub fn codex_device_start() -> CmdResult<serde_json::Value> {
122+
let provider = crate::ai::codex::CodexProvider::new();
123+
let resp = provider.start_device_code_login().map_err(|e| e.to_string())?;
124+
125+
Ok(serde_json::json!({
126+
"device_auth_id": resp.device_auth_id,
127+
"user_code": resp.user_code,
128+
"verification_uri": resp.verification_uri,
129+
"interval": resp.interval,
130+
}))
131+
}
132+
133+
#[tauri::command]
134+
pub fn codex_device_poll(
135+
router: State<'_, AIRouter>,
136+
device_auth_id: String,
137+
user_code: String,
138+
interval: u64,
139+
) -> CmdResult<serde_json::Value> {
140+
let provider = crate::ai::codex::CodexProvider::new();
141+
let token = provider
142+
.poll_device_code(&device_auth_id, &user_code, interval)
143+
.map_err(|e| e.to_string())?;
144+
145+
// 注册到 router
146+
let codex = crate::ai::codex::CodexProvider::with_token(token.clone());
147+
router.register(Box::new(codex));
148+
149+
Ok(serde_json::json!({
150+
"success": true,
151+
"expires_at": token.expires_at,
152+
}))
153+
}
154+
120155
// ─── AI Commands ───
121156

122157
#[tauri::command]

src-tauri/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ fn main() {
3434
config_validate_provider,
3535
codex_login,
3636
codex_is_logged_in,
37+
codex_device_start,
38+
codex_device_poll,
3739
ai_send_message,
3840
ai_list_providers,
3941
channel_list,

src/components/Settings/AISetupWizard.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ export function AISetupWizard() {
5858
const [formData, setFormData] = useState({ baseUrl: "", apiKey: "", model: "" });
5959
const [oauthLoading, setOauthLoading] = useState(false);
6060
const [oauthError, setOauthError] = useState("");
61+
const [deviceCode, setDeviceCode] = useState<{
62+
device_auth_id: string;
63+
user_code: string;
64+
verification_uri: string;
65+
interval: number;
66+
} | null>(null);
67+
const [devicePolling, setDevicePolling] = useState(false);
6168

6269
const handleSelectPreset = (index: number) => {
6370
setSelectedPreset(index);
@@ -86,6 +93,45 @@ export function AISetupWizard() {
8693
}
8794
};
8895

96+
const handleDeviceCodeStart = async () => {
97+
setOauthLoading(true);
98+
setOauthError("");
99+
setDeviceCode(null);
100+
try {
101+
const resp = await codexAPI.deviceStart();
102+
setDeviceCode(resp);
103+
} catch (e) {
104+
setOauthError(String(e));
105+
} finally {
106+
setOauthLoading(false);
107+
}
108+
};
109+
110+
const handleDeviceCodePoll = async () => {
111+
if (!deviceCode) return;
112+
setDevicePolling(true);
113+
setOauthError("");
114+
try {
115+
const result = await codexAPI.devicePoll(
116+
deviceCode.device_auth_id,
117+
deviceCode.user_code,
118+
deviceCode.interval,
119+
);
120+
if (result.success) {
121+
addProvider("codex", {
122+
baseUrl: "https://api.openai.com",
123+
enabled: true,
124+
displayName: "Codex (ChatGPT 订阅)",
125+
});
126+
setFirstRun(false);
127+
}
128+
} catch (e) {
129+
setOauthError(String(e));
130+
} finally {
131+
setDevicePolling(false);
132+
}
133+
};
134+
89135
const handleSave = () => {
90136
if (selectedPreset === null) return;
91137
const preset = PROVIDER_PRESETS[selectedPreset];
@@ -140,7 +186,7 @@ export function AISetupWizard() {
140186
<div className="space-y-4">
141187
<button
142188
onClick={handleCodexLogin}
143-
disabled={oauthLoading}
189+
disabled={oauthLoading || devicePolling}
144190
className="w-full py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
145191
>
146192
{oauthLoading ? (
@@ -149,9 +195,59 @@ export function AISetupWizard() {
149195
等待浏览器授权...
150196
</>
151197
) : (
152-
"🔐 使用 ChatGPT 账号登录"
198+
"🔐 浏览器授权登录"
153199
)}
154200
</button>
201+
202+
<div className="relative flex items-center my-2">
203+
<div className="flex-grow border-t border-gray-300" />
204+
<span className="mx-3 text-xs text-gray-400"></span>
205+
<div className="flex-grow border-t border-gray-300" />
206+
</div>
207+
208+
{!deviceCode ? (
209+
<button
210+
onClick={handleDeviceCodeStart}
211+
disabled={oauthLoading || devicePolling}
212+
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
213+
>
214+
📱 设备码授权(适用于远程/无浏览器环境)
215+
</button>
216+
) : (
217+
<div className="border border-blue-200 bg-blue-50 rounded-lg p-4 space-y-3">
218+
<p className="text-sm text-gray-700">
219+
1. 在任意设备上访问:
220+
</p>
221+
<a
222+
href={deviceCode.verification_uri}
223+
target="_blank"
224+
rel="noreferrer"
225+
className="block text-center text-blue-600 font-mono text-sm underline"
226+
>
227+
{deviceCode.verification_uri}
228+
</a>
229+
<p className="text-sm text-gray-700">2. 输入以下代码:</p>
230+
<div className="text-center">
231+
<span className="inline-block bg-white border-2 border-blue-400 rounded-lg px-6 py-3 font-mono text-2xl font-bold tracking-widest select-all">
232+
{deviceCode.user_code}
233+
</span>
234+
</div>
235+
{!devicePolling ? (
236+
<button
237+
onClick={handleDeviceCodePoll}
238+
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
239+
>
240+
✅ 我已输入代码,开始验证
241+
</button>
242+
) : (
243+
<div className="flex items-center justify-center gap-2 py-2 text-sm text-blue-600">
244+
<span className="animate-spin"></span>
245+
等待授权中...
246+
</div>
247+
)}
248+
</div>
249+
)}
250+
155251
{oauthError && (
156252
<p className="text-red-500 text-sm">{oauthError}</p>
157253
)}

src/hooks/useAPI.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,14 @@ export const aiAPI = {
1919
export const codexAPI = {
2020
login: () => invoke<{ success: boolean; expires_at?: number }>("codex_login"),
2121
isLoggedIn: () => invoke<boolean>("codex_is_logged_in"),
22+
deviceStart: () => invoke<{
23+
device_auth_id: string;
24+
user_code: string;
25+
verification_uri: string;
26+
interval: number;
27+
}>("codex_device_start"),
28+
devicePoll: (deviceAuthId: string, userCode: string, interval: number) =>
29+
invoke<{ success: boolean; expires_at?: number }>("codex_device_poll", {
30+
deviceAuthId, userCode, interval,
31+
}),
2232
};

temp_repo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit f5c8d246723a5e2aa3458d4060150da1a78c8433

0 commit comments

Comments
 (0)