privy_rs/
utils.rs

1use base64::{Engine, engine::general_purpose::STANDARD};
2use futures::TryStreamExt;
3use serde::Serialize;
4
5use crate::{AuthorizationContext, SignatureGenerationError};
6
7/// A convenience wrapper used as a namespace for utility functions
8pub struct Utils {
9    pub(crate) app_id: String,
10}
11/// A convenience wrapper used as a namespace for utility functions
12pub struct RequestSigner {
13    app_id: String,
14}
15/// A convenience wrapper used as a namespace for utility functions
16pub struct RequestFormatter {
17    app_id: String,
18}
19
20impl Utils {
21    /// Returns a new [`RequestSigner`] instance
22    pub fn signer(&self) -> RequestSigner {
23        RequestSigner {
24            app_id: self.app_id.clone(),
25        }
26    }
27
28    /// Returns a new [`RequestFormatter`] instance
29    pub fn formatter(&self) -> RequestFormatter {
30        RequestFormatter {
31            app_id: self.app_id.clone(),
32        }
33    }
34}
35
36impl RequestFormatter {
37    pub async fn build_canonical_request<S: Serialize>(
38        &self,
39        method: Method,
40        url: String,
41        body: S,
42        idempotency_key: Option<String>,
43    ) -> Result<String, serde_json::Error> {
44        format_request_for_authorization_signature(&self.app_id, method, url, body, idempotency_key)
45    }
46}
47
48impl RequestSigner {
49    pub async fn sign_canonical_request<S: Serialize>(
50        &self,
51        ctx: &AuthorizationContext,
52        method: Method,
53        url: String,
54        body: S,
55        idempotency_key: Option<String>,
56    ) -> Result<String, SignatureGenerationError> {
57        generate_authorization_signatures(ctx, &self.app_id, method, url, body, idempotency_key)
58            .await
59    }
60}
61
62/// Create canonical request data for signing
63///
64/// # Errors
65/// This can fail if JSON serialization fails
66pub fn format_request_for_authorization_signature<S: Serialize>(
67    app_id: &str,
68    method: Method,
69    url: String,
70    body: S,
71    idempotency_key: Option<String>,
72) -> Result<String, serde_json::Error> {
73    let mut headers = serde_json::Map::new();
74    headers.insert(
75        "privy-app-id".into(),
76        serde_json::Value::String(app_id.to_owned()),
77    );
78    if let Some(key) = idempotency_key {
79        headers.insert(
80            "privy-idempotency-key".to_string(),
81            serde_json::Value::String(key),
82        );
83    }
84
85    WalletApiRequestSignatureInput::new(method, url)
86        .headers(serde_json::Value::Object(headers))
87        .body(body)
88        .canonicalize()
89}
90
91/// Generates an authorization signature for a given request
92///
93/// # Arguments
94/// * `ctx` - The [`AuthorizationContext`] to use for signing
95/// * `app_id` - The application ID to use for signing
96/// * `method` - The HTTP method to use for the request
97/// * `url` - The URL to use for the request
98/// * `body` - The body of the request
99/// * `idempotency_key` - The idempotency key to use for the request
100///
101/// # Returns
102/// A `Result` containing the generated signature or an error if the signature could not be generated
103///
104/// # Errors
105/// This function will return an error if the signature could not be generated, whether
106/// it be due to a serialization error or base64 encoding error.
107pub async fn generate_authorization_signatures<S: Serialize>(
108    ctx: &AuthorizationContext,
109    app_id: &str,
110    method: Method,
111    url: String,
112    body: S,
113    idempotency_key: Option<String>,
114) -> Result<String, SignatureGenerationError> {
115    let canonical =
116        format_request_for_authorization_signature(app_id, method, url, body, idempotency_key)?;
117
118    tracing::debug!("canonical request data: {}", canonical);
119
120    Ok(ctx
121        .sign(canonical.as_bytes())
122        .map_ok(|s| {
123            let der_bytes = s.to_der();
124            STANDARD.encode(&der_bytes)
125        })
126        .try_collect::<Vec<_>>()
127        .await?
128        .join(","))
129}
130
131/// The HTTP method used in the request.
132///
133/// Note that `GET` requests do not need
134/// signatures by definition.
135#[derive(serde::Serialize, Debug)]
136pub enum Method {
137    /// `PATCH` requests are used to update an existing resource.
138    PATCH,
139    /// `POST` requests are used to create a new resource.
140    POST,
141    /// `PUT` requests are used to update an existing resource.
142    PUT,
143    /// `GET` requests are used to retrieve an existing resource.
144    DELETE,
145}
146
147/// The wallet API request signature input is used
148/// during the signing process as a canonical representation
149/// of the request. Ensure that you serialize this struct
150/// with the `serde_json_canonicalizer` to get the appropriate
151/// RFC-8785 canonicalized string. For more information, see
152/// <https://datatracker.ietf.org/doc/html/rfc8785>
153///
154/// Note: Version is currently hardcoded to 1.
155#[derive(serde::Serialize)]
156pub struct WalletApiRequestSignatureInput<S: Serialize> {
157    version: u32,
158    method: Method,
159    url: String,
160    body: Option<S>,
161    headers: Option<serde_json::Value>,
162}
163
164impl<S: Serialize> WalletApiRequestSignatureInput<S> {
165    /// Create a new request builder.
166    #[must_use]
167    pub fn new(method: Method, url: String) -> Self {
168        Self {
169            version: 1,
170            method,
171            url,
172            body: None,
173            headers: None,
174        }
175    }
176
177    /// Set the request body.
178    #[must_use]
179    pub fn body(mut self, body: S) -> Self {
180        self.body = Some(body);
181        self
182    }
183
184    /// Set the request headers.
185    #[must_use]
186    pub fn headers(mut self, headers: serde_json::Value) -> Self {
187        self.headers = Some(headers);
188        self
189    }
190
191    /// Canonicalize the request body.
192    ///
193    /// # Errors
194    /// Returns an error if the serialization fails.
195    pub fn canonicalize(self) -> Result<String, serde_json::Error> {
196        serde_json_canonicalizer::to_string(&self)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use std::f64;
203
204    use serde_json::json;
205    use test_case::test_case;
206    use tracing_test::traced_test;
207
208    use super::*;
209    use crate::{
210        AuthorizationContext, IntoKey, PrivateKey,
211        generated::types::{OwnerInput, UpdateWalletBody},
212        get_auth_header,
213    };
214
215    const TEST_PRIVATE_KEY_PEM: &str = include_str!("../tests/test_private_key.pem");
216
217    #[tokio::test]
218    async fn test_build_canonical_request() {
219        let private_key = include_str!("../tests/test_private_key.pem");
220        let key = PrivateKey(private_key.to_string());
221        let public_key = key.get_key().await.unwrap().public_key();
222
223        // Create the request body that will be sent using the generated privy-api type
224        let update_wallet_body = UpdateWalletBody {
225            owner: Some(OwnerInput::PublicKey(public_key.to_string())),
226            ..Default::default()
227        };
228
229        // Build the canonical request data for signing using the serialized body
230        let canonical_data = format_request_for_authorization_signature(
231            "cmf418pa801bxl40b5rcgjvd9",
232            Method::PATCH,
233            "https://api.privy.io/v1/wallets/o5zuf7fbygwze9l9gaxyc0bm".into(),
234            update_wallet_body.clone(),
235            None,
236        )
237        .unwrap();
238
239        assert_eq!(
240            canonical_data,
241            "{\"body\":{\"owner\":{\"public_key\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESYrvEwooR33jt/8Up0lWdDNAcxmg\\nNZrCX23OThCPA+WxDx+dHYrjRlfPmHX0/aMTopp1PdKAtlQjRJDHSNd8XA==\\n-----END PUBLIC KEY-----\\n\"}},\"headers\":{\"privy-app-id\":\"cmf418pa801bxl40b5rcgjvd9\"},\"method\":\"PATCH\",\"url\":\"https://api.privy.io/v1/wallets/o5zuf7fbygwze9l9gaxyc0bm\",\"version\":1}"
242        );
243    }
244
245    // Method enum tests
246    #[test]
247    fn test_method_serialization() {
248        assert_eq!(serde_json::to_string(&Method::PATCH).unwrap(), "\"PATCH\"");
249        assert_eq!(serde_json::to_string(&Method::POST).unwrap(), "\"POST\"");
250        assert_eq!(serde_json::to_string(&Method::PUT).unwrap(), "\"PUT\"");
251        assert_eq!(
252            serde_json::to_string(&Method::DELETE).unwrap(),
253            "\"DELETE\""
254        );
255    }
256
257    // WalletApiRequestSignatureInput tests
258    #[test]
259    fn test_wallet_api_request_signature_input_new() {
260        let input = WalletApiRequestSignatureInput::new(
261            Method::POST,
262            "https://api.privy.io/v1/test".to_string(),
263        )
264        .body(json!({}));
265
266        // Can't directly test private fields, but we can test the behavior
267        let canonical = input.canonicalize().unwrap();
268        assert!(canonical.contains("\"version\":1"));
269        assert!(canonical.contains("\"method\":\"POST\""));
270        assert!(canonical.contains("https://api.privy.io/v1/test"));
271    }
272
273    #[test]
274    fn test_wallet_api_request_signature_input_with_body() {
275        let body = json!({"test": "value"});
276        let input = WalletApiRequestSignatureInput::new(
277            Method::POST,
278            "https://api.privy.io/v1/test".to_string(),
279        )
280        .body(body);
281
282        let canonical = input.canonicalize().unwrap();
283        assert!(canonical.contains("\"body\":{\"test\":\"value\"}"));
284    }
285
286    #[test]
287    fn test_wallet_api_request_signature_input_with_headers() {
288        let headers = json!({"header1": "value1", "header2": "value2"});
289        let input = WalletApiRequestSignatureInput::new(
290            Method::POST,
291            "https://api.privy.io/v1/test".to_string(),
292        )
293        .body(json!({}))
294        .headers(headers);
295
296        let canonical = input.canonicalize().unwrap();
297        assert!(canonical.contains("\"headers\":{\"header1\":\"value1\",\"header2\":\"value2\"}"));
298    }
299
300    #[test]
301    fn test_wallet_api_request_signature_input_complete() {
302        let body = json!({"data": "test"});
303        let headers = json!({"auth": "token"});
304        let input = WalletApiRequestSignatureInput::new(
305            Method::PATCH,
306            "https://api.privy.io/v1/wallets/123".to_string(),
307        )
308        .body(body)
309        .headers(headers);
310
311        let canonical = input.canonicalize().unwrap();
312        assert!(canonical.contains("\"body\":{\"data\":\"test\"}"));
313        assert!(canonical.contains("\"headers\":{\"auth\":\"token\"}"));
314        assert!(canonical.contains("\"method\":\"PATCH\""));
315        assert!(canonical.contains("\"version\":1"));
316    }
317
318    #[test]
319    fn test_wallet_api_request_signature_input_no_body() {
320        let input = WalletApiRequestSignatureInput::new(
321            Method::DELETE,
322            "https://api.privy.io/v1/test".to_string(),
323        )
324        .body(json!(null));
325
326        let canonical = input.canonicalize().unwrap();
327        assert!(canonical.contains("\"body\":null"));
328    }
329
330    #[test]
331    fn test_wallet_api_request_signature_input_no_headers() {
332        let input = WalletApiRequestSignatureInput::new(
333            Method::POST,
334            "https://api.privy.io/v1/test".to_string(),
335        )
336        .body(json!({}));
337
338        let canonical = input.canonicalize().unwrap();
339        assert!(canonical.contains("\"headers\":null"));
340    }
341
342    #[test]
343    fn test_build_canonical_request_different_methods() {
344        for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
345            let result = format_request_for_authorization_signature(
346                "test_app_id",
347                method,
348                "https://api.privy.io/v1/test".to_string(),
349                json!({}),
350                None,
351            );
352
353            assert!(result.is_ok());
354            let canonical = result.unwrap();
355            assert!(canonical.contains("\"version\":1"));
356        }
357    }
358
359    #[test]
360    fn test_key_ordering() {
361        let builder =
362            WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
363                .body(json!({
364                    "z_last": "last",
365                    "a_first": "first",
366                    "m_middle": "middle"
367                }))
368                .headers(json!({
369                    "z-header": "last",
370                    "a-header": "first"
371                }));
372
373        let canonical = builder
374            .canonicalize()
375            .expect("canonicalization should succeed");
376
377        // Keys should be sorted alphabetically at all levels
378        assert!(canonical.contains(r#"{"a_first":"first","m_middle":"middle","z_last":"last"}"#));
379        assert!(canonical.contains(r#"{"a-header":"first","z-header":"last"}"#));
380    }
381
382    #[test]
383    fn test_nested_object_sorting() {
384        let builder =
385            WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
386                .body(json!({
387                    "outer": {
388                        "z_inner": "last",
389                        "a_inner": "first"
390                    }
391                }));
392
393        let canonical = builder
394            .canonicalize()
395            .expect("canonicalization should succeed");
396
397        // Nested object keys should also be sorted
398        assert!(canonical.contains(r#"{"a_inner":"first","z_inner":"last"}"#));
399    }
400
401    #[test]
402    fn test_array_preservation() {
403        let builder =
404            WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
405                .body(json!({
406                    "items": ["third", "first", "second"]
407                }));
408
409        let canonical = builder
410            .canonicalize()
411            .expect("canonicalization should succeed");
412
413        // Array order should be preserved (not sorted)
414        assert!(canonical.contains(r#"["third","first","second"]"#));
415    }
416
417    #[test]
418    fn test_canonicalization_special_values() {
419        let builder =
420            WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
421                .body(json!({
422                    "null_value": null,
423                    "boolean_true": true,
424                    "boolean_false": false,
425                    "number_int": 42,
426                    "number_float": f64::consts::PI,
427                    "string_empty": "",
428                    "string_with_quotes": "He said \"Hello\"",
429                    "string_with_newlines": "line1\nline2\r\nline3",
430                    "array_mixed": [null, true, 1, "string"]
431                }));
432
433        let canonical = builder.canonicalize().unwrap();
434
435        // Verify special values are handled correctly
436        assert!(canonical.contains("\"null_value\":null"));
437        assert!(canonical.contains("\"boolean_true\":true"));
438        assert!(canonical.contains("\"boolean_false\":false"));
439        assert!(canonical.contains("\"number_int\":42"));
440        assert!(canonical.contains("\"string_empty\":\"\""));
441        assert!(canonical.contains("\\\"Hello\\\""));
442        assert!(canonical.contains("\"array_mixed\":[null,true,1,\"string\"]"));
443    }
444
445    #[test]
446    fn test_canonicalization_unicode() {
447        let builder =
448            WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
449                .body(json!({
450                    "unicode": "Hello 世界 🌍",
451                    "emoji": "🔐🚀💎",
452                    "accents": "café naïve résumé"
453                }));
454
455        let canonical = builder.canonicalize().unwrap();
456
457        // Unicode should be preserved
458        assert!(canonical.contains("Hello 世界 🌍"));
459        assert!(canonical.contains("🔐🚀💎"));
460        assert!(canonical.contains("café naïve résumé"));
461    }
462
463    #[test_case(
464        &json!({"name": "John", "age": 30}),
465        r#"{"age":30,"name":"John"}"#;
466        "simple object"
467    )]
468    #[test_case(
469        &json!({"name": "John", "address": {"street": "123 Main St", "city": "Boston"}}),
470        r#"{"address":{"city":"Boston","street":"123 Main St"},"name":"John"}"#;
471        "nested object"
472    )]
473    #[test_case(
474        &json!({"name": "John", "numbers": [1, 2, 3]}),
475        r#"{"name":"John","numbers":[1,2,3]}"#;
476        "array"
477    )]
478    #[test_case(
479        &json!({"name": "John", "age": null}),
480        r#"{"age":null,"name":"John"}"#;
481        "null value"
482    )]
483    #[test_case(
484        &json!({"name": "John", "age": 30, "address": {"street": "123 Main St", "city": "Boston"}, "hobbies": ["reading", "gaming"], "middleName": null}),
485        r#"{"address":{"city":"Boston","street":"123 Main St"},"age":30,"hobbies":["reading","gaming"],"middleName":null,"name":"John"}"#;
486        "complex object"
487    )]
488    fn test_json_canonicalization(json: &serde_json::Value, expected: &str) {
489        let result =
490            serde_json_canonicalizer::to_string(json).expect("canonicalization should succeed");
491        assert_eq!(result, expected);
492    }
493
494    #[test]
495    fn test_build_canonical_request_with_idempotency_key() {
496        let body = serde_json::json!({"test": "data"});
497        let idempotency_key = "unique-key-123".to_string();
498
499        let canonical_data = format_request_for_authorization_signature(
500            "test_app_id",
501            Method::POST,
502            "https://api.privy.io/v1/test".to_string(),
503            body,
504            Some(idempotency_key.clone()),
505        )
506        .unwrap();
507
508        assert!(
509            canonical_data.contains(&idempotency_key),
510            "Should include idempotency key"
511        );
512        assert!(
513            canonical_data.contains("privy-idempotency-key"),
514            "Should include idempotency key header"
515        );
516    }
517
518    #[tokio::test]
519    #[traced_test]
520    async fn test_sign_canonical_request() {
521        let ctx = AuthorizationContext::new().push(PrivateKey(TEST_PRIVATE_KEY_PEM.to_string()));
522
523        let body = serde_json::json!({"test": "data"});
524
525        let result = generate_authorization_signatures(
526            &ctx,
527            "test_app_id",
528            Method::POST,
529            "https://api.privy.io/v1/test".to_string(),
530            body,
531            None,
532        )
533        .await;
534
535        assert!(result.is_ok(), "Should successfully sign canonical request");
536
537        let signature = result.unwrap();
538        assert!(!signature.is_empty(), "Signature should not be empty");
539        assert!(
540            !signature.contains(',') || signature.split(',').count() == 1,
541            "Should have one signature for one key"
542        );
543    }
544
545    #[tokio::test]
546    #[traced_test]
547    async fn test_sign_canonical_request_multiple_keys() {
548        // Add another key
549        use p256::elliptic_curve::SecretKey;
550        let key_bytes = [2u8; 32];
551        let second_key = SecretKey::<p256::NistP256>::from_bytes(&key_bytes.into()).unwrap();
552
553        let ctx = AuthorizationContext::new()
554            .push(PrivateKey(TEST_PRIVATE_KEY_PEM.to_string()))
555            .push(second_key);
556
557        let body = serde_json::json!({"test": "data"});
558
559        let result = generate_authorization_signatures(
560            &ctx,
561            "test_app_id",
562            Method::POST,
563            "https://api.privy.io/v1/test".to_string(),
564            body,
565            None,
566        )
567        .await;
568
569        assert!(
570            result.is_ok(),
571            "Should successfully sign with multiple keys"
572        );
573
574        let signature = result.unwrap();
575        assert!(
576            signature.contains(','),
577            "Should have comma-separated signatures for multiple keys"
578        );
579        assert_eq!(
580            signature.split(',').count(),
581            2,
582            "Should have exactly two signatures"
583        );
584    }
585
586    #[tokio::test]
587    async fn test_sign_canonical_request_deterministic() {
588        let ctx = AuthorizationContext::new().push(PrivateKey(TEST_PRIVATE_KEY_PEM.to_string()));
589
590        let body = serde_json::json!({"test": "data"});
591
592        let signature1 = generate_authorization_signatures(
593            &ctx,
594            "test_app_id",
595            Method::POST,
596            "https://api.privy.io/v1/test".to_string(),
597            body.clone(),
598            None,
599        )
600        .await
601        .unwrap();
602
603        let signature2 = generate_authorization_signatures(
604            &ctx,
605            "test_app_id",
606            Method::POST,
607            "https://api.privy.io/v1/test".to_string(),
608            body,
609            None,
610        )
611        .await
612        .unwrap();
613
614        assert_eq!(signature1, signature2, "Signatures should be deterministic");
615    }
616
617    #[test]
618    fn test_build_canonical_request_json_serialization_error() {
619        // This should not fail in practice with serde_json, but test the error path
620        use std::f64;
621        let body = serde_json::json!({"invalid": f64::NAN});
622
623        let result = format_request_for_authorization_signature(
624            "test_app_id",
625            Method::POST,
626            "https://api.privy.io/v1/test".to_string(),
627            body,
628            None,
629        );
630
631        // NaN should serialize to null in serde_json, so this should actually succeed
632        assert!(result.is_ok(), "serde_json handles NaN gracefully");
633    }
634
635    // Test auth header generation
636    #[test]
637    fn test_auth_header_generation() {
638        let app_id = "test_app_id";
639        let app_secret = "test_app_secret";
640
641        let auth_header = get_auth_header(app_id, app_secret);
642
643        assert!(
644            auth_header.starts_with("Basic "),
645            "Should start with Basic "
646        );
647
648        // Decode and verify
649        let encoded = auth_header.strip_prefix("Basic ").unwrap();
650        let decoded = STANDARD.decode(encoded).unwrap();
651        let credentials = String::from_utf8(decoded).unwrap();
652
653        assert_eq!(credentials, "test_app_id:test_app_secret");
654    }
655
656    #[test]
657    fn test_canonical_request_url_encoding() {
658        let body = serde_json::json!({"test": "data"});
659        let url_with_query = "https://api.privy.io/v1/test?param=value&other=123";
660
661        let canonical_data = format_request_for_authorization_signature(
662            "test_app_id",
663            Method::POST,
664            url_with_query.to_string(),
665            body,
666            None,
667        )
668        .unwrap();
669
670        assert!(
671            canonical_data.contains(url_with_query),
672            "Should preserve URL as-is including query parameters"
673        );
674    }
675
676    #[test]
677    fn test_canonical_request_special_characters() {
678        let body = serde_json::json!({
679            "special": "test with spaces and símböls",
680            "unicode": "🔐🌟",
681            "escaped": "quotes \"inside\" string"
682        });
683
684        let canonical_data = format_request_for_authorization_signature(
685            "test_app_id",
686            Method::POST,
687            "https://api.privy.io/v1/test".to_string(),
688            body,
689            None,
690        )
691        .unwrap();
692
693        // Should properly escape JSON
694        assert!(
695            canonical_data.contains("\\\"inside\\\""),
696            "Should escape internal quotes"
697        );
698        assert!(
699            canonical_data.contains("🔐🌟"),
700            "Should preserve Unicode characters"
701        );
702    }
703}