@@ -5,13 +5,22 @@ use std::time::Duration;
55use anyhow:: Context ;
66use anyhow:: Result ;
77use codex_config:: types:: AuthKeyringBackendKind ;
8+ use codex_config:: types:: OAuthCredentialsStoreMode ;
9+ use codex_exec_server:: Environment ;
810use keyring:: Error as KeyringError ;
911use oauth2:: AccessToken ;
1012use oauth2:: TokenResponse ;
1113use pretty_assertions:: assert_eq;
1214use rmcp:: transport:: auth:: AuthorizationManager ;
1315use rmcp:: transport:: auth:: OAuthState ;
1416use tokio:: sync:: Mutex as TokioMutex ;
17+ use tracing:: Event ;
18+ use tracing:: Id ;
19+ use tracing:: Metadata ;
20+ use tracing:: Subscriber ;
21+ use tracing:: span:: Attributes ;
22+ use tracing:: span:: Record ;
23+ use tracing:: subscriber:: Interest ;
1524use wiremock:: Mock ;
1625use wiremock:: MockServer ;
1726use wiremock:: ResponseTemplate ;
@@ -27,6 +36,7 @@ use crate::oauth::ResolvedOAuthCredentialStore;
2736use crate :: oauth:: StoredOAuthTokens ;
2837use crate :: oauth:: WrappedOAuthTokenResponse ;
2938use crate :: oauth:: compute_store_key;
39+ use crate :: oauth:: delete_oauth_tokens_locked;
3040use crate :: oauth:: fallback_file_path;
3141use crate :: oauth:: load_oauth_tokens_from_file;
3242use crate :: oauth:: load_oauth_tokens_from_keyring;
@@ -35,6 +45,51 @@ use crate::oauth::refresh_transaction::RefreshReason;
3545use crate :: oauth:: save_oauth_tokens_to_file;
3646use crate :: oauth:: save_oauth_tokens_with_keyring;
3747
48+ const REFRESH_LOCK_CONTENTION_EVENT_TARGET : & str =
49+ "codex_rmcp_client::oauth::refresh_lock::contention" ;
50+
51+ struct LockContentionSubscriber {
52+ contended_tx : mpsc:: Sender < ( ) > ,
53+ }
54+
55+ impl Subscriber for LockContentionSubscriber {
56+ fn enabled ( & self , metadata : & Metadata < ' _ > ) -> bool {
57+ metadata. target ( ) == REFRESH_LOCK_CONTENTION_EVENT_TARGET
58+ }
59+
60+ fn register_callsite ( & self , metadata : & ' static Metadata < ' static > ) -> Interest {
61+ if self . enabled ( metadata) {
62+ Interest :: always ( )
63+ } else {
64+ Interest :: never ( )
65+ }
66+ }
67+
68+ fn max_level_hint ( & self ) -> Option < tracing:: level_filters:: LevelFilter > {
69+ Some ( tracing:: level_filters:: LevelFilter :: DEBUG )
70+ }
71+
72+ fn new_span ( & self , _span : & Attributes < ' _ > ) -> Id {
73+ Id :: from_u64 ( /*u*/ 1 )
74+ }
75+
76+ fn record ( & self , _span : & Id , _values : & Record < ' _ > ) { }
77+
78+ fn record_follows_from ( & self , _span : & Id , _follows_from : & Id ) { }
79+
80+ fn event ( & self , event : & Event < ' _ > ) {
81+ if self . enabled ( event. metadata ( ) ) {
82+ self . contended_tx
83+ . send ( ( ) )
84+ . expect ( "signal actual OAuth credential-lock contention" ) ;
85+ }
86+ }
87+
88+ fn enter ( & self , _span : & Id ) { }
89+
90+ fn exit ( & self , _span : & Id ) { }
91+ }
92+
3893#[ tokio:: test( flavor = "current_thread" ) ]
3994async fn concurrent_refreshes_call_provider_once_and_carry_omitted_fields ( ) -> Result < ( ) > {
4095 let _env = TempCodexHome :: new ( ) ;
@@ -122,6 +177,130 @@ async fn delayed_unauthorized_retries_adopt_the_winning_token() -> Result<()> {
122177 Ok ( ( ) )
123178}
124179
180+ #[ tokio:: test( flavor = "current_thread" ) ]
181+ async fn oauth_callback_waits_for_refresh_and_then_becomes_authoritative ( ) -> Result < ( ) > {
182+ let _env = TempCodexHome :: new ( ) ;
183+ let server = MockServer :: start ( ) . await ;
184+ mount_oauth_metadata ( & server) . await ;
185+ Mock :: given ( method ( "POST" ) )
186+ . and ( path ( "/oauth/token" ) )
187+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_json ( serde_json:: json!( {
188+ "access_token" : "login-access-token" ,
189+ "token_type" : "Bearer" ,
190+ "expires_in" : 3600 ,
191+ "refresh_token" : "login-refresh-token" ,
192+ "scope" : "scope-a scope-b" ,
193+ } ) ) )
194+ . expect ( 1 )
195+ . mount ( & server)
196+ . await ;
197+
198+ let server_name = "callback-lock-test" ;
199+ let server_url = format ! ( "{}/mcp" , server. uri( ) ) ;
200+ let held_lock = RefreshCredentialLock :: acquire_for_server ( server_name, & server_url) . await ?;
201+ let ( contended_tx, contended_rx) = mpsc:: channel ( ) ;
202+ let _subscriber_guard =
203+ tracing:: subscriber:: set_default ( LockContentionSubscriber { contended_tx } ) ;
204+ let handle = crate :: perform_oauth_login_return_url_with_http_client (
205+ server_name,
206+ & server_url,
207+ OAuthCredentialsStoreMode :: File ,
208+ AuthKeyringBackendKind :: default ( ) ,
209+ /*http_headers*/ None ,
210+ /*env_http_headers*/ None ,
211+ & [ "scope-a" . to_string ( ) , "scope-b" . to_string ( ) ] ,
212+ Some ( "test-client-id" ) ,
213+ /*oauth_resource*/ None ,
214+ Some ( /*timeout_secs*/ 5 ) ,
215+ /*callback_port*/ None ,
216+ /*callback_url*/ None ,
217+ Environment :: default_for_tests ( ) . get_http_client ( ) ,
218+ )
219+ . await ?;
220+ let authorization_url = reqwest:: Url :: parse ( handle. authorization_url ( ) ) ?;
221+ let query = authorization_url
222+ . query_pairs ( )
223+ . collect :: < std:: collections:: HashMap < _ , _ > > ( ) ;
224+ let redirect_uri = query
225+ . get ( "redirect_uri" )
226+ . context ( "authorization URL omitted redirect_uri" ) ?;
227+ let state = query
228+ . get ( "state" )
229+ . context ( "authorization URL omitted state" ) ?;
230+ let mut callback_url = reqwest:: Url :: parse ( redirect_uri) ?;
231+ callback_url
232+ . query_pairs_mut ( )
233+ . append_pair ( "code" , "authorization-code" )
234+ . append_pair ( "state" , state) ;
235+ let ( _authorization_url, completion) = handle. into_parts ( ) ;
236+ reqwest:: Client :: new ( )
237+ . get ( callback_url)
238+ . send ( )
239+ . await ?
240+ . error_for_status ( ) ?;
241+
242+ // This event is emitted only after the callback's real persistence path observes WouldBlock.
243+ // Writing while the lock is held models a refresh that started first; login must overwrite it
244+ // after the transaction finishes.
245+ wait_for_signal ( contended_rx) . await ?;
246+ let mut refresh_winner = sample_tokens ( ) ;
247+ refresh_winner. server_name = server_name. to_string ( ) ;
248+ refresh_winner. url . clone_from ( & server_url) ;
249+ refresh_winner
250+ . token_response
251+ . 0
252+ . set_access_token ( AccessToken :: new ( "refresh-winner" . to_string ( ) ) ) ;
253+ save_oauth_tokens_to_file ( & refresh_winner) ?;
254+ drop ( held_lock) ;
255+ completion
256+ . await
257+ . context ( "OAuth login task was cancelled" ) ??;
258+
259+ let stored = load_oauth_tokens_from_file ( server_name, & server_url) ?
260+ . expect ( "callback credentials should be persisted" ) ;
261+ assert_eq ! (
262+ stored. token_response. 0 . access_token( ) . secret( ) ,
263+ "login-access-token"
264+ ) ;
265+ server. verify ( ) . await ;
266+ Ok ( ( ) )
267+ }
268+
269+ #[ tokio:: test( flavor = "current_thread" ) ]
270+ async fn locked_logout_waits_for_refresh_and_removes_its_result ( ) -> Result < ( ) > {
271+ let _env = TempCodexHome :: new ( ) ;
272+ let server = MockServer :: start ( ) . await ;
273+ mount_oauth_metadata ( & server) . await ;
274+ let refresh_started = mount_delayed_refresh ( & server, "refreshed-before-logout" ) . await ;
275+ let initial = expired_tokens ( & format ! ( "{}/mcp" , server. uri( ) ) ) ;
276+ save_oauth_tokens_to_file ( & initial) ?;
277+ let persistor = persistor_for ( & initial) . await ?;
278+ let refresh_task = tokio:: spawn ( async move { persistor. refresh_if_needed ( ) . await } ) ;
279+ wait_for_signal ( refresh_started) . await ?;
280+
281+ let ( contended_tx, contended_rx) = mpsc:: channel ( ) ;
282+ let _subscriber_guard =
283+ tracing:: subscriber:: set_default ( LockContentionSubscriber { contended_tx } ) ;
284+ let server_name = initial. server_name . clone ( ) ;
285+ let url = initial. url . clone ( ) ;
286+ let logout_task = tokio:: spawn ( async move {
287+ delete_oauth_tokens_locked (
288+ & server_name,
289+ & url,
290+ OAuthCredentialsStoreMode :: File ,
291+ AuthKeyringBackendKind :: default ( ) ,
292+ )
293+ . await
294+ } ) ;
295+
296+ wait_for_signal ( contended_rx) . await ?;
297+ refresh_task. await ??;
298+ assert ! ( logout_task. await ??) ;
299+ assert ! ( load_oauth_tokens_from_file( & initial. server_name, & initial. url) ?. is_none( ) ) ;
300+ server. verify ( ) . await ;
301+ Ok ( ( ) )
302+ }
303+
125304#[ tokio:: test( flavor = "current_thread" ) ]
126305async fn resolved_keyring_read_error_preserves_in_memory_credentials ( ) -> Result < ( ) > {
127306 let _env = TempCodexHome :: new ( ) ;
0 commit comments