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, Debug)]
38pub struct Signer {
39    secret: Vec<u8>,
40}
41
42impl Signer {
43    /// Create a new signer from base64-encoded secret
44    ///
45    /// Attempts to decode the secret using multiple base64 formats in order:
46    /// 1. URL-safe without padding (most common for API keys)
47    /// 2. URL-safe with padding
48    /// 3. Standard base64
49    /// 4. Falls back to raw bytes if all decoding attempts fail
50    pub fn new(secret: &str) -> Result<Self, String> {
51        let decoded = BASE64_URL_SAFE_NO_PAD
52            .decode(secret)
53            .or_else(|_| URL_SAFE.decode(secret))
54            .or_else(|_| STANDARD.decode(secret))
55            .unwrap_or_else(|_| secret.as_bytes().to_vec());
56
57        Ok(Self { secret: decoded })
58    }
59
60    /// Create a new signer from raw string secret (no base64 decoding)
61    pub fn from_raw(secret: &str) -> Self {
62        Self {
63            secret: secret.as_bytes().to_vec(),
64        }
65    }
66
67    /// Sign a message with HMAC-SHA256
68    ///
69    /// # Arguments
70    /// * `message` - The message to sign
71    /// * `format` - The base64 encoding format for the output signature
72    pub fn sign(&self, message: &str, format: Base64Format) -> Result<String, String> {
73        let mut mac = Hmac::<Sha256>::new_from_slice(&self.secret)
74            .map_err(|e| format!("Failed to create HMAC: {}", e))?;
75
76        mac.update(message.as_bytes());
77        let result = mac.finalize();
78
79        let signature = match format {
80            Base64Format::UrlSafe => {
81                // Encode with standard base64 then convert to URL-safe
82                let sig = STANDARD.encode(result.into_bytes());
83                sig.replace('+', "-").replace('/', "_")
84            }
85            Base64Format::Standard => STANDARD.encode(result.into_bytes()),
86        };
87
88        Ok(signature)
89    }
90
91    /// Create signature message for API request
92    ///
93    /// Format: timestamp + method + path + body
94    pub fn create_message(timestamp: u64, method: &str, path: &str, body: Option<&str>) -> String {
95        format!("{}{}{}{}", timestamp, method, path, body.unwrap_or(""))
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_current_timestamp() {
105        let ts = current_timestamp();
106        // Should be a reasonable Unix timestamp (after 2020)
107        assert!(ts > 1_600_000_000);
108    }
109
110    #[test]
111    fn test_signer_new() {
112        // Test with base64-encoded secret
113        let secret = "c2VjcmV0"; // "secret" in base64
114        let signer = Signer::new(secret).unwrap();
115        assert_eq!(signer.secret, b"secret");
116    }
117
118    #[test]
119    fn test_signer_from_raw() {
120        let signer = Signer::from_raw("secret");
121        assert_eq!(signer.secret, b"secret");
122    }
123
124    #[test]
125    fn test_sign_url_safe() {
126        let secret = "c2VjcmV0"; // "secret" in base64
127        let signer = Signer::new(secret).unwrap();
128
129        let message = Signer::create_message(1234567890, "GET", "/api/test", None);
130        let signature = signer.sign(&message, Base64Format::UrlSafe).unwrap();
131
132        // Signature should be URL-safe base64 (no + or /)
133        assert!(!signature.contains('+'));
134        assert!(!signature.contains('/'));
135    }
136
137    #[test]
138    fn test_sign_standard() {
139        let secret = "c2VjcmV0"; // "secret" in base64
140        let signer = Signer::new(secret).unwrap();
141
142        let message = Signer::create_message(1234567890, "GET", "/api/test", None);
143        let signature = signer.sign(&message, Base64Format::Standard).unwrap();
144
145        // Standard base64 may contain + or /
146        assert!(!signature.is_empty());
147    }
148
149    #[test]
150    fn test_create_message() {
151        let msg = Signer::create_message(1234567890, "GET", "/api/test", None);
152        assert_eq!(msg, "1234567890GET/api/test");
153
154        let msg_with_body =
155            Signer::create_message(1234567890, "POST", "/api/test", Some(r#"{"key":"value"}"#));
156        assert_eq!(msg_with_body, r#"1234567890POST/api/test{"key":"value"}"#);
157    }
158}