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