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