Skip to main content

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(&params);
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}