daml_util/
sandbox_auth.rs

1use chrono::Utc;
2use itertools::Itertools;
3use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// Build JWT tokens suitable for use in the Daml Sandbox.
8///
9/// The Daml Sandbox support the use JWT tokens for authentication.  The following JSON structure represents the claims
10/// that may be supplied (see [here](https://docs.daml.com/tools/sandbox.html#running-with-authentication) for details):
11///
12/// ```json
13/// {
14///   "https://daml.com/ledger-api": {
15///     "ledgerId": "my-ledger",
16///     "participantId": null,
17///     "applicationId": null,
18///     "admin": true,
19///     "actAs": ["Alice"],
20///     "readAs": ["Alice", "Bob"]
21///   },
22///   "exp": 1300819380,
23/// }
24/// ```
25///
26/// All ledger API endpoints support passing a `Bearer` token in the `authentication` http header.  This builder
27/// produces bearer token strings in `HS256`, `RS256` & `EC256` formats which are suitable for use by the Daml ledger
28/// API.
29///
30/// Note that test JWT tokens created with [https://jwt.io/](https://jwt.io/) will, by default, place the `alg` attribute ahead of
31/// the `typ` attribute in the header whereas the library used here will places them the opposite wa around.  Whilst
32/// both produce valid tokens this can be confusing when trying to compare examples.
33///
34/// # Examples
35///
36/// A `HS256` (shared secret) bearer token matching the example above can be created as follows:
37///
38/// ```
39/// # use daml_util::DamlSandboxAuthResult;
40/// # fn main() -> DamlSandboxAuthResult<()> {
41/// use daml_util::DamlSandboxTokenBuilder;
42///
43/// let token = DamlSandboxTokenBuilder::new_with_expiry(1300819380)
44///     .ledger_id("my-ledger")
45///     .admin(true)
46///     .act_as(vec!["Alice".to_owned()])
47///     .read_as(vec!["Alice".to_owned(), "Bob".to_owned()])
48///     .new_hs256_unsafe_token("some secret phrase")?;
49/// # Ok(())
50/// # }
51/// ```
52///
53/// The generated token can then supplied to the [`DamlGrpcClientBuilder`] via the `with_auth` method as follows:
54///
55/// ```no_run
56/// # use std::error::Error;
57/// # #[tokio::main]
58/// # async fn main() -> Result<(), Box<dyn Error>> {
59/// use daml_grpc::DamlGrpcClientBuilder;
60/// use daml_util::DamlSandboxTokenBuilder;
61///
62/// let token = DamlSandboxTokenBuilder::new_with_expiry(1300819380)
63///     .ledger_id("my-ledger")
64///     .admin(true)
65///     .act_as(vec!["Alice".to_owned()])
66///     .read_as(vec!["Alice".to_owned(), "Bob".to_owned()])
67///     .new_ec256_token("... EC256 key in bytes ...")?;
68///
69/// let ledger_client = DamlGrpcClientBuilder::uri("http://localhost:8080").with_auth(token).connect().await?;
70/// # Ok(())
71/// # }
72/// ```
73///
74/// [`DamlGrpcClientBuilder`]: daml-grpc::DamlGrpcClientBuilder
75#[derive(Default, Clone)]
76pub struct DamlSandboxTokenBuilder {
77    ledger_id: Option<String>,
78    participant_id: Option<String>,
79    application_id: Option<String>,
80    admin: bool,
81    act_as: Vec<String>,
82    read_as: Vec<String>,
83    expiry: i64,
84}
85
86impl DamlSandboxTokenBuilder {
87    /// Create with an expiry relative to the current system time.
88    pub fn new_with_duration_secs(secs: i64) -> Self {
89        Self {
90            expiry: Utc::now().timestamp() + secs,
91            ..Self::default()
92        }
93    }
94
95    /// Create with an absolute expiry timestamp (unix).
96    pub fn new_with_expiry(timestamp: i64) -> Self {
97        Self {
98            expiry: timestamp,
99            ..Self::default()
100        }
101    }
102
103    /// DOCME
104    pub fn ledger_id(self, ledger_id: impl Into<String>) -> Self {
105        Self {
106            ledger_id: Some(ledger_id.into()),
107            ..self
108        }
109    }
110
111    /// DOCME
112    pub fn participant_id(self, participant_id: impl Into<String>) -> Self {
113        Self {
114            participant_id: Some(participant_id.into()),
115            ..self
116        }
117    }
118
119    /// DOCME
120    pub fn application_id(self, application_id: impl Into<String>) -> Self {
121        Self {
122            application_id: Some(application_id.into()),
123            ..self
124        }
125    }
126
127    /// DOCME
128    pub fn admin(self, admin: bool) -> Self {
129        Self {
130            admin,
131            ..self
132        }
133    }
134
135    /// DOCME
136    pub fn act_as(self, act_as: Vec<String>) -> Self {
137        Self {
138            act_as,
139            ..self
140        }
141    }
142
143    /// DOCME
144    pub fn read_as(self, read_as: Vec<String>) -> Self {
145        Self {
146            read_as,
147            ..self
148        }
149    }
150
151    /// Create a new HS256 JWT token based on a shared secret.
152    ///
153    /// This approach is considered unsafe for production use and should be used for local testing only.  Note that
154    /// whilst the method name contains the word `unsafe` to highlight the above, the method does not contain any
155    /// `unsafe` blocks or call any `unsafe` methods.
156    pub fn new_hs256_unsafe_token(self, secret: impl AsRef<[u8]>) -> DamlSandboxAuthResult<String> {
157        let encoding_key = &EncodingKey::from_secret(secret.as_ref());
158        self.generate_token(Algorithm::HS256, encoding_key)
159    }
160
161    /// Create a new RS256 JWT token based on the supplied RSA key.
162    ///
163    /// The key is expected to be in `pem` format.
164    pub fn new_rs256_token(self, rsa_pem: impl AsRef<[u8]>) -> DamlSandboxAuthResult<String> {
165        let encoding_key = &EncodingKey::from_rsa_pem(rsa_pem.as_ref())?;
166        self.generate_token(Algorithm::RS256, encoding_key)
167    }
168
169    /// Create a new EC256 JWT token based on the supplied RSA key.
170    ///
171    /// The key is expected to be in `pem` format.
172    pub fn new_ec256_token(self, ec_pem: impl AsRef<[u8]>) -> DamlSandboxAuthResult<String> {
173        let encoding_key = &EncodingKey::from_ec_pem(ec_pem.as_ref())?;
174        self.generate_token(Algorithm::ES256, encoding_key)
175    }
176
177    /// Render the token claims as a JSON string.
178    pub fn claims_json(&self) -> DamlSandboxAuthResult<String> {
179        Ok(serde_json::to_string(&(*self).clone().into_token())?)
180    }
181
182    fn generate_token(self, algorithm: Algorithm, encoding_key: &EncodingKey) -> DamlSandboxAuthResult<String> {
183        Ok(jsonwebtoken::encode(&Header::new(algorithm), &self.into_token(), encoding_key)?)
184    }
185
186    fn into_token(self) -> DamlSandboxAuthToken {
187        DamlSandboxAuthToken {
188            details: DamlSandboxAuthDetails {
189                ledger_id: self.ledger_id,
190                participant_id: self.participant_id,
191                application_id: self.application_id,
192                admin: self.admin,
193                act_as: self.act_as,
194                read_as: self.read_as,
195            },
196            exp: self.expiry,
197        }
198    }
199}
200
201/// Daml Sandbox Auth Result.
202pub type DamlSandboxAuthResult<T> = Result<T, DamlSandboxAuthError>;
203
204/// Daml Sandbox Auth Error.
205#[derive(Error, Debug)]
206pub enum DamlSandboxAuthError {
207    #[error("failed to create JSON Web Token: {0}")]
208    JsonWebTokenError(#[from] jsonwebtoken::errors::Error),
209    #[error("failed to serialize JSON Web Token: {0}")]
210    JsonSerializeError(#[from] serde_json::error::Error),
211    #[error("unsupported algorithm")]
212    UnsupportedAlgorithm,
213}
214
215/// A opaque Daml sandbox authentication token.
216#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
217pub struct DamlSandboxAuthToken {
218    #[serde(rename = "https://daml.com/ledger-api")]
219    details: DamlSandboxAuthDetails,
220    exp: i64,
221}
222
223impl DamlSandboxAuthToken {
224    /// Parse and validate a [`DamlSandboxAuthToken`] from a JWT token string.
225    pub fn parse_jwt(token: &str, key: impl AsRef<[u8]>) -> DamlSandboxAuthResult<Self> {
226        let algorithm = jsonwebtoken::decode_header(token)?.alg;
227        let decoding_key = match algorithm {
228            Algorithm::ES256 => DecodingKey::from_ec_pem(key.as_ref())?,
229            Algorithm::RS256 => DecodingKey::from_rsa_pem(key.as_ref())?,
230            Algorithm::HS256 => DecodingKey::from_secret(key.as_ref()),
231            _ => return Err(DamlSandboxAuthError::UnsupportedAlgorithm),
232        };
233        Ok(jsonwebtoken::decode::<Self>(token, &decoding_key, &Validation::new(algorithm))?.claims)
234    }
235
236    /// Parse a [`DamlSandboxAuthToken`] from a JWT token string (without validating).
237    pub fn parse_jwt_no_validation(token: &str) -> DamlSandboxAuthResult<Self> {
238        let algorithm = jsonwebtoken::decode_header(token)?.alg;
239        let mut validation = Validation::new(algorithm);
240        validation.insecure_disable_signature_validation();
241        Ok(jsonwebtoken::decode::<Self>(token, &DecodingKey::from_secret(&[]), &validation)?.claims)
242    }
243
244    /// The token expiry time (unix timestamp).
245    pub fn expiry(&self) -> i64 {
246        self.exp
247    }
248
249    /// The ledger id for which this token was issued.
250    pub fn ledger_id(&self) -> Option<&str> {
251        self.details.ledger_id.as_deref()
252    }
253
254    /// The participant id for which this token was issued.
255    pub fn participant_id(&self) -> Option<&str> {
256        self.details.participant_id.as_deref()
257    }
258
259    /// The application id for which this token was issued.
260    pub fn application_id(&self) -> Option<&str> {
261        self.details.application_id.as_deref()
262    }
263
264    /// Whether this token has admin privilege.
265    pub fn admin(&self) -> bool {
266        self.details.admin
267    }
268
269    /// The parties the bearer of this token claims to read & execute on behalf of.
270    pub fn act_as(&self) -> &[String] {
271        self.details.act_as.as_slice()
272    }
273
274    /// The parties the bearer of this token claims to read data on behalf of.
275    pub fn read_as(&self) -> &[String] {
276        self.details.read_as.as_slice()
277    }
278
279    /// The distinct parties which claim read or execute permissions in the token.
280    pub fn parties(&self) -> impl Iterator<Item = &str> {
281        self.details.read_as.iter().chain(self.details.act_as.iter()).map(AsRef::as_ref).unique()
282    }
283
284    /// Return the single party with read or execute permissions otherwise None.
285    pub fn single_party(&self) -> Option<&str> {
286        match (self.details.act_as.as_slice(), self.details.read_as.as_slice()) {
287            ([p], []) | ([], [p]) => Some(p),
288            ([p1], [p2]) if p1 == p2 => Some(p1),
289            _ => None,
290        }
291    }
292}
293
294#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
295#[serde(rename_all = "camelCase")]
296struct DamlSandboxAuthDetails {
297    ledger_id: Option<String>,
298    participant_id: Option<String>,
299    application_id: Option<String>,
300    admin: bool,
301    act_as: Vec<String>,
302    read_as: Vec<String>,
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{DamlSandboxAuthDetails, DamlSandboxAuthResult, DamlSandboxAuthToken, DamlSandboxTokenBuilder};
308    use jsonwebtoken::{encode, EncodingKey, Header};
309
310    #[test]
311    fn test_serialise() {
312        let token = DamlSandboxAuthToken {
313            details: DamlSandboxAuthDetails {
314                ledger_id: Some("test-sandbox".to_owned()),
315                participant_id: None,
316                application_id: None,
317                admin: true,
318                act_as: vec!["Alice".to_owned(), "Bob".to_owned()],
319                read_as: vec!["Alice".to_owned(), "Bob".to_owned()],
320            },
321            exp: 1_804_287_349,
322        };
323        let serialized = serde_json::to_string(&token).unwrap();
324        assert_eq!(
325            r#"{"https://daml.com/ledger-api":{"ledgerId":"test-sandbox","participantId":null,"applicationId":null,"admin":true,"actAs":["Alice","Bob"],"readAs":["Alice","Bob"]},"exp":1804287349}"#,
326            serialized
327        );
328    }
329
330    #[test]
331    fn test_encode_with_secret() {
332        let token = DamlSandboxAuthToken {
333            details: DamlSandboxAuthDetails {
334                ledger_id: Some("sandbox".to_owned()),
335                participant_id: None,
336                application_id: None,
337                admin: true,
338                act_as: vec!["Alice".to_owned(), "Bob".to_owned()],
339                read_as: vec!["Alice".to_owned(), "Bob".to_owned()],
340            },
341            exp: 1_804_287_349,
342        };
343        let token_str =
344            encode(&Header::default(), &token, &EncodingKey::from_secret("testsecret".as_ref())).expect("token");
345        assert_eq!(
346            r#"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJodHRwczovL2RhbWwuY29tL2xlZGdlci1hcGkiOnsibGVkZ2VySWQiOiJzYW5kYm94IiwicGFydGljaXBhbnRJZCI6bnVsbCwiYXBwbGljYXRpb25JZCI6bnVsbCwiYWRtaW4iOnRydWUsImFjdEFzIjpbIkFsaWNlIiwiQm9iIl0sInJlYWRBcyI6WyJBbGljZSIsIkJvYiJdfSwiZXhwIjoxODA0Mjg3MzQ5fQ.Y5hlJvK7h_9rancE_iO_3tGKWl8xsFVNLPJw9iNBreY"#,
347            token_str
348        );
349    }
350
351    #[test]
352    fn test_builder_with_secret() -> DamlSandboxAuthResult<()> {
353        let token_str = DamlSandboxTokenBuilder::new_with_expiry(1_804_287_349)
354            .ledger_id("sandbox")
355            .admin(true)
356            .act_as(vec!["Alice".to_owned(), "Bob".to_owned()])
357            .read_as(vec!["Alice".to_owned(), "Bob".to_owned()])
358            .new_hs256_unsafe_token("testsecret")?;
359        assert_eq!(
360            r#"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJodHRwczovL2RhbWwuY29tL2xlZGdlci1hcGkiOnsibGVkZ2VySWQiOiJzYW5kYm94IiwicGFydGljaXBhbnRJZCI6bnVsbCwiYXBwbGljYXRpb25JZCI6bnVsbCwiYWRtaW4iOnRydWUsImFjdEFzIjpbIkFsaWNlIiwiQm9iIl0sInJlYWRBcyI6WyJBbGljZSIsIkJvYiJdfSwiZXhwIjoxODA0Mjg3MzQ5fQ.Y5hlJvK7h_9rancE_iO_3tGKWl8xsFVNLPJw9iNBreY"#,
361            token_str
362        );
363        Ok(())
364    }
365
366    #[test]
367    fn test_decode_no_validation() -> DamlSandboxAuthResult<()> {
368        let jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJodHRwczovL2RhbWwuY29tL2xlZGdlci1hcGkiOnsibGVkZ2VySWQiOiJ3YWxsY2xvY2stdW5zZWN1cmVkLXNhbmRib3giLCJwYXJ0aWNpcGFudElkIjoiQWxpY2UiLCJhcHBsaWNhdGlvbklkIjoiZGVtbyIsImFkbWluIjpmYWxzZSwiYWN0QXMiOlsiQWxpY2UiXSwicmVhZEFzIjpbXX0sImV4cCI6MTgwNDI4NzM0OX0.dlJ0dxeOwEYfAmuuKngRNsibci-w0TSdn1NZRmFjHT9aoW8wsAeuYuLXjtx7e6oQaT-m_rlJqgDdmfTXHhE_t9LkngtpgcG8g0h7sCEq7O-SYGiB1B1jzTX2ZO0QHp6Xdes7QkVnyMn2vwaDv8KWAurchGOJUwDVpgU7k2JKpnFh1ui-AMf0rmP7yu7rSZchD-NTg_1_-RL0rgbwzmWJWL81n2zz213yQW5w_dqhitueFeluyppuZgzNQfni8jtdZF32trHwocg8C6zI9DdqmJSl-TsykQPV8z5wLSOSKCCFwnecEZ0QvZSxEWycNAQvNJTAMiKFcagiYGEeIDc4yQ";
369        let decoded = DamlSandboxAuthToken::parse_jwt_no_validation(jwt_token)?;
370        let token = DamlSandboxAuthToken {
371            details: DamlSandboxAuthDetails {
372                ledger_id: Some("wallclock-unsecured-sandbox".to_owned()),
373                participant_id: Some("Alice".to_owned()),
374                application_id: Some("demo".to_owned()),
375                admin: false,
376                act_as: vec!["Alice".to_owned()],
377                read_as: vec![],
378            },
379            exp: 1_804_287_349,
380        };
381        assert_eq!(decoded, token);
382        Ok(())
383    }
384
385    #[test]
386    fn test_decode() -> DamlSandboxAuthResult<()> {
387        let jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJodHRwczovL2RhbWwuY29tL2xlZGdlci1hcGkiOnsibGVkZ2VySWQiOiJzYW5kYm94IiwicGFydGljaXBhbnRJZCI6bnVsbCwiYXBwbGljYXRpb25JZCI6bnVsbCwiYWRtaW4iOnRydWUsImFjdEFzIjpbIkFsaWNlIiwiQm9iIl0sInJlYWRBcyI6WyJBbGljZSIsIkJvYiJdfSwiZXhwIjoxODA0Mjg3MzQ5fQ.Y5hlJvK7h_9rancE_iO_3tGKWl8xsFVNLPJw9iNBreY";
388        let decoded = DamlSandboxAuthToken::parse_jwt(jwt_token, "testsecret")?;
389        let token = DamlSandboxAuthToken {
390            details: DamlSandboxAuthDetails {
391                ledger_id: Some("sandbox".to_owned()),
392                participant_id: None,
393                application_id: None,
394                admin: true,
395                act_as: vec!["Alice".to_owned(), "Bob".to_owned()],
396                read_as: vec!["Alice".to_owned(), "Bob".to_owned()],
397            },
398            exp: 1_804_287_349,
399        };
400        assert_eq!(decoded, token);
401        Ok(())
402    }
403
404    #[test]
405    fn test_parties() -> DamlSandboxAuthResult<()> {
406        let jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJodHRwczovL2RhbWwuY29tL2xlZGdlci1hcGkiOnsibGVkZ2VySWQiOiJzYW5kYm94LXN0YXRpYyIsInBhcnRpY2lwYW50SWQiOm51bGwsImFwcGxpY2F0aW9uSWQiOm51bGwsImFkbWluIjpmYWxzZSwiYWN0QXMiOlsiQWxpY2UiLCJCb2IiXSwicmVhZEFzIjpbXX0sImV4cCI6MTgwNDI4NzM0OX0.EnjK8is1g0I8BGVu1ZPgSSFRW0WKEGcwdIBLiPPQmo_xcMngu_KzOKADezRJap6B_10IMwRn95b9A3vpBT_E8fZQ95BTMbL8yaODrSjus6feLuKxPhZMy0UgPZjReuPu2x1BsjNWZvl5UXGNz8NMs21X7Uh4fEk5ehdLqctiTzsrjUjVCz-KJSjsJafU-F0VnJJgvb3A2QQprfDg5L7_-yv7HsEZxJov-nJ29ycsYfPfQ1JlwetNoBgCPA2C3QZLusvHhGGJPuot2cw1JG43VxpOTYc9slqSWuC5gZhGDAOEEsslb0LeQU_JjLh4JjFT4iROEyj9ARdqD7tCxm0h2A";
407        let token = DamlSandboxAuthToken::parse_jwt_no_validation(jwt_token)?;
408        assert_eq!(token.parties().collect::<Vec<_>>(), vec!["Alice", "Bob"]);
409        Ok(())
410    }
411}