Skip to main content

assay_auth/
state.rs

1//! Shared state types for auth router composition.
2//!
3//! The auth router is generic over a parent state `S`. The parent must
4//! `FromRef`-provide an `AuthCtx` and an [`AdminApiKeys`] for admin
5//! routes. The engine binary's `EngineState<S>` does both — tests use
6//! [`AuthCtxWithAdmin`] for an in-process state.
7
8use std::sync::Arc;
9
10use axum::extract::FromRef;
11
12use crate::ctx::AuthCtx;
13
14/// Bearer token guard for `/admin/*` routes. Compared in constant time
15/// against the configured admin keys list. Operators rotate keys via
16/// the engine config (`auth.admin_api_keys`).
17#[derive(Clone, Default)]
18pub struct AdminApiKeys(pub Arc<Vec<String>>);
19
20impl AdminApiKeys {
21    /// Empty — no admin keys configured. Every admin route returns 401.
22    pub fn empty() -> Self {
23        Self(Arc::new(Vec::new()))
24    }
25
26    /// Build from a static slice — tests + simple programmatic setups.
27    /// Named `from_keys` (not `from_iter`) to avoid clashing with the
28    /// `std::iter::FromIterator::from_iter` trait method's call site
29    /// (which would silently shadow this inherent method).
30    pub fn from_keys<I, S>(iter: I) -> Self
31    where
32        I: IntoIterator<Item = S>,
33        S: Into<String>,
34    {
35        Self(Arc::new(iter.into_iter().map(Into::into).collect()))
36    }
37
38    /// Whether `presented` matches any configured admin key. Constant-
39    /// time bytewise compare — short-circuits on length difference.
40    pub fn check(&self, presented: &str) -> bool {
41        let presented = presented.as_bytes();
42        for key in self.0.iter() {
43            let key = key.as_bytes();
44            if key.len() != presented.len() {
45                continue;
46            }
47            let mut diff = 0u8;
48            for (a, b) in key.iter().zip(presented.iter()) {
49                diff |= a ^ b;
50            }
51            if diff == 0 {
52                return true;
53            }
54        }
55        false
56    }
57
58    /// Whether at least one admin key is configured.
59    pub fn enabled(&self) -> bool {
60        !self.0.is_empty()
61    }
62}
63
64/// Convenience parent state for tests: `AuthCtx` + `AdminApiKeys`. The
65/// engine uses its own `EngineState<S>` instead.
66#[derive(Clone)]
67pub struct AuthCtxWithAdmin {
68    pub auth: AuthCtx,
69    pub admin: AdminApiKeys,
70}
71
72impl AuthCtxWithAdmin {
73    pub fn new(auth: AuthCtx) -> Self {
74        Self {
75            auth,
76            admin: AdminApiKeys::empty(),
77        }
78    }
79
80    pub fn with_admin_keys(mut self, admin: AdminApiKeys) -> Self {
81        self.admin = admin;
82        self
83    }
84}
85
86impl FromRef<AuthCtxWithAdmin> for AuthCtx {
87    fn from_ref(s: &AuthCtxWithAdmin) -> Self {
88        s.auth.clone()
89    }
90}
91
92impl FromRef<AuthCtxWithAdmin> for AdminApiKeys {
93    fn from_ref(s: &AuthCtxWithAdmin) -> Self {
94        s.admin.clone()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn admin_keys_constant_time_check() {
104        let keys = AdminApiKeys::from_keys(["abc", "xyz"]);
105        assert!(keys.check("abc"));
106        assert!(keys.check("xyz"));
107        assert!(!keys.check("abd"));
108        assert!(!keys.check(""));
109        assert!(!keys.check("abcd"));
110    }
111
112    #[test]
113    fn admin_keys_empty_disables() {
114        let keys = AdminApiKeys::empty();
115        assert!(!keys.enabled());
116        assert!(!keys.check("anything"));
117    }
118
119    #[test]
120    fn admin_keys_enabled_when_populated() {
121        let keys = AdminApiKeys::from_keys(["k"]);
122        assert!(keys.enabled());
123    }
124}