1use std::fmt;
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use canton_core::{Error, Result, TokenSource};
29use serde::Deserialize;
30use tokio::sync::Mutex;
31
32const REFRESH_SKEW: u64 = 30;
35
36const DEFAULT_TTL_SECS: u64 = 300;
39
40const MAX_TTL_SECS: u64 = 30 * 24 * 60 * 60;
44
45fn effective_ttl(expires_in: u64) -> u64 {
50 let lifetime = if expires_in == 0 {
51 DEFAULT_TTL_SECS
52 } else {
53 expires_in
54 };
55 lifetime.saturating_sub(REFRESH_SKEW).clamp(1, MAX_TTL_SECS)
56}
57
58const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
62
63#[derive(Clone)]
68#[non_exhaustive]
69pub struct OidcConfig {
70 token_url: String,
71 client_id: String,
72 client_secret: String,
76 scope: Option<String>,
77}
78
79impl OidcConfig {
80 #[must_use]
82 pub fn new(
83 token_url: impl Into<String>,
84 client_id: impl Into<String>,
85 client_secret: impl Into<String>,
86 ) -> Self {
87 Self {
88 token_url: token_url.into(),
89 client_id: client_id.into(),
90 client_secret: client_secret.into(),
91 scope: None,
92 }
93 }
94
95 #[must_use]
97 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
98 self.scope = Some(scope.into());
99 self
100 }
101
102 #[must_use]
104 pub fn token_url(&self) -> &str {
105 &self.token_url
106 }
107
108 #[must_use]
110 pub fn client_id(&self) -> &str {
111 &self.client_id
112 }
113
114 #[must_use]
117 pub fn keycloak(
118 base_url: impl AsRef<str>,
119 realm: impl AsRef<str>,
120 client_id: impl Into<String>,
121 client_secret: impl Into<String>,
122 ) -> Self {
123 let base = base_url.as_ref().trim_end_matches('/');
124 Self::new(
125 format!(
126 "{base}/realms/{}/protocol/openid-connect/token",
127 realm.as_ref()
128 ),
129 client_id,
130 client_secret,
131 )
132 }
133
134 #[must_use]
136 pub fn auth0(
137 domain: impl AsRef<str>,
138 client_id: impl Into<String>,
139 client_secret: impl Into<String>,
140 ) -> Self {
141 let domain = domain.as_ref().trim_end_matches('/');
142 Self::new(
143 format!("https://{domain}/oauth/token"),
144 client_id,
145 client_secret,
146 )
147 }
148
149 #[must_use]
153 pub fn okta(
154 domain: impl AsRef<str>,
155 auth_server: impl AsRef<str>,
156 client_id: impl Into<String>,
157 client_secret: impl Into<String>,
158 ) -> Self {
159 let domain = domain.as_ref().trim_end_matches('/');
160 Self::new(
161 format!("https://{domain}/oauth2/{}/v1/token", auth_server.as_ref()),
162 client_id,
163 client_secret,
164 )
165 }
166}
167
168impl fmt::Debug for OidcConfig {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 f.debug_struct("OidcConfig")
171 .field("token_url", &self.token_url)
172 .field("client_id", &self.client_id)
173 .field("client_secret", &"<redacted>")
174 .field("scope", &self.scope)
175 .finish()
176 }
177}
178
179#[derive(Debug, Deserialize)]
180struct TokenResponse {
181 access_token: String,
182 #[serde(default)]
183 expires_in: u64,
184}
185
186struct Cached {
187 token: String,
188 deadline: Instant,
189}
190
191#[derive(Clone)]
194pub struct TokenProvider {
195 inner: Arc<Inner>,
196}
197
198struct Inner {
199 config: OidcConfig,
200 http: reqwest::Client,
201 cache: Mutex<Option<Cached>>,
202}
203
204impl fmt::Debug for TokenProvider {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.debug_struct("TokenProvider")
207 .field("config", &self.inner.config)
208 .finish_non_exhaustive()
209 }
210}
211
212impl TokenProvider {
213 #[must_use]
219 pub fn new(config: OidcConfig) -> Self {
220 let http = reqwest::Client::builder()
224 .timeout(FETCH_TIMEOUT)
225 .build()
226 .unwrap_or_else(|_| reqwest::Client::new());
227 Self {
228 inner: Arc::new(Inner {
229 config,
230 http,
231 cache: Mutex::new(None),
232 }),
233 }
234 }
235
236 pub async fn invalidate(&self) {
240 *self.inner.cache.lock().await = None;
241 }
242
243 pub async fn token(&self) -> Result<String> {
258 let mut guard = self.inner.cache.lock().await;
259 if let Some(cached) = guard.as_ref()
260 && Instant::now() < cached.deadline
261 {
262 return Ok(cached.token.clone());
263 }
264
265 let response = self.fetch().await?;
266 let ttl = effective_ttl(response.expires_in);
267 let token = response.access_token;
268 *guard = Some(Cached {
269 token: token.clone(),
270 deadline: Instant::now() + Duration::from_secs(ttl),
271 });
272 Ok(token)
273 }
274
275 async fn fetch(&self) -> Result<TokenResponse> {
276 let config = &self.inner.config;
277 let mut params = vec![
278 ("grant_type", "client_credentials"),
279 ("client_id", config.client_id.as_str()),
280 ("client_secret", config.client_secret.as_str()),
281 ];
282 if let Some(scope) = &config.scope {
283 params.push(("scope", scope.as_str()));
284 }
285
286 let response = self
290 .inner
291 .http
292 .post(&config.token_url)
293 .timeout(FETCH_TIMEOUT)
294 .form(¶ms)
295 .send()
296 .await
297 .map_err(|e| {
298 Error::Connection(format!("token request to {} failed: {e}", config.token_url))
299 })?;
300
301 if !response.status().is_success() {
305 let status = response.status().as_u16();
306 let body = response.text().await.unwrap_or_default();
307 if matches!(status, 401 | 403) {
308 return Err(Error::Auth(format!(
309 "token endpoint rejected the credentials (http {status}): {body}"
310 )));
311 }
312 return Err(Error::Http { status, body });
313 }
314
315 let body = response
316 .text()
317 .await
318 .map_err(|e| Error::Connection(format!("reading token response failed: {e}")))?;
319 serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
320 }
321}
322
323impl TokenSource for TokenProvider {
327 fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
328 Box::pin(async move { self.token().await.map(Some) })
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn sample_config() -> OidcConfig {
337 OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
338 }
339
340 #[test]
341 fn effective_ttl_subtracts_skew_and_clamps() {
342 assert_eq!(effective_ttl(3600), 3600 - REFRESH_SKEW);
343 assert_eq!(effective_ttl(0), DEFAULT_TTL_SECS - REFRESH_SKEW);
345 assert_eq!(effective_ttl(10), 1);
347 }
348
349 #[test]
350 fn huge_expires_in_is_clamped_and_never_overflows() {
351 for expires_in in [u64::MAX, 1_000_000_000_000_000_000] {
354 assert_eq!(
355 effective_ttl(expires_in),
356 MAX_TTL_SECS,
357 "clamped to the max"
358 );
359 }
360 for expires_in in [0, 10, 3600, MAX_TTL_SECS + 1, u64::MAX] {
363 let ttl = effective_ttl(expires_in);
364 assert!(ttl <= MAX_TTL_SECS);
365 let _deadline = std::time::Instant::now() + std::time::Duration::from_secs(ttl);
366 }
367 }
368
369 #[test]
370 fn debug_redacts_the_client_secret() {
371 let rendered = format!("{:?}", sample_config());
372 assert!(
373 !rendered.contains("TOP-SECRET-VALUE"),
374 "client_secret must never appear in Debug output: {rendered}"
375 );
376 assert!(rendered.contains("<redacted>"));
377 assert!(rendered.contains("my-client"));
378 }
379
380 #[test]
381 fn provider_debug_does_not_leak_the_secret() {
382 let rendered = format!("{:?}", TokenProvider::new(sample_config()));
383 assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
384 }
385
386 #[test]
387 fn presets_build_the_expected_token_urls() {
388 assert_eq!(
389 OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
390 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
391 );
392 assert_eq!(
393 OidcConfig::auth0("my.eu.auth0.com", "c", "s").token_url,
394 "https://my.eu.auth0.com/oauth/token"
395 );
396 assert_eq!(
397 OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
398 "https://my.okta.com/oauth2/default/v1/token"
399 );
400 }
401
402 #[test]
403 fn with_scope_sets_the_scope() {
404 let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
405 assert_eq!(config.scope.as_deref(), Some("openid profile"));
406 }
407}