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 (path.contains("/accounts") || path.contains("/spend-permissions"))
458 && (method == "POST" || method == "DELETE" || method == "PUT")
459 }
460
461 fn get_correlation_data(&self) -> String {
462 let mut data = HashMap::new();
463
464 data.insert("sdk_version".to_string(), VERSION.to_string());
465 data.insert("sdk_language".to_string(), "rust".to_string());
466 data.insert("source".to_string(), self.source.clone());
467
468 if let Some(ref source_version) = self.source_version {
469 data.insert("source_version".to_string(), source_version.clone());
470 }
471
472 data.into_iter()
473 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
474 .collect::<Vec<_>>()
475 .join(",")
476 }
477}
478
479#[async_trait::async_trait]
480impl Middleware for WalletAuth {
481 async fn handle(
482 &self,
483 mut req: Request,
484 extensions: &mut http::Extensions,
485 next: Next<'_>,
486 ) -> reqwest_middleware::Result<Response> {
487 let method = req.method().as_str().to_uppercase();
488 let url = req.url().clone();
489 let host = url.host_str().unwrap_or("api.cdp.coinbase.com");
490 let path = url.path();
491
492 let body = if let Some(body) = req.body() {
494 body.as_bytes().unwrap_or_default().to_vec()
495 } else {
496 Vec::new()
497 };
498
499 let expires_in = self.expires_in;
500
501 let jwt = self
503 .generate_jwt(&method, host, path, expires_in)
504 .map_err(reqwest_middleware::Error::middleware)?;
505
506 req.headers_mut()
508 .insert("Authorization", format!("Bearer {}", jwt).parse().unwrap());
509
510 req.headers_mut()
512 .insert("Content-Type", "application/json".parse().unwrap());
513
514 if self.requires_wallet_auth(&method, path)
516 && (!req.headers().contains_key("X-Wallet-Auth")
517 || req
518 .headers()
519 .get("X-Wallet-Auth")
520 .is_none_or(|v| v.is_empty()))
521 {
522 let wallet_jwt = self
523 .generate_wallet_jwt(&method, host, path, &body)
524 .map_err(reqwest_middleware::Error::middleware)?;
525
526 req.headers_mut()
527 .insert("X-Wallet-Auth", wallet_jwt.parse().unwrap());
528 }
529
530 req.headers_mut().insert(
532 "Correlation-Context",
533 self.get_correlation_data().parse().unwrap(),
534 );
535
536 if self.debug {
537 println!("Request: {} {}", method, url);
538 println!("Headers: {:?}", req.headers());
539 }
540
541 let response = next.run(req, extensions).await;
542
543 if self.debug {
544 if let Ok(ref resp) = response {
545 println!(
546 "Response: {} {}",
547 resp.status(),
548 resp.status().canonical_reason().unwrap_or("")
549 );
550 }
551 }
552
553 response
554 }
555}
556
557fn sort_keys(value: Value) -> Value {
558 match value {
559 Value::Object(map) => {
560 let mut sorted_map = serde_json::Map::new();
561 let mut keys: Vec<_> = map.keys().collect();
562 keys.sort();
563 for key in keys {
564 if let Some(val) = map.get(key) {
565 sorted_map.insert(key.clone(), sort_keys(val.clone()));
566 }
567 }
568 Value::Object(sorted_map)
569 }
570 Value::Array(arr) => Value::Array(arr.into_iter().map(sort_keys).collect()),
571 _ => value,
572 }
573}
574
575pub fn is_ed25519_key(key: &str) -> bool {
580 if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
581 decoded.len() == 64
582 } else {
583 false
584 }
585}
586
587pub fn is_ec_pem_key(key: &str) -> bool {
592 key.contains("-----BEGIN")
593 && key.contains("-----END")
594 && (key.contains("EC PRIVATE KEY") || key.contains("PRIVATE KEY"))
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn test_wallet_auth_builder_with_all_fields() {
603 let auth = WalletAuth::builder()
604 .api_key_id("test_key_id".to_string())
605 .api_key_secret("test_key_secret".to_string())
606 .wallet_secret("test_wallet_secret".to_string())
607 .debug(true)
608 .source("test_source".to_string())
609 .source_version("1.0.0".to_string())
610 .expires_in(300)
611 .build()
612 .unwrap();
613
614 assert_eq!(auth.api_key_id, "test_key_id");
615 assert_eq!(auth.api_key_secret, "test_key_secret");
616 assert_eq!(auth.wallet_secret, Some("test_wallet_secret".to_string()));
617 assert!(auth.debug);
618 assert_eq!(auth.source, "test_source");
619 assert_eq!(auth.source_version, Some("1.0.0".to_string()));
620 assert_eq!(auth.expires_in, 300);
621 }
622
623 #[test]
624 fn test_wallet_auth_builder_with_required_fields_only() {
625 let auth = WalletAuth::builder()
626 .api_key_id("test_key_id".to_string())
627 .api_key_secret("test_key_secret".to_string())
628 .build()
629 .unwrap();
630
631 assert_eq!(auth.api_key_id, "test_key_id");
632 assert_eq!(auth.api_key_secret, "test_key_secret");
633 assert_eq!(auth.wallet_secret, None);
634 assert!(!auth.debug);
635 assert_eq!(auth.source, "sdk-auth");
636 assert_eq!(auth.source_version, None);
637 assert_eq!(auth.expires_in, 120);
638 }
639
640 #[test]
641 fn test_wallet_auth_builder_with_optional_fields() {
642 let auth = WalletAuth::builder()
643 .api_key_id("test_key_id".to_string())
644 .api_key_secret("test_key_secret".to_string())
645 .debug(true)
646 .expires_in(600)
647 .build()
648 .unwrap();
649
650 assert_eq!(auth.api_key_id, "test_key_id");
651 assert_eq!(auth.api_key_secret, "test_key_secret");
652 assert!(auth.debug);
653 assert_eq!(auth.expires_in, 600);
654 assert_eq!(auth.source, "sdk-auth"); }
656
657 #[test]
658 fn test_wallet_auth_builder_missing_api_key_id() {
659 let result = WalletAuth::builder()
660 .api_key_secret("test_key_secret".to_string())
661 .build();
662
663 assert!(result.is_err());
664 if let Err(CdpError::Config(msg)) = result {
665 assert!(msg.contains("Missing required CDP API Key ID configuration"));
666 } else {
667 panic!("Expected Config error for missing api_key_id");
668 }
669 }
670
671 #[test]
672 fn test_wallet_auth_builder_missing_api_key_secret() {
673 let result = WalletAuth::builder()
674 .api_key_id("test_key_id".to_string())
675 .build();
676
677 assert!(result.is_err());
678 if let Err(CdpError::Config(msg)) = result {
679 assert!(msg.contains("Missing required CDP API Key Secret configuration"));
680 } else {
681 panic!("Expected Config error for missing api_key_secret");
682 }
683 }
684
685 #[test]
686 fn test_wallet_auth_builder_custom_source() {
687 let auth = WalletAuth::builder()
688 .api_key_id("test_key_id".to_string())
689 .api_key_secret("test_key_secret".to_string())
690 .source("my-custom-app".to_string())
691 .source_version("2.1.0".to_string())
692 .build()
693 .unwrap();
694
695 assert_eq!(auth.source, "my-custom-app");
696 assert_eq!(auth.source_version, Some("2.1.0".to_string()));
697 }
698
699 #[test]
700 fn test_requires_wallet_auth() {
701 let auth = WalletAuth::builder()
702 .api_key_id("test_key_id".to_string())
703 .api_key_secret("test_key_secret".to_string())
704 .build()
705 .unwrap();
706
707 assert!(auth.requires_wallet_auth("POST", "/v2/evm/accounts"));
709
710 assert!(auth.requires_wallet_auth("PUT", "/v2/evm/accounts/0x123"));
712
713 assert!(auth.requires_wallet_auth("DELETE", "/v2/evm/accounts/0x123"));
715
716 assert!(auth.requires_wallet_auth("POST", "/v2/spend-permissions"));
718
719 assert!(!auth.requires_wallet_auth("GET", "/v2/evm/accounts"));
721
722 assert!(!auth.requires_wallet_auth("POST", "/v2/other/endpoint"));
724 }
725
726 #[test]
727 fn test_is_ed25519_key() {
728 let valid_ed25519 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
730 assert!(is_ed25519_key(&valid_ed25519));
731
732 let invalid_key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
734 assert!(!is_ed25519_key(&invalid_key));
735
736 assert!(!is_ed25519_key("not-base64"));
738 }
739
740 #[test]
741 fn test_is_ec_pem_key() {
742 let pem_key = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----";
743 assert!(is_ec_pem_key(pem_key));
744
745 let generic_pem_key = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
746 assert!(is_ec_pem_key(generic_pem_key));
747
748 let not_pem_key = "just-a-string";
749 assert!(!is_ec_pem_key(not_pem_key));
750 }
751
752 fn generate_ec_pem_key() -> String {
755 use p256::ecdsa::SigningKey;
756 use p256::elliptic_curve::rand_core::OsRng;
757 use p256::pkcs8::EncodePrivateKey;
758
759 let signing_key = SigningKey::random(&mut OsRng);
760 let pkcs8_pem = signing_key
761 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
762 .expect("Failed to export EC key as PKCS8 PEM");
763 pkcs8_pem.to_string()
764 }
765
766 fn generate_ed25519_key_base64() -> String {
767 use ed25519_dalek::SigningKey;
768
769 let mut csprng = p256::elliptic_curve::rand_core::OsRng;
770 let signing_key = SigningKey::generate(&mut csprng);
771 let seed = signing_key.to_bytes();
772 let public = signing_key.verifying_key().to_bytes();
773
774 let mut combined = Vec::with_capacity(64);
775 combined.extend_from_slice(&seed);
776 combined.extend_from_slice(&public);
777
778 base64::engine::general_purpose::STANDARD.encode(&combined)
779 }
780
781 fn decode_jwt_header(token: &str) -> serde_json::Value {
782 let parts: Vec<&str> = token.split('.').collect();
783 assert_eq!(parts.len(), 3, "JWT should have 3 parts");
784 let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
785 .decode(parts[0])
786 .expect("Failed to decode JWT header");
787 serde_json::from_slice(&header_bytes).expect("Failed to parse JWT header as JSON")
788 }
789
790 fn decode_jwt_claims(token: &str) -> serde_json::Value {
791 let parts: Vec<&str> = token.split('.').collect();
792 assert_eq!(parts.len(), 3, "JWT should have 3 parts");
793 let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
794 .decode(parts[1])
795 .expect("Failed to decode JWT claims");
796 serde_json::from_slice(&claims_bytes).expect("Failed to parse JWT claims as JSON")
797 }
798
799 #[test]
802 fn test_generate_jwt_with_ec_key() {
803 let ec_key = generate_ec_pem_key();
804 let token = generate_jwt(
805 JwtOptions::builder()
806 .api_key_id("test-key-id".to_string())
807 .api_key_secret(ec_key)
808 .request_method("GET".to_string())
809 .request_host("api.cdp.coinbase.com".to_string())
810 .request_path("/platform/v2/evm/accounts".to_string())
811 .expires_in(120u64)
812 .build(),
813 )
814 .unwrap();
815
816 let header = decode_jwt_header(&token);
817 assert_eq!(header["alg"], "ES256");
818 assert_eq!(header["kid"], "test-key-id");
819
820 let claims = decode_jwt_claims(&token);
821 assert_eq!(claims["sub"], "test-key-id");
822 assert_eq!(claims["iss"], "cdp");
823 assert!(claims.get("aud").is_none() || claims["aud"].is_null());
824
825 let uris = claims["uris"].as_array().expect("uris should be an array");
826 assert_eq!(uris.len(), 1);
827 assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
828
829 let exp = claims["exp"].as_u64().unwrap();
830 let iat = claims["iat"].as_u64().unwrap();
831 assert_eq!(exp - iat, 120);
832 }
833
834 #[test]
835 fn test_generate_jwt_with_ed25519_key() {
836 let ed_key = generate_ed25519_key_base64();
837 let token = generate_jwt(
838 JwtOptions::builder()
839 .api_key_id("ed-key-id".to_string())
840 .api_key_secret(ed_key)
841 .request_method("POST".to_string())
842 .request_host("api.cdp.coinbase.com".to_string())
843 .request_path("/platform/v2/evm/accounts".to_string())
844 .build(),
845 )
846 .unwrap();
847
848 let header = decode_jwt_header(&token);
849 assert_eq!(header["alg"], "EdDSA");
850 assert_eq!(header["kid"], "ed-key-id");
851
852 let claims = decode_jwt_claims(&token);
853 assert_eq!(claims["sub"], "ed-key-id");
854 assert_eq!(claims["iss"], "cdp");
855
856 let uris = claims["uris"].as_array().expect("uris should be an array");
857 assert_eq!(
858 uris[0],
859 "POST api.cdp.coinbase.com/platform/v2/evm/accounts"
860 );
861 }
862
863 #[test]
864 fn test_generate_jwt_websocket_no_uris() {
865 let ec_key = generate_ec_pem_key();
866 let token = generate_jwt(
867 JwtOptions::builder()
868 .api_key_id("ws-key-id".to_string())
869 .api_key_secret(ec_key)
870 .build(),
871 )
872 .unwrap();
873
874 let claims = decode_jwt_claims(&token);
875 assert_eq!(claims["sub"], "ws-key-id");
876 assert_eq!(claims["iss"], "cdp");
877 assert!(claims.get("uris").is_none() || claims["uris"].is_null());
879 }
880
881 #[test]
882 fn test_generate_jwt_partial_request_params_error() {
883 let ec_key = generate_ec_pem_key();
884 let result = generate_jwt(
885 JwtOptions::builder()
886 .api_key_id("test-key-id".to_string())
887 .api_key_secret(ec_key)
888 .request_method("GET".to_string())
889 .build(),
891 );
892
893 assert!(result.is_err());
894 if let Err(CdpError::Auth(msg)) = result {
895 assert!(msg.contains("Either all request details"));
896 } else {
897 panic!("Expected Auth error for partial request params");
898 }
899 }
900
901 #[test]
902 fn test_generate_jwt_with_audience() {
903 let ec_key = generate_ec_pem_key();
904 let token = generate_jwt(
905 JwtOptions::builder()
906 .api_key_id("aud-key-id".to_string())
907 .api_key_secret(ec_key)
908 .audience(vec!["custom-audience".to_string()])
909 .build(),
910 )
911 .unwrap();
912
913 let claims = decode_jwt_claims(&token);
914 let aud = claims["aud"].as_array().expect("aud should be an array");
915 assert_eq!(aud.len(), 1);
916 assert_eq!(aud[0], "custom-audience");
917 }
918
919 #[test]
920 fn test_generate_jwt_default_expires_in() {
921 let ec_key = generate_ec_pem_key();
922 let token = generate_jwt(
923 JwtOptions::builder()
924 .api_key_id("default-exp-key".to_string())
925 .api_key_secret(ec_key)
926 .request_method("GET".to_string())
927 .request_host("api.cdp.coinbase.com".to_string())
928 .request_path("/test".to_string())
929 .build(),
930 )
931 .unwrap();
932
933 let claims = decode_jwt_claims(&token);
934 let exp = claims["exp"].as_u64().unwrap();
935 let iat = claims["iat"].as_u64().unwrap();
936 assert_eq!(exp - iat, 120); }
938
939 #[test]
940 fn test_generate_jwt_custom_expires_in() {
941 let ec_key = generate_ec_pem_key();
942 let token = generate_jwt(
943 JwtOptions::builder()
944 .api_key_id("custom-exp-key".to_string())
945 .api_key_secret(ec_key)
946 .request_method("GET".to_string())
947 .request_host("api.cdp.coinbase.com".to_string())
948 .request_path("/test".to_string())
949 .expires_in(300u64)
950 .build(),
951 )
952 .unwrap();
953
954 let claims = decode_jwt_claims(&token);
955 let exp = claims["exp"].as_u64().unwrap();
956 let iat = claims["iat"].as_u64().unwrap();
957 assert_eq!(exp - iat, 300);
958 }
959
960 #[test]
961 fn test_generate_jwt_invalid_key_format() {
962 let result = generate_jwt(
963 JwtOptions::builder()
964 .api_key_id("bad-key-id".to_string())
965 .api_key_secret("not-a-valid-key-format".to_string())
966 .request_method("GET".to_string())
967 .request_host("api.cdp.coinbase.com".to_string())
968 .request_path("/test".to_string())
969 .build(),
970 );
971
972 assert!(result.is_err());
973 if let Err(CdpError::Auth(msg)) = result {
974 assert!(msg.contains("Invalid key format"));
975 } else {
976 panic!("Expected Auth error for invalid key format");
977 }
978 }
979
980 #[test]
983 fn test_wallet_auth_generate_jwt_ec_key() {
984 let ec_key = generate_ec_pem_key();
985 let auth = WalletAuth::builder()
986 .api_key_id("wa-ec-key-id".to_string())
987 .api_key_secret(ec_key)
988 .build()
989 .unwrap();
990
991 let token = auth
992 .generate_jwt(
993 "GET",
994 "api.cdp.coinbase.com",
995 "/platform/v2/evm/accounts",
996 120,
997 )
998 .unwrap();
999
1000 let header = decode_jwt_header(&token);
1001 assert_eq!(header["alg"], "ES256");
1002 assert_eq!(header["kid"], "wa-ec-key-id");
1003
1004 let claims = decode_jwt_claims(&token);
1005 assert_eq!(claims["sub"], "wa-ec-key-id");
1006 assert_eq!(claims["iss"], "cdp");
1007
1008 let uris = claims["uris"].as_array().expect("uris should be an array");
1009 assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
1010 }
1011
1012 #[test]
1013 fn test_wallet_auth_generate_jwt_ed25519_key() {
1014 let ed_key = generate_ed25519_key_base64();
1015 let auth = WalletAuth::builder()
1016 .api_key_id("wa-ed-key-id".to_string())
1017 .api_key_secret(ed_key)
1018 .build()
1019 .unwrap();
1020
1021 let token = auth
1022 .generate_jwt(
1023 "POST",
1024 "api.cdp.coinbase.com",
1025 "/platform/v2/evm/accounts",
1026 60,
1027 )
1028 .unwrap();
1029
1030 let header = decode_jwt_header(&token);
1031 assert_eq!(header["alg"], "EdDSA");
1032 assert_eq!(header["kid"], "wa-ed-key-id");
1033
1034 let claims = decode_jwt_claims(&token);
1035 assert_eq!(claims["sub"], "wa-ed-key-id");
1036 assert_eq!(claims["iss"], "cdp");
1037
1038 let exp = claims["exp"].as_u64().unwrap();
1039 let iat = claims["iat"].as_u64().unwrap();
1040 assert_eq!(exp - iat, 60);
1041 }
1042}