Skip to content

Commit a067264

Browse files
Merge pull request #156 from datum-cloud/fix/stale-auth-recovery-and-tunnel-delete-ux
fix: stale-auth recovery in PCP client and tunnel delete UX
2 parents 9562219 + 7eebfa2 commit a067264

8 files changed

Lines changed: 1113 additions & 481 deletions

File tree

lib/src/datum_cloud.rs

Lines changed: 275 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::http_user_agent::datum_http_user_agent;
1414
use crate::{ProjectControlPlaneClient, Repo, SelectedContext};
1515

1616
pub 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

Comments
 (0)