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