Skip to main content

daml_util/
canton_auth.rs

1//! JWT token builder for **Canton v2** participants.
2//!
3//! v0.2's `DamlSandboxTokenBuilder` was built around the v1
4//! `https://daml.com/ledger-api` custom claim shape; Canton v2
5//! switched to audience-scoped tokens whose contents follow the
6//! standard `aud`/`sub`/`scope` claim layout. This module is the
7//! v2 replacement.
8//!
9//! # Claim shape
10//!
11//! A Canton v2 token typically looks like:
12//!
13//! ```json
14//! {
15//!   "aud": "https://daml.com/jwt/aud/participant/<participant-id>",
16//!   "sub": "<user-id>",
17//!   "iss": "<issuer>",
18//!   "scope": "daml_ledger_api",
19//!   "exp": 1700000000,
20//!   "iat": 1700000000
21//! }
22//! ```
23//!
24//! * `aud` — the participant's expected audience URL (configured
25//!   on the participant side under `auth-services`). For dev
26//!   sandboxes this is often left wildcard or empty.
27//! * `sub` — the Daml ledger user-id the token authorises. The
28//!   user must already have been registered via
29//!   `UserManagementService.CreateUser` for the participant to
30//!   honour the token's `act_as` / `read_as` rights.
31//! * `scope` — must contain `daml_ledger_api` for ledger API
32//!   access. Multiple scopes are space-separated per RFC 6749.
33//! * `iss`, `iat`, `exp` — standard JWT timestamps.
34//!
35//! # Examples
36//!
37//! ```
38//! # use daml_util::DamlCantonTokenResult;
39//! # fn main() -> DamlCantonTokenResult<()> {
40//! use daml_util::DamlCantonTokenBuilder;
41//!
42//! let token = DamlCantonTokenBuilder::new_with_duration_secs(60)
43//!     .audience("https://daml.com/jwt/aud/participant/sandbox")
44//!     .subject("alice")
45//!     .scope("daml_ledger_api")
46//!     .new_hs256_unsafe_token("dev-shared-secret")?;
47//! # let _ = token;
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! The `_unsafe` suffix on `new_hs256_unsafe_token` is intentional:
53//! HS256 with a shared secret is fine for local sandboxes, but
54//! production deployments should use an asymmetric key (RS256 / ES256)
55//! managed by an OIDC IdP so the participant doesn't need to share
56//! signing material with token-minting code.
57
58use chrono::{Duration, Utc};
59use jsonwebtoken::{Algorithm, EncodingKey, Header};
60use serde::{Deserialize, Serialize};
61use thiserror::Error;
62
63#[derive(Debug, Error)]
64pub enum DamlCantonTokenError {
65    #[error("JWT signing failed: {0}")]
66    Jwt(#[from] jsonwebtoken::errors::Error),
67}
68
69pub type DamlCantonTokenResult<T> = std::result::Result<T, DamlCantonTokenError>;
70
71/// Standard-shaped JWT claims emitted by [`DamlCantonTokenBuilder`].
72///
73/// All fields are `Option<...>` except `exp` so absent values
74/// don't get serialised as empty strings (which the participant
75/// would reject as a claim-format violation).
76#[derive(Debug, Default, Clone, Serialize, Deserialize)]
77pub struct DamlCantonClaims {
78    /// Issuer URL — typically the OIDC `IdP`. Optional.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub iss: Option<String>,
81    /// Subject — the Daml user-id the token authorises.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub sub: Option<String>,
84    /// Audience — the participant's `aud` URL.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub aud: Option<String>,
87    /// Space-separated scope list; must contain `daml_ledger_api`.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub scope: Option<String>,
90    /// Issued-at (seconds since epoch).
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub iat: Option<i64>,
93    /// Expiry (seconds since epoch). Required by the participant.
94    pub exp: i64,
95}
96
97/// Build a Canton v2 JWT.
98///
99/// Configure the claims first via the builder setters, then sign
100/// with one of `new_hs256_unsafe_token`, `new_rs256_token`, or
101/// `new_es256_token`.
102#[derive(Debug, Clone)]
103pub struct DamlCantonTokenBuilder {
104    claims: DamlCantonClaims,
105}
106
107impl DamlCantonTokenBuilder {
108    /// Construct a builder with a TTL in seconds (relative to the
109    /// current wall clock). Sets `iat` and `exp` accordingly.
110    pub fn new_with_duration_secs(duration_secs: i64) -> Self {
111        let now = Utc::now();
112        let exp = (now + Duration::seconds(duration_secs)).timestamp();
113        Self {
114            claims: DamlCantonClaims {
115                iat: Some(now.timestamp()),
116                exp,
117                ..DamlCantonClaims::default()
118            },
119        }
120    }
121
122    /// Construct a builder with an absolute expiry (seconds since
123    /// epoch). Useful when minting a token that must align with an
124    /// external session boundary.
125    pub fn new_with_expiry(expiry_epoch_secs: i64) -> Self {
126        Self {
127            claims: DamlCantonClaims {
128                iat: Some(Utc::now().timestamp()),
129                exp: expiry_epoch_secs,
130                ..DamlCantonClaims::default()
131            },
132        }
133    }
134
135    pub fn issuer(mut self, iss: impl Into<String>) -> Self {
136        self.claims.iss = Some(iss.into());
137        self
138    }
139
140    pub fn subject(mut self, sub: impl Into<String>) -> Self {
141        self.claims.sub = Some(sub.into());
142        self
143    }
144
145    pub fn audience(mut self, aud: impl Into<String>) -> Self {
146        self.claims.aud = Some(aud.into());
147        self
148    }
149
150    pub fn scope(mut self, scope: impl Into<String>) -> Self {
151        self.claims.scope = Some(scope.into());
152        self
153    }
154
155    pub fn claims(&self) -> &DamlCantonClaims {
156        &self.claims
157    }
158
159    /// Sign with **HMAC-SHA256** using a shared secret.
160    ///
161    /// The `_unsafe` suffix is intentional: shared secrets are
162    /// fine for local sandbox testing but generally inappropriate
163    /// for production because every party that needs to mint a
164    /// token must also be able to forge anyone else's token.
165    pub fn new_hs256_unsafe_token(self, secret: impl AsRef<[u8]>) -> DamlCantonTokenResult<String> {
166        let header = Header::new(Algorithm::HS256);
167        let key = EncodingKey::from_secret(secret.as_ref());
168        Ok(jsonwebtoken::encode(&header, &self.claims, &key)?)
169    }
170
171    /// Sign with **RS256** using a PEM-encoded private RSA key.
172    pub fn new_rs256_token(self, pem: impl AsRef<[u8]>) -> DamlCantonTokenResult<String> {
173        let header = Header::new(Algorithm::RS256);
174        let key = EncodingKey::from_rsa_pem(pem.as_ref())?;
175        Ok(jsonwebtoken::encode(&header, &self.claims, &key)?)
176    }
177
178    /// Sign with **ES256** using a PEM-encoded private EC key.
179    pub fn new_es256_token(self, pem: impl AsRef<[u8]>) -> DamlCantonTokenResult<String> {
180        let header = Header::new(Algorithm::ES256);
181        let key = EncodingKey::from_ec_pem(pem.as_ref())?;
182        Ok(jsonwebtoken::encode(&header, &self.claims, &key)?)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use jsonwebtoken::{DecodingKey, Validation, decode};
190
191    #[test]
192    fn hs256_roundtrip() {
193        let secret = "test-secret";
194        let token = DamlCantonTokenBuilder::new_with_duration_secs(60)
195            .issuer("https://example.invalid")
196            .subject("alice")
197            .audience("https://daml.com/jwt/aud/participant/sandbox")
198            .scope("daml_ledger_api")
199            .new_hs256_unsafe_token(secret)
200            .expect("sign");
201        let mut validation = Validation::new(Algorithm::HS256);
202        validation.set_audience(&["https://daml.com/jwt/aud/participant/sandbox"]);
203        let decoded = decode::<DamlCantonClaims>(&token, &DecodingKey::from_secret(secret.as_ref()), &validation)
204            .expect("decode");
205        let claims = decoded.claims;
206        assert_eq!(claims.sub.as_deref(), Some("alice"));
207        assert_eq!(claims.scope.as_deref(), Some("daml_ledger_api"));
208        assert_eq!(claims.iss.as_deref(), Some("https://example.invalid"));
209    }
210
211    #[test]
212    fn expiry_is_in_the_future() {
213        let builder = DamlCantonTokenBuilder::new_with_duration_secs(60);
214        let now = Utc::now().timestamp();
215        assert!(builder.claims().exp >= now);
216        assert!(builder.claims().exp <= now + 60 + 1);
217    }
218}