1use crate::error::CdpError;
2use base64::Engine;
3use bon::bon;
4use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
5use reqwest::{Request, Response};
6use reqwest_middleware::{Middleware, Next};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12use uuid::Uuid;
13
14const VERSION: &str = env!("CARGO_PKG_VERSION");
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17struct Claims {
18 sub: String,
19 iss: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 aud: Option<Vec<String>>,
22 exp: u64,
23 iat: u64,
24 nbf: u64,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 uris: Option<Vec<String>>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30struct WalletClaims {
31 iat: u64,
32 nbf: u64,
33 jti: String,
34 uris: Vec<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 #[serde(rename = "reqHash")]
37 req_hash: Option<String>,
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct WalletAuth {
43 pub api_key_id: String,
45 pub api_key_secret: String,
47 pub wallet_secret: Option<String>,
49 pub debug: bool,
51 pub source: String,
53 pub source_version: Option<String>,
55 pub expires_in: u64,
57}
58
59#[derive(Debug, Clone)]
85pub struct JwtOptions {
86 pub api_key_id: String,
90 pub api_key_secret: String,
94 pub request_method: Option<String>,
96 pub request_host: Option<String>,
98 pub request_path: Option<String>,
100 pub expires_in: Option<u64>,
102 pub audience: Option<Vec<String>>,
104}
105
106#[bon::bon]
107impl JwtOptions {
108 #[builder]
109 pub fn new(
110 api_key_id: String,
111 api_key_secret: String,
112 request_method: Option<String>,
113 request_host: Option<String>,
114 request_path: Option<String>,
115 expires_in: Option<u64>,
116 audience: Option<Vec<String>>,
117 ) -> Self {
118 Self {
119 api_key_id,
120 api_key_secret,
121 request_method,
122 request_host,
123 request_path,
124 expires_in,
125 audience,
126 }
127 }
128}
129
130pub fn generate_jwt(options: JwtOptions) -> Result<String, CdpError> {
179 let now = SystemTime::now()
180 .duration_since(UNIX_EPOCH)
181 .unwrap()
182 .as_secs();
183
184 let expires_in = options.expires_in.unwrap_or(120);
185
186 let uris = match (
188 &options.request_method,
189 &options.request_host,
190 &options.request_path,
191 ) {
192 (Some(method), Some(host), Some(path)) => {
193 Some(vec![format!("{} {}{}", method, host, path)])
194 }
195 (None, None, None) => None,
196 _ => {
197 return Err(CdpError::Auth(
198 "Either all request details (request_method, request_host, request_path) \
199 must be provided, or all must be None for websocket JWTs"
200 .to_string(),
201 ));
202 }
203 };
204
205 let claims = Claims {
206 sub: options.api_key_id.clone(),
207 iss: "cdp".to_string(),
208 aud: options.audience,
209 exp: now + expires_in,
210 iat: now,
211 nbf: now,
212 uris,
213 };
214
215 let (algorithm, encoding_key) = parse_signing_key(&options.api_key_secret)?;
216
217 let mut header = Header::new(algorithm);
218 header.kid = Some(options.api_key_id);
219
220 encode(&header, &claims, &encoding_key)
221 .map_err(|e| CdpError::Auth(format!("Failed to encode JWT: {}", e)))
222}
223
224fn parse_signing_key(api_key_secret: &str) -> Result<(Algorithm, EncodingKey), CdpError> {
228 if is_ec_pem_key(api_key_secret) {
229 let key = EncodingKey::from_ec_pem(api_key_secret.as_bytes())
231 .map_err(|e| CdpError::Auth(format!("Failed to parse EC PEM key: {}", e)))?;
232 Ok((Algorithm::ES256, key))
233 } else if is_ed25519_key(api_key_secret) {
234 let decoded = base64::engine::general_purpose::STANDARD
236 .decode(api_key_secret)
237 .map_err(|e| CdpError::Auth(format!("Failed to decode Ed25519 key: {}", e)))?;
238
239 if decoded.len() != 64 {
240 return Err(CdpError::Auth(
241 "Invalid Ed25519 key length, expected 64 bytes".to_string(),
242 ));
243 }
244
245 let seed = &decoded[0..32];
247 let mut pkcs8_der = Vec::new();
248 let pkcs8_header = hex::decode("302e020100300506032b657004220420").unwrap();
249 pkcs8_der.extend_from_slice(&pkcs8_header);
250 pkcs8_der.extend_from_slice(seed);
251
252 let pem_content = base64::engine::general_purpose::STANDARD.encode(&pkcs8_der);
254 let pem_formatted = format!(
255 "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----",
256 pem_content
257 .chars()
258 .collect::<Vec<_>>()
259 .chunks(64)
260 .map(|chunk| chunk.iter().collect::<String>())
261 .collect::<Vec<_>>()
262 .join("\n")
263 );
264
265 let key = EncodingKey::from_ed_pem(pem_formatted.as_bytes())
266 .map_err(|e| CdpError::Auth(format!("Failed to parse Ed25519 key: {}", e)))?;
267 Ok((Algorithm::EdDSA, key))
268 } else {
269 Err(CdpError::Auth(
270 "Invalid key format - must be either PEM EC key or base64 Ed25519 key".to_string(),
271 ))
272 }
273}
274
275#[bon]
276impl WalletAuth {
277 #[builder]
278 pub fn new(
279 api_key_id: Option<String>,
281 api_key_secret: Option<String>,
283 wallet_secret: Option<String>,
285 debug: Option<bool>,
287 source: Option<String>,
289 source_version: Option<String>,
291 expires_in: Option<u64>,
293 ) -> Result<Self, CdpError> {
294 use std::env;
295
296 let api_key_id = api_key_id
298 .or_else(|| env::var("CDP_API_KEY_ID").ok())
299 .ok_or_else(|| {
300 CdpError::Config(
301 "Missing required CDP API Key ID configuration.\n\n\
302 You can set them as environment variables:\n\
303 CDP_API_KEY_ID=your-api-key-id\n\
304 CDP_API_KEY_SECRET=your-api-key-secret\n\n\
305 Or pass them directly to the CdpClientOptions."
306 .to_string(),
307 )
308 })?;
309
310 let api_key_secret = api_key_secret
311 .or_else(|| env::var("CDP_API_KEY_SECRET").ok())
312 .ok_or_else(|| {
313 CdpError::Config(
314 "Missing required CDP API Key Secret configuration.\n\n\
315 You can set them as environment variables:\n\
316 CDP_API_KEY_ID=your-api-key-id\n\
317 CDP_API_KEY_SECRET=your-api-key-secret\n\n\
318 Or pass them directly to the CdpClientOptions."
319 .to_string(),
320 )
321 })?;
322
323 let wallet_secret = wallet_secret.or_else(|| env::var("CDP_WALLET_SECRET").ok());
324
325 let debug = debug.unwrap_or(false);
326 let expires_in = expires_in.unwrap_or(120);
327 let source = source.unwrap_or("sdk-auth".to_string());
328
329 Ok(WalletAuth {
330 api_key_id,
331 api_key_secret,
332 wallet_secret,
333 debug,
334 source,
335 source_version,
336 expires_in,
337 })
338 }
339
340 pub fn generate_jwt(
361 &self,
362 method: &str,
363 host: &str,
364 path: &str,
365 expires_in: u64,
366 ) -> Result<String, CdpError> {
367 let now = SystemTime::now()
368 .duration_since(UNIX_EPOCH)
369 .unwrap()
370 .as_secs();
371
372 let claims = Claims {
373 sub: self.api_key_id.clone(),
374 iss: "cdp".to_string(),
375 aud: None,
376 exp: now + expires_in,
377 iat: now,
378 nbf: now,
379 uris: Some(vec![format!("{} {}{}", method, host, path)]),
380 };
381
382 let (algorithm, encoding_key) = parse_signing_key(&self.api_key_secret)?;
383
384 let mut header = Header::new(algorithm);
385 header.kid = Some(self.api_key_id.clone());
386
387 encode(&header, &claims, &encoding_key)
388 .map_err(|e| CdpError::Auth(format!("Failed to encode JWT: {}", e)))
389 }
390
391 pub fn generate_wallet_jwt(
392 &self,
393 method: &str,
394 host: &str,
395 path: &str,
396 body: &[u8],
397 ) -> Result<String, CdpError> {
398 let wallet_secret = self.wallet_secret.as_ref().ok_or_else(|| {
399 CdpError::Auth("Wallet secret required for this operation".to_string())
400 })?;
401
402 let now = SystemTime::now()
403 .duration_since(UNIX_EPOCH)
404 .unwrap()
405 .as_secs();
406
407 let uri = format!("{} {}{}", method, host, path);
408 let jti = format!("{:x}", Uuid::new_v4().simple()); let req_hash = if !body.is_empty() {
412 let body_str = std::str::from_utf8(body)
414 .map_err(|e| CdpError::Auth(format!("Invalid UTF-8 in request body: {}", e)))?;
415
416 if !body_str.trim().is_empty() {
417 let parsed: Value = serde_json::from_str(body_str)
418 .map_err(|e| CdpError::Auth(format!("Failed to parse JSON body: {}", e)))?;
419
420 let sorted = sort_keys(parsed);
421 let sorted_json = serde_json::to_string(&sorted).map_err(|e| {
422 CdpError::Auth(format!("Failed to serialize sorted JSON: {}", e))
423 })?;
424
425 let mut hasher = Sha256::new();
426 hasher.update(sorted_json.as_bytes());
427 Some(format!("{:x}", hasher.finalize()))
428 } else {
429 None
430 }
431 } else {
432 None
433 };
434
435 let claims = WalletClaims {
436 iat: now,
437 nbf: now, jti,
439 uris: vec![uri],
440 req_hash,
441 };
442
443 let header = Header::new(Algorithm::ES256);
444
445 let der_bytes = base64::engine::general_purpose::STANDARD
447 .decode(wallet_secret)
448 .map_err(|e| CdpError::Auth(format!("Failed to decode wallet secret: {}", e)))?;
449
450 let encoding_key = EncodingKey::from_ec_der(&der_bytes);
451
452 encode(&header, &claims, &encoding_key)
453 .map_err(|e| CdpError::Auth(format!("Failed to encode wallet JWT: {}", e)))
454 }
455
456 fn requires_wallet_auth(&self, method: &str, path: &str) -> bool {
457 if method != "POST" && method != "DELETE" && method != "PUT" {
458 return false;
459 }
460
461 path.contains("/accounts")
462 || path.contains("/spend-permissions")
463 || path.contains("/user-operations/prepare-and-send")
464 || path.contains("/embedded-wallet-api/")
465 || path.ends_with("/end-users")
466 || path.ends_with("/end-users/import")
467 || Self::matches_end_user_pattern(path, "/evm")
468 || Self::matches_end_user_pattern(path, "/evm-smart-account")
469 || Self::matches_end_user_pattern(path, "/solana")
470 }
471
472 fn matches_end_user_pattern(path: &str, suffix: &str) -> bool {
475 if let Some(pos) = path.find("/end-users/") {
476 let after = &path[pos + "/end-users/".len()..];
477 if let Some(slash_pos) = after.find('/') {
479 let id_part = &after[..slash_pos];
480 let rest = &after[slash_pos..];
481 !id_part.is_empty() && rest == suffix
482 } else {
483 false
484 }
485 } else {
486 false
487 }
488 }
489
490 fn get_correlation_data(&self) -> String {
491 let mut data = HashMap::new();
492
493 data.insert("sdk_version".to_string(), VERSION.to_string());
494 data.insert("sdk_language".to_string(), "rust".to_string());
495 data.insert("source".to_string(), self.source.clone());
496
497 if let Some(ref source_version) = self.source_version {
498 data.insert("source_version".to_string(), source_version.clone());
499 }
500
501 data.into_iter()
502 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
503 .collect::<Vec<_>>()
504 .join(",")
505 }
506}
507
508#[async_trait::async_trait]
509impl Middleware for WalletAuth {
510 async fn handle(
511 &self,
512 mut req: Request,
513 extensions: &mut http::Extensions,
514 next: Next<'_>,
515 ) -> reqwest_middleware::Result<Response> {
516 let method = req.method().as_str().to_uppercase();
517 let url = req.url().clone();
518 let host = url.host_str().unwrap_or("api.cdp.coinbase.com");
519 let path = url.path();
520
521 let body = if let Some(body) = req.body() {
523 body.as_bytes().unwrap_or_default().to_vec()
524 } else {
525 Vec::new()
526 };
527
528 let expires_in = self.expires_in;
529
530 let jwt = self
532 .generate_jwt(&method, host, path, expires_in)
533 .map_err(reqwest_middleware::Error::middleware)?;
534
535 req.headers_mut()
537 .insert("Authorization", format!("Bearer {}", jwt).parse().unwrap());
538
539 req.headers_mut()
541 .insert("Content-Type", "application/json".parse().unwrap());
542
543 if self.requires_wallet_auth(&method, path)
545 && (!req.headers().contains_key("X-Wallet-Auth")
546 || req
547 .headers()
548 .get("X-Wallet-Auth")
549 .is_none_or(|v| v.is_empty()))
550 {
551 let wallet_jwt = self
552 .generate_wallet_jwt(&method, host, path, &body)
553 .map_err(reqwest_middleware::Error::middleware)?;
554
555 req.headers_mut()
556 .insert("X-Wallet-Auth", wallet_jwt.parse().unwrap());
557 }
558
559 req.headers_mut().insert(
561 "Correlation-Context",
562 self.get_correlation_data().parse().unwrap(),
563 );
564
565 if self.debug {
566 println!("Request: {} {}", method, url);
567 println!("Headers: {:?}", req.headers());
568 }
569
570 let response = next.run(req, extensions).await;
571
572 if self.debug {
573 if let Ok(ref resp) = response {
574 println!(
575 "Response: {} {}",
576 resp.status(),
577 resp.status().canonical_reason().unwrap_or("")
578 );
579 }
580 }
581
582 response
583 }
584}
585
586fn sort_keys(value: Value) -> Value {
587 match value {
588 Value::Object(map) => {
589 let mut sorted_map = serde_json::Map::new();
590 let mut keys: Vec<_> = map.keys().collect();
591 keys.sort();
592 for key in keys {
593 if let Some(val) = map.get(key) {
594 sorted_map.insert(key.clone(), sort_keys(val.clone()));
595 }
596 }
597 Value::Object(sorted_map)
598 }
599 Value::Array(arr) => Value::Array(arr.into_iter().map(sort_keys).collect()),
600 _ => value,
601 }
602}
603
604pub fn is_ed25519_key(key: &str) -> bool {
609 if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
610 decoded.len() == 64
611 } else {
612 false
613 }
614}
615
616pub fn is_ec_pem_key(key: &str) -> bool {
621 key.contains("-----BEGIN")
622 && key.contains("-----END")
623 && (key.contains("EC PRIVATE KEY") || key.contains("PRIVATE KEY"))
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
631 fn test_wallet_auth_builder_with_all_fields() {
632 let auth = WalletAuth::builder()
633 .api_key_id("test_key_id".to_string())
634 .api_key_secret("test_key_secret".to_string())
635 .wallet_secret("test_wallet_secret".to_string())
636 .debug(true)
637 .source("test_source".to_string())
638 .source_version("1.0.0".to_string())
639 .expires_in(300)
640 .build()
641 .unwrap();
642
643 assert_eq!(auth.api_key_id, "test_key_id");
644 assert_eq!(auth.api_key_secret, "test_key_secret");
645 assert_eq!(auth.wallet_secret, Some("test_wallet_secret".to_string()));
646 assert!(auth.debug);
647 assert_eq!(auth.source, "test_source");
648 assert_eq!(auth.source_version, Some("1.0.0".to_string()));
649 assert_eq!(auth.expires_in, 300);
650 }
651
652 #[test]
653 fn test_wallet_auth_builder_with_required_fields_only() {
654 let auth = WalletAuth::builder()
655 .api_key_id("test_key_id".to_string())
656 .api_key_secret("test_key_secret".to_string())
657 .build()
658 .unwrap();
659
660 assert_eq!(auth.api_key_id, "test_key_id");
661 assert_eq!(auth.api_key_secret, "test_key_secret");
662 assert_eq!(auth.wallet_secret, None);
663 assert!(!auth.debug);
664 assert_eq!(auth.source, "sdk-auth");
665 assert_eq!(auth.source_version, None);
666 assert_eq!(auth.expires_in, 120);
667 }
668
669 #[test]
670 fn test_wallet_auth_builder_with_optional_fields() {
671 let auth = WalletAuth::builder()
672 .api_key_id("test_key_id".to_string())
673 .api_key_secret("test_key_secret".to_string())
674 .debug(true)
675 .expires_in(600)
676 .build()
677 .unwrap();
678
679 assert_eq!(auth.api_key_id, "test_key_id");
680 assert_eq!(auth.api_key_secret, "test_key_secret");
681 assert!(auth.debug);
682 assert_eq!(auth.expires_in, 600);
683 assert_eq!(auth.source, "sdk-auth"); }
685
686 #[test]
687 fn test_wallet_auth_builder_missing_api_key_id() {
688 let result = WalletAuth::builder()
689 .api_key_secret("test_key_secret".to_string())
690 .build();
691
692 assert!(result.is_err());
693 if let Err(CdpError::Config(msg)) = result {
694 assert!(msg.contains("Missing required CDP API Key ID configuration"));
695 } else {
696 panic!("Expected Config error for missing api_key_id");
697 }
698 }
699
700 #[test]
701 fn test_wallet_auth_builder_missing_api_key_secret() {
702 let result = WalletAuth::builder()
703 .api_key_id("test_key_id".to_string())
704 .build();
705
706 assert!(result.is_err());
707 if let Err(CdpError::Config(msg)) = result {
708 assert!(msg.contains("Missing required CDP API Key Secret configuration"));
709 } else {
710 panic!("Expected Config error for missing api_key_secret");
711 }
712 }
713
714 #[test]
715 fn test_wallet_auth_builder_custom_source() {
716 let auth = WalletAuth::builder()
717 .api_key_id("test_key_id".to_string())
718 .api_key_secret("test_key_secret".to_string())
719 .source("my-custom-app".to_string())
720 .source_version("2.1.0".to_string())
721 .build()
722 .unwrap();
723
724 assert_eq!(auth.source, "my-custom-app");
725 assert_eq!(auth.source_version, Some("2.1.0".to_string()));
726 }
727
728 #[test]
729 fn test_requires_wallet_auth() {
730 let auth = WalletAuth::builder()
731 .api_key_id("test_key_id".to_string())
732 .api_key_secret("test_key_secret".to_string())
733 .build()
734 .unwrap();
735
736 assert!(auth.requires_wallet_auth("POST", "/v2/evm/accounts"));
738
739 assert!(auth.requires_wallet_auth("PUT", "/v2/evm/accounts/0x123"));
741
742 assert!(auth.requires_wallet_auth("DELETE", "/v2/evm/accounts/0x123"));
744
745 assert!(auth.requires_wallet_auth("POST", "/v2/spend-permissions"));
747
748 assert!(auth.requires_wallet_auth("POST", "/v2/user-operations/prepare-and-send"));
750
751 assert!(auth.requires_wallet_auth("POST", "/embedded-wallet-api/some-route"));
753 assert!(auth.requires_wallet_auth("PUT", "/embedded-wallet-api/other-route"));
754 assert!(!auth.requires_wallet_auth("GET", "/embedded-wallet-api/some-route"));
755
756 assert!(auth.requires_wallet_auth("POST", "/v2/end-users"));
758 assert!(auth.requires_wallet_auth("POST", "/v2/end-users/import"));
759 assert!(auth.requires_wallet_auth("POST", "/v2/end-users/user123/evm"));
760 assert!(auth.requires_wallet_auth("POST", "/v2/end-users/user123/evm-smart-account"));
761 assert!(auth.requires_wallet_auth("POST", "/v2/end-users/user123/solana"));
762
763 assert!(!auth.requires_wallet_auth("GET", "/v2/evm/accounts"));
765 assert!(!auth.requires_wallet_auth("GET", "/v2/end-users"));
766
767 assert!(!auth.requires_wallet_auth("POST", "/v2/other/endpoint"));
769 }
770
771 #[test]
772 fn test_is_ed25519_key() {
773 let valid_ed25519 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
775 assert!(is_ed25519_key(&valid_ed25519));
776
777 let invalid_key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
779 assert!(!is_ed25519_key(&invalid_key));
780
781 assert!(!is_ed25519_key("not-base64"));
783 }
784
785 #[test]
786 fn test_is_ec_pem_key() {
787 let pem_key = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----";
788 assert!(is_ec_pem_key(pem_key));
789
790 let generic_pem_key = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
791 assert!(is_ec_pem_key(generic_pem_key));
792
793 let not_pem_key = "just-a-string";
794 assert!(!is_ec_pem_key(not_pem_key));
795 }
796
797 fn generate_ec_pem_key() -> String {
800 use p256::ecdsa::SigningKey;
801 use p256::elliptic_curve::rand_core::OsRng;
802 use p256::pkcs8::EncodePrivateKey;
803
804 let signing_key = SigningKey::random(&mut OsRng);
805 let pkcs8_pem = signing_key
806 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
807 .expect("Failed to export EC key as PKCS8 PEM");
808 pkcs8_pem.to_string()
809 }
810
811 fn generate_ed25519_key_base64() -> String {
812 use ed25519_dalek::SigningKey;
813
814 let mut csprng = p256::elliptic_curve::rand_core::OsRng;
815 let signing_key = SigningKey::generate(&mut csprng);
816 let seed = signing_key.to_bytes();
817 let public = signing_key.verifying_key().to_bytes();
818
819 let mut combined = Vec::with_capacity(64);
820 combined.extend_from_slice(&seed);
821 combined.extend_from_slice(&public);
822
823 base64::engine::general_purpose::STANDARD.encode(&combined)
824 }
825
826 fn decode_jwt_header(token: &str) -> serde_json::Value {
827 let parts: Vec<&str> = token.split('.').collect();
828 assert_eq!(parts.len(), 3, "JWT should have 3 parts");
829 let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
830 .decode(parts[0])
831 .expect("Failed to decode JWT header");
832 serde_json::from_slice(&header_bytes).expect("Failed to parse JWT header as JSON")
833 }
834
835 fn decode_jwt_claims(token: &str) -> serde_json::Value {
836 let parts: Vec<&str> = token.split('.').collect();
837 assert_eq!(parts.len(), 3, "JWT should have 3 parts");
838 let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
839 .decode(parts[1])
840 .expect("Failed to decode JWT claims");
841 serde_json::from_slice(&claims_bytes).expect("Failed to parse JWT claims as JSON")
842 }
843
844 #[test]
847 fn test_generate_jwt_with_ec_key() {
848 let ec_key = generate_ec_pem_key();
849 let token = generate_jwt(
850 JwtOptions::builder()
851 .api_key_id("test-key-id".to_string())
852 .api_key_secret(ec_key)
853 .request_method("GET".to_string())
854 .request_host("api.cdp.coinbase.com".to_string())
855 .request_path("/platform/v2/evm/accounts".to_string())
856 .expires_in(120u64)
857 .build(),
858 )
859 .unwrap();
860
861 let header = decode_jwt_header(&token);
862 assert_eq!(header["alg"], "ES256");
863 assert_eq!(header["kid"], "test-key-id");
864
865 let claims = decode_jwt_claims(&token);
866 assert_eq!(claims["sub"], "test-key-id");
867 assert_eq!(claims["iss"], "cdp");
868 assert!(claims.get("aud").is_none() || claims["aud"].is_null());
869
870 let uris = claims["uris"].as_array().expect("uris should be an array");
871 assert_eq!(uris.len(), 1);
872 assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
873
874 let exp = claims["exp"].as_u64().unwrap();
875 let iat = claims["iat"].as_u64().unwrap();
876 assert_eq!(exp - iat, 120);
877 }
878
879 #[test]
880 fn test_generate_jwt_with_ed25519_key() {
881 let ed_key = generate_ed25519_key_base64();
882 let token = generate_jwt(
883 JwtOptions::builder()
884 .api_key_id("ed-key-id".to_string())
885 .api_key_secret(ed_key)
886 .request_method("POST".to_string())
887 .request_host("api.cdp.coinbase.com".to_string())
888 .request_path("/platform/v2/evm/accounts".to_string())
889 .build(),
890 )
891 .unwrap();
892
893 let header = decode_jwt_header(&token);
894 assert_eq!(header["alg"], "EdDSA");
895 assert_eq!(header["kid"], "ed-key-id");
896
897 let claims = decode_jwt_claims(&token);
898 assert_eq!(claims["sub"], "ed-key-id");
899 assert_eq!(claims["iss"], "cdp");
900
901 let uris = claims["uris"].as_array().expect("uris should be an array");
902 assert_eq!(
903 uris[0],
904 "POST api.cdp.coinbase.com/platform/v2/evm/accounts"
905 );
906 }
907
908 #[test]
909 fn test_generate_jwt_websocket_no_uris() {
910 let ec_key = generate_ec_pem_key();
911 let token = generate_jwt(
912 JwtOptions::builder()
913 .api_key_id("ws-key-id".to_string())
914 .api_key_secret(ec_key)
915 .build(),
916 )
917 .unwrap();
918
919 let claims = decode_jwt_claims(&token);
920 assert_eq!(claims["sub"], "ws-key-id");
921 assert_eq!(claims["iss"], "cdp");
922 assert!(claims.get("uris").is_none() || claims["uris"].is_null());
924 }
925
926 #[test]
927 fn test_generate_jwt_partial_request_params_error() {
928 let ec_key = generate_ec_pem_key();
929 let result = generate_jwt(
930 JwtOptions::builder()
931 .api_key_id("test-key-id".to_string())
932 .api_key_secret(ec_key)
933 .request_method("GET".to_string())
934 .build(),
936 );
937
938 assert!(result.is_err());
939 if let Err(CdpError::Auth(msg)) = result {
940 assert!(msg.contains("Either all request details"));
941 } else {
942 panic!("Expected Auth error for partial request params");
943 }
944 }
945
946 #[test]
947 fn test_generate_jwt_with_audience() {
948 let ec_key = generate_ec_pem_key();
949 let token = generate_jwt(
950 JwtOptions::builder()
951 .api_key_id("aud-key-id".to_string())
952 .api_key_secret(ec_key)
953 .audience(vec!["custom-audience".to_string()])
954 .build(),
955 )
956 .unwrap();
957
958 let claims = decode_jwt_claims(&token);
959 let aud = claims["aud"].as_array().expect("aud should be an array");
960 assert_eq!(aud.len(), 1);
961 assert_eq!(aud[0], "custom-audience");
962 }
963
964 #[test]
965 fn test_generate_jwt_default_expires_in() {
966 let ec_key = generate_ec_pem_key();
967 let token = generate_jwt(
968 JwtOptions::builder()
969 .api_key_id("default-exp-key".to_string())
970 .api_key_secret(ec_key)
971 .request_method("GET".to_string())
972 .request_host("api.cdp.coinbase.com".to_string())
973 .request_path("/test".to_string())
974 .build(),
975 )
976 .unwrap();
977
978 let claims = decode_jwt_claims(&token);
979 let exp = claims["exp"].as_u64().unwrap();
980 let iat = claims["iat"].as_u64().unwrap();
981 assert_eq!(exp - iat, 120); }
983
984 #[test]
985 fn test_generate_jwt_custom_expires_in() {
986 let ec_key = generate_ec_pem_key();
987 let token = generate_jwt(
988 JwtOptions::builder()
989 .api_key_id("custom-exp-key".to_string())
990 .api_key_secret(ec_key)
991 .request_method("GET".to_string())
992 .request_host("api.cdp.coinbase.com".to_string())
993 .request_path("/test".to_string())
994 .expires_in(300u64)
995 .build(),
996 )
997 .unwrap();
998
999 let claims = decode_jwt_claims(&token);
1000 let exp = claims["exp"].as_u64().unwrap();
1001 let iat = claims["iat"].as_u64().unwrap();
1002 assert_eq!(exp - iat, 300);
1003 }
1004
1005 #[test]
1006 fn test_generate_jwt_invalid_key_format() {
1007 let result = generate_jwt(
1008 JwtOptions::builder()
1009 .api_key_id("bad-key-id".to_string())
1010 .api_key_secret("not-a-valid-key-format".to_string())
1011 .request_method("GET".to_string())
1012 .request_host("api.cdp.coinbase.com".to_string())
1013 .request_path("/test".to_string())
1014 .build(),
1015 );
1016
1017 assert!(result.is_err());
1018 if let Err(CdpError::Auth(msg)) = result {
1019 assert!(msg.contains("Invalid key format"));
1020 } else {
1021 panic!("Expected Auth error for invalid key format");
1022 }
1023 }
1024
1025 #[test]
1028 fn test_wallet_auth_generate_jwt_ec_key() {
1029 let ec_key = generate_ec_pem_key();
1030 let auth = WalletAuth::builder()
1031 .api_key_id("wa-ec-key-id".to_string())
1032 .api_key_secret(ec_key)
1033 .build()
1034 .unwrap();
1035
1036 let token = auth
1037 .generate_jwt(
1038 "GET",
1039 "api.cdp.coinbase.com",
1040 "/platform/v2/evm/accounts",
1041 120,
1042 )
1043 .unwrap();
1044
1045 let header = decode_jwt_header(&token);
1046 assert_eq!(header["alg"], "ES256");
1047 assert_eq!(header["kid"], "wa-ec-key-id");
1048
1049 let claims = decode_jwt_claims(&token);
1050 assert_eq!(claims["sub"], "wa-ec-key-id");
1051 assert_eq!(claims["iss"], "cdp");
1052
1053 let uris = claims["uris"].as_array().expect("uris should be an array");
1054 assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
1055 }
1056
1057 #[test]
1058 fn test_wallet_auth_generate_jwt_ed25519_key() {
1059 let ed_key = generate_ed25519_key_base64();
1060 let auth = WalletAuth::builder()
1061 .api_key_id("wa-ed-key-id".to_string())
1062 .api_key_secret(ed_key)
1063 .build()
1064 .unwrap();
1065
1066 let token = auth
1067 .generate_jwt(
1068 "POST",
1069 "api.cdp.coinbase.com",
1070 "/platform/v2/evm/accounts",
1071 60,
1072 )
1073 .unwrap();
1074
1075 let header = decode_jwt_header(&token);
1076 assert_eq!(header["alg"], "EdDSA");
1077 assert_eq!(header["kid"], "wa-ed-key-id");
1078
1079 let claims = decode_jwt_claims(&token);
1080 assert_eq!(claims["sub"], "wa-ed-key-id");
1081 assert_eq!(claims["iss"], "cdp");
1082
1083 let exp = claims["exp"].as_u64().unwrap();
1084 let iat = claims["iat"].as_u64().unwrap();
1085 assert_eq!(exp - iat, 60);
1086 }
1087}