Skip to main content

homecore_api/
auth.rs

1//! Bearer-token auth helper. Validates against the
2//! [`LongLivedTokenStore`] on `SharedState` (audit fix HC-01/02).
3//!
4//! - P1 placeholder accepted any non-empty bearer
5//! - P2 (this commit) requires the token to be present in the store
6//! - DEV escape hatch: `LongLivedTokenStore::allow_any_non_empty()`
7//!   preserves the legacy behaviour for users mid-migration, with
8//!   a warn log on every check
9
10use axum::http::HeaderMap;
11use crate::error::ApiError;
12use crate::tokens::LongLivedTokenStore;
13
14#[derive(Clone, Debug)]
15pub struct BearerAuth(pub String);
16
17impl BearerAuth {
18    /// Parse the `Authorization: Bearer <token>` header out of the
19    /// request AND validate it against the supplied token store.
20    /// Returns `ApiError::Unauthorized` on missing header, malformed
21    /// header, empty token, OR a token not present in the store.
22    pub async fn from_headers(
23        headers: &HeaderMap,
24        tokens: &LongLivedTokenStore,
25    ) -> Result<Self, ApiError> {
26        let token = Self::extract_token(headers)?;
27        if !tokens.is_valid(&token).await {
28            return Err(ApiError::Unauthorized);
29        }
30        Ok(Self(token))
31    }
32
33    /// Extract the bearer token from headers without validating it.
34    /// Used by the WS handshake which validates inline.
35    pub fn extract_token(headers: &HeaderMap) -> Result<String, ApiError> {
36        let header = headers
37            .get(axum::http::header::AUTHORIZATION)
38            .ok_or(ApiError::Unauthorized)?;
39        let value = header.to_str().map_err(|_| ApiError::Unauthorized)?;
40        let token = value
41            .strip_prefix("Bearer ")
42            .ok_or(ApiError::Unauthorized)?
43            .trim()
44            .to_string();
45        if token.is_empty() {
46            return Err(ApiError::Unauthorized);
47        }
48        Ok(token)
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use axum::http::header::AUTHORIZATION;
56
57    fn mkheaders(value: &str) -> HeaderMap {
58        let mut h = HeaderMap::new();
59        h.insert(AUTHORIZATION, value.parse().unwrap());
60        h
61    }
62
63    #[test]
64    fn extract_strips_bearer_prefix() {
65        let h = mkheaders("Bearer abc123");
66        assert_eq!(BearerAuth::extract_token(&h).unwrap(), "abc123");
67    }
68
69    #[test]
70    fn extract_rejects_missing_prefix() {
71        let h = mkheaders("abc123");
72        assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
73    }
74
75    #[test]
76    fn extract_rejects_missing_header() {
77        let h = HeaderMap::new();
78        assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
79    }
80
81    #[test]
82    fn extract_rejects_empty_token() {
83        let h = mkheaders("Bearer   ");
84        assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
85    }
86
87    #[tokio::test]
88    async fn from_headers_accepts_registered_token() {
89        let store = LongLivedTokenStore::empty();
90        store.register("good_token").await;
91        let h = mkheaders("Bearer good_token");
92        let auth = BearerAuth::from_headers(&h, &store).await.unwrap();
93        assert_eq!(auth.0, "good_token");
94    }
95
96    #[tokio::test]
97    async fn from_headers_rejects_unregistered_token() {
98        let store = LongLivedTokenStore::empty();
99        store.register("good_token").await;
100        let h = mkheaders("Bearer wrong_token");
101        assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
102    }
103
104    #[tokio::test]
105    async fn dev_mode_still_accepts_any_non_empty() {
106        let store = LongLivedTokenStore::allow_any_non_empty();
107        let h = mkheaders("Bearer literally-anything");
108        assert!(BearerAuth::from_headers(&h, &store).await.is_ok());
109    }
110
111    #[tokio::test]
112    async fn dev_mode_still_rejects_empty() {
113        let store = LongLivedTokenStore::allow_any_non_empty();
114        let h = mkheaders("Bearer ");
115        assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
116    }
117}