use crate::provider_oauth;
use crate::store::auth_storage::{AuthCredential, AuthStorage, shared_auth_storage};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use tokio::sync::Mutex;
#[derive(Debug, Clone, thiserror::Error)]
pub enum RefreshError {
#[error("no OAuth credential for '{0}'")]
NotOAuth(String),
#[error("refresh token missing — re-login required for '{0}'")]
ReLoginRequired(String),
#[error("refresh failed: {0}")]
Failed(String),
}
type CoalesceCell = Arc<tokio::sync::OnceCell<Result<(), RefreshError>>>;
static COALESCE: LazyLock<Mutex<HashMap<String, CoalesceCell>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub async fn refresh_if_expired(provider: &str) -> Result<(), RefreshError> {
let auth = shared_auth_storage();
refresh_if_expired_with_storage(provider, &auth).await
}
pub async fn refresh_if_expired_with_storage(
provider: &str,
auth: &Arc<AuthStorage>,
) -> Result<(), RefreshError> {
let creds = auth.get_all();
let credential = creds
.get(provider)
.ok_or_else(|| RefreshError::NotOAuth(provider.to_string()))?;
let (refresh_token, spec) = match credential {
AuthCredential::OAuth {
access_token: _,
refresh_token,
expires_at,
scopes: _,
provider_data: _,
} => {
if *expires_at == 0 {
return Ok(());
}
let now = chrono::Utc::now().timestamp().max(0) as u64;
if *expires_at > now + 60 {
return Ok(());
}
let rt = refresh_token
.clone()
.ok_or_else(|| RefreshError::ReLoginRequired(provider.to_string()))?;
let spec = provider_oauth::spec_for(provider)
.ok_or_else(|| RefreshError::Failed(format!("no OAuth spec for {provider}")))?;
(rt, spec)
}
_ => return Err(RefreshError::NotOAuth(provider.to_string())),
};
let cell = {
let mut map = COALESCE.lock().await;
map.entry(provider.to_string())
.or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
.clone()
};
let cell_value = cell.get_or_init(|| async {
do_refresh(provider, &spec, &refresh_token)
.await
.map_err(|e| RefreshError::Failed(format!("{e}")))
});
let outcome: Result<(), RefreshError> = match cell_value.await {
Ok(()) => Ok(()),
Err(e) => Err(e.clone()),
};
outcome?;
Ok(())
}
pub async fn invalidate_coalesce(provider: &str) {
let mut map = COALESCE.lock().await;
map.remove(provider);
}
async fn do_refresh(
provider: &str,
spec: &provider_oauth::ProviderOAuthSpec,
refresh_token: &str,
) -> anyhow::Result<()> {
let tokens = provider_oauth::refresh_grant(spec, refresh_token).await?;
let auth = shared_auth_storage();
let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
auth.update_oauth_tokens(
provider,
tokens.access_token,
tokens.refresh_token,
new_expires_at,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::auth_storage::{AuthCredential, AuthStorage};
#[tokio::test]
async fn refresh_if_expired_noop_when_not_expired() {
let auth = Arc::new(AuthStorage::in_memory());
let now = chrono::Utc::now().timestamp() as u64;
auth.set_oauth_full(
"test-provider",
"AT-unexpired".to_string(),
Some("RT-unexpired".to_string()),
now + 3600,
None,
None,
);
let result = refresh_if_expired_with_storage("test-provider", &auth).await;
assert!(
result.is_ok(),
"unexpired credential should be a no-op, got {result:?}"
);
let stored = auth
.get_all()
.remove("test-provider")
.expect("credential still present");
match stored {
AuthCredential::OAuth { access_token, .. } => {
assert_eq!(access_token, "AT-unexpired");
}
other => panic!("expected OAuth credential, got {other:?}"),
}
}
#[tokio::test]
async fn refresh_if_expired_noop_when_expires_at_zero() {
let auth = Arc::new(AuthStorage::in_memory());
auth.set_oauth_full(
"never-exp",
"AT-never".to_string(),
Some("RT-never".to_string()),
0, None,
None,
);
let result = refresh_if_expired_with_storage("never-exp", &auth).await;
assert!(
result.is_ok(),
"expires_at=0 must be a no-op, got {result:?}"
);
}
#[tokio::test]
async fn refresh_if_expired_returns_not_oauth_when_missing() {
let auth = Arc::new(AuthStorage::in_memory());
let result = refresh_if_expired_with_storage("ghost", &auth).await;
match result {
Err(RefreshError::NotOAuth(name)) => assert_eq!(name, "ghost"),
other => panic!("expected NotOAuth(\"ghost\"), got {other:?}"),
}
}
#[tokio::test]
async fn refresh_if_expired_returns_not_oauth_for_api_key() {
let auth = Arc::new(AuthStorage::in_memory());
auth.set_api_key("anthropic", "sk-test".to_string());
let result = refresh_if_expired_with_storage("anthropic", &auth).await;
match result {
Err(RefreshError::NotOAuth(name)) => assert_eq!(name, "anthropic"),
other => panic!("expected NotOAuth, got {other:?}"),
}
}
#[tokio::test]
async fn refresh_if_expired_returns_re_login_when_no_refresh_token() {
let auth = Arc::new(AuthStorage::in_memory());
let now = chrono::Utc::now().timestamp() as u64;
auth.set_oauth_full(
"openai",
"AT-stale".to_string(),
None,
now.saturating_sub(10), None,
None,
);
let result = refresh_if_expired_with_storage("openai", &auth).await;
match result {
Err(RefreshError::ReLoginRequired(name)) => assert_eq!(name, "openai"),
other => panic!("expected ReLoginRequired, got {other:?}"),
}
}
}