Skip to main content

cdp_sdk/
auth.rs

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/// Configuration options for the CDP Wallet Auth client
41#[derive(Debug, Clone, Default)]
42pub struct WalletAuth {
43    /// The API key ID
44    pub api_key_id: String,
45    /// The API key secret
46    pub api_key_secret: String,
47    /// The wallet secret
48    pub wallet_secret: Option<String>,
49    /// Whether to enable debugging
50    pub debug: bool,
51    /// The source identifier for requests
52    pub source: String,
53    /// The version of the source making requests
54    pub source_version: Option<String>,
55    /// JWT expiration time in seconds
56    pub expires_in: u64,
57}
58
59/// Configuration options for standalone JWT generation.
60///
61/// This struct holds all necessary parameters for generating a JWT token
62/// for authenticating with the [Coinbase Developer Platform (CDP)](https://docs.cdp.coinbase.com/) REST APIs.
63/// It supports both EC (ES256) and Ed25519 (EdDSA) keys.
64///
65/// When `request_method`, `request_host`, and `request_path` are all `None`,
66/// the `uris` claim is omitted from the JWT (useful for websocket connections).
67///
68/// # Examples
69///
70/// ```no_run
71/// use cdp_sdk::auth::{generate_jwt, JwtOptions};
72///
73/// // For REST API requests
74/// let jwt = generate_jwt(JwtOptions::builder()
75///     .api_key_id("your-api-key-id".to_string())
76///     .api_key_secret("your-api-key-secret".to_string())
77///     .request_method("GET".to_string())
78///     .request_host("api.cdp.coinbase.com".to_string())
79///     .request_path("/platform/v2/evm/accounts".to_string())
80///     .build()
81/// )?;
82/// # Ok::<(), cdp_sdk::error::CdpError>(())
83/// ```
84#[derive(Debug, Clone)]
85pub struct JwtOptions {
86    /// The API key ID. Supported formats:
87    /// - `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
88    /// - `organizations/{orgId}/apiKeys/{keyId}`
89    pub api_key_id: String,
90    /// The API key secret. Supported formats:
91    /// - Edwards key (Ed25519): base64-encoded 64-byte key
92    /// - Elliptic Curve key (ES256): PEM-encoded EC private key
93    pub api_key_secret: String,
94    /// HTTP method (e.g. "GET", "POST"). `None` for websocket JWTs.
95    pub request_method: Option<String>,
96    /// Request host (e.g. "api.cdp.coinbase.com"). `None` for websocket JWTs.
97    pub request_host: Option<String>,
98    /// Request path (e.g. "/platform/v2/evm/accounts"). `None` for websocket JWTs.
99    pub request_path: Option<String>,
100    /// JWT expiration time in seconds. Defaults to 120 (2 minutes).
101    pub expires_in: Option<u64>,
102    /// Optional audience claim for the JWT.
103    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
130/// Generates a JWT (Bearer token) for authenticating with the CDP REST APIs.
131///
132/// This is a standalone function that accepts explicit options for JWT generation,
133/// matching the pattern used by the TypeScript, Python, and Go SDKs. Use this when
134/// you need to generate JWTs without constructing a [`WalletAuth`] instance.
135///
136/// Supports both EC (ES256) and Ed25519 (EdDSA) keys. When all request parameters
137/// (`request_method`, `request_host`, `request_path`) are `None`, the `uris` claim
138/// is omitted from the JWT (for websocket connections).
139///
140/// # Arguments
141///
142/// * `options` - Configuration options for JWT generation
143///
144/// # Returns
145///
146/// The signed JWT string.
147///
148/// # Errors
149///
150/// Returns [`CdpError::Auth`] if:
151/// - The key format is invalid (neither EC PEM nor base64 Ed25519)
152/// - Only some request parameters are provided (must be all or none)
153/// - JWT signing fails
154///
155/// # Examples
156///
157/// ```no_run
158/// use cdp_sdk::auth::{generate_jwt, JwtOptions};
159///
160/// // For REST API requests
161/// let jwt = generate_jwt(JwtOptions::builder()
162///     .api_key_id("your-key-id".to_string())
163///     .api_key_secret("your-key-secret".to_string())
164///     .request_method("GET".to_string())
165///     .request_host("api.cdp.coinbase.com".to_string())
166///     .request_path("/platform/v2/evm/accounts".to_string())
167///     .build()
168/// )?;
169///
170/// // For websocket connections (no uris claim)
171/// let ws_jwt = generate_jwt(JwtOptions::builder()
172///     .api_key_id("your-key-id".to_string())
173///     .api_key_secret("your-key-secret".to_string())
174///     .build()
175/// )?;
176/// # Ok::<(), cdp_sdk::error::CdpError>(())
177/// ```
178pub 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    // Validate: either all request params or none
187    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
224/// Parses an API key secret and returns the appropriate signing algorithm and encoding key.
225///
226/// Supports EC PEM keys (ES256) and base64-encoded Ed25519 keys (EdDSA).
227fn parse_signing_key(api_key_secret: &str) -> Result<(Algorithm, EncodingKey), CdpError> {
228    if is_ec_pem_key(api_key_secret) {
229        // PEM format EC key - use ES256
230        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        // Base64 Ed25519 key - use EdDSA
235        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        // Extract the seed (first 32 bytes) and create PKCS#8 DER format
246        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        // Convert to PEM format
253        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        /// The API key ID
280        api_key_id: Option<String>,
281        /// The API key secret
282        api_key_secret: Option<String>,
283        /// The wallet secret
284        wallet_secret: Option<String>,
285        /// Whether to enable debugging
286        debug: Option<bool>,
287        /// The source identifier for requests
288        source: Option<String>,
289        /// The version of the source making requests
290        source_version: Option<String>,
291        /// JWT expiration time in seconds
292        expires_in: Option<u64>,
293    ) -> Result<Self, CdpError> {
294        use std::env;
295
296        // Get configuration from options or environment variables
297        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    /// Generates a JWT (Bearer token) for authenticating with the CDP REST APIs.
341    ///
342    /// Uses the `api_key_id` and `api_key_secret` from this [`WalletAuth`] instance
343    /// to sign the JWT. For a standalone function that doesn't require a `WalletAuth`
344    /// instance, see [`generate_jwt`].
345    ///
346    /// # Arguments
347    ///
348    /// * `method` - The HTTP method (e.g. "GET", "POST")
349    /// * `host` - The request host (e.g. "api.cdp.coinbase.com")
350    /// * `path` - The request path (e.g. "/platform/v2/evm/accounts")
351    /// * `expires_in` - JWT expiration time in seconds
352    ///
353    /// # Returns
354    ///
355    /// The signed JWT string.
356    ///
357    /// # Errors
358    ///
359    /// Returns [`CdpError::Auth`] if the key format is invalid or signing fails.
360    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()); // Use hex format like JavaScript
409
410        // Calculate reqHash only if body is not empty, using hex format like JavaScript
411        let req_hash = if !body.is_empty() {
412            // Parse body as JSON and sort keys
413            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, // Add nbf like JavaScript
438            jti,
439            uris: vec![uri],
440            req_hash,
441        };
442
443        let header = Header::new(Algorithm::ES256);
444
445        // Decode the base64 wallet secret
446        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    /// Checks if path matches `/end-users/{id}/{suffix}` pattern.
473    /// Equivalent to regex `/end-users/[^/]+/{suffix}$`.
474    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            // after should be "{id}/{suffix}" with no extra segments
478            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        // Get request body for wallet auth
522        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        // Generate main JWT
531        let jwt = self
532            .generate_jwt(&method, host, path, expires_in)
533            .map_err(reqwest_middleware::Error::middleware)?;
534
535        // Add authorization header
536        req.headers_mut()
537            .insert("Authorization", format!("Bearer {}", jwt).parse().unwrap());
538
539        // Add content type
540        req.headers_mut()
541            .insert("Content-Type", "application/json".parse().unwrap());
542
543        // Add wallet auth if needed, and not already provided or if empty
544        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        // Add correlation data
560        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
604/// Returns `true` if the given key string is a base64-encoded Ed25519 key (64 bytes when decoded).
605///
606/// Ed25519 keys in this format consist of 32 bytes of seed followed by 32 bytes of public key,
607/// encoded together as a single 64-byte base64 string.
608pub 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
616/// Returns `true` if the given key string looks like a PEM-format EC private key.
617///
618/// Checks for the presence of PEM markers (`BEGIN`/`END`) and key type indicators
619/// (`EC PRIVATE KEY` or `PRIVATE KEY`).
620pub 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"); // default value
684    }
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        // Should require wallet auth for POST to accounts
737        assert!(auth.requires_wallet_auth("POST", "/v2/evm/accounts"));
738
739        // Should require wallet auth for PUT to accounts
740        assert!(auth.requires_wallet_auth("PUT", "/v2/evm/accounts/0x123"));
741
742        // Should require wallet auth for DELETE to accounts
743        assert!(auth.requires_wallet_auth("DELETE", "/v2/evm/accounts/0x123"));
744
745        // Should require wallet auth for spend-permissions
746        assert!(auth.requires_wallet_auth("POST", "/v2/spend-permissions"));
747
748        // Should require wallet auth for user-operations/prepare-and-send
749        assert!(auth.requires_wallet_auth("POST", "/v2/user-operations/prepare-and-send"));
750
751        // Should require wallet auth for embedded-wallet-api routes
752        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        // Should require wallet auth for end-users endpoints
757        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        // Should NOT require wallet auth for GET requests
764        assert!(!auth.requires_wallet_auth("GET", "/v2/evm/accounts"));
765        assert!(!auth.requires_wallet_auth("GET", "/v2/end-users"));
766
767        // Should NOT require wallet auth for non-matching endpoints
768        assert!(!auth.requires_wallet_auth("POST", "/v2/other/endpoint"));
769    }
770
771    #[test]
772    fn test_is_ed25519_key() {
773        // Valid base64 encoded 64-byte key
774        let valid_ed25519 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
775        assert!(is_ed25519_key(&valid_ed25519));
776
777        // Invalid key (wrong length)
778        let invalid_key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
779        assert!(!is_ed25519_key(&invalid_key));
780
781        // Not base64
782        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    // --- Test helpers ---
798
799    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    // --- Standalone generate_jwt tests ---
845
846    #[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        // uris should be absent for websocket JWTs
923        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                // missing host and path
935                .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); // default 120 seconds
982    }
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    // --- WalletAuth::generate_jwt tests ---
1026
1027    #[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}