use anyhow::{Result, anyhow};
use chrono::{Duration as ChronoDuration, Utc};
use openidconnect::core::CoreClient;
use openidconnect::{OAuth2TokenResponse, RefreshToken, TokenResponse};
use secrecy::{ExposeSecret, SecretString};
use super::{OidcConfig, OidcTokens, discovery};
use crate::credentials::Profile;
const REFRESH_SKEW_MINUTES: i64 = 5;
pub fn refresh_needed(profile: &Profile) -> bool {
if !matches!(profile.auth_method, crate::credentials::AuthMethod::Oidc) {
return false;
}
let Some(expires_at) = profile.expires_at else {
return true;
};
let cutoff = Utc::now() + ChronoDuration::minutes(REFRESH_SKEW_MINUTES);
expires_at <= cutoff
}
pub async fn refresh(profile: &mut Profile, http: &reqwest::Client) -> Result<()> {
let Some(refresh_token) = profile
.refresh_token
.as_ref()
.map(|s| s.expose_secret().to_string())
else {
return Err(anyhow!(
"no refresh_token in profile — run `boxlite auth login` to reauthenticate"
));
};
let cfg = oidc_config_from_profile(profile)?;
let metadata = discovery::load_provider_metadata(&cfg, http).await?;
let client = CoreClient::from_provider_metadata(metadata, cfg.client_id.clone(), None);
let refresh = RefreshToken::new(refresh_token);
let request = client
.exchange_refresh_token(&refresh)
.map_err(|e| anyhow!("building refresh-token request: {e}"))?;
let response = match request.request_async(http).await {
Ok(r) => r,
Err(err) => {
let msg = err.to_string();
if msg.contains("invalid_grant") {
clear_oidc_session(profile);
return Err(anyhow!(
"session expired (refresh token rejected); run `boxlite auth login`"
));
}
return Err(anyhow!("refreshing OIDC session: {err}"));
}
};
profile.access_token = Some(SecretString::from(response.access_token().secret().clone()));
if let Some(rt) = response.refresh_token() {
profile.refresh_token = Some(SecretString::from(rt.secret().clone()));
}
if let Some(it) = response.id_token() {
profile.id_token = Some(SecretString::from(it.to_string()));
}
let new_expiry = response
.expires_in()
.and_then(|d| ChronoDuration::from_std(d).ok())
.map(|d| Utc::now() + d)
.or(profile.expires_at);
profile.expires_at = new_expiry;
Ok(())
}
fn oidc_config_from_profile(profile: &Profile) -> Result<OidcConfig> {
let issuer = profile
.oidc_issuer
.as_ref()
.ok_or_else(|| anyhow!("profile is missing oidc_issuer; cannot refresh"))?;
let client_id = profile
.client_id
.as_ref()
.ok_or_else(|| anyhow!("profile is missing client_id; cannot refresh"))?;
let issuer_url = openidconnect::IssuerUrl::new(issuer.clone())
.map_err(|e| anyhow!("stored OIDC issuer is not a valid URL: {e}"))?;
Ok(OidcConfig {
issuer: issuer_url,
client_id: openidconnect::ClientId::new(client_id.clone()),
audience: profile.audience.clone(),
scopes: OidcConfig::default_scopes(),
})
}
fn clear_oidc_session(profile: &mut Profile) {
profile.access_token = None;
profile.refresh_token = None;
profile.id_token = None;
profile.expires_at = None;
profile.oidc_issuer = None;
profile.client_id = None;
profile.audience = None;
}
pub fn apply_tokens(profile: &mut Profile, cfg: &OidcConfig, tokens: OidcTokens) {
profile.auth_method = crate::credentials::AuthMethod::Oidc;
profile.api_key = None;
profile.access_token = Some(tokens.access_token);
profile.refresh_token = tokens.refresh_token;
profile.id_token = tokens.id_token;
profile.expires_at = Some(tokens.expires_at);
profile.oidc_issuer = Some(cfg.issuer.as_str().to_string());
profile.client_id = Some(cfg.client_id.as_str().to_string());
profile.audience = cfg.audience.clone();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::{AuthMethod, Profile};
fn oidc_profile_with_expiry(expires_at: chrono::DateTime<Utc>) -> Profile {
Profile {
url: "https://api.boxlite.ai/api".to_string(),
access_token: Some(SecretString::from("at".to_string())),
refresh_token: Some(SecretString::from("rt".to_string())),
expires_at: Some(expires_at),
oidc_issuer: Some("https://dex.boxlite.ai/dex".to_string()),
client_id: Some("boxlite".to_string()),
auth_method: AuthMethod::Oidc,
..Profile::default()
}
}
#[test]
fn refresh_needed_when_token_expires_within_skew() {
let near_future = Utc::now() + ChronoDuration::minutes(2);
assert!(refresh_needed(&oidc_profile_with_expiry(near_future)));
}
#[test]
fn refresh_not_needed_for_long_lived_token() {
let far_future = Utc::now() + ChronoDuration::hours(2);
assert!(!refresh_needed(&oidc_profile_with_expiry(far_future)));
}
#[test]
fn api_key_profile_never_needs_refresh() {
let mut p = oidc_profile_with_expiry(Utc::now() - ChronoDuration::hours(1));
p.auth_method = AuthMethod::ApiKey;
assert!(!refresh_needed(&p));
}
#[test]
fn missing_expires_at_treated_as_expired() {
let mut p = oidc_profile_with_expiry(Utc::now() + ChronoDuration::hours(1));
p.expires_at = None;
assert!(refresh_needed(&p));
}
#[test]
fn clear_oidc_session_keeps_url_and_method() {
let mut p = oidc_profile_with_expiry(Utc::now());
clear_oidc_session(&mut p);
assert_eq!(p.url, "https://api.boxlite.ai/api");
assert!(p.access_token.is_none());
assert!(p.refresh_token.is_none());
assert!(p.oidc_issuer.is_none());
}
#[test]
fn apply_tokens_populates_all_oidc_fields() {
let mut p = Profile {
url: "https://api.boxlite.ai/api".to_string(),
..Profile::default()
};
let cfg = OidcConfig {
issuer: openidconnect::IssuerUrl::new("https://dex.boxlite.ai/dex".to_string())
.unwrap(),
client_id: openidconnect::ClientId::new("boxlite".to_string()),
audience: Some("boxlite-api".to_string()),
scopes: OidcConfig::default_scopes(),
};
let tokens = OidcTokens {
access_token: SecretString::from("AT".to_string()),
refresh_token: Some(SecretString::from("RT".to_string())),
id_token: Some(SecretString::from("ID".to_string())),
expires_at: Utc::now() + ChronoDuration::hours(1),
};
apply_tokens(&mut p, &cfg, tokens);
assert_eq!(p.auth_method, AuthMethod::Oidc);
assert!(p.api_key.is_none());
assert_eq!(
p.access_token.as_ref().map(|s| s.expose_secret()),
Some("AT")
);
assert_eq!(p.oidc_issuer.as_deref(), Some("https://dex.boxlite.ai/dex"));
assert_eq!(p.client_id.as_deref(), Some("boxlite"));
assert_eq!(p.audience.as_deref(), Some("boxlite-api"));
}
}