1use base64::{Engine, engine::general_purpose::STANDARD};
2use futures::TryStreamExt;
3use serde::Serialize;
4
5use crate::{AuthorizationContext, SignatureGenerationError};
6
7pub struct Utils {
9 pub(crate) app_id: String,
10}
11pub struct RequestSigner {
13 app_id: String,
14}
15pub struct RequestFormatter {
17 app_id: String,
18}
19
20impl Utils {
21 pub fn signer(&self) -> RequestSigner {
23 RequestSigner {
24 app_id: self.app_id.clone(),
25 }
26 }
27
28 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
62pub 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
91pub 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#[derive(serde::Serialize, Debug)]
136pub enum Method {
137 PATCH,
139 POST,
141 PUT,
143 DELETE,
145}
146
147#[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 #[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 #[must_use]
179 pub fn body(mut self, body: S) -> Self {
180 self.body = Some(body);
181 self
182 }
183
184 #[must_use]
186 pub fn headers(mut self, headers: serde_json::Value) -> Self {
187 self.headers = Some(headers);
188 self
189 }
190
191 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 let update_wallet_body = UpdateWalletBody {
225 owner: Some(OwnerInput::PublicKey(public_key.to_string())),
226 ..Default::default()
227 };
228
229 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 #[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 #[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 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 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 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 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 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 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 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 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 assert!(result.is_ok(), "serde_json handles NaN gracefully");
633 }
634
635 #[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 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 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}