Skip to main content

homecore_api/
tokens.rs

1//! Long-lived bearer-token store.
2//!
3//! Closes audit findings **HC-01** and **HC-02** by replacing the
4//! "any non-empty bearer" P1 placeholder with a real token whitelist.
5//!
6//! P2 scope (this commit):
7//! - Token set held in memory; populated at boot from env / config /
8//!   programmatic registration
9//! - `O(1)` `is_valid(&str) -> bool` lookup via `HashSet`
10//! - No expiry, no rotation, no per-user attribution yet — P3
11//!
12//! Boot-time provisioning paths supported:
13//! - `HOMECORE_TOKENS` env var: comma-separated bearer tokens
14//! - `LongLivedTokenStore::register(token)` for programmatic insert
15//!
16//! Provided constructors:
17//! - `LongLivedTokenStore::empty()` → no tokens accepted (use after
18//!   boot to add tokens manually)
19//! - `LongLivedTokenStore::from_env()` → reads `HOMECORE_TOKENS`,
20//!   splits on commas, trims, drops empties
21//! - `LongLivedTokenStore::allow_any_non_empty()` → **DEV ONLY**;
22//!   preserves the legacy "accept anything non-empty" behaviour
23//!   for users who haven't migrated yet. Emits a warning on every
24//!   call. Removed in P3.
25
26use std::collections::HashSet;
27use std::sync::Arc;
28
29use tokio::sync::RwLock;
30use tracing::warn;
31
32#[derive(Clone)]
33pub struct LongLivedTokenStore {
34    inner: Arc<RwLock<LongLivedTokenStoreInner>>,
35}
36
37struct LongLivedTokenStoreInner {
38    tokens: HashSet<String>,
39    /// DEV-only escape hatch: when true, ANY non-empty bearer is
40    /// accepted. Logged on every check so the operator notices.
41    allow_any: bool,
42}
43
44impl LongLivedTokenStore {
45    /// Empty store. No tokens accepted. Register tokens explicitly
46    /// via [`Self::register`] before exposing the API to the network.
47    pub fn empty() -> Self {
48        Self {
49            inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
50                tokens: HashSet::new(),
51                allow_any: false,
52            })),
53        }
54    }
55
56    /// Reads `HOMECORE_TOKENS` from the environment and registers
57    /// each comma-separated value. Trims whitespace; drops empty
58    /// values. If the env var is unset / empty, the store starts
59    /// empty.
60    pub fn from_env() -> Self {
61        let store = Self::empty();
62        if let Ok(raw) = std::env::var("HOMECORE_TOKENS") {
63            // Note: we'd ideally `.await` here but constructors stay
64            // sync. Use try_write to populate synchronously at boot.
65            // If the lock isn't immediately available something else
66            // is using it, which is impossible at construction time.
67            if let Ok(mut guard) = store.inner.try_write() {
68                for raw_token in raw.split(',') {
69                    let t = raw_token.trim();
70                    if !t.is_empty() {
71                        guard.tokens.insert(t.to_string());
72                    }
73                }
74            }
75        }
76        store
77    }
78
79    /// **DEV ONLY** — closes HC-01/02 audit findings on paper while
80    /// preserving the legacy "any non-empty bearer" behaviour for
81    /// users mid-migration. Emits a warn on every check. Removed
82    /// in P3.
83    pub fn allow_any_non_empty() -> Self {
84        Self {
85            inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
86                tokens: HashSet::new(),
87                allow_any: true,
88            })),
89        }
90    }
91
92    /// Register a token. Idempotent. Returns true if the token was
93    /// new, false if it was already in the set.
94    pub async fn register(&self, token: impl Into<String>) -> bool {
95        let mut guard = self.inner.write().await;
96        guard.tokens.insert(token.into())
97    }
98
99    /// Revoke a token. Returns true if the token was in the set.
100    pub async fn revoke(&self, token: &str) -> bool {
101        let mut guard = self.inner.write().await;
102        guard.tokens.remove(token)
103    }
104
105    /// Check a token against the store. Fast O(1) hashset lookup.
106    /// In `allow_any` mode, any non-empty token returns true and a
107    /// warn is logged.
108    pub async fn is_valid(&self, token: &str) -> bool {
109        if token.is_empty() {
110            return false;
111        }
112        let guard = self.inner.read().await;
113        if guard.allow_any {
114            warn!(
115                "LongLivedTokenStore::is_valid called in `allow_any` mode — \
116                 any non-empty bearer is accepted. Provision real tokens via \
117                 HOMECORE_TOKENS or LongLivedTokenStore::register() before \
118                 production."
119            );
120            return true;
121        }
122        guard.tokens.contains(token)
123    }
124
125    /// Number of registered tokens. Useful for boot log lines.
126    pub async fn len(&self) -> usize {
127        self.inner.read().await.tokens.len()
128    }
129
130    pub async fn is_empty(&self) -> bool {
131        self.inner.read().await.tokens.is_empty()
132    }
133
134    /// Is the store accepting any non-empty bearer (DEV mode)?
135    pub async fn is_dev_mode(&self) -> bool {
136        self.inner.read().await.allow_any
137    }
138}
139
140impl Default for LongLivedTokenStore {
141    fn default() -> Self {
142        Self::empty()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[tokio::test]
151    async fn empty_store_rejects_everything() {
152        let s = LongLivedTokenStore::empty();
153        assert!(!s.is_valid("anything").await);
154        assert!(!s.is_valid("").await);
155    }
156
157    #[tokio::test]
158    async fn registered_token_is_valid() {
159        let s = LongLivedTokenStore::empty();
160        s.register("hc_abc_123").await;
161        assert!(s.is_valid("hc_abc_123").await);
162        assert!(!s.is_valid("hc_abc_124").await);
163    }
164
165    #[tokio::test]
166    async fn revoke_invalidates() {
167        let s = LongLivedTokenStore::empty();
168        s.register("t1").await;
169        s.register("t2").await;
170        assert!(s.is_valid("t1").await);
171        assert!(s.revoke("t1").await);
172        assert!(!s.is_valid("t1").await);
173        assert!(s.is_valid("t2").await);
174        assert_eq!(s.len().await, 1);
175    }
176
177    #[tokio::test]
178    async fn register_is_idempotent() {
179        let s = LongLivedTokenStore::empty();
180        assert!(s.register("t").await);
181        assert!(!s.register("t").await);
182        assert_eq!(s.len().await, 1);
183    }
184
185    #[tokio::test]
186    async fn empty_token_always_rejected() {
187        let s = LongLivedTokenStore::allow_any_non_empty();
188        assert!(!s.is_valid("").await);
189    }
190
191    #[tokio::test]
192    async fn allow_any_mode_accepts_any_non_empty() {
193        let s = LongLivedTokenStore::allow_any_non_empty();
194        assert!(s.is_valid("literally-anything").await);
195        assert!(s.is_dev_mode().await);
196    }
197
198    #[tokio::test]
199    async fn from_env_unset_is_empty() {
200        // Don't set HOMECORE_TOKENS for this test
201        std::env::remove_var("HOMECORE_TOKENS");
202        let s = LongLivedTokenStore::from_env();
203        assert_eq!(s.len().await, 0);
204    }
205}