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        (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        // Get request body for wallet auth
493        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        // Generate main JWT
502        let jwt = self
503            .generate_jwt(&method, host, path, expires_in)
504            .map_err(reqwest_middleware::Error::middleware)?;
505
506        // Add authorization header
507        req.headers_mut()
508            .insert("Authorization", format!("Bearer {}", jwt).parse().unwrap());
509
510        // Add content type
511        req.headers_mut()
512            .insert("Content-Type", "application/json".parse().unwrap());
513
514        // Add wallet auth if needed, and not already provided or if empty
515        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        // Add correlation data
531        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
575/// Returns `true` if the given key string is a base64-encoded Ed25519 key (64 bytes when decoded).
576///
577/// Ed25519 keys in this format consist of 32 bytes of seed followed by 32 bytes of public key,
578/// encoded together as a single 64-byte base64 string.
579pub 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
587/// Returns `true` if the given key string looks like a PEM-format EC private key.
588///
589/// Checks for the presence of PEM markers (`BEGIN`/`END`) and key type indicators
590/// (`EC PRIVATE KEY` or `PRIVATE KEY`).
591pub 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"); // default value
655    }
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        // Should require wallet auth for POST to accounts
708        assert!(auth.requires_wallet_auth("POST", "/v2/evm/accounts"));
709
710        // Should require wallet auth for PUT to accounts
711        assert!(auth.requires_wallet_auth("PUT", "/v2/evm/accounts/0x123"));
712
713        // Should require wallet auth for DELETE to accounts
714        assert!(auth.requires_wallet_auth("DELETE", "/v2/evm/accounts/0x123"));
715
716        // Should require wallet auth for spend-permissions
717        assert!(auth.requires_wallet_auth("POST", "/v2/spend-permissions"));
718
719        // Should NOT require wallet auth for GET requests
720        assert!(!auth.requires_wallet_auth("GET", "/v2/evm/accounts"));
721
722        // Should NOT require wallet auth for non-account endpoints
723        assert!(!auth.requires_wallet_auth("POST", "/v2/other/endpoint"));
724    }
725
726    #[test]
727    fn test_is_ed25519_key() {
728        // Valid base64 encoded 64-byte key
729        let valid_ed25519 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
730        assert!(is_ed25519_key(&valid_ed25519));
731
732        // Invalid key (wrong length)
733        let invalid_key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
734        assert!(!is_ed25519_key(&invalid_key));
735
736        // Not base64
737        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    // --- Test helpers ---
753
754    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    // --- Standalone generate_jwt tests ---
800
801    #[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        // uris should be absent for websocket JWTs
878        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                // missing host and path
890                .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); // default 120 seconds
937    }
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    // --- WalletAuth::generate_jwt tests ---
981
982    #[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}