use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::http::{HeaderMap, Method, StatusCode};
use serde_json::Value;
use tokio::sync::watch;
use crate::adapter::{Forwarded, Inner, Reply, UpstreamCall, bearer_token, failure, read_json};
use crate::cookies::{CookieWrite, KIND_REFRESH, raw_cookie, read_cookie, session_cookies};
use crate::jwt::{Claims, str_claim};
use crate::routes::{carries_session, cookie_transport_body};
const REUSE_WINDOW: Duration = Duration::from_secs(5);
const FLIGHT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub(crate) enum RefreshOutcome {
Session(Claims),
Rejected {
status: StatusCode,
body: Option<Value>,
is_json: bool,
},
Failed(String),
}
struct Flight {
id: u64,
rx: watch::Receiver<Option<RefreshOutcome>>,
finished: Option<Instant>,
}
#[derive(Default)]
pub(crate) struct Refresher {
flights: Mutex<HashMap<String, Flight>>,
next_id: Mutex<u64>,
}
impl Refresher {
pub async fn share<Fut>(
self: &Arc<Self>,
key: String,
rotation: impl FnOnce() -> Fut,
) -> RefreshOutcome
where
Fut: Future<Output = RefreshOutcome> + Send + 'static,
{
let mut rx = {
let mut flights = self.flights.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
flights.retain(|_, flight| {
flight
.finished
.is_none_or(|at| now.duration_since(at) <= REUSE_WINDOW)
});
match flights.get(&key) {
Some(flight) => flight.rx.clone(),
None => {
let id = {
let mut next = self.next_id.lock().unwrap_or_else(|e| e.into_inner());
*next += 1;
*next
};
let (tx, rx) = watch::channel(None);
flights.insert(
key.clone(),
Flight {
id,
rx: rx.clone(),
finished: None,
},
);
let this = self.clone();
let rotation = rotation();
let flight_key = key.clone();
tokio::spawn(async move {
let outcome = tokio::time::timeout(FLIGHT_TIMEOUT, rotation)
.await
.unwrap_or_else(|_| RefreshOutcome::Failed("refresh timed out".into()));
this.settle(
&flight_key,
id,
matches!(outcome, RefreshOutcome::Session(_)),
);
let _ = tx.send(Some(outcome));
});
rx
}
}
};
match rx.wait_for(Option::is_some).await {
Ok(outcome) => outcome
.clone()
.unwrap_or_else(|| RefreshOutcome::Failed("refresh lost".into())),
Err(_) => {
let mut flights = self.flights.lock().unwrap_or_else(|e| e.into_inner());
if flights.get(&key).is_some_and(|f| f.finished.is_none()) {
flights.remove(&key);
}
RefreshOutcome::Failed("refresh task ended".into())
}
}
}
fn settle(&self, key: &str, id: u64, succeeded: bool) {
let mut flights = self.flights.lock().unwrap_or_else(|e| e.into_inner());
if flights.get(key).is_some_and(|f| f.id == id) {
if succeeded {
if let Some(flight) = flights.get_mut(key) {
flight.finished = Some(Instant::now());
}
} else {
flights.remove(key);
}
}
}
}
pub(crate) enum SilentRefreshError {
NoRefreshCookie,
Failed(String),
}
impl Inner {
pub(crate) async fn silent_refresh(
self: &Arc<Self>,
headers: &HeaderMap,
forwarded: &Forwarded,
) -> Result<(Claims, Vec<CookieWrite>), SilentRefreshError> {
let name = &self.config.refresh_cookie_name;
let Some(raw) = raw_cookie(headers, name).filter(|v| !v.is_empty()) else {
return Err(SilentRefreshError::NoRefreshCookie);
};
let payload = read_cookie(&self.config, headers, name, KIND_REFRESH);
let refresh_token = payload
.as_ref()
.map(|p| str_claim(p, "refreshToken"))
.unwrap_or("");
if refresh_token.is_empty() {
return Err(SilentRefreshError::Failed("invalid refresh cookie".into()));
}
let subject = payload.as_ref().map(|p| str_claim(p, "sub")).unwrap_or("");
let mut extra = Claims::new();
extra.insert("refreshToken".into(), Value::from(refresh_token));
let Some(service) = self.tokens.sign(subject, extra) else {
return Err(SilentRefreshError::Failed(
"could not sign the service token".into(),
));
};
let inner = self.clone();
let refresh_token = refresh_token.to_string();
let forwarded = forwarded.clone();
let outcome = self
.refreshes
.share(format!("cookie:{raw}"), move || async move {
inner.rotate(&forwarded, &refresh_token, service).await
})
.await;
let session = match outcome {
RefreshOutcome::Session(session) => session,
RefreshOutcome::Rejected { status, .. } => {
return Err(SilentRefreshError::Failed(format!(
"refresh answered {}",
status.as_u16()
)));
}
RefreshOutcome::Failed(err) => return Err(SilentRefreshError::Failed(err)),
};
let session_id = self
.verify_session(&session, "access")
.await
.map_err(SilentRefreshError::Failed)?;
let cookies = session_cookies(&self.config, &session, &session_id, true)
.map_err(SilentRefreshError::Failed)?;
Ok((session, cookies))
}
async fn rotate(
&self,
forwarded: &Forwarded,
refresh_token: &str,
service: String,
) -> RefreshOutcome {
let mut call = UpstreamCall::new(Method::POST, "/refresh");
call.authorization = Some(format!("Bearer {refresh_token}"));
call.service = Some(service);
let res = match self.call(forwarded, call).await {
Ok(res) => res,
Err(err) => return RefreshOutcome::Failed(err.without_url().to_string()),
};
let status = res.status();
let (body, is_json) = read_json(res).await;
if status != StatusCode::OK {
return RefreshOutcome::Rejected {
status,
body,
is_json,
};
}
match body {
Some(Value::Object(session)) if carries_session(&session) => {
RefreshOutcome::Session(session)
}
_ => RefreshOutcome::Failed("refresh returned no session".into()),
}
}
pub(crate) async fn refresh_route(
self: &Arc<Self>,
headers: &HeaderMap,
forwarded: &Forwarded,
bearer: bool,
) -> Reply {
if bearer {
let Some(token) = bearer_token(headers) else {
return Reply::error(StatusCode::UNAUTHORIZED, "refresh token required");
};
let Some(service) = self.tokens.proxy_authorization() else {
return Reply::error(StatusCode::INTERNAL_SERVER_ERROR, "internal_error");
};
let inner = self.clone();
let refresh_token = token.to_string();
let forwarded = forwarded.clone();
let outcome = self
.refreshes
.share(format!("bearer:{token}"), move || async move {
inner.rotate(&forwarded, &refresh_token, service).await
})
.await;
return match outcome {
RefreshOutcome::Session(session) => {
match self.verify_session(&session, "access").await {
Ok(_) => Reply::new(StatusCode::OK, Some(Value::Object(session))),
Err(_) => Reply::error(StatusCode::BAD_GATEWAY, "invalid_upstream_session"),
}
}
RefreshOutcome::Rejected {
status,
body,
is_json,
} => failure(status, body, is_json),
RefreshOutcome::Failed(_) => {
Reply::error(StatusCode::BAD_GATEWAY, "upstream_unavailable")
}
};
}
match self.silent_refresh(headers, forwarded).await {
Ok((session, cookies)) => Reply::new(
StatusCode::OK,
Some(cookie_transport_body(None, Value::Object(session))),
)
.with_set(cookies),
Err(SilentRefreshError::NoRefreshCookie) => {
Reply::error(StatusCode::UNAUTHORIZED, "refresh token required")
}
Err(SilentRefreshError::Failed(err)) => {
tracing::debug!("[seamless-auth] Refresh failed: {err}");
Reply::error(StatusCode::UNAUTHORIZED, "invalid_refresh_token").with_clear(vec![
self.config.access_cookie_name.clone(),
self.config.refresh_cookie_name.clone(),
])
}
}
}
pub(crate) async fn logout_route(
self: &Arc<Self>,
headers: &HeaderMap,
forwarded: &Forwarded,
bearer: bool,
path: &str,
) -> Reply {
let clears = vec![
self.config.access_cookie_name.clone(),
self.config.registration_cookie_name.clone(),
self.config.refresh_cookie_name.clone(),
];
let credential = match self
.credential_for(headers, forwarded, "access", bearer)
.await
{
Ok(credential) => credential,
Err(rejected) => return rejected.with_clear(clears),
};
let mut call = UpstreamCall::new(Method::DELETE, path);
call.authorization = credential.authorization;
call.service = self.tokens.proxy_authorization();
match self.call(forwarded, call).await {
Err(_) => {
Reply::error(StatusCode::BAD_GATEWAY, "upstream_unavailable").with_clear(clears)
}
Ok(res) => {
let status = if res.status().is_success() {
StatusCode::NO_CONTENT
} else {
res.status()
};
Reply::new(status, None).with_clear(clears)
}
}
}
}