Skip to content

Commit ff146a8

Browse files
committed
feat: redesign account import flow
1 parent ed78e90 commit ff146a8

23 files changed

Lines changed: 1569 additions & 472 deletions

changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
## 更新日志
2+
- v1.5.0
3+
1. 重做账号导入弹窗,区分 OAuth 登录、同步当前设备登录和 JSON 导入流程
4+
2. OAuth 登录改为自动生成授权链接并监听本地回调,授权完成后自动导入账号
5+
3. 清理旧的添加账号冗余链路,统一当前回调和导入逻辑
26
- v1.4.1
37
1. 去除工作区相关,因为无法获取到用户工作区
48
- v1.4.0

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "codex-tools",
33
"private": true,
4-
"version": "1.4.1",
4+
"version": "1.5.0",
55
"license": "MIT",
66
"homepage": "https://github.com/170-carry/codex-tools",
77
"bugs": {

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "app"
3-
version = "1.4.1"
3+
version = "1.5.0"
44
description = "Codex Tools"
55
authors = ["you"]
66
license = "MIT"
@@ -33,6 +33,7 @@ reqwest = { version = "0.12", features = ["json", "gzip", "stream"] }
3333
uuid = { version = "1", features = ["v4"] }
3434
dirs = "6"
3535
base64 = "0.22"
36+
sha2 = "0.10"
3637
tauri-plugin-process = "2"
3738
tauri-plugin-updater = "2"
3839
tauri-plugin-autostart = "2"

src-tauri/src/auth.rs

Lines changed: 182 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,42 @@ use serde_json::Map;
55
use serde_json::Value;
66
use std::fs;
77
use std::path::PathBuf;
8+
use std::time::SystemTime;
89
use std::time::UNIX_EPOCH;
10+
use sha2::Digest;
11+
use sha2::Sha256;
912

10-
use crate::models::CurrentAuthStatus;
1113
use crate::models::ExtractedAuth;
14+
use crate::models::PreparedOauthLogin;
1215
use crate::utils::set_private_permissions;
1316
use crate::utils::truncate_for_error;
1417

18+
const DEFAULT_OAUTH_ISSUER: &str = "https://auth.openai.com";
19+
const DEFAULT_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
20+
const DEFAULT_OAUTH_SCOPE: &str = "openid profile email offline_access";
21+
const DEFAULT_OAUTH_ORIGINATOR: &str = "codex_vscode";
22+
const DEFAULT_OAUTH_REDIRECT_PORT: u16 = 1455;
23+
const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300;
24+
1525
pub(crate) struct CodexOAuthTokens {
1626
pub(crate) access_token: String,
1727
pub(crate) refresh_token: String,
1828
pub(crate) account_id: Option<String>,
1929
pub(crate) expires_at_ms: Option<i64>,
2030
}
2131

32+
#[derive(Debug, Clone)]
33+
pub(crate) struct PendingOauthLogin {
34+
pub(crate) redirect_uri: String,
35+
pub(crate) state: String,
36+
pub(crate) code_verifier: String,
37+
pub(crate) expires_at: i64,
38+
}
39+
40+
pub(crate) fn oauth_redirect_port() -> u16 {
41+
DEFAULT_OAUTH_REDIRECT_PORT
42+
}
43+
2244
pub(crate) fn read_current_codex_auth() -> Result<Value, String> {
2345
let path = codex_auth_path()?;
2446
let raw = fs::read_to_string(&path)
@@ -39,70 +61,6 @@ pub(crate) fn read_current_codex_auth_optional() -> Result<Option<Value>, String
3961
Ok(Some(value))
4062
}
4163

42-
pub(crate) fn read_current_auth_status() -> Result<CurrentAuthStatus, String> {
43-
let path = codex_auth_path()?;
44-
if !path.exists() {
45-
return Ok(CurrentAuthStatus {
46-
available: false,
47-
account_id: None,
48-
email: None,
49-
plan_type: None,
50-
auth_mode: None,
51-
last_refresh: None,
52-
file_modified_at: None,
53-
fingerprint: None,
54-
});
55-
}
56-
57-
let metadata = fs::metadata(&path)
58-
.map_err(|e| format!("读取 auth.json 文件信息失败 {}: {e}", path.display()))?;
59-
let modified_at = metadata
60-
.modified()
61-
.ok()
62-
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
63-
.map(|duration| duration.as_secs() as i64);
64-
65-
let raw = fs::read_to_string(&path)
66-
.map_err(|e| format!("读取 auth.json 失败 {}: {e}", path.display()))?;
67-
let value: Value =
68-
serde_json::from_str(&raw).map_err(|e| format!("auth.json 不是合法 JSON: {e}"))?;
69-
70-
let auth_mode = value
71-
.get("auth_mode")
72-
.and_then(Value::as_str)
73-
.map(ToString::to_string);
74-
let last_refresh = value
75-
.get("last_refresh")
76-
.and_then(Value::as_str)
77-
.map(ToString::to_string);
78-
79-
let extracted = extract_auth(&value).ok();
80-
let principal_id = extracted.as_ref().map(|auth| auth.principal_id.clone());
81-
let account_id = extracted.as_ref().map(|auth| auth.account_id.clone());
82-
let email = extracted.as_ref().and_then(|auth| auth.email.clone());
83-
let plan_type = extracted.as_ref().and_then(|auth| auth.plan_type.clone());
84-
85-
let fingerprint = Some(format!(
86-
"{}|{}|{}|{}|{}",
87-
principal_id.unwrap_or_default(),
88-
account_id.clone().unwrap_or_default(),
89-
last_refresh.clone().unwrap_or_default(),
90-
modified_at.unwrap_or_default(),
91-
auth_mode.clone().unwrap_or_default()
92-
));
93-
94-
Ok(CurrentAuthStatus {
95-
available: true,
96-
account_id,
97-
email,
98-
plan_type,
99-
auth_mode,
100-
last_refresh,
101-
file_modified_at: modified_at,
102-
fingerprint,
103-
})
104-
}
105-
10664
pub(crate) fn write_active_codex_auth(auth_json: &Value) -> Result<(), String> {
10765
let path = codex_auth_path()?;
10866
let parent = path
@@ -119,12 +77,85 @@ pub(crate) fn write_active_codex_auth(auth_json: &Value) -> Result<(), String> {
11977
Ok(())
12078
}
12179

122-
pub(crate) fn remove_active_codex_auth() -> Result<(), String> {
123-
let path = codex_auth_path()?;
124-
if !path.exists() {
125-
return Ok(());
80+
pub(crate) fn prepare_oauth_login() -> Result<(PendingOauthLogin, PreparedOauthLogin), String> {
81+
let state = uuid::Uuid::new_v4().simple().to_string();
82+
let code_verifier = format!(
83+
"{}{}",
84+
uuid::Uuid::new_v4().simple(),
85+
uuid::Uuid::new_v4().simple()
86+
);
87+
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
88+
let redirect_uri = format!("http://localhost:{DEFAULT_OAUTH_REDIRECT_PORT}/auth/callback");
89+
let expires_at = SystemTime::now()
90+
.duration_since(UNIX_EPOCH)
91+
.map_err(|error| format!("读取系统时间失败: {error}"))?
92+
.as_secs() as i64
93+
+ DEFAULT_OAUTH_TIMEOUT_SECS;
94+
95+
let mut auth_url = reqwest::Url::parse(&format!("{DEFAULT_OAUTH_ISSUER}/oauth/authorize"))
96+
.map_err(|error| format!("生成授权链接失败: {error}"))?;
97+
auth_url
98+
.query_pairs_mut()
99+
.append_pair("response_type", "code")
100+
.append_pair("client_id", DEFAULT_OAUTH_CLIENT_ID)
101+
.append_pair("redirect_uri", &redirect_uri)
102+
.append_pair("scope", DEFAULT_OAUTH_SCOPE)
103+
.append_pair("state", &state)
104+
.append_pair("code_challenge", &code_challenge)
105+
.append_pair("code_challenge_method", "S256")
106+
.append_pair("id_token_add_organizations", "true")
107+
.append_pair("codex_cli_simplified_flow", "true")
108+
.append_pair("originator", DEFAULT_OAUTH_ORIGINATOR);
109+
110+
let auth_url = auth_url.to_string();
111+
let pending = PendingOauthLogin {
112+
redirect_uri: redirect_uri.clone(),
113+
state,
114+
code_verifier,
115+
expires_at,
116+
};
117+
let prepared = PreparedOauthLogin {
118+
auth_url,
119+
redirect_uri,
120+
};
121+
Ok((pending, prepared))
122+
}
123+
124+
pub(crate) async fn complete_oauth_callback_login(
125+
pending: &PendingOauthLogin,
126+
callback_url: &str,
127+
) -> Result<Value, String> {
128+
let callback_url = callback_url.trim();
129+
if callback_url.is_empty() {
130+
return Err("请粘贴回调链接".to_string());
131+
}
132+
133+
let parsed_url = parse_oauth_callback_url(callback_url)?;
134+
let params: std::collections::HashMap<String, String> = parsed_url
135+
.query_pairs()
136+
.map(|(key, value)| (key.to_string(), value.to_string()))
137+
.collect();
138+
139+
if let Some(error) = params.get("error") {
140+
let description = params
141+
.get("error_description")
142+
.map(String::as_str)
143+
.unwrap_or(error.as_str());
144+
return Err(format!("授权失败: {description}"));
126145
}
127-
fs::remove_file(&path).map_err(|e| format!("删除 auth.json 失败 {}: {e}", path.display()))
146+
147+
let Some(state) = params.get("state") else {
148+
return Err("回调链接缺少 state 参数".to_string());
149+
};
150+
if state != &pending.state {
151+
return Err("回调链接 state 不匹配,请重新生成授权链接".to_string());
152+
}
153+
154+
let Some(code) = params.get("code") else {
155+
return Err("回调链接缺少 code 参数".to_string());
156+
};
157+
158+
exchange_authorization_code(code, pending).await
128159
}
129160

130161
pub(crate) fn normalize_imported_auth_json(auth_json: Value) -> Value {
@@ -448,6 +479,80 @@ pub(crate) async fn refresh_chatgpt_auth_tokens(auth_json: &Value) -> Result<Val
448479
Ok(updated)
449480
}
450481

482+
fn parse_oauth_callback_url(callback_url: &str) -> Result<reqwest::Url, String> {
483+
reqwest::Url::parse(callback_url)
484+
.or_else(|_| reqwest::Url::parse(&format!("http://localhost{callback_url}")))
485+
.map_err(|error| format!("回调链接格式无效: {error}"))
486+
}
487+
488+
async fn exchange_authorization_code(
489+
code: &str,
490+
pending: &PendingOauthLogin,
491+
) -> Result<Value, String> {
492+
let client = reqwest::Client::builder()
493+
.user_agent("codex-tools/0.1")
494+
.build()
495+
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))?;
496+
497+
let token_url = format!("{DEFAULT_OAUTH_ISSUER}/oauth/token");
498+
let response = client
499+
.post(&token_url)
500+
.form(&[
501+
("grant_type", "authorization_code"),
502+
("code", code),
503+
("redirect_uri", pending.redirect_uri.as_str()),
504+
("client_id", DEFAULT_OAUTH_CLIENT_ID),
505+
("code_verifier", pending.code_verifier.as_str()),
506+
])
507+
.send()
508+
.await
509+
.map_err(|error| format!("换取登录令牌失败 {token_url}: {error}"))?;
510+
511+
let status = response.status();
512+
if !status.is_success() {
513+
let body = response.text().await.unwrap_or_default();
514+
return Err(format!(
515+
"换取登录令牌失败 {token_url} -> {status}: {}",
516+
truncate_for_error(&body, 200)
517+
));
518+
}
519+
520+
let token_response: OAuthTokenResponse = response
521+
.json()
522+
.await
523+
.map_err(|error| format!("解析 OAuth 登录响应失败: {error}"))?;
524+
525+
build_auth_json_from_oauth_tokens(token_response)
526+
}
527+
528+
fn build_auth_json_from_oauth_tokens(token_response: OAuthTokenResponse) -> Result<Value, String> {
529+
let id_token_claims = decode_jwt_payload(&token_response.id_token)?;
530+
let account_id = id_token_claims
531+
.get("https://api.openai.com/auth")
532+
.and_then(Value::as_object)
533+
.and_then(|auth| auth.get("chatgpt_account_id"))
534+
.and_then(Value::as_str)
535+
.ok_or_else(|| "无法从 OAuth 登录结果识别 chatgpt_account_id".to_string())?;
536+
537+
let last_refresh = SystemTime::now()
538+
.duration_since(UNIX_EPOCH)
539+
.map_err(|error| format!("读取系统时间失败: {error}"))?
540+
.as_secs()
541+
.to_string();
542+
543+
Ok(serde_json::json!({
544+
"OPENAI_API_KEY": Value::Null,
545+
"auth_mode": "chatgpt",
546+
"last_refresh": last_refresh,
547+
"tokens": {
548+
"access_token": token_response.access_token,
549+
"refresh_token": token_response.refresh_token,
550+
"id_token": token_response.id_token,
551+
"account_id": account_id
552+
}
553+
}))
554+
}
555+
451556
fn codex_auth_path() -> Result<PathBuf, String> {
452557
let home = dirs::home_dir().ok_or_else(|| "无法读取 HOME 目录".to_string())?;
453558
Ok(home.join(".codex").join("auth.json"))
@@ -496,6 +601,13 @@ struct RefreshedTokenPayload {
496601
refresh_token: Option<String>,
497602
}
498603

604+
#[derive(Debug, serde::Deserialize)]
605+
struct OAuthTokenResponse {
606+
access_token: String,
607+
refresh_token: String,
608+
id_token: String,
609+
}
610+
499611
fn extract_client_id_from_claims(claims: &Value) -> Option<String> {
500612
let aud = claims.get("aud")?;
501613
match aud {

0 commit comments

Comments
 (0)