noah_sdk/
auth.rs

1//! Authentication module for JWT signing and API key management
2
3use crate::error::{NoahError, Result};
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5use hmac::{Hmac, Mac};
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Authentication configuration
13#[derive(Debug, Clone)]
14pub struct AuthConfig {
15    /// API key for X-Api-Key header
16    pub api_key: Option<String>,
17    /// Secret key for JWT signing
18    pub secret_key: Option<String>,
19}
20
21impl AuthConfig {
22    /// Create new auth config with API key only
23    pub fn with_api_key(api_key: String) -> Self {
24        Self {
25            api_key: Some(api_key),
26            secret_key: None,
27        }
28    }
29
30    /// Create new auth config with JWT secret key only
31    pub fn with_secret_key(secret_key: String) -> Self {
32        Self {
33            api_key: None,
34            secret_key: Some(secret_key),
35        }
36    }
37
38    /// Create new auth config with both API key and secret key
39    pub fn with_both(api_key: String, secret_key: String) -> Self {
40        Self {
41            api_key: Some(api_key),
42            secret_key: Some(secret_key),
43        }
44    }
45}
46
47/// JWT payload structure
48#[derive(Debug, Serialize, Deserialize)]
49struct JwtPayload {
50    /// Issued at timestamp
51    iat: u64,
52    /// Expiration timestamp
53    exp: u64,
54    /// HTTP method
55    method: String,
56    /// Request path
57    path: String,
58    /// Request body hash (if applicable)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    body_hash: Option<String>,
61}
62
63/// Generate JWT signature for Api-Signature header
64pub fn generate_jwt_signature(
65    secret_key: &str,
66    method: &str,
67    path: &str,
68    body: Option<&[u8]>,
69) -> Result<String> {
70    let now = SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .map_err(|e| NoahError::JwtError(format!("Failed to get timestamp: {e}")))?;
73
74    let iat = now.as_secs();
75    let exp = iat + 300; // 5 minutes expiration
76
77    // Calculate body hash if body is provided
78    let body_hash = body.map(|b| {
79        use sha2::Digest;
80        let mut hasher = sha2::Sha256::new();
81        hasher.update(b);
82        format!("{:x}", hasher.finalize())
83    });
84
85    let payload = JwtPayload {
86        iat,
87        exp,
88        method: method.to_uppercase(),
89        path: path.to_string(),
90        body_hash,
91    };
92
93    // Create JWT header
94    let header = serde_json::json!({
95        "alg": "HS256",
96        "typ": "JWT"
97    });
98
99    // Encode header and payload
100    let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?);
101    let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?);
102    let message = format!("{header_b64}.{payload_b64}");
103
104    // Sign with HMAC-SHA256
105    let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
106        .map_err(|e| NoahError::JwtError(format!("Invalid secret key: {e}")))?;
107    mac.update(message.as_bytes());
108    let signature = mac.finalize();
109    let signature_b64 = URL_SAFE_NO_PAD.encode(signature.into_bytes());
110
111    Ok(format!("{message}.{signature_b64}"))
112}
113
114/// Add authentication headers to a request builder
115#[cfg(feature = "async")]
116pub fn add_auth_headers_async(
117    builder: reqwest::RequestBuilder,
118    auth_config: &AuthConfig,
119    method: &str,
120    path: &str,
121    body: Option<&[u8]>,
122) -> Result<reqwest::RequestBuilder> {
123    let mut builder = builder;
124
125    // Add API key if available
126    if let Some(ref api_key) = auth_config.api_key {
127        builder = builder.header("X-Api-Key", api_key);
128    }
129
130    // Add JWT signature if secret key is available
131    if let Some(ref secret_key) = auth_config.secret_key {
132        let signature = generate_jwt_signature(secret_key, method, path, body)?;
133        builder = builder.header("Api-Signature", signature);
134    }
135
136    Ok(builder)
137}
138
139/// Add authentication headers to a request builder (blocking)
140#[cfg(feature = "sync")]
141pub fn add_auth_headers_sync(
142    builder: reqwest::blocking::RequestBuilder,
143    auth_config: &AuthConfig,
144    method: &str,
145    path: &str,
146    body: Option<&[u8]>,
147) -> Result<reqwest::blocking::RequestBuilder> {
148    let mut builder = builder;
149
150    // Add API key if available
151    if let Some(ref api_key) = auth_config.api_key {
152        builder = builder.header("X-Api-Key", api_key);
153    }
154
155    // Add JWT signature if secret key is available
156    if let Some(ref secret_key) = auth_config.secret_key {
157        let signature = generate_jwt_signature(secret_key, method, path, body)?;
158        builder = builder.header("Api-Signature", signature);
159    }
160
161    Ok(builder)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_jwt_signature_generation() {
170        let secret = "test-secret-key";
171        let method = "GET";
172        let path = "/balances";
173        let signature = generate_jwt_signature(secret, method, path, None).unwrap();
174        assert!(!signature.is_empty());
175        assert!(signature.contains('.'));
176    }
177}