use crate::error::CdpError;
use base64::Engine;
use bon::bon;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Claims {
sub: String,
iss: String,
#[serde(skip_serializing_if = "Option::is_none")]
aud: Option<Vec<String>>,
exp: u64,
iat: u64,
nbf: u64,
#[serde(skip_serializing_if = "Option::is_none")]
uris: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WalletClaims {
iat: u64,
nbf: u64,
jti: String,
uris: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "reqHash")]
req_hash: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct WalletAuth {
pub api_key_id: String,
pub api_key_secret: String,
pub wallet_secret: Option<String>,
pub debug: bool,
pub source: String,
pub source_version: Option<String>,
pub expires_in: u64,
}
#[derive(Debug, Clone)]
pub struct JwtOptions {
pub api_key_id: String,
pub api_key_secret: String,
pub request_method: Option<String>,
pub request_host: Option<String>,
pub request_path: Option<String>,
pub expires_in: Option<u64>,
pub audience: Option<Vec<String>>,
}
#[bon::bon]
impl JwtOptions {
#[builder]
pub fn new(
api_key_id: String,
api_key_secret: String,
request_method: Option<String>,
request_host: Option<String>,
request_path: Option<String>,
expires_in: Option<u64>,
audience: Option<Vec<String>>,
) -> Self {
Self {
api_key_id,
api_key_secret,
request_method,
request_host,
request_path,
expires_in,
audience,
}
}
}
pub fn generate_jwt(options: JwtOptions) -> Result<String, CdpError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let expires_in = options.expires_in.unwrap_or(120);
let uris = match (
&options.request_method,
&options.request_host,
&options.request_path,
) {
(Some(method), Some(host), Some(path)) => {
Some(vec![format!("{} {}{}", method, host, path)])
}
(None, None, None) => None,
_ => {
return Err(CdpError::Auth(
"Either all request details (request_method, request_host, request_path) \
must be provided, or all must be None for websocket JWTs"
.to_string(),
));
}
};
let claims = Claims {
sub: options.api_key_id.clone(),
iss: "cdp".to_string(),
aud: options.audience,
exp: now + expires_in,
iat: now,
nbf: now,
uris,
};
let (algorithm, encoding_key) = parse_signing_key(&options.api_key_secret)?;
let mut header = Header::new(algorithm);
header.kid = Some(options.api_key_id);
encode(&header, &claims, &encoding_key)
.map_err(|e| CdpError::Auth(format!("Failed to encode JWT: {}", e)))
}
fn parse_signing_key(api_key_secret: &str) -> Result<(Algorithm, EncodingKey), CdpError> {
if is_ec_pem_key(api_key_secret) {
let key = EncodingKey::from_ec_pem(api_key_secret.as_bytes())
.map_err(|e| CdpError::Auth(format!("Failed to parse EC PEM key: {}", e)))?;
Ok((Algorithm::ES256, key))
} else if is_ed25519_key(api_key_secret) {
let decoded = base64::engine::general_purpose::STANDARD
.decode(api_key_secret)
.map_err(|e| CdpError::Auth(format!("Failed to decode Ed25519 key: {}", e)))?;
if decoded.len() != 64 {
return Err(CdpError::Auth(
"Invalid Ed25519 key length, expected 64 bytes".to_string(),
));
}
let seed = &decoded[0..32];
let mut pkcs8_der = Vec::new();
let pkcs8_header = hex::decode("302e020100300506032b657004220420").unwrap();
pkcs8_der.extend_from_slice(&pkcs8_header);
pkcs8_der.extend_from_slice(seed);
let pem_content = base64::engine::general_purpose::STANDARD.encode(&pkcs8_der);
let pem_formatted = format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----",
pem_content
.chars()
.collect::<Vec<_>>()
.chunks(64)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
);
let key = EncodingKey::from_ed_pem(pem_formatted.as_bytes())
.map_err(|e| CdpError::Auth(format!("Failed to parse Ed25519 key: {}", e)))?;
Ok((Algorithm::EdDSA, key))
} else {
Err(CdpError::Auth(
"Invalid key format - must be either PEM EC key or base64 Ed25519 key".to_string(),
))
}
}
#[bon]
impl WalletAuth {
#[builder]
pub fn new(
api_key_id: Option<String>,
api_key_secret: Option<String>,
wallet_secret: Option<String>,
debug: Option<bool>,
source: Option<String>,
source_version: Option<String>,
expires_in: Option<u64>,
) -> Result<Self, CdpError> {
use std::env;
let api_key_id = api_key_id
.or_else(|| env::var("CDP_API_KEY_ID").ok())
.ok_or_else(|| {
CdpError::Config(
"Missing required CDP API Key ID configuration.\n\n\
You can set them as environment variables:\n\
CDP_API_KEY_ID=your-api-key-id\n\
CDP_API_KEY_SECRET=your-api-key-secret\n\n\
Or pass them directly to the CdpClientOptions."
.to_string(),
)
})?;
let api_key_secret = api_key_secret
.or_else(|| env::var("CDP_API_KEY_SECRET").ok())
.ok_or_else(|| {
CdpError::Config(
"Missing required CDP API Key Secret configuration.\n\n\
You can set them as environment variables:\n\
CDP_API_KEY_ID=your-api-key-id\n\
CDP_API_KEY_SECRET=your-api-key-secret\n\n\
Or pass them directly to the CdpClientOptions."
.to_string(),
)
})?;
let wallet_secret = wallet_secret.or_else(|| env::var("CDP_WALLET_SECRET").ok());
let debug = debug.unwrap_or(false);
let expires_in = expires_in.unwrap_or(120);
let source = source.unwrap_or("sdk-auth".to_string());
Ok(WalletAuth {
api_key_id,
api_key_secret,
wallet_secret,
debug,
source,
source_version,
expires_in,
})
}
pub fn generate_jwt(
&self,
method: &str,
host: &str,
path: &str,
expires_in: u64,
) -> Result<String, CdpError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = Claims {
sub: self.api_key_id.clone(),
iss: "cdp".to_string(),
aud: None,
exp: now + expires_in,
iat: now,
nbf: now,
uris: Some(vec![format!("{} {}{}", method, host, path)]),
};
let (algorithm, encoding_key) = parse_signing_key(&self.api_key_secret)?;
let mut header = Header::new(algorithm);
header.kid = Some(self.api_key_id.clone());
encode(&header, &claims, &encoding_key)
.map_err(|e| CdpError::Auth(format!("Failed to encode JWT: {}", e)))
}
pub fn generate_wallet_jwt(
&self,
method: &str,
host: &str,
path: &str,
body: &[u8],
) -> Result<String, CdpError> {
let wallet_secret = self.wallet_secret.as_ref().ok_or_else(|| {
CdpError::Auth("Wallet secret required for this operation".to_string())
})?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let uri = format!("{} {}{}", method, host, path);
let jti = format!("{:x}", Uuid::new_v4().simple());
let req_hash = if !body.is_empty() {
let body_str = std::str::from_utf8(body)
.map_err(|e| CdpError::Auth(format!("Invalid UTF-8 in request body: {}", e)))?;
if !body_str.trim().is_empty() {
let parsed: Value = serde_json::from_str(body_str)
.map_err(|e| CdpError::Auth(format!("Failed to parse JSON body: {}", e)))?;
let sorted = sort_keys(parsed);
let sorted_json = serde_json::to_string(&sorted).map_err(|e| {
CdpError::Auth(format!("Failed to serialize sorted JSON: {}", e))
})?;
let mut hasher = Sha256::new();
hasher.update(sorted_json.as_bytes());
Some(format!("{:x}", hasher.finalize()))
} else {
None
}
} else {
None
};
let claims = WalletClaims {
iat: now,
nbf: now, jti,
uris: vec![uri],
req_hash,
};
let header = Header::new(Algorithm::ES256);
let der_bytes = base64::engine::general_purpose::STANDARD
.decode(wallet_secret)
.map_err(|e| CdpError::Auth(format!("Failed to decode wallet secret: {}", e)))?;
let encoding_key = EncodingKey::from_ec_der(&der_bytes);
encode(&header, &claims, &encoding_key)
.map_err(|e| CdpError::Auth(format!("Failed to encode wallet JWT: {}", e)))
}
fn requires_wallet_auth(&self, method: &str, path: &str) -> bool {
(path.contains("/accounts") || path.contains("/spend-permissions"))
&& (method == "POST" || method == "DELETE" || method == "PUT")
}
fn get_correlation_data(&self) -> String {
let mut data = HashMap::new();
data.insert("sdk_version".to_string(), VERSION.to_string());
data.insert("sdk_language".to_string(), "rust".to_string());
data.insert("source".to_string(), self.source.clone());
if let Some(ref source_version) = self.source_version {
data.insert("source_version".to_string(), source_version.clone());
}
data.into_iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
.collect::<Vec<_>>()
.join(",")
}
}
#[async_trait::async_trait]
impl Middleware for WalletAuth {
async fn handle(
&self,
mut req: Request,
extensions: &mut http::Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
let method = req.method().as_str().to_uppercase();
let url = req.url().clone();
let host = url.host_str().unwrap_or("api.cdp.coinbase.com");
let path = url.path();
let body = if let Some(body) = req.body() {
body.as_bytes().unwrap_or_default().to_vec()
} else {
Vec::new()
};
let expires_in = self.expires_in;
let jwt = self
.generate_jwt(&method, host, path, expires_in)
.map_err(reqwest_middleware::Error::middleware)?;
req.headers_mut()
.insert("Authorization", format!("Bearer {}", jwt).parse().unwrap());
req.headers_mut()
.insert("Content-Type", "application/json".parse().unwrap());
if self.requires_wallet_auth(&method, path)
&& (!req.headers().contains_key("X-Wallet-Auth")
|| req
.headers()
.get("X-Wallet-Auth")
.is_none_or(|v| v.is_empty()))
{
let wallet_jwt = self
.generate_wallet_jwt(&method, host, path, &body)
.map_err(reqwest_middleware::Error::middleware)?;
req.headers_mut()
.insert("X-Wallet-Auth", wallet_jwt.parse().unwrap());
}
req.headers_mut().insert(
"Correlation-Context",
self.get_correlation_data().parse().unwrap(),
);
if self.debug {
println!("Request: {} {}", method, url);
println!("Headers: {:?}", req.headers());
}
let response = next.run(req, extensions).await;
if self.debug {
if let Ok(ref resp) = response {
println!(
"Response: {} {}",
resp.status(),
resp.status().canonical_reason().unwrap_or("")
);
}
}
response
}
}
fn sort_keys(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted_map = serde_json::Map::new();
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
for key in keys {
if let Some(val) = map.get(key) {
sorted_map.insert(key.clone(), sort_keys(val.clone()));
}
}
Value::Object(sorted_map)
}
Value::Array(arr) => Value::Array(arr.into_iter().map(sort_keys).collect()),
_ => value,
}
}
pub fn is_ed25519_key(key: &str) -> bool {
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
decoded.len() == 64
} else {
false
}
}
pub fn is_ec_pem_key(key: &str) -> bool {
key.contains("-----BEGIN")
&& key.contains("-----END")
&& (key.contains("EC PRIVATE KEY") || key.contains("PRIVATE KEY"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wallet_auth_builder_with_all_fields() {
let auth = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.api_key_secret("test_key_secret".to_string())
.wallet_secret("test_wallet_secret".to_string())
.debug(true)
.source("test_source".to_string())
.source_version("1.0.0".to_string())
.expires_in(300)
.build()
.unwrap();
assert_eq!(auth.api_key_id, "test_key_id");
assert_eq!(auth.api_key_secret, "test_key_secret");
assert_eq!(auth.wallet_secret, Some("test_wallet_secret".to_string()));
assert!(auth.debug);
assert_eq!(auth.source, "test_source");
assert_eq!(auth.source_version, Some("1.0.0".to_string()));
assert_eq!(auth.expires_in, 300);
}
#[test]
fn test_wallet_auth_builder_with_required_fields_only() {
let auth = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.api_key_secret("test_key_secret".to_string())
.build()
.unwrap();
assert_eq!(auth.api_key_id, "test_key_id");
assert_eq!(auth.api_key_secret, "test_key_secret");
assert_eq!(auth.wallet_secret, None);
assert!(!auth.debug);
assert_eq!(auth.source, "sdk-auth");
assert_eq!(auth.source_version, None);
assert_eq!(auth.expires_in, 120);
}
#[test]
fn test_wallet_auth_builder_with_optional_fields() {
let auth = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.api_key_secret("test_key_secret".to_string())
.debug(true)
.expires_in(600)
.build()
.unwrap();
assert_eq!(auth.api_key_id, "test_key_id");
assert_eq!(auth.api_key_secret, "test_key_secret");
assert!(auth.debug);
assert_eq!(auth.expires_in, 600);
assert_eq!(auth.source, "sdk-auth"); }
#[test]
fn test_wallet_auth_builder_missing_api_key_id() {
let result = WalletAuth::builder()
.api_key_secret("test_key_secret".to_string())
.build();
assert!(result.is_err());
if let Err(CdpError::Config(msg)) = result {
assert!(msg.contains("Missing required CDP API Key ID configuration"));
} else {
panic!("Expected Config error for missing api_key_id");
}
}
#[test]
fn test_wallet_auth_builder_missing_api_key_secret() {
let result = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.build();
assert!(result.is_err());
if let Err(CdpError::Config(msg)) = result {
assert!(msg.contains("Missing required CDP API Key Secret configuration"));
} else {
panic!("Expected Config error for missing api_key_secret");
}
}
#[test]
fn test_wallet_auth_builder_custom_source() {
let auth = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.api_key_secret("test_key_secret".to_string())
.source("my-custom-app".to_string())
.source_version("2.1.0".to_string())
.build()
.unwrap();
assert_eq!(auth.source, "my-custom-app");
assert_eq!(auth.source_version, Some("2.1.0".to_string()));
}
#[test]
fn test_requires_wallet_auth() {
let auth = WalletAuth::builder()
.api_key_id("test_key_id".to_string())
.api_key_secret("test_key_secret".to_string())
.build()
.unwrap();
assert!(auth.requires_wallet_auth("POST", "/v2/evm/accounts"));
assert!(auth.requires_wallet_auth("PUT", "/v2/evm/accounts/0x123"));
assert!(auth.requires_wallet_auth("DELETE", "/v2/evm/accounts/0x123"));
assert!(auth.requires_wallet_auth("POST", "/v2/spend-permissions"));
assert!(!auth.requires_wallet_auth("GET", "/v2/evm/accounts"));
assert!(!auth.requires_wallet_auth("POST", "/v2/other/endpoint"));
}
#[test]
fn test_is_ed25519_key() {
let valid_ed25519 = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
assert!(is_ed25519_key(&valid_ed25519));
let invalid_key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
assert!(!is_ed25519_key(&invalid_key));
assert!(!is_ed25519_key("not-base64"));
}
#[test]
fn test_is_ec_pem_key() {
let pem_key = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----";
assert!(is_ec_pem_key(pem_key));
let generic_pem_key = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
assert!(is_ec_pem_key(generic_pem_key));
let not_pem_key = "just-a-string";
assert!(!is_ec_pem_key(not_pem_key));
}
fn generate_ec_pem_key() -> String {
use p256::ecdsa::SigningKey;
use p256::elliptic_curve::rand_core::OsRng;
use p256::pkcs8::EncodePrivateKey;
let signing_key = SigningKey::random(&mut OsRng);
let pkcs8_pem = signing_key
.to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
.expect("Failed to export EC key as PKCS8 PEM");
pkcs8_pem.to_string()
}
fn generate_ed25519_key_base64() -> String {
use ed25519_dalek::SigningKey;
let mut csprng = p256::elliptic_curve::rand_core::OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let seed = signing_key.to_bytes();
let public = signing_key.verifying_key().to_bytes();
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(&seed);
combined.extend_from_slice(&public);
base64::engine::general_purpose::STANDARD.encode(&combined)
}
fn decode_jwt_header(token: &str) -> serde_json::Value {
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3, "JWT should have 3 parts");
let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[0])
.expect("Failed to decode JWT header");
serde_json::from_slice(&header_bytes).expect("Failed to parse JWT header as JSON")
}
fn decode_jwt_claims(token: &str) -> serde_json::Value {
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3, "JWT should have 3 parts");
let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.expect("Failed to decode JWT claims");
serde_json::from_slice(&claims_bytes).expect("Failed to parse JWT claims as JSON")
}
#[test]
fn test_generate_jwt_with_ec_key() {
let ec_key = generate_ec_pem_key();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("test-key-id".to_string())
.api_key_secret(ec_key)
.request_method("GET".to_string())
.request_host("api.cdp.coinbase.com".to_string())
.request_path("/platform/v2/evm/accounts".to_string())
.expires_in(120u64)
.build(),
)
.unwrap();
let header = decode_jwt_header(&token);
assert_eq!(header["alg"], "ES256");
assert_eq!(header["kid"], "test-key-id");
let claims = decode_jwt_claims(&token);
assert_eq!(claims["sub"], "test-key-id");
assert_eq!(claims["iss"], "cdp");
assert!(claims.get("aud").is_none() || claims["aud"].is_null());
let uris = claims["uris"].as_array().expect("uris should be an array");
assert_eq!(uris.len(), 1);
assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
let exp = claims["exp"].as_u64().unwrap();
let iat = claims["iat"].as_u64().unwrap();
assert_eq!(exp - iat, 120);
}
#[test]
fn test_generate_jwt_with_ed25519_key() {
let ed_key = generate_ed25519_key_base64();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("ed-key-id".to_string())
.api_key_secret(ed_key)
.request_method("POST".to_string())
.request_host("api.cdp.coinbase.com".to_string())
.request_path("/platform/v2/evm/accounts".to_string())
.build(),
)
.unwrap();
let header = decode_jwt_header(&token);
assert_eq!(header["alg"], "EdDSA");
assert_eq!(header["kid"], "ed-key-id");
let claims = decode_jwt_claims(&token);
assert_eq!(claims["sub"], "ed-key-id");
assert_eq!(claims["iss"], "cdp");
let uris = claims["uris"].as_array().expect("uris should be an array");
assert_eq!(
uris[0],
"POST api.cdp.coinbase.com/platform/v2/evm/accounts"
);
}
#[test]
fn test_generate_jwt_websocket_no_uris() {
let ec_key = generate_ec_pem_key();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("ws-key-id".to_string())
.api_key_secret(ec_key)
.build(),
)
.unwrap();
let claims = decode_jwt_claims(&token);
assert_eq!(claims["sub"], "ws-key-id");
assert_eq!(claims["iss"], "cdp");
assert!(claims.get("uris").is_none() || claims["uris"].is_null());
}
#[test]
fn test_generate_jwt_partial_request_params_error() {
let ec_key = generate_ec_pem_key();
let result = generate_jwt(
JwtOptions::builder()
.api_key_id("test-key-id".to_string())
.api_key_secret(ec_key)
.request_method("GET".to_string())
.build(),
);
assert!(result.is_err());
if let Err(CdpError::Auth(msg)) = result {
assert!(msg.contains("Either all request details"));
} else {
panic!("Expected Auth error for partial request params");
}
}
#[test]
fn test_generate_jwt_with_audience() {
let ec_key = generate_ec_pem_key();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("aud-key-id".to_string())
.api_key_secret(ec_key)
.audience(vec!["custom-audience".to_string()])
.build(),
)
.unwrap();
let claims = decode_jwt_claims(&token);
let aud = claims["aud"].as_array().expect("aud should be an array");
assert_eq!(aud.len(), 1);
assert_eq!(aud[0], "custom-audience");
}
#[test]
fn test_generate_jwt_default_expires_in() {
let ec_key = generate_ec_pem_key();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("default-exp-key".to_string())
.api_key_secret(ec_key)
.request_method("GET".to_string())
.request_host("api.cdp.coinbase.com".to_string())
.request_path("/test".to_string())
.build(),
)
.unwrap();
let claims = decode_jwt_claims(&token);
let exp = claims["exp"].as_u64().unwrap();
let iat = claims["iat"].as_u64().unwrap();
assert_eq!(exp - iat, 120); }
#[test]
fn test_generate_jwt_custom_expires_in() {
let ec_key = generate_ec_pem_key();
let token = generate_jwt(
JwtOptions::builder()
.api_key_id("custom-exp-key".to_string())
.api_key_secret(ec_key)
.request_method("GET".to_string())
.request_host("api.cdp.coinbase.com".to_string())
.request_path("/test".to_string())
.expires_in(300u64)
.build(),
)
.unwrap();
let claims = decode_jwt_claims(&token);
let exp = claims["exp"].as_u64().unwrap();
let iat = claims["iat"].as_u64().unwrap();
assert_eq!(exp - iat, 300);
}
#[test]
fn test_generate_jwt_invalid_key_format() {
let result = generate_jwt(
JwtOptions::builder()
.api_key_id("bad-key-id".to_string())
.api_key_secret("not-a-valid-key-format".to_string())
.request_method("GET".to_string())
.request_host("api.cdp.coinbase.com".to_string())
.request_path("/test".to_string())
.build(),
);
assert!(result.is_err());
if let Err(CdpError::Auth(msg)) = result {
assert!(msg.contains("Invalid key format"));
} else {
panic!("Expected Auth error for invalid key format");
}
}
#[test]
fn test_wallet_auth_generate_jwt_ec_key() {
let ec_key = generate_ec_pem_key();
let auth = WalletAuth::builder()
.api_key_id("wa-ec-key-id".to_string())
.api_key_secret(ec_key)
.build()
.unwrap();
let token = auth
.generate_jwt(
"GET",
"api.cdp.coinbase.com",
"/platform/v2/evm/accounts",
120,
)
.unwrap();
let header = decode_jwt_header(&token);
assert_eq!(header["alg"], "ES256");
assert_eq!(header["kid"], "wa-ec-key-id");
let claims = decode_jwt_claims(&token);
assert_eq!(claims["sub"], "wa-ec-key-id");
assert_eq!(claims["iss"], "cdp");
let uris = claims["uris"].as_array().expect("uris should be an array");
assert_eq!(uris[0], "GET api.cdp.coinbase.com/platform/v2/evm/accounts");
}
#[test]
fn test_wallet_auth_generate_jwt_ed25519_key() {
let ed_key = generate_ed25519_key_base64();
let auth = WalletAuth::builder()
.api_key_id("wa-ed-key-id".to_string())
.api_key_secret(ed_key)
.build()
.unwrap();
let token = auth
.generate_jwt(
"POST",
"api.cdp.coinbase.com",
"/platform/v2/evm/accounts",
60,
)
.unwrap();
let header = decode_jwt_header(&token);
assert_eq!(header["alg"], "EdDSA");
assert_eq!(header["kid"], "wa-ed-key-id");
let claims = decode_jwt_claims(&token);
assert_eq!(claims["sub"], "wa-ed-key-id");
assert_eq!(claims["iss"], "cdp");
let exp = claims["exp"].as_u64().unwrap();
let iat = claims["iat"].as_u64().unwrap();
assert_eq!(exp - iat, 60);
}
}