use crate::{AuthProvider, Result};
use async_trait::async_trait;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::instrument;
#[derive(Debug, Clone, Deserialize, Serialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
}
#[derive(Debug, Clone)]
struct StoredToken {
access_token: String,
fetched_at: Instant,
expires_in: u64,
}
pub struct ClientCredentialsAuth {
client_id: String,
client_secret: SecretString,
http_client: reqwest::Client,
token: Arc<RwLock<Option<StoredToken>>>,
}
impl ClientCredentialsAuth {
#[instrument(skip(client_id, client_secret))]
pub fn new(client_id: String, client_secret: String) -> Result<Self> {
tracing::debug!("Creating OAuth Client Credentials authenticator");
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
tracing::debug!("OAuth Client Credentials authenticator created");
Ok(Self {
client_id,
client_secret: SecretString::new(client_secret.into_boxed_str()),
http_client,
token: Arc::new(RwLock::new(None)),
})
}
#[instrument]
pub fn from_env() -> Result<Self> {
tracing::debug!("Loading OAuth credentials from environment");
let config = crate::EnvConfig::global();
let client_id = config.arcgis_client_id.as_ref().ok_or_else(|| {
tracing::error!("ARCGIS_CLIENT_ID environment variable not set or invalid");
crate::Error::from(crate::ErrorKind::Env(crate::EnvError::new(
std::env::VarError::NotPresent,
)))
})?;
let client_secret = config.arcgis_client_secret.as_ref().ok_or_else(|| {
tracing::error!("ARCGIS_CLIENT_SECRET environment variable not set or invalid");
crate::Error::from(crate::ErrorKind::Env(crate::EnvError::new(
std::env::VarError::NotPresent,
)))
})?;
tracing::debug!("Successfully loaded OAuth credentials from environment");
Self::new(
client_id.expose_secret().to_string(),
client_secret.expose_secret().to_string(),
)
}
#[instrument(skip(self))]
async fn fetch_token(&self) -> Result<()> {
tracing::debug!("Fetching new access token via client credentials flow");
let params = [
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.expose_secret()),
("grant_type", "client_credentials"),
("f", "json"), ];
let response = self
.http_client
.post("https://www.arcgis.com/sharing/rest/oauth2/token")
.form(¶ms)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<no body>".to_string());
return Err(crate::ErrorKind::OAuth(format!(
"Token request failed with status {}: {}",
status, body
))
.into());
}
let token_response: TokenResponse = response.json().await?;
tracing::info!(
expires_in = token_response.expires_in,
"Access token obtained successfully"
);
let stored_token = StoredToken {
access_token: token_response.access_token,
fetched_at: Instant::now(),
expires_in: token_response.expires_in,
};
*self.token.write().await = Some(stored_token);
Ok(())
}
fn is_token_expired(token: &StoredToken) -> bool {
let age = token.fetched_at.elapsed();
let expires_in = Duration::from_secs(token.expires_in);
let buffer = Duration::from_secs(300);
age + buffer >= expires_in
}
}
#[async_trait]
impl AuthProvider for ClientCredentialsAuth {
#[instrument(skip(self))]
async fn get_token(&self) -> Result<String> {
let token_guard = self.token.read().await;
if let Some(token) = token_guard.as_ref() {
if Self::is_token_expired(token) {
drop(token_guard);
tracing::debug!("Token expiring soon, fetching new token");
self.fetch_token().await?;
let new_guard = self.token.read().await;
let token = new_guard.as_ref().ok_or_else(|| {
crate::ErrorKind::OAuth("Token missing after successful fetch".to_string())
})?;
return Ok(token.access_token.clone());
}
tracing::debug!("Returning cached access token");
Ok(token.access_token.clone())
} else {
drop(token_guard);
tracing::debug!("No token exists, fetching initial token");
self.fetch_token().await?;
let guard = self.token.read().await;
let token = guard.as_ref().ok_or_else(|| {
crate::ErrorKind::OAuth("Token missing after successful fetch".to_string())
})?;
Ok(token.access_token.clone())
}
}
}