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 FETCH_TIMEOUT: Duration = Duration::from_secs(30);
44
45#[derive(Clone)]
50#[non_exhaustive]
51pub struct OidcConfig {
52 token_url: String,
53 client_id: String,
54 client_secret: String,
58 scope: Option<String>,
59}
60
61impl OidcConfig {
62 #[must_use]
64 pub fn new(
65 token_url: impl Into<String>,
66 client_id: impl Into<String>,
67 client_secret: impl Into<String>,
68 ) -> Self {
69 Self {
70 token_url: token_url.into(),
71 client_id: client_id.into(),
72 client_secret: client_secret.into(),
73 scope: None,
74 }
75 }
76
77 #[must_use]
79 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
80 self.scope = Some(scope.into());
81 self
82 }
83
84 #[must_use]
86 pub fn token_url(&self) -> &str {
87 &self.token_url
88 }
89
90 #[must_use]
92 pub fn client_id(&self) -> &str {
93 &self.client_id
94 }
95
96 #[must_use]
99 pub fn keycloak(
100 base_url: impl AsRef<str>,
101 realm: impl AsRef<str>,
102 client_id: impl Into<String>,
103 client_secret: impl Into<String>,
104 ) -> Self {
105 let base = base_url.as_ref().trim_end_matches('/');
106 Self::new(
107 format!(
108 "{base}/realms/{}/protocol/openid-connect/token",
109 realm.as_ref()
110 ),
111 client_id,
112 client_secret,
113 )
114 }
115
116 #[must_use]
118 pub fn auth0(
119 domain: impl AsRef<str>,
120 client_id: impl Into<String>,
121 client_secret: impl Into<String>,
122 ) -> Self {
123 let domain = domain.as_ref().trim_end_matches('/');
124 Self::new(
125 format!("https://{domain}/oauth/token"),
126 client_id,
127 client_secret,
128 )
129 }
130
131 #[must_use]
135 pub fn okta(
136 domain: impl AsRef<str>,
137 auth_server: 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}/oauth2/{}/v1/token", auth_server.as_ref()),
144 client_id,
145 client_secret,
146 )
147 }
148}
149
150impl fmt::Debug for OidcConfig {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 f.debug_struct("OidcConfig")
153 .field("token_url", &self.token_url)
154 .field("client_id", &self.client_id)
155 .field("client_secret", &"<redacted>")
156 .field("scope", &self.scope)
157 .finish()
158 }
159}
160
161#[derive(Debug, Deserialize)]
162struct TokenResponse {
163 access_token: String,
164 #[serde(default)]
165 expires_in: u64,
166}
167
168struct Cached {
169 token: String,
170 deadline: Instant,
171}
172
173#[derive(Clone)]
176pub struct TokenProvider {
177 inner: Arc<Inner>,
178}
179
180struct Inner {
181 config: OidcConfig,
182 http: reqwest::Client,
183 cache: Mutex<Option<Cached>>,
184}
185
186impl fmt::Debug for TokenProvider {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.debug_struct("TokenProvider")
189 .field("config", &self.inner.config)
190 .finish_non_exhaustive()
191 }
192}
193
194impl TokenProvider {
195 #[must_use]
201 pub fn new(config: OidcConfig) -> Self {
202 let http = reqwest::Client::builder()
206 .timeout(FETCH_TIMEOUT)
207 .build()
208 .unwrap_or_else(|_| reqwest::Client::new());
209 Self {
210 inner: Arc::new(Inner {
211 config,
212 http,
213 cache: Mutex::new(None),
214 }),
215 }
216 }
217
218 pub async fn invalidate(&self) {
222 *self.inner.cache.lock().await = None;
223 }
224
225 pub async fn token(&self) -> Result<String> {
240 let mut guard = self.inner.cache.lock().await;
241 if let Some(cached) = guard.as_ref()
242 && Instant::now() < cached.deadline
243 {
244 return Ok(cached.token.clone());
245 }
246
247 let response = self.fetch().await?;
248 let lifetime = if response.expires_in == 0 {
249 DEFAULT_TTL_SECS
250 } else {
251 response.expires_in
252 };
253 let ttl = lifetime.saturating_sub(REFRESH_SKEW).max(1);
254 let token = response.access_token;
255 *guard = Some(Cached {
256 token: token.clone(),
257 deadline: Instant::now() + Duration::from_secs(ttl),
258 });
259 Ok(token)
260 }
261
262 async fn fetch(&self) -> Result<TokenResponse> {
263 let config = &self.inner.config;
264 let mut params = vec![
265 ("grant_type", "client_credentials"),
266 ("client_id", config.client_id.as_str()),
267 ("client_secret", config.client_secret.as_str()),
268 ];
269 if let Some(scope) = &config.scope {
270 params.push(("scope", scope.as_str()));
271 }
272
273 let response = self
277 .inner
278 .http
279 .post(&config.token_url)
280 .timeout(FETCH_TIMEOUT)
281 .form(¶ms)
282 .send()
283 .await
284 .map_err(|e| {
285 Error::Connection(format!("token request to {} failed: {e}", config.token_url))
286 })?;
287
288 if !response.status().is_success() {
292 let status = response.status().as_u16();
293 let body = response.text().await.unwrap_or_default();
294 if matches!(status, 401 | 403) {
295 return Err(Error::Auth(format!(
296 "token endpoint rejected the credentials (http {status}): {body}"
297 )));
298 }
299 return Err(Error::Http { status, body });
300 }
301
302 let body = response
303 .text()
304 .await
305 .map_err(|e| Error::Connection(format!("reading token response failed: {e}")))?;
306 serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
307 }
308}
309
310impl TokenSource for TokenProvider {
314 fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
315 Box::pin(async move { self.token().await.map(Some) })
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn sample_config() -> OidcConfig {
324 OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
325 }
326
327 #[test]
328 fn debug_redacts_the_client_secret() {
329 let rendered = format!("{:?}", sample_config());
330 assert!(
331 !rendered.contains("TOP-SECRET-VALUE"),
332 "client_secret must never appear in Debug output: {rendered}"
333 );
334 assert!(rendered.contains("<redacted>"));
335 assert!(rendered.contains("my-client"));
336 }
337
338 #[test]
339 fn provider_debug_does_not_leak_the_secret() {
340 let rendered = format!("{:?}", TokenProvider::new(sample_config()));
341 assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
342 }
343
344 #[test]
345 fn presets_build_the_expected_token_urls() {
346 assert_eq!(
347 OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
348 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
349 );
350 assert_eq!(
351 OidcConfig::auth0("my.eu.auth0.com", "c", "s").token_url,
352 "https://my.eu.auth0.com/oauth/token"
353 );
354 assert_eq!(
355 OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
356 "https://my.okta.com/oauth2/default/v1/token"
357 );
358 }
359
360 #[test]
361 fn with_scope_sets_the_scope() {
362 let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
363 assert_eq!(config.scope.as_deref(), Some("openid profile"));
364 }
365}