@@ -4,6 +4,7 @@ use crate::auth::storage::get_auth_file;
44use crate :: token_data:: IdTokenInfo ;
55use crate :: token_data:: KnownPlan as InternalKnownPlan ;
66use crate :: token_data:: PlanType as InternalPlanType ;
7+ use async_trait:: async_trait;
78use codex_protocol:: account:: PlanType as AccountPlanType ;
89
910use base64:: Engine ;
@@ -12,6 +13,7 @@ use pretty_assertions::assert_eq;
1213use serde:: Serialize ;
1314use serde_json:: json;
1415use std:: sync:: Arc ;
16+ use std:: sync:: Mutex ;
1517use tempfile:: tempdir;
1618
1719#[ tokio:: test]
@@ -265,6 +267,87 @@ fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() {
265267 ) ;
266268}
267269
270+ #[ tokio:: test]
271+ async fn auth_manager_with_external_bearer_refresher_returns_provider_token_only_for_derived_manager ( )
272+ {
273+ let base_manager = AuthManager :: from_auth_for_testing ( CodexAuth :: from_api_key ( "base-token" ) ) ;
274+ let derived_manager =
275+ base_manager. with_external_bearer_refresher ( Arc :: new ( StaticExternalAuthRefresher :: new (
276+ Some ( ExternalAuthTokens :: access_token_only ( "provider-token" ) ) ,
277+ ExternalAuthTokens :: access_token_only ( "refreshed-provider-token" ) ,
278+ ) ) ) ;
279+
280+ assert_eq ! (
281+ base_manager
282+ . auth( )
283+ . await
284+ . and_then( |auth| auth. api_key( ) . map( str :: to_string) ) ,
285+ Some ( "base-token" . to_string( ) )
286+ ) ;
287+ assert_eq ! (
288+ derived_manager
289+ . auth( )
290+ . await
291+ . and_then( |auth| auth. api_key( ) . map( str :: to_string) ) ,
292+ Some ( "provider-token" . to_string( ) )
293+ ) ;
294+ }
295+
296+ #[ tokio:: test]
297+ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager ( ) {
298+ let base_manager = AuthManager :: from_auth_for_testing ( CodexAuth :: from_api_key ( "base-token" ) ) ;
299+ let refresher = Arc :: new ( StaticExternalAuthRefresher :: new (
300+ Some ( ExternalAuthTokens :: access_token_only ( "provider-token" ) ) ,
301+ ExternalAuthTokens :: access_token_only ( "refreshed-provider-token" ) ,
302+ ) ) ;
303+ let derived_manager = base_manager. with_external_bearer_refresher ( refresher. clone ( ) ) ;
304+ let mut recovery = derived_manager. unauthorized_recovery ( ) ;
305+
306+ assert ! ( recovery. has_next( ) ) ;
307+ assert_eq ! ( recovery. mode_name( ) , "external" ) ;
308+ assert_eq ! ( recovery. step_name( ) , "external_refresh" ) ;
309+
310+ let result = recovery
311+ . next ( )
312+ . await
313+ . expect ( "external refresh should succeed" ) ;
314+
315+ assert_eq ! ( result. auth_state_changed( ) , Some ( true ) ) ;
316+ assert_eq ! ( * refresher. refresh_calls. lock( ) . unwrap( ) , 1 ) ;
317+ }
318+
319+ #[ derive( Debug ) ]
320+ struct StaticExternalAuthRefresher {
321+ resolved : Option < ExternalAuthTokens > ,
322+ refreshed : ExternalAuthTokens ,
323+ refresh_calls : Mutex < usize > ,
324+ }
325+
326+ impl StaticExternalAuthRefresher {
327+ fn new ( resolved : Option < ExternalAuthTokens > , refreshed : ExternalAuthTokens ) -> Self {
328+ Self {
329+ resolved,
330+ refreshed,
331+ refresh_calls : Mutex :: new ( 0 ) ,
332+ }
333+ }
334+ }
335+
336+ #[ async_trait]
337+ impl ExternalAuthRefresher for StaticExternalAuthRefresher {
338+ async fn resolve ( & self ) -> std:: io:: Result < Option < ExternalAuthTokens > > {
339+ Ok ( self . resolved . clone ( ) )
340+ }
341+
342+ async fn refresh (
343+ & self ,
344+ _context : ExternalAuthRefreshContext ,
345+ ) -> std:: io:: Result < ExternalAuthTokens > {
346+ * self . refresh_calls . lock ( ) . unwrap ( ) += 1 ;
347+ Ok ( self . refreshed . clone ( ) )
348+ }
349+ }
350+
268351struct AuthFileParams {
269352 openai_api_key : Option < String > ,
270353 chatgpt_plan_type : Option < String > ,
0 commit comments