Skip to main content

asknothingx2_util/oauth/
signed_token.rs

1//! Time-based HMAC-signed token generation and validation.
2//!
3//! This module provides a secure way to generate and validate time-stamped tokens
4//! using HMAC-SHA256 signatures. It's commonly used for CSRF protection, state tokens,
5//! and other security-sensitive operations requiring time-limited authentication.
6//!
7//! # Features
8//!
9//! - **Time-based validation**: Tokens include timestamps and can be validated with configurable max age
10//! - **Clock skew tolerance**: Handle slight time differences between systems
11//! - **Context binding**: Optionally bind tokens to specific contexts (e.g., user IDs)
12//! - **Cryptographically secure**: Uses HMAC-SHA256 for signature generation
13//! - **URL-safe encoding**: Tokens are base64url-encoded without padding
14//!
15//! # Token Format
16//!
17//! Tokens are structured as: `base64url(timestamp:signature)`
18//! - `timestamp`: Unix timestamp in seconds
19//! - `signature`: HMAC-SHA256 signature of `timestamp:context`
20//!
21//! # Examples
22//!
23//! ```
24//! # use asknothingx2_util::oauth::signed_token;
25//! let secret = signed_token::generate_secret_key();
26//! let token = signed_token::generate(&secret, Some("user123"));
27//!
28//! // Validate within 1 hour
29//! assert!(signed_token::verify(&secret, &token, Some("user123"), 3600).is_ok());
30//! ```
31use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
32use chrono::{DateTime, TimeZone, Utc};
33use hmac::{Hmac, Mac};
34use sha2::Sha256;
35
36type HmacSha256 = Hmac<Sha256>;
37
38/// Configuration for token validation behavior.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct TokenConfig {
41    pub clock_skew: Option<u64>,
42    pub max_age: u64,
43}
44
45impl Default for TokenConfig {
46    fn default() -> Self {
47        Self {
48            clock_skew: None,
49            // 30 minutes
50            max_age: 1800,
51        }
52    }
53}
54
55impl TokenConfig {
56    /// Creates a new token configuration.
57    ///
58    /// # Arguments
59    ///
60    /// * `clock_skew` - Clock skew tolerance in seconds (0 means disabled)
61    /// * `max_age` - Maximum token age in seconds
62    #[inline]
63    pub fn new(clock_skew: u64, max_age: u64) -> Self {
64        Self {
65            clock_skew: (clock_skew > 0).then_some(clock_skew),
66            max_age,
67        }
68    }
69
70    /// Sets the clock skew tolerance.
71    ///
72    /// # Arguments
73    ///
74    /// * `clock_skew` - Tolerance in seconds (0 to disable)
75    #[inline]
76    pub fn with_clock_skew(mut self, clock_skew: u64) -> Self {
77        self.clock_skew = (clock_skew > 0).then_some(clock_skew);
78        self
79    }
80
81    /// Sets the maximum token age.
82    #[inline]
83    pub fn with_max_age(mut self, max_age: u64) -> Self {
84        self.max_age = max_age;
85        self
86    }
87}
88
89/// Generates a new time-stamped signed token.
90///
91/// Creates a token containing the current timestamp and an optional context string,
92/// signed with HMAC-SHA256 using the provided secret key.
93///
94/// # Arguments
95///
96/// * `secret_key` - 32-byte secret key for HMAC signing
97/// * `context` - Optional context string to bind the token to (e.g., user ID, session ID)
98///
99/// # Returns
100///
101/// A base64url-encoded token string without padding.
102///
103/// # Examples
104///
105/// ```
106/// # use asknothingx2_util::oauth::signed_token::{generate, generate_secret_key};
107/// let secret = generate_secret_key();
108///
109/// let token = generate(&secret, Some("user123"));
110/// ```
111pub fn generate(secret_key: &[u8; 32], context: Option<&str>) -> String {
112    generate_at_time(secret_key, context, current_timestamp())
113}
114
115/// Generates a token with a specific timestamp.
116#[inline]
117pub fn generate_at_time(secret_key: &[u8; 32], context: Option<&str>, timestamp: i64) -> String {
118    let context_str = context.unwrap_or("");
119    let payload = format!("{timestamp}:{context_str}");
120
121    let mut mac = HmacSha256::new_from_slice(secret_key).expect("HMAC can accept keys of any size");
122    mac.update(payload.as_bytes());
123    let signature = mac.finalize().into_bytes();
124
125    let token_data = format!("{timestamp}:{}", hex::encode(signature));
126    URL_SAFE_NO_PAD.encode(token_data.as_bytes())
127}
128
129/// Validates a token.
130///
131/// # Arguments
132///
133/// * `secret_key` - 32-byte secret key used to generate the token
134/// * `token` - Token string to validate
135/// * `context` - Expected context (must match the one used during generation)
136/// * `max_age_seconds` - Maximum age in seconds
137///
138#[inline]
139pub fn verify(
140    secret_key: &[u8; 32],
141    token: &str,
142    context: Option<&str>,
143    max_age_seconds: u64,
144) -> Result<(), TokenError> {
145    verify_with_config(
146        secret_key,
147        token,
148        context,
149        &TokenConfig::default().with_max_age(max_age_seconds),
150    )
151}
152
153/// Validates a token with a custom configuration.
154#[inline]
155pub fn verify_with_config(
156    secret_key: &[u8; 32],
157    token: &str,
158    context: Option<&str>,
159    config: &TokenConfig,
160) -> Result<(), TokenError> {
161    verify_at_time(secret_key, token, context, current_timestamp(), config)
162}
163
164/// Validates a token at a specific validation time.
165pub fn verify_at_time(
166    secret_key: &[u8; 32],
167    token: &str,
168    context: Option<&str>,
169    validation_time: i64,
170    config: &TokenConfig,
171) -> Result<(), TokenError> {
172    let decoded = URL_SAFE_NO_PAD
173        .decode(token)
174        .map_err(|_| TokenError::InvalidFormat)?;
175
176    let decoded_str = String::from_utf8(decoded).map_err(|_| TokenError::InvalidFormat)?;
177
178    let mut parts = decoded_str.splitn(2, ':');
179    let timestamp_str = parts.next().ok_or(TokenError::InvalidFormat)?;
180    let signature_str = parts.next().ok_or(TokenError::InvalidFormat)?;
181
182    let timestamp: i64 = timestamp_str
183        .parse()
184        .map_err(|_| TokenError::InvalidFormat)?;
185
186    if timestamp < 0 {
187        return Err(TokenError::InvalidTimestamp);
188    }
189
190    let age = validation_time - timestamp;
191    let tolerance = config.clock_skew.unwrap_or(0) as i64;
192    let max_age = config.max_age as i64;
193    let effective_max_age = max_age + tolerance;
194    let min_age = -tolerance;
195
196    if age >= effective_max_age {
197        return Err(TokenError::Expired);
198    }
199    if age < min_age {
200        return Err(TokenError::InvalidTimestamp);
201    }
202
203    let provided_signature = hex::decode(signature_str).map_err(|_| TokenError::InvalidFormat)?;
204
205    let context_str = context.unwrap_or("");
206    let payload = format!("{timestamp}:{context_str}");
207
208    let mut mac = HmacSha256::new_from_slice(secret_key).expect("HMAC can accept keys of any size");
209    mac.update(payload.as_bytes());
210
211    mac.verify_slice(&provided_signature)
212        .map_err(|_| TokenError::InvalidSignature)
213}
214
215/// Checks if a token is expired based on max age.
216///
217/// # Errors
218///
219/// Returns [`TokenError::InvalidFormat`] if the token cannot be decoded or parsed.
220/// Returns [`TokenError::InvalidTimestamp`] if the timestamp is negative or represents a future time.
221pub fn is_expired(token: &str, max_age_seconds: u64) -> Result<bool, TokenError> {
222    let timestamp = extract_timestamp(token)?;
223
224    if timestamp < 0 {
225        return Err(TokenError::InvalidTimestamp);
226    }
227
228    let now = current_timestamp();
229    let age = now - timestamp;
230
231    if age < 0 {
232        return Err(TokenError::InvalidTimestamp);
233    }
234
235    Ok(age >= max_age_seconds as i64)
236}
237
238/// Extracts the creation timestamp from a token as a [`DateTime<Utc>`].
239///
240/// # Errors
241///
242/// Returns [`TokenError::InvalidFormat`] if the token cannot be decoded or parsed.
243/// Returns [`TokenError::InvalidTimestamp`] if the timestamp cannot be converted to a valid DateTime.
244#[inline]
245pub fn extract_datetime(token: &str) -> Result<DateTime<Utc>, TokenError> {
246    let timestamp = extract_timestamp(token)?;
247    Utc.timestamp_opt(timestamp, 0)
248        .single()
249        .ok_or(TokenError::InvalidTimestamp)
250}
251
252/// Calculates the age of a token in seconds to current time.
253///
254/// # Errors
255///
256/// Returns [`TokenError::InvalidFormat`] if the token cannot be decoded or parsed.
257#[inline]
258pub fn token_age(token: &str) -> Result<i64, TokenError> {
259    let timestamp = extract_timestamp(token)?;
260    Ok(current_timestamp() - timestamp)
261}
262
263/// Extracts the Unix timestamp from a token.
264///
265/// # Errors
266///
267/// Returns [`TokenError::InvalidFormat`] if the token cannot be decoded,
268/// is not valid UTF-8, or doesn't contain a valid timestamp.
269#[inline]
270pub fn extract_timestamp(token: &str) -> Result<i64, TokenError> {
271    let decoded = URL_SAFE_NO_PAD
272        .decode(token)
273        .map_err(|_| TokenError::InvalidFormat)?;
274    let decoded_str = String::from_utf8(decoded).map_err(|_| TokenError::InvalidFormat)?;
275    let timestamp_str = decoded_str
276        .split(':')
277        .next()
278        .ok_or(TokenError::InvalidFormat)?;
279    timestamp_str.parse().map_err(|_| TokenError::InvalidFormat)
280}
281
282/// Generates a random 32-byte secret key.
283#[inline]
284pub fn generate_secret_key() -> [u8; 32] {
285    rand::random()
286}
287
288/// Returns the current Unix timestamp in seconds.
289#[inline]
290pub fn current_timestamp() -> i64 {
291    Utc::now().timestamp()
292}
293
294#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
295pub enum TokenError {
296    #[error("token format is invalid")]
297    InvalidFormat,
298    #[error("token signature is invalid")]
299    InvalidSignature,
300    #[error("token has expired")]
301    Expired,
302    #[error("token timestamp is invalid")]
303    InvalidTimestamp,
304}