use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::warn;
#[derive(Clone)]
pub struct LongLivedTokenStore {
inner: Arc<RwLock<LongLivedTokenStoreInner>>,
}
struct LongLivedTokenStoreInner {
tokens: HashSet<String>,
allow_any: bool,
}
impl LongLivedTokenStore {
pub fn empty() -> Self {
Self {
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
tokens: HashSet::new(),
allow_any: false,
})),
}
}
pub fn from_env() -> Self {
let store = Self::empty();
if let Ok(raw) = std::env::var("HOMECORE_TOKENS") {
if let Ok(mut guard) = store.inner.try_write() {
for raw_token in raw.split(',') {
let t = raw_token.trim();
if !t.is_empty() {
guard.tokens.insert(t.to_string());
}
}
}
}
store
}
pub fn allow_any_non_empty() -> Self {
Self {
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
tokens: HashSet::new(),
allow_any: true,
})),
}
}
pub async fn register(&self, token: impl Into<String>) -> bool {
let mut guard = self.inner.write().await;
guard.tokens.insert(token.into())
}
pub async fn revoke(&self, token: &str) -> bool {
let mut guard = self.inner.write().await;
guard.tokens.remove(token)
}
pub async fn is_valid(&self, token: &str) -> bool {
if token.is_empty() {
return false;
}
let guard = self.inner.read().await;
if guard.allow_any {
warn!(
"LongLivedTokenStore::is_valid called in `allow_any` mode — \
any non-empty bearer is accepted. Provision real tokens via \
HOMECORE_TOKENS or LongLivedTokenStore::register() before \
production."
);
return true;
}
guard.tokens.contains(token)
}
pub async fn len(&self) -> usize {
self.inner.read().await.tokens.len()
}
pub async fn is_empty(&self) -> bool {
self.inner.read().await.tokens.is_empty()
}
pub async fn is_dev_mode(&self) -> bool {
self.inner.read().await.allow_any
}
}
impl Default for LongLivedTokenStore {
fn default() -> Self {
Self::empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn empty_store_rejects_everything() {
let s = LongLivedTokenStore::empty();
assert!(!s.is_valid("anything").await);
assert!(!s.is_valid("").await);
}
#[tokio::test]
async fn registered_token_is_valid() {
let s = LongLivedTokenStore::empty();
s.register("hc_abc_123").await;
assert!(s.is_valid("hc_abc_123").await);
assert!(!s.is_valid("hc_abc_124").await);
}
#[tokio::test]
async fn revoke_invalidates() {
let s = LongLivedTokenStore::empty();
s.register("t1").await;
s.register("t2").await;
assert!(s.is_valid("t1").await);
assert!(s.revoke("t1").await);
assert!(!s.is_valid("t1").await);
assert!(s.is_valid("t2").await);
assert_eq!(s.len().await, 1);
}
#[tokio::test]
async fn register_is_idempotent() {
let s = LongLivedTokenStore::empty();
assert!(s.register("t").await);
assert!(!s.register("t").await);
assert_eq!(s.len().await, 1);
}
#[tokio::test]
async fn empty_token_always_rejected() {
let s = LongLivedTokenStore::allow_any_non_empty();
assert!(!s.is_valid("").await);
}
#[tokio::test]
async fn allow_any_mode_accepts_any_non_empty() {
let s = LongLivedTokenStore::allow_any_non_empty();
assert!(s.is_valid("literally-anything").await);
assert!(s.is_dev_mode().await);
}
#[tokio::test]
async fn from_env_unset_is_empty() {
std::env::remove_var("HOMECORE_TOKENS");
let s = LongLivedTokenStore::from_env();
assert_eq!(s.len().await, 0);
}
}