use crate::auth::provider::AuthProvider;
use crate::client::truncate_str;
use crate::error::{EveryMapError, EveryMapResult};
use async_trait::async_trait;
use reqwest::header::{HeaderValue, AUTHORIZATION};
use reqwest::RequestBuilder;
use serde::Deserialize;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use zeroize::Zeroize;
const TOKEN_EXPIRY_SKEW_SECS: u64 = 30;
const DEFAULT_EXPIRES_IN_SECS: u64 = 3600;
const ERROR_BODY_LIMIT: usize = 256;
const OAUTH2_PROVIDER_NAME: &str = "oauth2";
pub struct OAuth2Provider {
credentials: ClientCredentials,
token_endpoint: String,
http_client: reqwest::Client,
cached_token: Mutex<Option<CachedToken>>,
}
#[derive(Zeroize)]
#[zeroize(drop)]
struct ClientCredentials {
client_id: String,
client_secret: String,
scope: Option<String>,
}
#[derive(Zeroize)]
#[zeroize(drop)]
struct CachedToken {
access_token: String,
#[zeroize(skip)]
expires_at: Instant,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
expires_in: Option<u64>,
}
impl OAuth2Provider {
pub fn new(
token_endpoint: String,
client_id: String,
client_secret: String,
scope: Option<String>,
) -> Self {
Self {
credentials: ClientCredentials {
client_id,
client_secret,
scope,
},
token_endpoint,
http_client: reqwest::Client::new(),
cached_token: Mutex::new(None),
}
}
async fn access_token(&self) -> EveryMapResult<String> {
let mut cached = self.cached_token.lock().await;
if let Some(token) = cached.as_ref() {
if Instant::now() < token.expires_at {
return Ok(token.access_token.clone());
}
}
let new_token = self.fetch_token().await?;
let access_token = new_token.access_token.clone();
*cached = Some(new_token);
Ok(access_token)
}
async fn fetch_token(&self) -> EveryMapResult<CachedToken> {
let mut form_pairs: Vec<(&str, &str)> = vec![
("grant_type", "client_credentials"),
("client_id", &self.credentials.client_id),
("client_secret", &self.credentials.client_secret),
];
if let Some(scope) = &self.credentials.scope {
form_pairs.push(("scope", scope));
}
let response = self
.http_client
.post(&self.token_endpoint)
.form(&form_pairs)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return Err(EveryMapError::auth(
OAUTH2_PROVIDER_NAME,
format!(
"Token endpoint returned HTTP {}: {}",
status.as_u16(),
truncate_str(&body, ERROR_BODY_LIMIT)
),
));
}
let token_response: TokenResponse = serde_json::from_str(&body).map_err(|error| {
EveryMapError::auth(
OAUTH2_PROVIDER_NAME,
format!(
"Failed to parse token response: {} (body: {})",
error,
truncate_str(&body, ERROR_BODY_LIMIT)
),
)
})?;
Ok(CachedToken {
access_token: token_response.access_token,
expires_at: expires_at_from(
token_response.expires_in.unwrap_or(DEFAULT_EXPIRES_IN_SECS),
),
})
}
}
#[async_trait]
impl AuthProvider for OAuth2Provider {
async fn apply(&self, request: RequestBuilder) -> EveryMapResult<RequestBuilder> {
let access_token = self.access_token().await?;
let header_value =
HeaderValue::from_str(&format!("Bearer {}", access_token)).map_err(|error| {
EveryMapError::auth(
OAUTH2_PROVIDER_NAME,
format!("Invalid bearer token header value: {}", error),
)
})?;
Ok(request.header(AUTHORIZATION, header_value))
}
}
fn expires_at_from(expires_in_secs: u64) -> Instant {
Instant::now() + Duration::from_secs(expires_in_secs.saturating_sub(TOKEN_EXPIRY_SKEW_SECS))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expires_at_respects_skew() {
let expires_at = expires_at_from(3600);
let remaining = expires_at.checked_duration_since(Instant::now());
let remaining = remaining.expect("token must not be expired");
assert!(remaining <= Duration::from_secs(3600 - TOKEN_EXPIRY_SKEW_SECS));
assert!(remaining >= Duration::from_secs(3600 - TOKEN_EXPIRY_SKEW_SECS - 5));
}
#[test]
fn test_expires_at_zero_lifetime_is_immediately_expired() {
let expires_at = expires_at_from(0);
assert!(Instant::now() >= expires_at);
}
#[test]
fn test_expires_at_lifetime_below_skew_is_immediately_expired() {
let expires_at = expires_at_from(TOKEN_EXPIRY_SKEW_SECS - 1);
assert!(Instant::now() >= expires_at);
}
#[test]
fn test_token_response_full() {
let token_response: TokenResponse =
serde_json::from_str(r#"{"access_token":"tok","expires_in":7200}"#).unwrap();
assert_eq!(token_response.access_token, "tok");
assert_eq!(token_response.expires_in, Some(7200));
}
#[test]
fn test_token_response_without_expires_in() {
let token_response: TokenResponse =
serde_json::from_str(r#"{"access_token":"tok"}"#).unwrap();
assert_eq!(token_response.access_token, "tok");
assert_eq!(token_response.expires_in, None);
}
#[test]
fn test_token_response_missing_access_token_fails() {
let parse_result = serde_json::from_str::<TokenResponse>(r#"{"expires_in":60}"#);
assert!(parse_result.is_err());
}
}