arcly-http-core 0.9.0

Foundation crate for arcly-http: zero-lock DI engine, HTTP boundary, unified request pipeline, auth, resilience, observability, and OpenAPI. Use the `arcly-http` facade instead of depending on this directly.
Documentation
//! The single credential-extraction pipeline shared by every request boundary.
//!
//! Order of precedence:
//! 1. `Authorization: Bearer <token>` header (decoded as an **access** JWT).
//! 2. HMAC-signed JWT cookie (`CookieService`) — same `decode_access` path,
//!    so the security properties are identical to the Bearer path.
//! 3. Server-side session (`SessionManager`) — loaded independently of claims;
//!    a request can carry both a JWT identity and a mutable session.
//!
//! WebSocket handshakes add one more source *after* the header and cookie —
//! `?access_token=<jwt>` ([`extract_claims_ws`]) — because the browser
//! `WebSocket` API cannot send headers. It is not offered to the HTTP boundary,
//! where URL-borne tokens would leak into logs.
//!
//! HTTP boundary, plugin routes, and the WebSocket handshake all call into
//! this module, so a security fix or a new credential source lands everywhere
//! at once — there is deliberately no second copy of this logic anywhere.

use std::sync::Arc;

use http::HeaderMap;

use crate::auth::cookie::CookieService;
use crate::auth::jwt::{decode_bearer_token, JwtService};
use crate::auth::session::{Session, SessionManager};
use crate::core::engine::FrozenDiContainer;
use crate::web::context::Claims;

/// Everything the boundaries need to authenticate one request.
pub struct AuthExtraction {
    pub claims: Option<Arc<Claims>>,
    pub session: Option<Arc<Session>>,
}

/// Extract JWT claims only (Bearer → signed-cookie fallback).
///
/// Used directly by the WebSocket handshake, which authenticates once at
/// upgrade time and never loads a server-side session.
pub fn extract_claims(headers: &HeaderMap, container: &FrozenDiContainer) -> Option<Arc<Claims>> {
    decode_bearer_token(headers, container).or_else(|| {
        let cookie = container.try_get::<CookieService>()?;
        let jwt = container.try_get::<JwtService>()?;
        let value = cookie.extract(headers)?;
        jwt.decode_access(&value)
    })
}

/// Extract claims at a **WebSocket handshake**: the same Bearer → signed-cookie
/// chain as everywhere else, plus `?access_token=<jwt>` as a last resort.
///
/// The query param exists because the browser `WebSocket` API has no way to set
/// request headers — `new WebSocket(url)` takes a URL and nothing else — so a
/// header-only handshake is unreachable from a browser at all, not merely
/// inconvenient. The token is decoded through the very same `decode_access`
/// path as the Bearer header, so every downstream check (scopes, `exp`, tenant
/// binding) behaves identically no matter which source carried it.
///
/// It is deliberately **last**: a header or cookie credential always wins, and
/// this is *not* wired into the HTTP boundary, where putting tokens in URLs
/// would leak them into access logs, proxy logs, and `Referer` headers across
/// every route. Callers should keep such tokens short-lived.
pub fn extract_claims_ws(
    headers: &HeaderMap,
    query: Option<&str>,
    container: &FrozenDiContainer,
) -> Option<Arc<Claims>> {
    extract_claims(headers, container).or_else(|| {
        let token = access_token_from_query(query?)?;
        container.try_get::<JwtService>()?.decode_access(&token)
    })
}

/// Pull `access_token` out of a raw (still percent-encoded) query string.
fn access_token_from_query(query: &str) -> Option<String> {
    serde_urlencoded::from_str::<Vec<(String, String)>>(query)
        .ok()?
        .into_iter()
        .find(|(k, _)| k == "access_token")
        .map(|(_, v)| v)
        .filter(|t| !t.is_empty())
}

/// Extract claims **and** load the server-side session.
///
/// Used by the HTTP boundary and plugin routes. Session loading is skipped
/// entirely (no store round-trip) when no `SessionManager` is registered.
pub async fn extract_auth(headers: &HeaderMap, container: &FrozenDiContainer) -> AuthExtraction {
    let claims = extract_claims(headers, container);

    let session = match container.try_get::<SessionManager>() {
        Some(sm) => sm.load_from_headers(headers).await,
        None => None,
    };

    AuthExtraction { claims, session }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::jwt::JwtConfig;
    use crate::core::engine::DiContainerBuilder;

    fn jwt() -> JwtService {
        JwtService::new(JwtConfig {
            secret: "test-secret-for-ws-handshake".to_owned(),
            ..Default::default()
        })
    }

    /// The container's `JwtService` and the test's signer share a secret, so a
    /// token minted here is one the handshake will accept.
    fn container_with_jwt() -> (std::sync::Arc<FrozenDiContainer>, JwtService) {
        let mut b = DiContainerBuilder::new();
        b.register(jwt());
        (b.freeze(), jwt())
    }

    /// The case the whole feature exists for: a browser's `new WebSocket(url)`
    /// cannot send an Authorization header, so the URL is its only channel.
    #[test]
    fn ws_handshake_authenticates_from_the_query_token() {
        let (container, jwt) = container_with_jwt();
        let token = jwt
            .issue_access("user-1", "admin", "u@example.com")
            .unwrap();

        let claims = extract_claims_ws(
            &HeaderMap::new(),
            Some(&format!("access_token={token}")),
            &container,
        )
        .expect("query token must authenticate the handshake");
        assert_eq!(claims.get("sub").and_then(|v| v.as_str()), Some("user-1"));
    }

    /// The query param is a fallback, not an override — a header credential
    /// wins, so existing server-side callers are unaffected.
    #[test]
    fn header_wins_over_the_query_token() {
        let (container, jwt) = container_with_jwt();
        let header_token = jwt
            .issue_access("from-header", "admin", "h@example.com")
            .unwrap();
        let query_token = jwt
            .issue_access("from-query", "admin", "q@example.com")
            .unwrap();

        let mut headers = HeaderMap::new();
        headers.insert(
            "authorization",
            format!("Bearer {header_token}").parse().unwrap(),
        );

        let claims = extract_claims_ws(
            &headers,
            Some(&format!("access_token={query_token}")),
            &container,
        )
        .expect("header credential still authenticates");
        assert_eq!(
            claims.get("sub").and_then(|v| v.as_str()),
            Some("from-header"),
        );
    }

    /// A query token is held to exactly the same bar as a Bearer one: it must
    /// verify. Garbage in the URL authenticates nothing.
    #[test]
    fn a_forged_query_token_is_rejected() {
        let (container, _) = container_with_jwt();
        assert!(
            extract_claims_ws(
                &HeaderMap::new(),
                Some("access_token=not-a-real-jwt"),
                &container,
            )
            .is_none(),
            "an unverifiable token must not produce claims"
        );
    }

    #[test]
    fn no_credential_anywhere_stays_anonymous() {
        let (container, _) = container_with_jwt();
        assert!(extract_claims_ws(&HeaderMap::new(), None, &container).is_none());
        assert!(extract_claims_ws(&HeaderMap::new(), Some("foo=bar"), &container).is_none());
        assert!(extract_claims_ws(&HeaderMap::new(), Some("access_token="), &container).is_none());
    }
}