Skip to main content

arcly_http_core/auth/
extract.rs

1//! The single credential-extraction pipeline shared by every request boundary.
2//!
3//! Order of precedence:
4//! 1. `Authorization: Bearer <token>` header (decoded as an **access** JWT).
5//! 2. HMAC-signed JWT cookie (`CookieService`) — same `decode_access` path,
6//!    so the security properties are identical to the Bearer path.
7//! 3. Server-side session (`SessionManager`) — loaded independently of claims;
8//!    a request can carry both a JWT identity and a mutable session.
9//!
10//! WebSocket handshakes add one more source *after* the header and cookie —
11//! `?access_token=<jwt>` ([`extract_claims_ws`]) — because the browser
12//! `WebSocket` API cannot send headers. It is not offered to the HTTP boundary,
13//! where URL-borne tokens would leak into logs.
14//!
15//! HTTP boundary, plugin routes, and the WebSocket handshake all call into
16//! this module, so a security fix or a new credential source lands everywhere
17//! at once — there is deliberately no second copy of this logic anywhere.
18
19use std::sync::Arc;
20
21use http::HeaderMap;
22
23use crate::auth::cookie::CookieService;
24use crate::auth::jwt::{decode_bearer_token, JwtService};
25use crate::auth::session::{Session, SessionManager};
26use crate::core::engine::FrozenDiContainer;
27use crate::web::context::Claims;
28
29/// Everything the boundaries need to authenticate one request.
30pub struct AuthExtraction {
31    pub claims: Option<Arc<Claims>>,
32    pub session: Option<Arc<Session>>,
33}
34
35/// Extract JWT claims only (Bearer → signed-cookie fallback).
36///
37/// Used directly by the WebSocket handshake, which authenticates once at
38/// upgrade time and never loads a server-side session.
39pub fn extract_claims(headers: &HeaderMap, container: &FrozenDiContainer) -> Option<Arc<Claims>> {
40    decode_bearer_token(headers, container).or_else(|| {
41        let cookie = container.try_get::<CookieService>()?;
42        let jwt = container.try_get::<JwtService>()?;
43        let value = cookie.extract(headers)?;
44        jwt.decode_access(&value)
45    })
46}
47
48/// Extract claims at a **WebSocket handshake**: the same Bearer → signed-cookie
49/// chain as everywhere else, plus `?access_token=<jwt>` as a last resort.
50///
51/// The query param exists because the browser `WebSocket` API has no way to set
52/// request headers — `new WebSocket(url)` takes a URL and nothing else — so a
53/// header-only handshake is unreachable from a browser at all, not merely
54/// inconvenient. The token is decoded through the very same `decode_access`
55/// path as the Bearer header, so every downstream check (scopes, `exp`, tenant
56/// binding) behaves identically no matter which source carried it.
57///
58/// It is deliberately **last**: a header or cookie credential always wins, and
59/// this is *not* wired into the HTTP boundary, where putting tokens in URLs
60/// would leak them into access logs, proxy logs, and `Referer` headers across
61/// every route. Callers should keep such tokens short-lived.
62pub fn extract_claims_ws(
63    headers: &HeaderMap,
64    query: Option<&str>,
65    container: &FrozenDiContainer,
66) -> Option<Arc<Claims>> {
67    extract_claims(headers, container).or_else(|| {
68        let token = access_token_from_query(query?)?;
69        container.try_get::<JwtService>()?.decode_access(&token)
70    })
71}
72
73/// Pull `access_token` out of a raw (still percent-encoded) query string.
74fn access_token_from_query(query: &str) -> Option<String> {
75    serde_urlencoded::from_str::<Vec<(String, String)>>(query)
76        .ok()?
77        .into_iter()
78        .find(|(k, _)| k == "access_token")
79        .map(|(_, v)| v)
80        .filter(|t| !t.is_empty())
81}
82
83/// Extract claims **and** load the server-side session.
84///
85/// Used by the HTTP boundary and plugin routes. Session loading is skipped
86/// entirely (no store round-trip) when no `SessionManager` is registered.
87pub async fn extract_auth(headers: &HeaderMap, container: &FrozenDiContainer) -> AuthExtraction {
88    let claims = extract_claims(headers, container);
89
90    let session = match container.try_get::<SessionManager>() {
91        Some(sm) => sm.load_from_headers(headers).await,
92        None => None,
93    };
94
95    AuthExtraction { claims, session }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::auth::jwt::JwtConfig;
102    use crate::core::engine::DiContainerBuilder;
103
104    fn jwt() -> JwtService {
105        JwtService::new(JwtConfig {
106            secret: "test-secret-for-ws-handshake".to_owned(),
107            ..Default::default()
108        })
109    }
110
111    /// The container's `JwtService` and the test's signer share a secret, so a
112    /// token minted here is one the handshake will accept.
113    fn container_with_jwt() -> (std::sync::Arc<FrozenDiContainer>, JwtService) {
114        let mut b = DiContainerBuilder::new();
115        b.register(jwt());
116        (b.freeze(), jwt())
117    }
118
119    /// The case the whole feature exists for: a browser's `new WebSocket(url)`
120    /// cannot send an Authorization header, so the URL is its only channel.
121    #[test]
122    fn ws_handshake_authenticates_from_the_query_token() {
123        let (container, jwt) = container_with_jwt();
124        let token = jwt
125            .issue_access("user-1", "admin", "u@example.com")
126            .unwrap();
127
128        let claims = extract_claims_ws(
129            &HeaderMap::new(),
130            Some(&format!("access_token={token}")),
131            &container,
132        )
133        .expect("query token must authenticate the handshake");
134        assert_eq!(claims.get("sub").and_then(|v| v.as_str()), Some("user-1"));
135    }
136
137    /// The query param is a fallback, not an override — a header credential
138    /// wins, so existing server-side callers are unaffected.
139    #[test]
140    fn header_wins_over_the_query_token() {
141        let (container, jwt) = container_with_jwt();
142        let header_token = jwt
143            .issue_access("from-header", "admin", "h@example.com")
144            .unwrap();
145        let query_token = jwt
146            .issue_access("from-query", "admin", "q@example.com")
147            .unwrap();
148
149        let mut headers = HeaderMap::new();
150        headers.insert(
151            "authorization",
152            format!("Bearer {header_token}").parse().unwrap(),
153        );
154
155        let claims = extract_claims_ws(
156            &headers,
157            Some(&format!("access_token={query_token}")),
158            &container,
159        )
160        .expect("header credential still authenticates");
161        assert_eq!(
162            claims.get("sub").and_then(|v| v.as_str()),
163            Some("from-header"),
164        );
165    }
166
167    /// A query token is held to exactly the same bar as a Bearer one: it must
168    /// verify. Garbage in the URL authenticates nothing.
169    #[test]
170    fn a_forged_query_token_is_rejected() {
171        let (container, _) = container_with_jwt();
172        assert!(
173            extract_claims_ws(
174                &HeaderMap::new(),
175                Some("access_token=not-a-real-jwt"),
176                &container,
177            )
178            .is_none(),
179            "an unverifiable token must not produce claims"
180        );
181    }
182
183    #[test]
184    fn no_credential_anywhere_stays_anonymous() {
185        let (container, _) = container_with_jwt();
186        assert!(extract_claims_ws(&HeaderMap::new(), None, &container).is_none());
187        assert!(extract_claims_ws(&HeaderMap::new(), Some("foo=bar"), &container).is_none());
188        assert!(extract_claims_ws(&HeaderMap::new(), Some("access_token="), &container).is_none());
189    }
190}