@@ -5,20 +5,42 @@ use serde_json::Map;
55use serde_json:: Value ;
66use std:: fs;
77use std:: path:: PathBuf ;
8+ use std:: time:: SystemTime ;
89use std:: time:: UNIX_EPOCH ;
10+ use sha2:: Digest ;
11+ use sha2:: Sha256 ;
912
10- use crate :: models:: CurrentAuthStatus ;
1113use crate :: models:: ExtractedAuth ;
14+ use crate :: models:: PreparedOauthLogin ;
1215use crate :: utils:: set_private_permissions;
1316use 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+
1525pub ( 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+
2244pub ( 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-
10664pub ( 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
130161pub ( 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+
451556fn 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+
499611fn extract_client_id_from_claims ( claims : & Value ) -> Option < String > {
500612 let aud = claims. get ( "aud" ) ?;
501613 match aud {
0 commit comments