canton_auth/lib.rs
1//! JWT/OIDC authentication for the Canton Ledger API.
2//!
3//! Provides the OAuth2 **client-credentials** flow with a [`TokenProvider`]
4//! that caches the access token and refreshes it before expiry. Secrets are
5//! redacted from `Debug` output.
6//!
7//! ```no_run
8//! # async fn run() -> canton_core::Result<()> {
9//! use canton_auth::{OidcConfig, TokenProvider};
10//!
11//! let provider = TokenProvider::new(OidcConfig::new(
12//! "http://localhost:8082/realms/AppProvider/protocol/openid-connect/token",
13//! "app-provider-backend",
14//! "…",
15//! ));
16//! let bearer = provider.token().await?;
17//! # let _ = bearer;
18//! # Ok(())
19//! # }
20//! ```
21
22use 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
32/// Refresh a token this many seconds before its stated expiry, to avoid racing
33/// the deadline on in-flight requests.
34const REFRESH_SKEW: u64 = 30;
35
36/// Fallback token lifetime (seconds) when the endpoint omits `expires_in`, so a
37/// missing/zero value does not collapse to a 1-second TTL that hammers the IdP.
38const DEFAULT_TTL_SECS: u64 = 300;
39
40/// Upper bound on a cached token's TTL (30 days). Caps an absurd or hostile
41/// `expires_in` so `Instant::now() + Duration::from_secs(ttl)` can never overflow
42/// (which panics); a real token re-fetches long before this.
43const MAX_TTL_SECS: u64 = 30 * 24 * 60 * 60;
44
45/// The cache TTL (seconds) for a token whose endpoint reported `expires_in`:
46/// a missing/zero value uses the default lifetime, a refresh skew is subtracted,
47/// and the result is clamped to `[1, MAX_TTL_SECS]` — the upper bound guarding
48/// against an overflow panic when computing the cache deadline.
49fn 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
58/// Per-request bound on a token fetch: a hung IdP fails the fetch (retriable
59/// [`Error::Connection`]) instead of blocking all token consumers behind the
60/// cache lock.
61const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
62
63/// OIDC client-credentials configuration for a token endpoint.
64///
65/// Construct with [`OidcConfig::new`]; `#[non_exhaustive]` so fields can be
66/// added without a breaking change.
67#[derive(Clone)]
68#[non_exhaustive]
69pub struct OidcConfig {
70 token_url: String,
71 client_id: String,
72 // Deliberately private with no getter: the secret is write-only from the
73 // caller's perspective (used internally for the token fetch, redacted from
74 // `Debug`), so it cannot leak via `println!`/serialization by accident.
75 client_secret: String,
76 scope: Option<String>,
77 client_auth: ClientAuth,
78 audience: Option<String>,
79}
80
81/// How the client credentials are presented to the token endpoint.
82///
83/// OAuth 2.0 defines both and RFC 6749 §2.3.1 says a server *must* support the
84/// `Basic` form; providers differ in what they accept, and picking the wrong
85/// one is rejected as `invalid_client` — which reads like a wrong secret. The
86/// provider presets choose for you; this is here for a custom endpoint that
87/// wants the other one. `#[non_exhaustive]`: a provider may need a third form
88/// (a signed assertion, say) without that being a breaking change.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
90#[non_exhaustive]
91pub enum ClientAuth {
92 /// `client_secret_post` — id and secret in the request body. The default,
93 /// and what Keycloak and Auth0 accept.
94 #[default]
95 Post,
96 /// `client_secret_basic` — id and secret in an HTTP `Authorization: Basic`
97 /// header. Okta's default for a confidential client.
98 Basic,
99}
100
101impl OidcConfig {
102 /// Create a client-credentials configuration (no scope).
103 #[must_use]
104 pub fn new(
105 token_url: impl Into<String>,
106 client_id: impl Into<String>,
107 client_secret: impl Into<String>,
108 ) -> Self {
109 Self {
110 token_url: token_url.into(),
111 client_id: client_id.into(),
112 client_secret: client_secret.into(),
113 scope: None,
114 client_auth: ClientAuth::Post,
115 audience: None,
116 }
117 }
118
119 /// Set the OAuth2 scope.
120 #[must_use]
121 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
122 self.scope = Some(scope.into());
123 self
124 }
125
126 /// Choose how the client credentials are presented ([`ClientAuth`]).
127 ///
128 /// The provider presets set this themselves; reach for it when pointing
129 /// [`OidcConfig::new`] at an endpoint that wants the other form.
130 #[must_use]
131 pub fn with_client_auth(mut self, client_auth: ClientAuth) -> Self {
132 self.client_auth = client_auth;
133 self
134 }
135
136 /// Set the `audience` parameter of the token request — the API the token
137 /// is being requested *for*.
138 ///
139 /// Auth0 requires it (without one it issues an opaque token for its own
140 /// userinfo endpoint, which a participant cannot verify). Most other
141 /// providers ignore it.
142 #[must_use]
143 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
144 self.audience = Some(audience.into());
145 self
146 }
147
148 /// How the client credentials are presented to the token endpoint.
149 #[must_use]
150 pub fn client_auth(&self) -> ClientAuth {
151 self.client_auth
152 }
153
154 /// The `audience` this configuration requests a token for, if any.
155 #[must_use]
156 pub fn audience(&self) -> Option<&str> {
157 self.audience.as_deref()
158 }
159
160 /// The OAuth2 token endpoint URL.
161 #[must_use]
162 pub fn token_url(&self) -> &str {
163 &self.token_url
164 }
165
166 /// The OAuth2 client id.
167 #[must_use]
168 pub fn client_id(&self) -> &str {
169 &self.client_id
170 }
171
172 /// Preset for **Keycloak** (also the Canton LocalNet IdP): builds the
173 /// `{base_url}/realms/{realm}/protocol/openid-connect/token` endpoint.
174 #[must_use]
175 pub fn keycloak(
176 base_url: impl AsRef<str>,
177 realm: impl AsRef<str>,
178 client_id: impl Into<String>,
179 client_secret: impl Into<String>,
180 ) -> Self {
181 let base = base_url.as_ref().trim_end_matches('/');
182 Self::new(
183 format!(
184 "{base}/realms/{}/protocol/openid-connect/token",
185 realm.as_ref()
186 ),
187 client_id,
188 client_secret,
189 )
190 }
191
192 /// Preset for **Auth0**: builds the `https://{domain}/oauth/token`
193 /// endpoint and requests a token for `audience`.
194 ///
195 /// The audience is the identifier of the Auth0 API the participant
196 /// validates against — it cannot be derived from the domain, and Auth0
197 /// answers a client-credentials request without one by issuing a token for
198 /// its own userinfo endpoint, which the participant will reject. Asking
199 /// for it here is what makes this preset produce Auth0's normal request.
200 #[must_use]
201 pub fn auth0(
202 domain: impl AsRef<str>,
203 audience: impl Into<String>,
204 client_id: impl Into<String>,
205 client_secret: impl Into<String>,
206 ) -> Self {
207 let domain = domain.as_ref().trim_end_matches('/');
208 Self::new(
209 format!("https://{domain}/oauth/token"),
210 client_id,
211 client_secret,
212 )
213 .with_audience(audience)
214 }
215
216 /// Preset for **Okta**: builds the
217 /// `https://{domain}/oauth2/{auth_server}/v1/token` endpoint (use
218 /// `"default"` for the default authorization server) and presents the
219 /// credentials as HTTP Basic, which is Okta's default for a confidential
220 /// client.
221 ///
222 /// An Okta app can be configured to accept them in the body instead; say
223 /// so with `.with_client_auth(ClientAuth::Post)`.
224 #[must_use]
225 pub fn okta(
226 domain: impl AsRef<str>,
227 auth_server: impl AsRef<str>,
228 client_id: impl Into<String>,
229 client_secret: impl Into<String>,
230 ) -> Self {
231 let domain = domain.as_ref().trim_end_matches('/');
232 Self::new(
233 format!("https://{domain}/oauth2/{}/v1/token", auth_server.as_ref()),
234 client_id,
235 client_secret,
236 )
237 .with_client_auth(ClientAuth::Basic)
238 }
239}
240
241impl fmt::Debug for OidcConfig {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 f.debug_struct("OidcConfig")
244 // Redacted for the same reason the secret is: an identity provider
245 // that takes client credentials as basic auth is configured as
246 // `https://id:secret@idp/token`, which puts the secret in the URL.
247 .field("token_url", &canton_core::redact_url(&self.token_url))
248 .field("client_id", &self.client_id)
249 .field("client_secret", &"<redacted>")
250 .field("scope", &self.scope)
251 .field("client_auth", &self.client_auth)
252 .field("audience", &self.audience)
253 .finish()
254 }
255}
256
257/// No `Debug`: the struct is one bearer token and a number, so deriving it
258/// would leave a `{resp:?}` one edit away from putting a live credential in a
259/// log line. Nothing prints it today, and nothing should need to.
260#[derive(Deserialize)]
261struct TokenResponse {
262 access_token: String,
263 #[serde(default)]
264 expires_in: u64,
265}
266
267struct Cached {
268 token: String,
269 deadline: Instant,
270}
271
272/// Fetches and caches an OAuth2 bearer token via the client-credentials grant,
273/// refreshing it shortly before expiry. Cloning shares the cache.
274#[derive(Clone)]
275pub struct TokenProvider {
276 inner: Arc<Inner>,
277}
278
279struct Inner {
280 config: OidcConfig,
281 http: reqwest::Client,
282 cache: Mutex<Option<Cached>>,
283}
284
285impl fmt::Debug for TokenProvider {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 f.debug_struct("TokenProvider")
288 .field("config", &self.inner.config)
289 .finish_non_exhaustive()
290 }
291}
292
293impl TokenProvider {
294 /// Create a provider for the given OIDC configuration.
295 ///
296 /// Token fetches are bounded by a per-request timeout (see
297 /// [`Self::token`]), so a hung token endpoint can never block callers
298 /// indefinitely (the fetch holds the cache lock while in flight).
299 #[must_use]
300 pub fn new(config: OidcConfig) -> Self {
301 // The per-request timeout in `fetch` is the guarantee; the client-level
302 // timeout here is belt-and-braces (a bare builder with a timeout does
303 // not fail in practice, but the fallback stays bounded either way).
304 // No redirects: the request carries the client secret, and a 3xx
305 // from the token endpoint (or whatever sits in front of it) would
306 // have `reqwest` replay it, form body and all, at the address of the
307 // redirect's choosing. A token endpoint that answers 3xx is refused
308 // in `fetch` instead.
309 let http = reqwest::Client::builder()
310 .timeout(FETCH_TIMEOUT)
311 .redirect(reqwest::redirect::Policy::none())
312 .build()
313 .unwrap_or_else(|_| reqwest::Client::new());
314 Self {
315 inner: Arc::new(Inner {
316 config,
317 http,
318 cache: Mutex::new(None),
319 }),
320 }
321 }
322
323 /// Drop the cached token so the next [`Self::token`] call fetches a fresh
324 /// one. Call this after a server rejects a token as expired/invalid so the
325 /// client can self-heal instead of replaying the stale token.
326 pub async fn invalidate(&self) {
327 *self.inner.cache.lock().await = None;
328 }
329
330 /// Return a valid bearer token, fetching or refreshing if the cached one is
331 /// absent or within the refresh-skew window of expiry.
332 ///
333 /// Concurrent callers that arrive during a refresh serialize behind a single
334 /// in-flight fetch (single-flight de-duplication), bounded by the HTTP
335 /// client's timeout — a slow IdP therefore stalls concurrent token consumers
336 /// for at most that timeout rather than triggering a fetch storm.
337 ///
338 /// # Errors
339 /// Returns [`Error::Auth`] if the endpoint rejects the credentials
340 /// (401/403, e.g. `invalid_client`); [`Error::Http`] for other non-success
341 /// statuses (5xx/429 stay retriable); [`Error::Connection`] if the endpoint
342 /// is unreachable or the fetch times out; [`Error::Json`] if the response
343 /// cannot be parsed.
344 pub async fn token(&self) -> Result<String> {
345 let mut guard = self.inner.cache.lock().await;
346 if let Some(cached) = guard.as_ref()
347 && Instant::now() < cached.deadline
348 {
349 return Ok(cached.token.clone());
350 }
351
352 let response = self.fetch().await?;
353 let ttl = effective_ttl(response.expires_in);
354 let token = response.access_token;
355 *guard = Some(Cached {
356 token: token.clone(),
357 deadline: Instant::now() + Duration::from_secs(ttl),
358 });
359 Ok(token)
360 }
361
362 async fn fetch(&self) -> Result<TokenResponse> {
363 let config = &self.inner.config;
364 let mut params = vec![("grant_type", "client_credentials")];
365 if config.client_auth == ClientAuth::Post {
366 params.push(("client_id", config.client_id.as_str()));
367 params.push(("client_secret", config.client_secret.as_str()));
368 }
369 if let Some(scope) = &config.scope {
370 params.push(("scope", scope.as_str()));
371 }
372 if let Some(audience) = &config.audience {
373 params.push(("audience", audience.as_str()));
374 }
375
376 // A send failure means the IdP was unreachable — retriable transport,
377 // not a credential rejection. The per-request timeout bounds the fetch
378 // even if the client was built without one.
379 let mut request = self
380 .inner
381 .http
382 .post(&config.token_url)
383 .timeout(FETCH_TIMEOUT)
384 .form(¶ms);
385 // Union of both branches' improvements: main's credential placement
386 // (Okta's Basic vs the body), and this branch's transport-error
387 // classification — a certificate failure is a permanent Auth error,
388 // not a retriable Connection one.
389 if config.client_auth == ClientAuth::Basic {
390 request = request.basic_auth(&config.client_id, Some(&config.client_secret));
391 }
392 let response = request.send().await.map_err(|e| {
393 let url = canton_core::redact_url(&config.token_url);
394 let detail = canton_core::chain(&e);
395 if e.is_timeout() {
396 return Error::Timeout;
397 }
398 if e.is_builder() || detail.to_ascii_lowercase().contains("certificate") {
399 return Error::Auth(format!(
400 "the token endpoint {url} cannot be reached: {detail}"
401 ));
402 }
403 Error::Connection(format!("token request to {url} failed: {detail}"))
404 })?;
405
406 // A credential rejection (401/403, e.g. `invalid_client`) is a definite
407 // auth failure; other non-success statuses keep their code so 5xx/429
408 // stay retriable via the shared error model.
409 if !response.status().is_success() {
410 let status = response.status().as_u16();
411 if response.status().is_redirection() {
412 // Not followed (see `new`): the secret stays with the endpoint
413 // it was configured for, and the caller hears why.
414 let target = response
415 .headers()
416 .get(reqwest::header::LOCATION)
417 .and_then(|v| v.to_str().ok())
418 .unwrap_or("<no Location header>");
419 let url = canton_core::redact_url(&config.token_url);
420 return Err(Error::Auth(format!(
421 "token endpoint {url} answered http {status} redirecting to {target}; \
422 a redirect is refused because following it would send the client \
423 secret elsewhere"
424 )));
425 }
426 let body = read_body(response, ERROR_BODY_CAP)
427 .await
428 .unwrap_or_else(|e| format!("<body not read: {e}>"));
429 if matches!(status, 401 | 403) {
430 return Err(Error::Auth(format!(
431 "token endpoint rejected the credentials (http {status}): {body}"
432 )));
433 }
434 return Err(Error::http(status, body));
435 }
436
437 let body = read_body(response, TOKEN_BODY_CAP).await?;
438 serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
439 }
440}
441
442/// A token response is a few kilobytes of JSON; anything larger is not one.
443const TOKEN_BODY_CAP: usize = 64 * 1024;
444/// How much of an error body is kept for the message.
445const ERROR_BODY_CAP: usize = 8 * 1024;
446
447/// Read a response body, refusing one that declares or grows past `cap`, so
448/// an endpoint (or whatever answers in its place) cannot make the caller
449/// allocate without bound.
450async fn read_body(mut response: reqwest::Response, cap: usize) -> Result<String> {
451 if let Some(declared) = response
452 .content_length()
453 .filter(|declared| usize::try_from(*declared).map_or(true, |n| n > cap))
454 {
455 {
456 return Err(Error::UnexpectedResponse(format!(
457 "token endpoint answered with a {declared}-byte body, above the {cap}-byte limit"
458 )));
459 }
460 }
461 let mut buffer: Vec<u8> = Vec::new();
462 while let Some(chunk) = response.chunk().await.map_err(|e| {
463 Error::Connection(format!(
464 "reading token response failed: {}",
465 canton_core::chain(&e)
466 ))
467 })? {
468 if buffer.len() + chunk.len() > cap {
469 return Err(Error::UnexpectedResponse(format!(
470 "token endpoint answered with a body above the {cap}-byte limit"
471 )));
472 }
473 buffer.extend_from_slice(&chunk);
474 }
475 String::from_utf8(buffer).map_err(|e| {
476 Error::UnexpectedResponse(format!(
477 "token endpoint answered with a body that is not UTF-8: {e}"
478 ))
479 })
480}
481
482/// Lets a [`TokenProvider`] back the SDK's shared [`canton_core::Auth`] without
483/// `canton-core` depending on this crate: `Config::with_oidc(provider)` stores
484/// it as an `Arc<dyn TokenSource>`.
485impl TokenSource for TokenProvider {
486 fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
487 Box::pin(async move { self.token().await.map(Some) })
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 fn sample_config() -> OidcConfig {
496 OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
497 }
498
499 #[test]
500 fn effective_ttl_subtracts_skew_and_clamps() {
501 assert_eq!(effective_ttl(3600), 3600 - REFRESH_SKEW);
502 // Missing/zero expires_in uses the default lifetime.
503 assert_eq!(effective_ttl(0), DEFAULT_TTL_SECS - REFRESH_SKEW);
504 // A tiny lifetime never collapses below 1.
505 assert_eq!(effective_ttl(10), 1);
506 }
507
508 #[test]
509 fn huge_expires_in_is_clamped_and_never_overflows() {
510 // A buggy/hostile IdP reporting a giant expires_in must not panic when
511 // the cache deadline is computed.
512 for expires_in in [u64::MAX, 1_000_000_000_000_000_000] {
513 assert_eq!(
514 effective_ttl(expires_in),
515 MAX_TTL_SECS,
516 "clamped to the max"
517 );
518 }
519 // Whatever the input, the ttl is bounded and the deadline the client
520 // computes never overflows (the bug this guards against).
521 for expires_in in [0, 10, 3600, MAX_TTL_SECS + 1, u64::MAX] {
522 let ttl = effective_ttl(expires_in);
523 assert!(ttl <= MAX_TTL_SECS);
524 let _deadline = std::time::Instant::now() + std::time::Duration::from_secs(ttl);
525 }
526 }
527
528 #[test]
529 fn debug_redacts_the_client_secret() {
530 let rendered = format!("{:?}", sample_config());
531 assert!(
532 !rendered.contains("TOP-SECRET-VALUE"),
533 "client_secret must never appear in Debug output: {rendered}"
534 );
535 assert!(rendered.contains("<redacted>"));
536 assert!(rendered.contains("my-client"));
537 }
538
539 /// The dedicated `client_secret` field is not the only way a secret gets
540 /// into this type. Providers that take client credentials as basic auth are
541 /// configured as `https://id:secret@idp/token`, and that URL was being
542 /// printed whole.
543 #[test]
544 fn debug_redacts_a_secret_carried_in_the_token_url() {
545 let config = OidcConfig::new(
546 "https://my-client:URL-EMBEDDED-SECRET@idp.example/realms/r/token",
547 "my-client",
548 "TOP-SECRET-VALUE",
549 );
550 let rendered = format!("{config:?}");
551 assert!(
552 !rendered.contains("URL-EMBEDDED-SECRET"),
553 "leaked via the token url: {rendered}"
554 );
555 assert!(
556 rendered.contains("idp.example"),
557 "should keep the provider host: {rendered}"
558 );
559 }
560
561 #[test]
562 fn provider_debug_does_not_leak_the_secret() {
563 let rendered = format!("{:?}", TokenProvider::new(sample_config()));
564 assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
565 }
566
567 #[test]
568 fn presets_build_the_expected_token_urls() {
569 assert_eq!(
570 OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
571 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
572 );
573 assert_eq!(
574 OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "c", "s").token_url,
575 "https://my.eu.auth0.com/oauth/token"
576 );
577 assert_eq!(
578 OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
579 "https://my.okta.com/oauth2/default/v1/token"
580 );
581 }
582
583 #[test]
584 fn with_scope_sets_the_scope() {
585 let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
586 assert_eq!(config.scope.as_deref(), Some("openid profile"));
587 }
588}