Skip to main content

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