asknothingx2_util/oauth/
signed_token.rs1use 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#[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 max_age: 1800,
51 }
52 }
53}
54
55impl TokenConfig {
56 #[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 #[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 #[inline]
83 pub fn with_max_age(mut self, max_age: u64) -> Self {
84 self.max_age = max_age;
85 self
86 }
87}
88
89pub fn generate(secret_key: &[u8; 32], context: Option<&str>) -> String {
112 generate_at_time(secret_key, context, current_timestamp())
113}
114
115#[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#[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#[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
164pub 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
215pub 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#[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#[inline]
258pub fn token_age(token: &str) -> Result<i64, TokenError> {
259 let timestamp = extract_timestamp(token)?;
260 Ok(current_timestamp() - timestamp)
261}
262
263#[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#[inline]
284pub fn generate_secret_key() -> [u8; 32] {
285 rand::random()
286}
287
288#[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}