openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
/// Bearer token authentication middleware for the daemon HTTP server.
///
/// Applied to all authenticated routes (POST endpoints). GET /health and GET /metrics
/// are excluded and require no authentication.
use axum::{
    extract::State,
    http::{self, Request, StatusCode},
    middleware::Next,
    response::Response,
};
use std::sync::Arc;
use subtle::ConstantTimeEq;

use crate::daemon::AppState;

/// Bearer token auth middleware.
///
/// Validates the `Authorization: Bearer <token>` header against the daemon's configured token.
///
/// # SECURITY
///
/// Uses constant-time comparison via the `subtle` crate to prevent timing-based token
/// extraction. A naive string comparison leaks information about matching prefix length;
/// `ct_eq` always takes the same time regardless of where the strings differ.
///
/// Returns `401 Unauthorized` if:
/// - The `Authorization` header is missing
/// - The header does not start with `Bearer `
/// - The token does not match (constant-time comparison)
pub async fn bearer_auth(
    State(state): State<Arc<AppState>>,
    request: Request<axum::body::Body>,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get(http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok());

    match auth_header {
        Some(h) if h.starts_with("Bearer ") => {
            let token = &h["Bearer ".len()..];
            // SECURITY: constant-time comparison prevents timing-based token extraction.
            // Do NOT replace with a simple == comparison.
            if token.as_bytes().ct_eq(state.token.as_bytes()).into() {
                Ok(next.run(request).await)
            } else {
                Err(StatusCode::UNAUTHORIZED)
            }
        }
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

#[cfg(test)]
mod tests {
    // Auth middleware integration tests are covered in Plan 01-06 (integration tests).
    // Unit tests here verify the token comparison logic in isolation.

    use super::*;

    #[test]
    fn test_constant_time_eq_valid_token_matches() {
        let token = "abc123def456";
        // Same bytes must match
        assert!(
            bool::from(token.as_bytes().ct_eq(token.as_bytes())),
            "identical tokens must match"
        );
    }

    #[test]
    fn test_constant_time_eq_invalid_token_does_not_match() {
        let expected = "correct-token-abc";
        let provided = "wrong-token-xyz";
        assert!(
            !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())),
            "different tokens must not match"
        );
    }

    #[test]
    fn test_constant_time_eq_empty_token_does_not_match_nonempty() {
        let expected = "correct-token";
        let provided = "";
        assert!(
            !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())),
            "empty token must not match non-empty expected"
        );
    }
}