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}
78
79impl OidcConfig {
80    /// Create a client-credentials configuration (no scope).
81    #[must_use]
82    pub fn new(
83        token_url: impl Into<String>,
84        client_id: impl Into<String>,
85        client_secret: impl Into<String>,
86    ) -> Self {
87        Self {
88            token_url: token_url.into(),
89            client_id: client_id.into(),
90            client_secret: client_secret.into(),
91            scope: None,
92        }
93    }
94
95    /// Set the OAuth2 scope.
96    #[must_use]
97    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
98        self.scope = Some(scope.into());
99        self
100    }
101
102    /// The OAuth2 token endpoint URL.
103    #[must_use]
104    pub fn token_url(&self) -> &str {
105        &self.token_url
106    }
107
108    /// The OAuth2 client id.
109    #[must_use]
110    pub fn client_id(&self) -> &str {
111        &self.client_id
112    }
113
114    /// Preset for **Keycloak** (also the Canton LocalNet IdP): builds the
115    /// `{base_url}/realms/{realm}/protocol/openid-connect/token` endpoint.
116    #[must_use]
117    pub fn keycloak(
118        base_url: impl AsRef<str>,
119        realm: impl AsRef<str>,
120        client_id: impl Into<String>,
121        client_secret: impl Into<String>,
122    ) -> Self {
123        let base = base_url.as_ref().trim_end_matches('/');
124        Self::new(
125            format!(
126                "{base}/realms/{}/protocol/openid-connect/token",
127                realm.as_ref()
128            ),
129            client_id,
130            client_secret,
131        )
132    }
133
134    /// Preset for **Auth0**: builds the `https://{domain}/oauth/token` endpoint.
135    #[must_use]
136    pub fn auth0(
137        domain: 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}/oauth/token"),
144            client_id,
145            client_secret,
146        )
147    }
148
149    /// Preset for **Okta**: builds the
150    /// `https://{domain}/oauth2/{auth_server}/v1/token` endpoint (use
151    /// `"default"` for the default authorization server).
152    #[must_use]
153    pub fn okta(
154        domain: impl AsRef<str>,
155        auth_server: impl AsRef<str>,
156        client_id: impl Into<String>,
157        client_secret: impl Into<String>,
158    ) -> Self {
159        let domain = domain.as_ref().trim_end_matches('/');
160        Self::new(
161            format!("https://{domain}/oauth2/{}/v1/token", auth_server.as_ref()),
162            client_id,
163            client_secret,
164        )
165    }
166}
167
168impl fmt::Debug for OidcConfig {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.debug_struct("OidcConfig")
171            .field("token_url", &self.token_url)
172            .field("client_id", &self.client_id)
173            .field("client_secret", &"<redacted>")
174            .field("scope", &self.scope)
175            .finish()
176    }
177}
178
179#[derive(Debug, Deserialize)]
180struct TokenResponse {
181    access_token: String,
182    #[serde(default)]
183    expires_in: u64,
184}
185
186struct Cached {
187    token: String,
188    deadline: Instant,
189}
190
191/// Fetches and caches an OAuth2 bearer token via the client-credentials grant,
192/// refreshing it shortly before expiry. Cloning shares the cache.
193#[derive(Clone)]
194pub struct TokenProvider {
195    inner: Arc<Inner>,
196}
197
198struct Inner {
199    config: OidcConfig,
200    http: reqwest::Client,
201    cache: Mutex<Option<Cached>>,
202}
203
204impl fmt::Debug for TokenProvider {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.debug_struct("TokenProvider")
207            .field("config", &self.inner.config)
208            .finish_non_exhaustive()
209    }
210}
211
212impl TokenProvider {
213    /// Create a provider for the given OIDC configuration.
214    ///
215    /// Token fetches are bounded by a per-request timeout (see
216    /// [`Self::token`]), so a hung token endpoint can never block callers
217    /// indefinitely (the fetch holds the cache lock while in flight).
218    #[must_use]
219    pub fn new(config: OidcConfig) -> Self {
220        // The per-request timeout in `fetch` is the guarantee; the client-level
221        // timeout here is belt-and-braces (a bare builder with a timeout does
222        // not fail in practice, but the fallback stays bounded either way).
223        let http = reqwest::Client::builder()
224            .timeout(FETCH_TIMEOUT)
225            .build()
226            .unwrap_or_else(|_| reqwest::Client::new());
227        Self {
228            inner: Arc::new(Inner {
229                config,
230                http,
231                cache: Mutex::new(None),
232            }),
233        }
234    }
235
236    /// Drop the cached token so the next [`Self::token`] call fetches a fresh
237    /// one. Call this after a server rejects a token as expired/invalid so the
238    /// client can self-heal instead of replaying the stale token.
239    pub async fn invalidate(&self) {
240        *self.inner.cache.lock().await = None;
241    }
242
243    /// Return a valid bearer token, fetching or refreshing if the cached one is
244    /// absent or within the refresh-skew window of expiry.
245    ///
246    /// Concurrent callers that arrive during a refresh serialize behind a single
247    /// in-flight fetch (single-flight de-duplication), bounded by the HTTP
248    /// client's timeout — a slow IdP therefore stalls concurrent token consumers
249    /// for at most that timeout rather than triggering a fetch storm.
250    ///
251    /// # Errors
252    /// Returns [`Error::Auth`] if the endpoint rejects the credentials
253    /// (401/403, e.g. `invalid_client`); [`Error::Http`] for other non-success
254    /// statuses (5xx/429 stay retriable); [`Error::Connection`] if the endpoint
255    /// is unreachable or the fetch times out; [`Error::Json`] if the response
256    /// cannot be parsed.
257    pub async fn token(&self) -> Result<String> {
258        let mut guard = self.inner.cache.lock().await;
259        if let Some(cached) = guard.as_ref()
260            && Instant::now() < cached.deadline
261        {
262            return Ok(cached.token.clone());
263        }
264
265        let response = self.fetch().await?;
266        let ttl = effective_ttl(response.expires_in);
267        let token = response.access_token;
268        *guard = Some(Cached {
269            token: token.clone(),
270            deadline: Instant::now() + Duration::from_secs(ttl),
271        });
272        Ok(token)
273    }
274
275    async fn fetch(&self) -> Result<TokenResponse> {
276        let config = &self.inner.config;
277        let mut params = vec![
278            ("grant_type", "client_credentials"),
279            ("client_id", config.client_id.as_str()),
280            ("client_secret", config.client_secret.as_str()),
281        ];
282        if let Some(scope) = &config.scope {
283            params.push(("scope", scope.as_str()));
284        }
285
286        // A send failure means the IdP was unreachable — retriable transport,
287        // not a credential rejection. The per-request timeout bounds the fetch
288        // even if the client was built without one.
289        let response = self
290            .inner
291            .http
292            .post(&config.token_url)
293            .timeout(FETCH_TIMEOUT)
294            .form(&params)
295            .send()
296            .await
297            .map_err(|e| {
298                Error::Connection(format!("token request to {} failed: {e}", config.token_url))
299            })?;
300
301        // A credential rejection (401/403, e.g. `invalid_client`) is a definite
302        // auth failure; other non-success statuses keep their code so 5xx/429
303        // stay retriable via the shared error model.
304        if !response.status().is_success() {
305            let status = response.status().as_u16();
306            let body = response.text().await.unwrap_or_default();
307            if matches!(status, 401 | 403) {
308                return Err(Error::Auth(format!(
309                    "token endpoint rejected the credentials (http {status}): {body}"
310                )));
311            }
312            return Err(Error::Http { status, body });
313        }
314
315        let body = response
316            .text()
317            .await
318            .map_err(|e| Error::Connection(format!("reading token response failed: {e}")))?;
319        serde_json::from_str::<TokenResponse>(&body).map_err(Error::from)
320    }
321}
322
323/// Lets a [`TokenProvider`] back the SDK's shared [`canton_core::Auth`] without
324/// `canton-core` depending on this crate: `Config::with_oidc(provider)` stores
325/// it as an `Arc<dyn TokenSource>`.
326impl TokenSource for TokenProvider {
327    fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>> {
328        Box::pin(async move { self.token().await.map(Some) })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn sample_config() -> OidcConfig {
337        OidcConfig::new("http://idp.example/token", "my-client", "TOP-SECRET-VALUE")
338    }
339
340    #[test]
341    fn effective_ttl_subtracts_skew_and_clamps() {
342        assert_eq!(effective_ttl(3600), 3600 - REFRESH_SKEW);
343        // Missing/zero expires_in uses the default lifetime.
344        assert_eq!(effective_ttl(0), DEFAULT_TTL_SECS - REFRESH_SKEW);
345        // A tiny lifetime never collapses below 1.
346        assert_eq!(effective_ttl(10), 1);
347    }
348
349    #[test]
350    fn huge_expires_in_is_clamped_and_never_overflows() {
351        // A buggy/hostile IdP reporting a giant expires_in must not panic when
352        // the cache deadline is computed.
353        for expires_in in [u64::MAX, 1_000_000_000_000_000_000] {
354            assert_eq!(
355                effective_ttl(expires_in),
356                MAX_TTL_SECS,
357                "clamped to the max"
358            );
359        }
360        // Whatever the input, the ttl is bounded and the deadline the client
361        // computes never overflows (the bug this guards against).
362        for expires_in in [0, 10, 3600, MAX_TTL_SECS + 1, u64::MAX] {
363            let ttl = effective_ttl(expires_in);
364            assert!(ttl <= MAX_TTL_SECS);
365            let _deadline = std::time::Instant::now() + std::time::Duration::from_secs(ttl);
366        }
367    }
368
369    #[test]
370    fn debug_redacts_the_client_secret() {
371        let rendered = format!("{:?}", sample_config());
372        assert!(
373            !rendered.contains("TOP-SECRET-VALUE"),
374            "client_secret must never appear in Debug output: {rendered}"
375        );
376        assert!(rendered.contains("<redacted>"));
377        assert!(rendered.contains("my-client"));
378    }
379
380    #[test]
381    fn provider_debug_does_not_leak_the_secret() {
382        let rendered = format!("{:?}", TokenProvider::new(sample_config()));
383        assert!(!rendered.contains("TOP-SECRET-VALUE"), "{rendered}");
384    }
385
386    #[test]
387    fn presets_build_the_expected_token_urls() {
388        assert_eq!(
389            OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", "s").token_url,
390            "http://kc:8082/realms/AppProvider/protocol/openid-connect/token"
391        );
392        assert_eq!(
393            OidcConfig::auth0("my.eu.auth0.com", "c", "s").token_url,
394            "https://my.eu.auth0.com/oauth/token"
395        );
396        assert_eq!(
397            OidcConfig::okta("my.okta.com", "default", "c", "s").token_url,
398            "https://my.okta.com/oauth2/default/v1/token"
399        );
400    }
401
402    #[test]
403    fn with_scope_sets_the_scope() {
404        let config = OidcConfig::new("http://idp/token", "c", "s").with_scope("openid profile");
405        assert_eq!(config.scope.as_deref(), Some("openid profile"));
406    }
407}