Skip to main content

polyoxide_core/
auth.rs

1//! Authentication utilities for Polymarket API clients
2//!
3//! This module provides shared authentication functionality used across CLOB and Relay clients.
4
5use base64::{
6    engine::general_purpose::{STANDARD, URL_SAFE},
7    prelude::BASE64_URL_SAFE_NO_PAD,
8    Engine,
9};
10use hmac::{Hmac, Mac};
11use sha2::Sha256;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Get current Unix timestamp in seconds
15///
16/// Uses `unwrap_or_default()` to safely handle potential system time errors
17/// by falling back to epoch (0) rather than panicking.
18pub fn current_timestamp() -> u64 {
19    SystemTime::now()
20        .duration_since(UNIX_EPOCH)
21        .unwrap_or_default()
22        .as_secs()
23}
24
25/// Base64 encoding format for HMAC signatures
26#[derive(Clone, Copy, Debug)]
27pub enum Base64Format {
28    /// URL-safe base64 (replaces + with - and / with _)
29    UrlSafe,
30    /// Standard base64
31    Standard,
32}
33
34/// HMAC signer for API authentication
35///
36/// Supports both base64-encoded and raw string secrets, with configurable output format.
37#[derive(Clone)]
38pub struct Signer {
39    secret: Vec<u8>,
40}
41
42impl std::fmt::Debug for Signer {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Signer")
45            .field("secret", &"[REDACTED]")
46            .finish()
47    }
48}
49
50impl Signer {
51    /// Create a new signer from base64-encoded secret
52    ///
53    /// Attempts to decode the secret using multiple base64 formats in order:
54    /// 1. URL-safe without padding (most common for API keys)
55    /// 2. URL-safe with padding
56    /// 3. Standard base64
57    /// 4. Falls back to raw bytes if all decoding attempts fail
58    pub fn new(secret: &str) -> Self {
59        let decoded = BASE64_URL_SAFE_NO_PAD
60            .decode(secret)
61            .or_else(|_| URL_SAFE.decode(secret))
62            .or_else(|_| STANDARD.decode(secret))
63            .unwrap_or_else(|_| secret.as_bytes().to_vec());
64
65        Self { secret: decoded }
66    }
67
68    /// Create a new signer from raw string secret (no base64 decoding)
69    pub fn from_raw(secret: &str) -> Self {
70        Self {
71            secret: secret.as_bytes().to_vec(),
72        }
73    }
74
75    /// Sign a message with HMAC-SHA256
76    ///
77    /// # Arguments
78    /// * `message` - The message to sign
79    /// * `format` - The base64 encoding format for the output signature
80    pub fn sign(&self, message: &str, format: Base64Format) -> Result<String, String> {
81        let mut mac = Hmac::<Sha256>::new_from_slice(&self.secret)
82            .map_err(|e| format!("Failed to create HMAC: {}", e))?;
83
84        mac.update(message.as_bytes());
85        let result = mac.finalize();
86
87        let signature = match format {
88            Base64Format::UrlSafe => {
89                // Encode with standard base64 then convert to URL-safe
90                let sig = STANDARD.encode(result.into_bytes());
91                sig.replace('+', "-").replace('/', "_")
92            }
93            Base64Format::Standard => STANDARD.encode(result.into_bytes()),
94        };
95
96        Ok(signature)
97    }
98
99    /// Create signature message for API request
100    ///
101    /// Format: timestamp + method + path + body
102    pub fn create_message(timestamp: u64, method: &str, path: &str, body: Option<&str>) -> String {
103        format!("{}{}{}{}", timestamp, method, path, body.unwrap_or(""))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_current_timestamp() {
113        let ts = current_timestamp();
114        // Should be a reasonable Unix timestamp (after 2020)
115        assert!(ts > 1_600_000_000);
116    }
117
118    #[test]
119    fn test_signer_new() {
120        // Test with base64-encoded secret
121        let secret = "c2VjcmV0"; // "secret" in base64
122        let signer = Signer::new(secret);
123        assert_eq!(signer.secret, b"secret");
124    }
125
126    #[test]
127    fn test_signer_from_raw() {
128        let signer = Signer::from_raw("secret");
129        assert_eq!(signer.secret, b"secret");
130    }
131
132    #[test]
133    fn test_sign_url_safe() {
134        let secret = "c2VjcmV0"; // "secret" in base64
135        let signer = Signer::new(secret);
136
137        let message = Signer::create_message(1234567890, "GET", "/api/test", None);
138        let signature = signer.sign(&message, Base64Format::UrlSafe).unwrap();
139
140        // Signature should be URL-safe base64 (no + or /)
141        assert!(!signature.contains('+'));
142        assert!(!signature.contains('/'));
143    }
144
145    #[test]
146    fn test_sign_standard() {
147        let secret = "c2VjcmV0"; // "secret" in base64
148        let signer = Signer::new(secret);
149
150        let message = Signer::create_message(1234567890, "GET", "/api/test", None);
151        let signature = signer.sign(&message, Base64Format::Standard).unwrap();
152
153        // Standard base64 may contain + or /
154        assert!(!signature.is_empty());
155    }
156
157    #[test]
158    fn test_create_message() {
159        let msg = Signer::create_message(1234567890, "GET", "/api/test", None);
160        assert_eq!(msg, "1234567890GET/api/test");
161
162        let msg_with_body =
163            Signer::create_message(1234567890, "POST", "/api/test", Some(r#"{"key":"value"}"#));
164        assert_eq!(msg_with_body, r#"1234567890POST/api/test{"key":"value"}"#);
165    }
166
167    #[test]
168    fn test_signer_debug_redacts_secret() {
169        // "c2VjcmV0" is "secret" in base64, decoded bytes are [115, 101, 99, 114, 101, 116]
170        let signer = Signer::new("c2VjcmV0");
171        let debug_output = format!("{:?}", signer);
172        assert!(
173            debug_output.contains("[REDACTED]"),
174            "Debug output should contain [REDACTED]: {}",
175            debug_output
176        );
177        // Should not contain the base64 secret or the decoded bytes
178        assert!(
179            !debug_output.contains("c2VjcmV0"),
180            "Debug output should not contain the base64 secret: {}",
181            debug_output
182        );
183        assert!(
184            !debug_output.contains("115, 101"),
185            "Debug output should not contain decoded secret bytes: {}",
186            debug_output
187        );
188    }
189}