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 let http = reqwest::Client::builder()
305 .timeout(FETCH_TIMEOUT)
306 .build()
307 .unwrap_or_else(|_| reqwest::Client::new());
308 Self {
309 inner: Arc::new(Inner {
310 config,
311 http,
312 cache: Mutex::new(None),
313 }),
314 }
315 }
316
317 /// Drop the cached token so the next [`Self::token`] call fetches a fresh
318 /// one. Call this after a server rejects a token as expired/invalid so the
319 /// client can self-heal instead of replaying the stale token.
320 pub async fn invalidate(&self) {
321 *self.inner.cache.lock().await = None;
322 }
323
324 /// Return a valid bearer token, fetching or refreshing if the cached one is
325 /// absent or within the refresh-skew window of expiry.
326 ///
327 /// Concurrent callers that arrive during a refresh serialize behind a single
328 /// in-flight fetch (single-flight de-duplication), bounded by the HTTP
329 /// client's timeout — a slow IdP therefore stalls concurrent token consumers
330 /// for at most that timeout rather than triggering a fetch storm.
331 ///
332 /// # Errors
333 /// Returns [`Error::Auth`] if the endpoint rejects the credentials
334 /// (401/403, e.g. `invalid_client`); [`Error::Http`] for other non-success
335 /// statuses (5xx/429 stay retriable); [`Error::Connection`] if the endpoint
336 /// is unreachable or the fetch times out; [`Error::Json`] if the response
337 /// cannot be parsed.
338 pub async fn token(&self) -> Result<String> {
339 let mut guard = self.inner.cache.lock().await;
340 if let Some(cached) = guard.as_ref()
341 && Instant::now() < cached.deadline
342 {
343 return Ok(cached.token.clone());
344 }
345
346 let response = self.fetch().await?;
347 let ttl = effective_ttl(response.expires_in);
348 let token = response.access_token;
349 *guard = Some(Cached {
350 token: token.clone(),
351 deadline: Instant::now() + Duration::from_secs(ttl),
352 });
353 Ok(token)
354 }
355
356 async fn fetch(&self) -> Result<TokenResponse> {
357 let config = &self.inner.config;
358 let mut params = vec![("grant_type", "client_credentials")];
359 if config.client_auth == ClientAuth::Post {
360 params.push(("client_id", config.client_id.as_str()));
361 params.push(("client_secret", config.client_secret.as_str()));
362 }
363 if let Some(scope) = &config.scope {
364 params.push(("scope", scope.as_str()));
365 }
366 if let Some(audience) = &config.audience {
367 params.push(("audience", audience.as_str()));
368 }
369
370 // A send failure means the IdP was unreachable — retriable transport,
371 // not a credential rejection. The per-request timeout bounds the fetch
372 // even if the client was built without one.
373 let mut request = self
374 .inner
375 .http
376 .post(&config.token_url)
377 .timeout(FETCH_TIMEOUT)
378 .form(¶ms);
379 if config.client_auth == ClientAuth::Basic {
380 request = request.basic_auth(&config.client_id, Some(&config.client_secret));
381 }
382 let response = request.send().await.map_err(|e| {
383 Error::Connection(format!(
384 "token request to {} failed: {e}",
385 canton_core::redact_url(&config.token_url)
386 ))
387 })?;
388
389 // A credential rejection (401/403, e.g. `invalid_client`) is a definite
390 // auth failure; other non-success statuses keep their code so 5xx/429
391 // stay retriable via the shared error model.
392 if !response.status().is_success() {
393 let status = response.status().as_u16();
394 let body = response.text().await.unwrap_or_default();
395 if matches!(status, 401 | 403) {
396 return Err(Error::Auth(format!(
397 "token endpoint rejected the credentials (http {status}): {body}"
398 )));
399 }
400 return Err(Error::Http { status, body });
401 }
402
403 let body = response
404 .text()
405 .await
406 .map_err(|e| Error::Connection(format!("reading token response failed: {e}")))?;
407 serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
408 }
409}
410
411/// Lets a [`TokenProvider`] back the SDK's shared [`canton_core::Auth`] without
412/// `canton-core` depending on this crate: `Config::with_oidc(provider)` stores
413/// it as an `Arc<dyn TokenSource>`.
414impl TokenSource for TokenProvider {
415 fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
416 Box::pin(async move { self.token().await.map(Some) })
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn sample_config() -> OidcConfig {
425 OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
426 }
427
428 #[test]
429 fn effective_ttl_subtracts_skew_and_clamps() {
430 assert_eq!(effective_ttl(3600), 3600 - REFRESH_SKEW);
431 // Missing/zero expires_in uses the default lifetime.
432 assert_eq!(effective_ttl(0), DEFAULT_TTL_SECS - REFRESH_SKEW);
433 // A tiny lifetime never collapses below 1.
434 assert_eq!(effective_ttl(10), 1);
435 }
436
437 #[test]
438 fn huge_expires_in_is_clamped_and_never_overflows() {
439 // A buggy/hostile IdP reporting a giant expires_in must not panic when
440 // the cache deadline is computed.
441 for expires_in in [u64::MAX, 1_000_000_000_000_000_000] {
442 assert_eq!(
443 effective_ttl(expires_in),
444 MAX_TTL_SECS,
445 "clamped to the max"
446 );
447 }
448 // Whatever the input, the ttl is bounded and the deadline the client
449 // computes never overflows (the bug this guards against).
450 for expires_in in [0, 10, 3600, MAX_TTL_SECS + 1, u64::MAX] {
451 let ttl = effective_ttl(expires_in);
452 assert!(ttl <= MAX_TTL_SECS);
453 let _deadline = std::time::Instant::now() + std::time::Duration::from_secs(ttl);
454 }
455 }
456
457 #[test]
458 fn debug_redacts_the_client_secret() {
459 let rendered = format!("{:?}", sample_config());
460 assert!(
461 !rendered.contains("TOP-SECRET-VALUE"),
462 "client_secret must never appear in Debug output: {rendered}"
463 );
464 assert!(rendered.contains("<redacted>"));
465 assert!(rendered.contains("my-client"));
466 }
467
468 /// The dedicated `client_secret` field is not the only way a secret gets
469 /// into this type. Providers that take client credentials as basic auth are
470 /// configured as `https://id:secret@idp/token`, and that URL was being
471 /// printed whole.
472 #[test]
473 fn debug_redacts_a_secret_carried_in_the_token_url() {
474 let config = OidcConfig::new(
475 "https://my-client:URL-EMBEDDED-SECRET@idp.example/realms/r/token",
476 "my-client",
477 "TOP-SECRET-VALUE",
478 );
479 let rendered = format!("{config:?}");
480 assert!(
481 !rendered.contains("URL-EMBEDDED-SECRET"),
482 "leaked via the token url: {rendered}"
483 );
484 assert!(
485 rendered.contains("idp.example"),
486 "should keep the provider host: {rendered}"
487 );
488 }
489
490 #[test]
491 fn provider_debug_does_not_leak_the_secret() {
492 let rendered = format!("{:?}", TokenProvider::new(sample_config()));
493 assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
494 }
495
496 #[test]
497 fn presets_build_the_expected_token_urls() {
498 assert_eq!(
499 OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
500 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
501 );
502 assert_eq!(
503 OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "c", "s").token_url,
504 "https://my.eu.auth0.com/oauth/token"
505 );
506 assert_eq!(
507 OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
508 "https://my.okta.com/oauth2/default/v1/token"
509 );
510 }
511
512 #[test]
513 fn with_scope_sets_the_scope() {
514 let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
515 assert_eq!(config.scope.as_deref(), Some("openid profile"));
516 }
517}