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}