arcly_http_core/auth/
extract.rs1use 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
29pub struct AuthExtraction {
31 pub claims: Option<Arc<Claims>>,
32 pub session: Option<Arc<Session>>,
33}
34
35pub 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
48pub 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
73fn 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
83pub 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 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 #[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 #[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 #[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}