@@ -14,7 +14,7 @@ use crate::http_user_agent::datum_http_user_agent;
1414use crate :: { ProjectControlPlaneClient , Repo , SelectedContext } ;
1515
1616pub use self :: {
17- auth:: { AuthClient , AuthState , LoginState , MaybeAuth , UserProfile } ,
17+ auth:: { AuthClient , AuthState , LoginState , MaybeAuth , NotLoggedIn , Unauthorized , UserProfile } ,
1818 env:: ApiEnv ,
1919} ;
2020
@@ -327,23 +327,15 @@ impl DatumCloudClient {
327327
328328 async fn post_json ( & self , url : & str , body : & serde_json:: Value ) -> Result < ( ) > {
329329 tracing:: debug!( "POST {url}" ) ;
330-
331- let auth_state = self . auth . load_refreshed ( ) . await ?;
332- let auth = auth_state. get ( ) ?;
333-
334330 let res = self
335- . http
336- . post ( url)
337- . header (
338- "Authorization" ,
339- format ! ( "Bearer {}" , auth. tokens. access_token. secret( ) ) ,
340- )
341- . header ( "Content-Type" , "application/json" )
342- . json ( body)
343- . send ( )
344- . await
345- . inspect_err ( |e| warn ! ( %url, "Failed to POST: {e:#}" ) )
346- . with_std_context ( |_| format ! ( "Failed to POST {url}" ) ) ?;
331+ . request_with_auth_retry ( |token| {
332+ self . http
333+ . post ( url)
334+ . header ( "Authorization" , format ! ( "Bearer {token}" ) )
335+ . header ( "Content-Type" , "application/json" )
336+ . json ( body)
337+ } )
338+ . await ?;
347339 let status = res. status ( ) ;
348340 if !status. is_success ( ) {
349341 let text = match res. text ( ) . await {
@@ -358,22 +350,13 @@ impl DatumCloudClient {
358350
359351 async fn fetch_direct ( & self , url : & str ) -> Result < serde_json:: Value > {
360352 tracing:: debug!( "GET {url}" ) ;
361-
362- // Refresh access token if they are close to expiring.
363- let auth_state = self . auth . load_refreshed ( ) . await ?;
364- let auth = auth_state. get ( ) ?;
365-
366353 let res = self
367- . http
368- . get ( url)
369- . header (
370- "Authorization" ,
371- format ! ( "Bearer {}" , auth. tokens. access_token. secret( ) ) ,
372- )
373- . send ( )
374- . await
375- . inspect_err ( |e| warn ! ( %url, "Failed to fetch: {e:#}" ) )
376- . with_std_context ( |_| format ! ( "Failed to fetch {url}" ) ) ?;
354+ . request_with_auth_retry ( |token| {
355+ self . http
356+ . get ( url)
357+ . header ( "Authorization" , format ! ( "Bearer {token}" ) )
358+ } )
359+ . await ?;
377360 let status = res. status ( ) ;
378361 if !status. is_success ( ) {
379362 let text = match res. text ( ) . await {
@@ -391,6 +374,57 @@ impl DatumCloudClient {
391374 Ok ( json)
392375 }
393376
377+ /// Send an authenticated request and, on 401/403, force a token refresh and retry once.
378+ /// If the second attempt still returns 401/403, clear the local auth state and return
379+ /// [`Unauthorized`] so the UI redirects to login.
380+ ///
381+ /// The closure builds the request (sans `.send()`) given the current bearer token, so we
382+ /// can rebuild it after a refresh without the caller having to reconstruct headers/body.
383+ async fn request_with_auth_retry < F > ( & self , build : F ) -> Result < reqwest:: Response >
384+ where
385+ F : Fn ( & str ) -> reqwest:: RequestBuilder ,
386+ {
387+ let auth_state = self . auth . load_refreshed ( ) . await ?;
388+ let auth = auth_state. get ( ) ?;
389+ let res = build ( auth. tokens . access_token . secret ( ) )
390+ . send ( )
391+ . await
392+ . inspect_err ( |e| warn ! ( "Request failed: {e:#}" ) )
393+ . std_context ( "HTTP request failed" ) ?;
394+ if !is_auth_failure ( res. status ( ) ) {
395+ return Ok ( res) ;
396+ }
397+
398+ warn ! (
399+ status = %res. status( ) ,
400+ "Server rejected token; attempting forced refresh"
401+ ) ;
402+ if let Err ( err) = self . auth . force_refresh ( ) . await {
403+ warn ! ( "Forced auth refresh failed: {err:#}" ) ;
404+ return Err ( Unauthorized . into ( ) ) ;
405+ }
406+ let auth_state = self . auth . load ( ) ;
407+ let Ok ( auth) = auth_state. get ( ) else {
408+ return Err ( Unauthorized . into ( ) ) ;
409+ } ;
410+ let retry = build ( auth. tokens . access_token . secret ( ) )
411+ . send ( )
412+ . await
413+ . inspect_err ( |e| warn ! ( "Retried request failed: {e:#}" ) )
414+ . std_context ( "HTTP request retry failed" ) ?;
415+ if is_auth_failure ( retry. status ( ) ) {
416+ warn ! (
417+ status = %retry. status( ) ,
418+ "Server still rejected token after refresh; logging out"
419+ ) ;
420+ if let Err ( err) = self . auth . logout ( ) . await {
421+ warn ! ( "Failed to clear auth state after persistent 401/403: {err:#}" ) ;
422+ }
423+ return Err ( Unauthorized . into ( ) ) ;
424+ }
425+ Ok ( retry)
426+ }
427+
394428 fn project_control_plane_client_with_token (
395429 & self ,
396430 project_id : & str ,
@@ -704,3 +738,212 @@ fn invitation_name(org_id: &str) -> String {
704738 . to_lowercase ( ) ;
705739 format ! ( "{org_id}-{suffix}" )
706740}
741+
742+ /// True if the response status indicates the bearer token is no longer accepted.
743+ fn is_auth_failure ( status : reqwest:: StatusCode ) -> bool {
744+ status == reqwest:: StatusCode :: UNAUTHORIZED || status == reqwest:: StatusCode :: FORBIDDEN
745+ }
746+
747+ #[ cfg( test) ]
748+ mod auth_failure_tests {
749+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
750+ use std:: sync:: { Arc , Mutex } ;
751+
752+ use http_body_util:: Full ;
753+ use hyper:: body:: Bytes ;
754+ use hyper:: service:: service_fn;
755+ use hyper:: { Request , Response } ;
756+ use hyper_util:: rt:: TokioIo ;
757+ use tokio:: net:: TcpListener ;
758+
759+ use super :: * ;
760+
761+ #[ test]
762+ fn classifies_401_and_403_as_auth_failures ( ) {
763+ assert ! ( is_auth_failure( reqwest:: StatusCode :: UNAUTHORIZED ) ) ;
764+ assert ! ( is_auth_failure( reqwest:: StatusCode :: FORBIDDEN ) ) ;
765+ }
766+
767+ #[ test]
768+ fn does_not_classify_other_statuses_as_auth_failures ( ) {
769+ assert ! ( !is_auth_failure( reqwest:: StatusCode :: OK ) ) ;
770+ assert ! ( !is_auth_failure( reqwest:: StatusCode :: NOT_FOUND ) ) ;
771+ assert ! ( !is_auth_failure( reqwest:: StatusCode :: INTERNAL_SERVER_ERROR ) ) ;
772+ assert ! ( !is_auth_failure( reqwest:: StatusCode :: BAD_REQUEST ) ) ;
773+ // 407 Proxy Authentication Required is distinct from end-user auth failures;
774+ // we intentionally do not treat it as a bearer-token rejection.
775+ assert ! ( !is_auth_failure(
776+ reqwest:: StatusCode :: PROXY_AUTHENTICATION_REQUIRED
777+ ) ) ;
778+ }
779+
780+ #[ test]
781+ fn unauthorized_error_displays_user_friendly_message ( ) {
782+ let err: n0_error:: AnyError = Unauthorized . into ( ) ;
783+ let msg = format ! ( "{err}" ) ;
784+ assert ! ( !msg. is_empty( ) , "Unauthorized should have a Display impl" ) ;
785+ // The roundtrip downcast must work so callers can switch on auth failures.
786+ assert ! ( err. downcast_ref:: <Unauthorized >( ) . is_some( ) ) ;
787+ }
788+
789+ /// Models the retry behavior we expect from [`DatumCloudClient::request_with_auth_retry`]:
790+ /// hit a 401 once, ask for a refreshed token, retry the same request with the new
791+ /// token, and observe a 200. We exercise the pattern at the HTTP layer against a
792+ /// local hyper server so the contract is pinned independent of the wider client.
793+ async fn run_with_auth_retry (
794+ client : & reqwest:: Client ,
795+ url : & str ,
796+ tokens : Arc < TokenStash > ,
797+ outcome_log : Arc < Mutex < Vec < & ' static str > > > ,
798+ ) -> reqwest:: Response {
799+ let send = |bearer : & str | {
800+ client
801+ . get ( url)
802+ . header ( "Authorization" , format ! ( "Bearer {bearer}" ) )
803+ } ;
804+
805+ let res = send ( & tokens. current ( ) ) . send ( ) . await . expect ( "first request" ) ;
806+ if !is_auth_failure ( res. status ( ) ) {
807+ outcome_log. lock ( ) . unwrap ( ) . push ( "first-ok" ) ;
808+ return res;
809+ }
810+ outcome_log. lock ( ) . unwrap ( ) . push ( "first-401" ) ;
811+
812+ tokens. rotate ( ) ;
813+ outcome_log. lock ( ) . unwrap ( ) . push ( "refreshed" ) ;
814+
815+ let retry = send ( & tokens. current ( ) ) . send ( ) . await . expect ( "retry request" ) ;
816+ if is_auth_failure ( retry. status ( ) ) {
817+ outcome_log. lock ( ) . unwrap ( ) . push ( "retry-401-logout" ) ;
818+ } else {
819+ outcome_log. lock ( ) . unwrap ( ) . push ( "retry-ok" ) ;
820+ }
821+ retry
822+ }
823+
824+ struct TokenStash {
825+ tokens : Mutex < Vec < String > > ,
826+ }
827+ impl TokenStash {
828+ fn new ( initial : & str ) -> Arc < Self > {
829+ Arc :: new ( Self {
830+ tokens : Mutex :: new ( vec ! [ initial. into( ) ] ) ,
831+ } )
832+ }
833+ fn current ( & self ) -> String {
834+ self . tokens . lock ( ) . unwrap ( ) . last ( ) . cloned ( ) . unwrap ( )
835+ }
836+ fn rotate ( & self ) {
837+ let mut tokens = self . tokens . lock ( ) . unwrap ( ) ;
838+ let next = format ! ( "fresh-{}" , tokens. len( ) ) ;
839+ tokens. push ( next) ;
840+ }
841+ }
842+
843+ async fn spawn_server < H > ( handler : H ) -> ( String , tokio:: task:: JoinHandle < ( ) > )
844+ where
845+ H : Fn ( Request < hyper:: body:: Incoming > ) -> Response < Full < Bytes > >
846+ + Send
847+ + Sync
848+ + Clone
849+ + ' static ,
850+ {
851+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
852+ let addr = listener. local_addr ( ) . unwrap ( ) ;
853+ let url = format ! ( "http://{addr}" ) ;
854+ let handle = tokio:: spawn ( async move {
855+ loop {
856+ let ( stream, _) = match listener. accept ( ) . await {
857+ Ok ( v) => v,
858+ Err ( _) => return ,
859+ } ;
860+ let handler = handler. clone ( ) ;
861+ tokio:: spawn ( async move {
862+ let svc = service_fn ( move |req| {
863+ let handler = handler. clone ( ) ;
864+ async move { Ok :: < _ , std:: convert:: Infallible > ( handler ( req) ) }
865+ } ) ;
866+ let _ = hyper:: server:: conn:: http1:: Builder :: new ( )
867+ . serve_connection ( TokioIo :: new ( stream) , svc)
868+ . await ;
869+ } ) ;
870+ }
871+ } ) ;
872+ ( url, handle)
873+ }
874+
875+ fn auth_header ( req : & Request < hyper:: body:: Incoming > ) -> Option < String > {
876+ req. headers ( )
877+ . get ( "authorization" )
878+ . and_then ( |v| v. to_str ( ) . ok ( ) )
879+ . map ( |s| s. to_string ( ) )
880+ }
881+
882+ #[ tokio:: test]
883+ async fn retry_succeeds_after_401_then_200 ( ) {
884+ let calls = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
885+ let calls_handler = calls. clone ( ) ;
886+ let ( url, handle) = spawn_server ( move |req| {
887+ let n = calls_handler. fetch_add ( 1 , Ordering :: SeqCst ) ;
888+ let bearer = auth_header ( & req) . unwrap_or_default ( ) ;
889+ if n == 0 {
890+ assert_eq ! ( bearer, "Bearer t0" , "first request uses initial token" ) ;
891+ Response :: builder ( )
892+ . status ( 401 )
893+ . body ( Full :: new ( Bytes :: from ( "unauthorized" ) ) )
894+ . unwrap ( )
895+ } else {
896+ assert_eq ! (
897+ bearer, "Bearer fresh-1" ,
898+ "retry uses the refreshed token from the stash"
899+ ) ;
900+ Response :: builder ( )
901+ . status ( 200 )
902+ . body ( Full :: new ( Bytes :: from ( "ok" ) ) )
903+ . unwrap ( )
904+ }
905+ } )
906+ . await ;
907+
908+ let client = reqwest:: Client :: new ( ) ;
909+ let tokens = TokenStash :: new ( "t0" ) ;
910+ let log = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
911+ let res = run_with_auth_retry ( & client, & url, tokens, log. clone ( ) ) . await ;
912+ handle. abort ( ) ;
913+
914+ assert ! ( res. status( ) . is_success( ) ) ;
915+ assert_eq ! ( calls. load( Ordering :: SeqCst ) , 2 ) ;
916+ assert_eq ! (
917+ & * log. lock( ) . unwrap( ) ,
918+ & [ "first-401" , "refreshed" , "retry-ok" ]
919+ ) ;
920+ }
921+
922+ #[ tokio:: test]
923+ async fn retry_still_401_triggers_logout_path ( ) {
924+ let calls = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
925+ let calls_handler = calls. clone ( ) ;
926+ let ( url, handle) = spawn_server ( move |_req| {
927+ calls_handler. fetch_add ( 1 , Ordering :: SeqCst ) ;
928+ Response :: builder ( )
929+ . status ( 401 )
930+ . body ( Full :: new ( Bytes :: from ( "still nope" ) ) )
931+ . unwrap ( )
932+ } )
933+ . await ;
934+
935+ let client = reqwest:: Client :: new ( ) ;
936+ let tokens = TokenStash :: new ( "t0" ) ;
937+ let log = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
938+ let res = run_with_auth_retry ( & client, & url, tokens, log. clone ( ) ) . await ;
939+ handle. abort ( ) ;
940+
941+ // After two 401s we surface the failure and the caller is expected to clear auth.
942+ assert_eq ! ( res. status( ) , reqwest:: StatusCode :: UNAUTHORIZED ) ;
943+ assert_eq ! ( calls. load( Ordering :: SeqCst ) , 2 ) ;
944+ assert_eq ! (
945+ & * log. lock( ) . unwrap( ) ,
946+ & [ "first-401" , "refreshed" , "retry-401-logout" ]
947+ ) ;
948+ }
949+ }
0 commit comments