@@ -14,6 +14,13 @@ const CALLBACK_PATH: &str = "/auth/callback";
1414const SCOPE : & str = "openid profile email offline_access" ;
1515const 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 ) ]
1926pub 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 模型
2642pub 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 ,
0 commit comments