use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use canton_core::{Error, Result, TokenSource};
use serde::Deserialize;
use tokio::sync::Mutex;
const REFRESH_SKEW: u64 = 30;
const DEFAULT_TTL_SECS: u64 = 300;
const MAX_TTL_SECS: u64 = 30 * 24 * 60 * 60;
fn effective_ttl(expires_in: u64) -> u64 {
let lifetime = if expires_in == 0 {
DEFAULT_TTL_SECS
} else {
expires_in
};
lifetime.saturating_sub(REFRESH_SKEW).clamp(1, MAX_TTL_SECS)
}
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
#[non_exhaustive]
pub struct OidcConfig {
token_url: String,
client_id: String,
client_secret: String,
scope: Option<String>,
client_auth: ClientAuth,
audience: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClientAuth {
#[default]
Post,
Basic,
}
impl OidcConfig {
#[must_use]
pub fn new(
token_url: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
Self {
token_url: token_url.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
scope: None,
client_auth: ClientAuth::Post,
audience: None,
}
}
#[must_use]
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
#[must_use]
pub fn with_client_auth(mut self, client_auth: ClientAuth) -> Self {
self.client_auth = client_auth;
self
}
#[must_use]
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audience = Some(audience.into());
self
}
#[must_use]
pub fn client_auth(&self) -> ClientAuth {
self.client_auth
}
#[must_use]
pub fn audience(&self) -> Option<&str> {
self.audience.as_deref()
}
#[must_use]
pub fn token_url(&self) -> &str {
&self.token_url
}
#[must_use]
pub fn client_id(&self) -> &str {
&self.client_id
}
#[must_use]
pub fn keycloak(
base_url: impl AsRef<str>,
realm: impl AsRef<str>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
let base = base_url.as_ref().trim_end_matches('/');
Self::new(
format!(
"{base}/realms/{}/protocol/openid-connect/token",
realm.as_ref()
),
client_id,
client_secret,
)
}
#[must_use]
pub fn auth0(
domain: impl AsRef<str>,
audience: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
let domain = domain.as_ref().trim_end_matches('/');
Self::new(
format!("https://{domain}/oauth/token"),
client_id,
client_secret,
)
.with_audience(audience)
}
#[must_use]
pub fn okta(
domain: impl AsRef<str>,
auth_server: impl AsRef<str>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
let domain = domain.as_ref().trim_end_matches('/');
Self::new(
format!("https://{domain}/oauth2/{}/v1/token", auth_server.as_ref()),
client_id,
client_secret,
)
.with_client_auth(ClientAuth::Basic)
}
}
impl fmt::Debug for OidcConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcConfig")
.field("token_url", &canton_core::redact_url(&self.token_url))
.field("client_id", &self.client_id)
.field("client_secret", &"<redacted>")
.field("scope", &self.scope)
.field("client_auth", &self.client_auth)
.field("audience", &self.audience)
.finish()
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
expires_in: u64,
}
struct Cached {
token: String,
deadline: Instant,
}
#[derive(Clone)]
pub struct TokenProvider {
inner: Arc<Inner>,
}
struct Inner {
config: OidcConfig,
http: reqwest::Client,
cache: Mutex<Option<Cached>>,
}
impl fmt::Debug for TokenProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenProvider")
.field("config", &self.inner.config)
.finish_non_exhaustive()
}
}
impl TokenProvider {
#[must_use]
pub fn new(config: OidcConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
inner: Arc::new(Inner {
config,
http,
cache: Mutex::new(None),
}),
}
}
pub async fn invalidate(&self) {
*self.inner.cache.lock().await = None;
}
pub async fn token(&self) -> Result<String> {
let mut guard = self.inner.cache.lock().await;
if let Some(cached) = guard.as_ref()
&& Instant::now() < cached.deadline
{
return Ok(cached.token.clone());
}
let response = self.fetch().await?;
let ttl = effective_ttl(response.expires_in);
let token = response.access_token;
*guard = Some(Cached {
token: token.clone(),
deadline: Instant::now() + Duration::from_secs(ttl),
});
Ok(token)
}
async fn fetch(&self) -> Result<TokenResponse> {
let config = &self.inner.config;
let mut params = vec![("grant_type", "client_credentials")];
if config.client_auth == ClientAuth::Post {
params.push(("client_id", config.client_id.as_str()));
params.push(("client_secret", config.client_secret.as_str()));
}
if let Some(scope) = &config.scope {
params.push(("scope", scope.as_str()));
}
if let Some(audience) = &config.audience {
params.push(("audience", audience.as_str()));
}
let mut request = self
.inner
.http
.post(&config.token_url)
.timeout(FETCH_TIMEOUT)
.form(¶ms);
if config.client_auth == ClientAuth::Basic {
request = request.basic_auth(&config.client_id, Some(&config.client_secret));
}
let response = request.send().await.map_err(|e| {
let url = canton_core::redact_url(&config.token_url);
let detail = canton_core::chain(&e);
if e.is_timeout() {
return Error::Timeout;
}
if e.is_builder() || detail.to_ascii_lowercase().contains("certificate") {
return Error::Auth(format!(
"the token endpoint {url} cannot be reached: {detail}"
));
}
Error::Connection(format!("token request to {url} failed: {detail}"))
})?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if matches!(status, 401 | 403) {
return Err(Error::Auth(format!(
"token endpoint rejected the credentials (http {status}): {body}"
)));
}
return Err(Error::http(status, body));
}
let body = response.text().await.map_err(|e| {
Error::Connection(format!(
"reading token response failed: {}",
canton_core::chain(&e)
))
})?;
serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
}
}
impl TokenSource for TokenProvider {
fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
Box::pin(async move { self.token().await.map(Some) })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> OidcConfig {
OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
}
#[test]
fn effective_ttl_subtracts_skew_and_clamps() {
assert_eq!(effective_ttl(3600), 3600 - REFRESH_SKEW);
assert_eq!(effective_ttl(0), DEFAULT_TTL_SECS - REFRESH_SKEW);
assert_eq!(effective_ttl(10), 1);
}
#[test]
fn huge_expires_in_is_clamped_and_never_overflows() {
for expires_in in [u64::MAX, 1_000_000_000_000_000_000] {
assert_eq!(
effective_ttl(expires_in),
MAX_TTL_SECS,
"clamped to the max"
);
}
for expires_in in [0, 10, 3600, MAX_TTL_SECS + 1, u64::MAX] {
let ttl = effective_ttl(expires_in);
assert!(ttl <= MAX_TTL_SECS);
let _deadline = std::time::Instant::now() + std::time::Duration::from_secs(ttl);
}
}
#[test]
fn debug_redacts_the_client_secret() {
let rendered = format!("{:?}", sample_config());
assert!(
!rendered.contains("TOP-SECRET-VALUE"),
"client_secret must never appear in Debug output: {rendered}"
);
assert!(rendered.contains("<redacted>"));
assert!(rendered.contains("my-client"));
}
#[test]
fn debug_redacts_a_secret_carried_in_the_token_url() {
let config = OidcConfig::new(
"https://my-client:URL-EMBEDDED-SECRET@idp.example/realms/r/token",
"my-client",
"TOP-SECRET-VALUE",
);
let rendered = format!("{config:?}");
assert!(
!rendered.contains("URL-EMBEDDED-SECRET"),
"leaked via the token url: {rendered}"
);
assert!(
rendered.contains("idp.example"),
"should keep the provider host: {rendered}"
);
}
#[test]
fn provider_debug_does_not_leak_the_secret() {
let rendered = format!("{:?}", TokenProvider::new(sample_config()));
assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
}
#[test]
fn presets_build_the_expected_token_urls() {
assert_eq!(
OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
"http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
);
assert_eq!(
OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "c", "s").token_url,
"https://my.eu.auth0.com/oauth/token"
);
assert_eq!(
OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
"https://my.okta.com/oauth2/default/v1/token"
);
}
#[test]
fn with_scope_sets_the_scope() {
let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
assert_eq!(config.scope.as_deref(), Some("openid profile"));
}
}