wsi_streamer/server/
auth.rs

1//! Signed URL authentication for WSI Streamer.
2//!
3//! This module provides HMAC-SHA256 based URL signing for secure tile access.
4//!
5//! # URL Signing Scheme
6//!
7//! URLs are signed by computing an HMAC-SHA256 over the path and query parameters
8//! (excluding `sig`). This binds signatures to the full request path and query:
9//!
10//! ```text
11//! signature = HMAC-SHA256(secret_key, "{path}?{canonical_query}")
12//! ```
13//!
14//! The query string must include `exp` and may include extra parameters like
15//! `quality`. The `sig` parameter is excluded from the canonical query.
16//!
17//! ```text
18//! /tiles/slides/sample.svs/0/1/2.jpg?quality=80&exp=1735689600&sig=abc123...
19//! ```
20//!
21//! # Security Properties
22//!
23//! - **Path + query binding**: Signatures are bound to paths and query params, preventing tampering
24//! - **Time-limited**: Signatures expire after a configurable TTL
25//! - **Constant-time comparison**: Signature verification uses constant-time comparison
26//!   to prevent timing attacks
27//!
28//! # Example
29//!
30//! ```rust
31//! use wsi_streamer::server::auth::SignedUrlAuth;
32//! use std::time::{SystemTime, Duration};
33//!
34//! // Create authenticator with secret key
35//! let auth = SignedUrlAuth::new("my-secret-key");
36//!
37//! // Generate a signed URL (valid for 1 hour)
38//! let path = "/tiles/slides/sample.svs/0/1/2.jpg";
39//! let (signature, expiry) = auth.sign(path, Duration::from_secs(3600));
40//!
41//! // Verify the signature
42//! assert!(auth.verify(path, &signature, expiry, &[]).is_ok());
43//! ```
44
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46
47use axum::{
48    extract::{FromRequestParts, OriginalUri, Request},
49    http::{request::Parts, StatusCode},
50    middleware::Next,
51    response::{IntoResponse, Response},
52    Json,
53};
54use hmac::{Hmac, Mac};
55use serde::Deserialize;
56use sha2::Sha256;
57use subtle::ConstantTimeEq;
58use tracing::{debug, warn};
59use url::form_urlencoded;
60
61use super::handlers::ErrorResponse;
62
63// =============================================================================
64// Types
65// =============================================================================
66
67/// HMAC-SHA256 type alias
68type HmacSha256 = Hmac<Sha256>;
69
70/// Authentication error types.
71#[derive(Debug, Clone)]
72pub enum AuthError {
73    /// Signature is missing from request
74    MissingSignature,
75
76    /// Expiry timestamp is missing from request
77    MissingExpiry,
78
79    /// Signature has expired
80    Expired {
81        /// When the signature expired
82        expired_at: u64,
83        /// Current time
84        current_time: u64,
85    },
86
87    /// Signature is invalid
88    InvalidSignature,
89
90    /// Signature format is invalid (not valid hex)
91    InvalidSignatureFormat,
92
93    /// Expiry timestamp is not a valid integer
94    InvalidExpiryFormat,
95}
96
97impl std::fmt::Display for AuthError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            AuthError::MissingSignature => write!(f, "Missing signature parameter"),
101            AuthError::MissingExpiry => write!(f, "Missing expiry parameter"),
102            AuthError::Expired {
103                expired_at,
104                current_time,
105            } => write!(
106                f,
107                "Signature expired at {} (current time: {})",
108                expired_at, current_time
109            ),
110            AuthError::InvalidSignature => write!(f, "Invalid signature"),
111            AuthError::InvalidSignatureFormat => write!(f, "Invalid signature format"),
112            AuthError::InvalidExpiryFormat => write!(f, "Invalid expiry format"),
113        }
114    }
115}
116
117impl IntoResponse for AuthError {
118    fn into_response(self) -> Response {
119        let (status, error_type, message) = match &self {
120            AuthError::MissingSignature => (
121                StatusCode::UNAUTHORIZED,
122                "missing_signature",
123                self.to_string(),
124            ),
125            AuthError::MissingExpiry => {
126                (StatusCode::UNAUTHORIZED, "missing_expiry", self.to_string())
127            }
128            AuthError::Expired { .. } => (
129                StatusCode::UNAUTHORIZED,
130                "signature_expired",
131                self.to_string(),
132            ),
133            AuthError::InvalidSignature => (
134                StatusCode::UNAUTHORIZED,
135                "invalid_signature",
136                self.to_string(),
137            ),
138            AuthError::InvalidSignatureFormat => (
139                StatusCode::BAD_REQUEST,
140                "invalid_signature_format",
141                self.to_string(),
142            ),
143            AuthError::InvalidExpiryFormat => (
144                StatusCode::BAD_REQUEST,
145                "invalid_expiry_format",
146                self.to_string(),
147            ),
148        };
149
150        // Log authentication errors
151        // Invalid signature could indicate an attack, so log at warn level
152        // Expired signatures are common and expected, log at debug
153        match &self {
154            AuthError::InvalidSignature => {
155                warn!(
156                    error_type = error_type,
157                    status = status.as_u16(),
158                    "Authentication failed: {}",
159                    message
160                );
161            }
162            AuthError::Expired { .. } => {
163                debug!(
164                    error_type = error_type,
165                    status = status.as_u16(),
166                    "Authentication failed: {}",
167                    message
168                );
169            }
170            _ => {
171                debug!(
172                    error_type = error_type,
173                    status = status.as_u16(),
174                    "Authentication failed: {}",
175                    message
176                );
177            }
178        }
179
180        let error_response = ErrorResponse::with_status(error_type, message, status);
181        (status, Json(error_response)).into_response()
182    }
183}
184
185// =============================================================================
186// Signed URL Authentication
187// =============================================================================
188
189/// Signed URL authenticator using HMAC-SHA256.
190///
191/// This struct provides methods for generating and verifying signed URLs.
192/// The signing scheme binds signatures to paths, query params, and expiry times.
193#[derive(Clone)]
194pub struct SignedUrlAuth {
195    /// Secret key for HMAC computation
196    secret_key: Vec<u8>,
197}
198
199impl SignedUrlAuth {
200    /// Create a new authenticator with the given secret key.
201    ///
202    /// # Arguments
203    ///
204    /// * `secret_key` - The secret key used for HMAC computation. Should be
205    ///   at least 32 bytes for security.
206    pub fn new(secret_key: impl AsRef<[u8]>) -> Self {
207        Self {
208            secret_key: secret_key.as_ref().to_vec(),
209        }
210    }
211
212    /// Sign a path with an expiry duration.
213    ///
214    /// Returns the hex-encoded signature and the expiry timestamp (Unix epoch seconds).
215    ///
216    /// # Arguments
217    ///
218    /// * `path` - The URL path to sign (e.g., "/tiles/slides/sample.svs/0/1/2.jpg")
219    /// * `ttl` - How long the signature should be valid
220    ///
221    /// # Returns
222    ///
223    /// A tuple of (signature, expiry_timestamp)
224    pub fn sign(&self, path: &str, ttl: Duration) -> (String, u64) {
225        self.sign_with_params(path, ttl, &[])
226    }
227
228    /// Sign a path with extra query parameters.
229    ///
230    /// `params` should exclude `exp` and `sig`; those are added automatically.
231    pub fn sign_with_params(
232        &self,
233        path: &str,
234        ttl: Duration,
235        params: &[(&str, &str)],
236    ) -> (String, u64) {
237        let expiry = SystemTime::now()
238            .duration_since(UNIX_EPOCH)
239            .unwrap()
240            .as_secs()
241            + ttl.as_secs();
242
243        let signature = self.compute_signature(path, expiry, params);
244        (signature, expiry)
245    }
246
247    /// Sign a path with a specific expiry timestamp.
248    ///
249    /// This is useful when you need to generate signatures for a specific time.
250    ///
251    /// # Arguments
252    ///
253    /// * `path` - The URL path to sign
254    /// * `expiry` - Unix timestamp when the signature expires
255    ///
256    /// # Returns
257    ///
258    /// The hex-encoded signature
259    pub fn sign_with_expiry(&self, path: &str, expiry: u64) -> String {
260        self.sign_with_expiry_and_params(path, expiry, &[])
261    }
262
263    /// Sign a path with a specific expiry timestamp and extra parameters.
264    ///
265    /// `params` should exclude `exp` and `sig`; those are added automatically.
266    pub fn sign_with_expiry_and_params(
267        &self,
268        path: &str,
269        expiry: u64,
270        params: &[(&str, &str)],
271    ) -> String {
272        self.compute_signature(path, expiry, params)
273    }
274
275    /// Verify a signature for a path and expiry.
276    ///
277    /// # Arguments
278    ///
279    /// * `path` - The URL path that was signed
280    /// * `signature` - The hex-encoded signature to verify
281    /// * `expiry` - The expiry timestamp from the URL
282    ///
283    /// # Returns
284    ///
285    /// `Ok(())` if the signature is valid and not expired, `Err(AuthError)` otherwise.
286    pub fn verify(
287        &self,
288        path: &str,
289        signature: &str,
290        expiry: u64,
291        params: &[(&str, &str)],
292    ) -> Result<(), AuthError> {
293        // Check expiry first
294        let current_time = SystemTime::now()
295            .duration_since(UNIX_EPOCH)
296            .unwrap()
297            .as_secs();
298
299        if current_time > expiry {
300            return Err(AuthError::Expired {
301                expired_at: expiry,
302                current_time,
303            });
304        }
305
306        // Decode the provided signature
307        let provided_sig = hex::decode(signature).map_err(|_| AuthError::InvalidSignatureFormat)?;
308
309        // Compute expected signature
310        let expected_sig_hex = self.compute_signature(path, expiry, params);
311        let expected_sig =
312            hex::decode(&expected_sig_hex).map_err(|_| AuthError::InvalidSignatureFormat)?;
313
314        // Constant-time comparison
315        if provided_sig.ct_eq(&expected_sig).into() {
316            Ok(())
317        } else {
318            Err(AuthError::InvalidSignature)
319        }
320    }
321
322    /// Compute the HMAC-SHA256 signature for a path and expiry.
323    fn compute_signature(&self, path: &str, expiry: u64, params: &[(&str, &str)]) -> String {
324        let message = signature_base(path, expiry, params);
325
326        // Compute HMAC-SHA256
327        let mut mac =
328            HmacSha256::new_from_slice(&self.secret_key).expect("HMAC can take key of any size");
329        mac.update(message.as_bytes());
330        let result = mac.finalize();
331
332        // Return hex-encoded signature
333        hex::encode(result.into_bytes())
334    }
335
336    /// Generate a complete signed URL.
337    ///
338    /// # Arguments
339    ///
340    /// * `base_url` - The base URL (e.g., `https://example.com`)
341    /// * `path` - The path to sign (e.g., "/tiles/slides/sample.svs/0/1/2.jpg")
342    /// * `ttl` - How long the signature should be valid
343    /// * `extra_params` - Additional query parameters to include
344    ///
345    /// # Returns
346    ///
347    /// The complete signed URL
348    pub fn generate_signed_url(
349        &self,
350        base_url: &str,
351        path: &str,
352        ttl: Duration,
353        extra_params: &[(&str, &str)],
354    ) -> String {
355        let (signature, expiry) = self.sign_with_params(path, ttl, extra_params);
356
357        let mut url = format!("{}{}", base_url, path);
358
359        let mut serializer = form_urlencoded::Serializer::new(String::new());
360        for (key, value) in extra_params {
361            serializer.append_pair(key, value);
362        }
363        serializer.append_pair("exp", &expiry.to_string());
364        serializer.append_pair("sig", &signature);
365
366        url.push('?');
367        url.push_str(&serializer.finish());
368
369        url
370    }
371
372    /// Generate a viewer token for accessing all tiles of a specific slide.
373    ///
374    /// Viewer tokens are special tokens that authorize access to all tiles
375    /// for a given slide, rather than signing individual tile paths. This is
376    /// used by the built-in viewer to access tiles when auth is enabled.
377    ///
378    /// # Arguments
379    ///
380    /// * `slide_id` - The slide identifier
381    /// * `ttl` - How long the token should be valid
382    ///
383    /// # Returns
384    ///
385    /// A tuple of (token, expiry_timestamp)
386    pub fn generate_viewer_token(&self, slide_id: &str, ttl: Duration) -> (String, u64) {
387        let expiry = SystemTime::now()
388            .duration_since(UNIX_EPOCH)
389            .unwrap()
390            .as_secs()
391            + ttl.as_secs();
392
393        let message = format!("viewer:{}:{}", slide_id, expiry);
394
395        let mut mac =
396            HmacSha256::new_from_slice(&self.secret_key).expect("HMAC can take key of any size");
397        mac.update(message.as_bytes());
398        let result = mac.finalize();
399
400        (hex::encode(result.into_bytes()), expiry)
401    }
402
403    /// Verify a viewer token for a specific slide.
404    ///
405    /// # Arguments
406    ///
407    /// * `slide_id` - The slide identifier the token should authorize
408    /// * `token` - The hex-encoded viewer token
409    /// * `expiry` - The expiry timestamp
410    ///
411    /// # Returns
412    ///
413    /// `Ok(())` if the token is valid and not expired, `Err(AuthError)` otherwise.
414    pub fn verify_viewer_token(
415        &self,
416        slide_id: &str,
417        token: &str,
418        expiry: u64,
419    ) -> Result<(), AuthError> {
420        // Check expiry first
421        let current_time = SystemTime::now()
422            .duration_since(UNIX_EPOCH)
423            .unwrap()
424            .as_secs();
425
426        if current_time > expiry {
427            return Err(AuthError::Expired {
428                expired_at: expiry,
429                current_time,
430            });
431        }
432
433        // Decode the provided token
434        let provided_token = hex::decode(token).map_err(|_| AuthError::InvalidSignatureFormat)?;
435
436        // Compute expected token
437        let message = format!("viewer:{}:{}", slide_id, expiry);
438        let mut mac =
439            HmacSha256::new_from_slice(&self.secret_key).expect("HMAC can take key of any size");
440        mac.update(message.as_bytes());
441        let expected_token = mac.finalize().into_bytes();
442
443        // Constant-time comparison
444        if provided_token.ct_eq(&expected_token).into() {
445            Ok(())
446        } else {
447            Err(AuthError::InvalidSignature)
448        }
449    }
450}
451
452fn signature_base(path: &str, expiry: u64, params: &[(&str, &str)]) -> String {
453    let mut all_params: Vec<(String, String)> = Vec::with_capacity(params.len() + 1);
454    for (key, value) in params {
455        all_params.push(((*key).to_string(), (*value).to_string()));
456    }
457    all_params.push(("exp".to_string(), expiry.to_string()));
458
459    let canonical = canonical_query(&all_params);
460    if canonical.is_empty() {
461        path.to_string()
462    } else {
463        format!("{}?{}", path, canonical)
464    }
465}
466
467fn canonical_query(params: &[(String, String)]) -> String {
468    let mut pairs = params.to_vec();
469    pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
470    pairs
471        .into_iter()
472        .map(|(key, value)| format!("{}={}", key, value))
473        .collect::<Vec<_>>()
474        .join("&")
475}
476
477// =============================================================================
478// Query Parameters for Auth
479// =============================================================================
480
481/// Query parameters for authentication.
482#[derive(Debug, Deserialize)]
483pub struct AuthQueryParams {
484    /// Signature (hex-encoded HMAC-SHA256)
485    pub sig: Option<String>,
486
487    /// Expiry timestamp (Unix epoch seconds)
488    pub exp: Option<u64>,
489}
490
491// =============================================================================
492// Axum Middleware
493// =============================================================================
494
495/// Axum middleware for verifying signed URLs.
496///
497/// This middleware extracts the signature and expiry from query parameters,
498/// verifies them against the request path, and rejects unauthorized requests
499/// with a 401 status code.
500///
501/// The middleware supports two authentication modes:
502/// 1. **Path-specific signatures**: Uses `sig` and `exp` query params to verify
503///    a signature for the exact request path.
504/// 2. **Viewer tokens**: Uses `vt` and `exp` query params to verify a token
505///    that authorizes access to all tiles for a specific slide.
506///
507/// # Example
508///
509/// ```ignore
510/// use axum::{Router, middleware};
511/// use wsi_streamer::server::auth::{SignedUrlAuth, auth_middleware};
512///
513/// let auth = SignedUrlAuth::new("secret-key");
514/// let app = Router::new()
515///     .route("/tiles/*path", get(tile_handler))
516///     .layer(middleware::from_fn_with_state(auth, auth_middleware));
517/// ```
518pub async fn auth_middleware(
519    axum::extract::State(auth): axum::extract::State<SignedUrlAuth>,
520    OriginalUri(original_uri): OriginalUri,
521    request: Request,
522    next: Next,
523) -> Result<Response, AuthError> {
524    let query = original_uri.query().unwrap_or("");
525    let mut signature: Option<String> = None;
526    let mut viewer_token: Option<String> = None;
527    let mut expiry: Option<u64> = None;
528    let mut extra_params: Vec<(String, String)> = Vec::new();
529
530    for (key, value) in form_urlencoded::parse(query.as_bytes()) {
531        if key == "sig" {
532            if signature.is_some() {
533                return Err(AuthError::InvalidSignatureFormat);
534            }
535            signature = Some(value.into_owned());
536            continue;
537        }
538        if key == "vt" {
539            if viewer_token.is_some() {
540                return Err(AuthError::InvalidSignatureFormat);
541            }
542            viewer_token = Some(value.into_owned());
543            continue;
544        }
545        if key == "exp" {
546            if expiry.is_some() {
547                return Err(AuthError::InvalidExpiryFormat);
548            }
549            let parsed = value
550                .parse::<u64>()
551                .map_err(|_| AuthError::InvalidExpiryFormat)?;
552            expiry = Some(parsed);
553            continue;
554        }
555
556        extra_params.push((key.into_owned(), value.into_owned()));
557    }
558
559    let expiry = expiry.ok_or(AuthError::MissingExpiry)?;
560    let path = original_uri.path();
561
562    // Check for viewer token first (used by built-in viewer)
563    if let Some(token) = viewer_token {
564        // Extract slide_id from the path
565        // Expected formats: /tiles/{slide_id}/... or /slides/{slide_id}/...
566        let slide_id = extract_slide_id_from_path(path);
567        if let Some(slide_id) = slide_id {
568            auth.verify_viewer_token(&slide_id, &token, expiry)?;
569            return Ok(next.run(request).await);
570        }
571        // If we can't extract slide_id, fall through to require regular signature
572    }
573
574    // Fall back to regular signature verification
575    let signature = signature.ok_or(AuthError::MissingSignature)?;
576
577    // Verify signature
578    let extra_params_ref: Vec<(&str, &str)> = extra_params
579        .iter()
580        .map(|(key, value)| (key.as_str(), value.as_str()))
581        .collect();
582    auth.verify(path, &signature, expiry, &extra_params_ref)?;
583
584    // Continue to the handler
585    Ok(next.run(request).await)
586}
587
588/// Extract the slide_id from a tile or slides path.
589///
590/// Handles paths like:
591/// - `/tiles/{slide_id}/{level}/{x}/{y}.jpg`
592/// - `/slides/{slide_id}`
593/// - `/slides/{slide_id}/dzi`
594/// - `/slides/{slide_id}/thumbnail`
595fn extract_slide_id_from_path(path: &str) -> Option<String> {
596    let parts: Vec<&str> = path.split('/').collect();
597
598    // Expected: ["", "tiles" or "slides", slide_id, ...]
599    if parts.len() < 3 {
600        return None;
601    }
602
603    match parts[1] {
604        "tiles" | "slides" => {
605            // URL-decode the slide_id
606            urlencoding::decode(parts[2]).ok().map(|s| s.into_owned())
607        }
608        _ => None,
609    }
610}
611
612/// Axum extractor for optional authentication.
613///
614/// This extractor verifies the signature if present, but allows requests
615/// without authentication to pass through. Useful for endpoints that support
616/// both authenticated and public access.
617#[derive(Debug, Clone)]
618pub struct OptionalAuth {
619    /// Whether the request was authenticated
620    pub authenticated: bool,
621}
622
623impl<S> FromRequestParts<S> for OptionalAuth
624where
625    S: Send + Sync,
626{
627    type Rejection = std::convert::Infallible;
628
629    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
630        // Check if auth parameters are present
631        let query = parts.uri.query().unwrap_or("");
632        let has_sig = query.contains("sig=");
633        let has_exp = query.contains("exp=");
634
635        Ok(OptionalAuth {
636            authenticated: has_sig && has_exp,
637        })
638    }
639}
640
641// =============================================================================
642// Tests
643// =============================================================================
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use std::time::Duration;
649
650    #[test]
651    fn test_sign_and_verify() {
652        let auth = SignedUrlAuth::new("test-secret-key");
653        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
654        let ttl = Duration::from_secs(3600);
655
656        let (signature, expiry) = auth.sign(path, ttl);
657
658        // Signature should be valid
659        assert!(auth.verify(path, &signature, expiry, &[]).is_ok());
660    }
661
662    #[test]
663    fn test_verify_wrong_signature() {
664        let auth = SignedUrlAuth::new("test-secret-key");
665        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
666        let ttl = Duration::from_secs(3600);
667
668        let (_, expiry) = auth.sign(path, ttl);
669
670        // Wrong signature should fail
671        let wrong_sig = "0".repeat(64); // Valid hex but wrong signature
672        let result = auth.verify(path, &wrong_sig, expiry, &[]);
673        assert!(matches!(result, Err(AuthError::InvalidSignature)));
674    }
675
676    #[test]
677    fn test_verify_wrong_path() {
678        let auth = SignedUrlAuth::new("test-secret-key");
679        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
680        let ttl = Duration::from_secs(3600);
681
682        let (signature, expiry) = auth.sign(path, ttl);
683
684        // Different path should fail
685        let wrong_path = "/tiles/slides/other.svs/0/1/2.jpg";
686        let result = auth.verify(wrong_path, &signature, expiry, &[]);
687        assert!(matches!(result, Err(AuthError::InvalidSignature)));
688    }
689
690    #[test]
691    fn test_verify_expired() {
692        let auth = SignedUrlAuth::new("test-secret-key");
693        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
694
695        // Create signature that's already expired
696        let expired_time = SystemTime::now()
697            .duration_since(UNIX_EPOCH)
698            .unwrap()
699            .as_secs()
700            - 100; // 100 seconds in the past
701
702        let signature = auth.sign_with_expiry(path, expired_time);
703
704        let result = auth.verify(path, &signature, expired_time, &[]);
705        assert!(matches!(result, Err(AuthError::Expired { .. })));
706    }
707
708    #[test]
709    fn test_verify_invalid_hex() {
710        let auth = SignedUrlAuth::new("test-secret-key");
711        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
712        let expiry = SystemTime::now()
713            .duration_since(UNIX_EPOCH)
714            .unwrap()
715            .as_secs()
716            + 3600;
717
718        // Invalid hex should fail
719        let result = auth.verify(path, "not-valid-hex!", expiry, &[]);
720        assert!(matches!(result, Err(AuthError::InvalidSignatureFormat)));
721    }
722
723    #[test]
724    fn test_different_keys_different_signatures() {
725        let auth1 = SignedUrlAuth::new("key1");
726        let auth2 = SignedUrlAuth::new("key2");
727        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
728        let ttl = Duration::from_secs(3600);
729
730        let (sig1, expiry) = auth1.sign(path, ttl);
731        let sig2 = auth2.sign_with_expiry(path, expiry);
732
733        // Signatures should be different
734        assert_ne!(sig1, sig2);
735
736        // Each should only verify with its own key
737        assert!(auth1.verify(path, &sig1, expiry, &[]).is_ok());
738        assert!(auth1.verify(path, &sig2, expiry, &[]).is_err());
739        assert!(auth2.verify(path, &sig2, expiry, &[]).is_ok());
740        assert!(auth2.verify(path, &sig1, expiry, &[]).is_err());
741    }
742
743    #[test]
744    fn test_signature_is_deterministic() {
745        let auth = SignedUrlAuth::new("test-secret-key");
746        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
747        let expiry = 1735689600u64;
748
749        let sig1 = auth.sign_with_expiry(path, expiry);
750        let sig2 = auth.sign_with_expiry(path, expiry);
751
752        // Same inputs should produce same signature
753        assert_eq!(sig1, sig2);
754    }
755
756    #[test]
757    fn test_generate_signed_url() {
758        let auth = SignedUrlAuth::new("test-secret-key");
759        let base_url = "https://example.com";
760        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
761        let ttl = Duration::from_secs(3600);
762
763        let url = auth.generate_signed_url(base_url, path, ttl, &[("quality", "80")]);
764
765        // URL should contain all components
766        assert!(url.starts_with("https://example.com/tiles/slides/sample.svs/0/1/2.jpg?"));
767        assert!(url.contains("quality=80"));
768        assert!(url.contains("exp="));
769        assert!(url.contains("sig="));
770    }
771
772    #[test]
773    fn test_auth_error_display() {
774        let err = AuthError::MissingSignature;
775        assert_eq!(err.to_string(), "Missing signature parameter");
776
777        let err = AuthError::MissingExpiry;
778        assert_eq!(err.to_string(), "Missing expiry parameter");
779
780        let err = AuthError::Expired {
781            expired_at: 1000,
782            current_time: 2000,
783        };
784        assert!(err.to_string().contains("1000"));
785        assert!(err.to_string().contains("2000"));
786
787        let err = AuthError::InvalidSignature;
788        assert_eq!(err.to_string(), "Invalid signature");
789
790        let err = AuthError::InvalidSignatureFormat;
791        assert_eq!(err.to_string(), "Invalid signature format");
792
793        let err = AuthError::InvalidExpiryFormat;
794        assert_eq!(err.to_string(), "Invalid expiry format");
795    }
796
797    #[test]
798    fn test_constant_time_comparison() {
799        // This test verifies that we're using constant-time comparison
800        // by ensuring the same result regardless of where differences occur
801        let auth = SignedUrlAuth::new("test-secret-key");
802        let path = "/tiles/slides/sample.svs/0/1/2.jpg";
803        let expiry = SystemTime::now()
804            .duration_since(UNIX_EPOCH)
805            .unwrap()
806            .as_secs()
807            + 3600;
808
809        let correct_sig = auth.sign_with_expiry(path, expiry);
810
811        // Helper to flip a hex character to a definitely different one
812        fn flip_hex_char(c: char) -> char {
813            match c {
814                '0'..='8' => ((c as u8) + 1) as char,
815                '9' => '0',
816                'a'..='e' => ((c as u8) + 1) as char,
817                'f' => 'a',
818                _ => '0',
819            }
820        }
821
822        // Create signatures with differences at different positions
823        let mut wrong_first = correct_sig.clone();
824        let first_char = correct_sig.chars().next().unwrap();
825        wrong_first.replace_range(0..1, &flip_hex_char(first_char).to_string());
826
827        let mut wrong_middle = correct_sig.clone();
828        let mid = correct_sig.len() / 2;
829        let mid_char = correct_sig.chars().nth(mid).unwrap();
830        wrong_middle.replace_range(mid..mid + 1, &flip_hex_char(mid_char).to_string());
831
832        let mut wrong_last = correct_sig.clone();
833        let last = correct_sig.len() - 1;
834        let last_char = correct_sig.chars().nth(last).unwrap();
835        wrong_last.replace_range(last..last + 1, &flip_hex_char(last_char).to_string());
836
837        // All should fail (we can't easily test timing, but we verify correctness)
838        assert!(auth.verify(path, &wrong_first, expiry, &[]).is_err());
839        assert!(auth.verify(path, &wrong_middle, expiry, &[]).is_err());
840        assert!(auth.verify(path, &wrong_last, expiry, &[]).is_err());
841    }
842
843    #[test]
844    fn test_viewer_token_generate_and_verify() {
845        let auth = SignedUrlAuth::new("test-secret-key");
846        let slide_id = "slides/sample.svs";
847        let ttl = Duration::from_secs(3600);
848
849        let (token, expiry) = auth.generate_viewer_token(slide_id, ttl);
850
851        // Token should verify for the same slide
852        assert!(auth.verify_viewer_token(slide_id, &token, expiry).is_ok());
853    }
854
855    #[test]
856    fn test_viewer_token_wrong_slide() {
857        let auth = SignedUrlAuth::new("test-secret-key");
858        let slide_id = "slides/sample.svs";
859        let wrong_slide = "slides/other.svs";
860        let ttl = Duration::from_secs(3600);
861
862        let (token, expiry) = auth.generate_viewer_token(slide_id, ttl);
863
864        // Token should NOT verify for a different slide
865        assert!(auth
866            .verify_viewer_token(wrong_slide, &token, expiry)
867            .is_err());
868    }
869
870    #[test]
871    fn test_viewer_token_expired() {
872        let auth = SignedUrlAuth::new("test-secret-key");
873        let slide_id = "slides/sample.svs";
874
875        // Generate token that's already expired
876        let expired_time = SystemTime::now()
877            .duration_since(UNIX_EPOCH)
878            .unwrap()
879            .as_secs()
880            - 100; // 100 seconds in the past
881
882        let message = format!("viewer:{}:{}", slide_id, expired_time);
883        let mut mac = HmacSha256::new_from_slice(b"test-secret-key").unwrap();
884        mac.update(message.as_bytes());
885        let token = hex::encode(mac.finalize().into_bytes());
886
887        let result = auth.verify_viewer_token(slide_id, &token, expired_time);
888        assert!(matches!(result, Err(AuthError::Expired { .. })));
889    }
890
891    #[test]
892    fn test_viewer_token_different_keys() {
893        let auth1 = SignedUrlAuth::new("key1");
894        let auth2 = SignedUrlAuth::new("key2");
895        let slide_id = "slides/sample.svs";
896        let ttl = Duration::from_secs(3600);
897
898        let (token, expiry) = auth1.generate_viewer_token(slide_id, ttl);
899
900        // Token from auth1 should not verify with auth2
901        assert!(auth2.verify_viewer_token(slide_id, &token, expiry).is_err());
902    }
903
904    #[test]
905    fn test_extract_slide_id_from_path_tiles() {
906        assert_eq!(
907            extract_slide_id_from_path("/tiles/sample.svs/0/1/2.jpg"),
908            Some("sample.svs".to_string())
909        );
910        assert_eq!(
911            extract_slide_id_from_path("/tiles/folder%2Fsample.svs/0/1/2.jpg"),
912            Some("folder/sample.svs".to_string())
913        );
914    }
915
916    #[test]
917    fn test_extract_slide_id_from_path_slides() {
918        assert_eq!(
919            extract_slide_id_from_path("/slides/sample.svs"),
920            Some("sample.svs".to_string())
921        );
922        assert_eq!(
923            extract_slide_id_from_path("/slides/sample.svs/dzi"),
924            Some("sample.svs".to_string())
925        );
926        assert_eq!(
927            extract_slide_id_from_path("/slides/sample.svs/thumbnail"),
928            Some("sample.svs".to_string())
929        );
930    }
931
932    #[test]
933    fn test_extract_slide_id_from_path_invalid() {
934        assert_eq!(extract_slide_id_from_path("/health"), None);
935        assert_eq!(extract_slide_id_from_path("/view/sample.svs"), None);
936        assert_eq!(extract_slide_id_from_path("/"), None);
937        assert_eq!(extract_slide_id_from_path(""), None);
938    }
939}