use std::fmt;
use std::time::Duration;
use url::Url;
use super::error::TokenError;
use super::types::{ClientAuthMethod, SecretString};
pub struct OAuthClientConfig {
pub token_endpoint: Option<Url>,
pub issuer_url: Option<Url>,
pub client_id: String,
pub client_secret: SecretString,
pub scopes: Vec<String>,
pub auth_method: ClientAuthMethod,
pub extra_headers: Vec<(String, String)>,
pub refresh_offset: Duration,
pub jitter_max: Duration,
pub min_refresh_period: Duration,
pub default_ttl: Duration,
pub http_config: Option<modkit_http::HttpClientConfig>,
}
impl OAuthClientConfig {
pub fn validate(&self) -> Result<(), TokenError> {
if self.client_id.trim().is_empty() {
return Err(TokenError::ConfigError(
"client_id must not be empty".into(),
));
}
if self.client_secret.expose().is_empty() {
return Err(TokenError::ConfigError(
"client_secret must not be empty".into(),
));
}
match (&self.token_endpoint, &self.issuer_url) {
(Some(_), Some(_)) => Err(TokenError::ConfigError(
"token_endpoint and issuer_url are mutually exclusive".into(),
)),
(None, None) => Err(TokenError::ConfigError(
"one of token_endpoint or issuer_url must be set".into(),
)),
_ => Ok(()),
}
}
}
impl Clone for OAuthClientConfig {
fn clone(&self) -> Self {
Self {
token_endpoint: self.token_endpoint.clone(),
issuer_url: self.issuer_url.clone(),
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
scopes: self.scopes.clone(),
auth_method: self.auth_method,
extra_headers: self.extra_headers.clone(),
refresh_offset: self.refresh_offset,
jitter_max: self.jitter_max,
min_refresh_period: self.min_refresh_period,
default_ttl: self.default_ttl,
http_config: self.http_config.clone(),
}
}
}
impl fmt::Debug for OAuthClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let redacted_headers: Vec<_> = self
.extra_headers
.iter()
.map(|(k, _)| (k.as_str(), "[REDACTED]"))
.collect();
f.debug_struct("OAuthClientConfig")
.field("token_endpoint", &self.token_endpoint)
.field("issuer_url", &self.issuer_url)
.field("client_id", &self.client_id)
.field("client_secret", &"[REDACTED]")
.field("scopes", &self.scopes)
.field("auth_method", &self.auth_method)
.field("extra_headers", &redacted_headers)
.field("refresh_offset", &self.refresh_offset)
.field("jitter_max", &self.jitter_max)
.field("min_refresh_period", &self.min_refresh_period)
.field("default_ttl", &self.default_ttl)
.field("http_config", &self.http_config)
.finish()
}
}
impl Default for OAuthClientConfig {
fn default() -> Self {
Self {
token_endpoint: None,
issuer_url: None,
client_id: String::new(),
client_secret: SecretString::new(String::new()),
scopes: Vec::new(),
auth_method: ClientAuthMethod::default(),
extra_headers: Vec::new(),
refresh_offset: Duration::from_secs(30 * 60),
jitter_max: Duration::from_secs(5 * 60),
min_refresh_period: Duration::from_secs(10),
default_ttl: Duration::from_secs(5 * 60),
http_config: None,
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn test_url(s: &str) -> Url {
Url::parse(s).unwrap()
}
fn valid_base() -> OAuthClientConfig {
OAuthClientConfig {
client_id: "my-client".into(),
client_secret: SecretString::new("my-secret"),
..Default::default()
}
}
#[test]
fn validate_ok_with_token_endpoint_only() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
..valid_base()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_ok_with_issuer_url_only() {
let cfg = OAuthClientConfig {
issuer_url: Some(test_url("https://auth.example.com")),
..valid_base()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_err_when_both_set() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://a.example.com/token")),
issuer_url: Some(test_url("https://b.example.com")),
..valid_base()
};
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("mutually exclusive"),
"unexpected error: {err}"
);
}
#[test]
fn validate_err_when_neither_set() {
let cfg = valid_base();
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("must be set"),
"unexpected error: {err}"
);
}
#[test]
fn validate_err_when_client_id_empty() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
client_id: String::new(),
client_secret: SecretString::new("my-secret"),
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("client_id"),
"unexpected error: {err}"
);
}
#[test]
fn validate_err_when_client_id_whitespace() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
client_id: " ".into(),
client_secret: SecretString::new("my-secret"),
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("client_id"),
"unexpected error: {err}"
);
}
#[test]
fn validate_err_when_client_secret_empty() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
client_id: "my-client".into(),
client_secret: SecretString::new(""),
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("client_secret"),
"unexpected error: {err}"
);
}
#[test]
fn debug_redacts_client_secret() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
client_id: "my-client".into(),
client_secret: SecretString::new("super-secret"),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(dbg.contains("[REDACTED]"), "Debug must contain [REDACTED]");
assert!(
!dbg.contains("super-secret"),
"Debug must not contain the raw secret"
);
assert!(dbg.contains("my-client"), "Debug should contain client_id");
}
#[test]
fn debug_redacts_extra_header_values() {
let cfg = OAuthClientConfig {
token_endpoint: Some(test_url("https://auth.example.com/token")),
client_id: "my-client".into(),
client_secret: SecretString::new("s"),
extra_headers: vec![("x-api-key".into(), "secret-api-key-value".into())],
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(
dbg.contains("x-api-key"),
"Debug should contain header name"
);
assert!(
!dbg.contains("secret-api-key-value"),
"Debug must not contain header value"
);
}
#[test]
fn default_durations() {
let cfg = OAuthClientConfig::default();
assert_eq!(cfg.refresh_offset, Duration::from_secs(30 * 60));
assert_eq!(cfg.jitter_max, Duration::from_secs(5 * 60));
assert_eq!(cfg.min_refresh_period, Duration::from_secs(10));
assert_eq!(cfg.default_ttl, Duration::from_secs(5 * 60));
assert_eq!(cfg.auth_method, ClientAuthMethod::Basic);
}
}