Skip to main content

brainos_core/
auth.rs

1//! Shared authentication logic for all protocol adapters.
2//!
3//! This module is the single source of truth for API key validation and
4//! permission checking. Every adapter (HTTP, WebSocket, gRPC, MCP) must
5//! use these functions instead of rolling their own validation.
6
7use subtle::ConstantTimeEq;
8
9use crate::config::ApiKeyConfig;
10
11/// Result of an authentication check.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum AuthResult {
14    /// Auth is disabled (no API keys configured) — allow all.
15    Open,
16    /// Key is valid and has the requested permission.
17    Allowed,
18    /// No key was provided.
19    MissingKey,
20    /// Key was provided but does not match any configured key.
21    InvalidKey,
22    /// Key is valid but lacks the requested permission.
23    InsufficientPermission,
24}
25
26impl AuthResult {
27    /// Returns `true` if the request should be allowed.
28    pub fn is_allowed(&self) -> bool {
29        matches!(self, AuthResult::Open | AuthResult::Allowed)
30    }
31
32    /// Returns a human-readable error message, or `None` if allowed.
33    pub fn error_message(&self, permission: &str) -> Option<String> {
34        match self {
35            AuthResult::Open | AuthResult::Allowed => None,
36            AuthResult::MissingKey => Some("Missing API key".to_string()),
37            AuthResult::InvalidKey => Some("Invalid API key".to_string()),
38            AuthResult::InsufficientPermission => {
39                Some(format!("API key does not have '{permission}' permission"))
40            }
41        }
42    }
43}
44
45/// Extract a bearer token from an "Authorization" header value string.
46///
47/// E.g. `extract_bearer_from_value("Bearer abc123")` returns `Some("abc123")`.
48pub fn extract_bearer_from_value(value: &str) -> Option<&str> {
49    value.strip_prefix("Bearer ")
50}
51
52/// Validate an API key against the configured keys with permission checking.
53///
54/// - If `api_keys` is empty, auth is disabled and all requests are allowed.
55/// - If `provided_key` is `None`, returns `MissingKey`.
56/// - If the key doesn't match any configured key, returns `InvalidKey`.
57/// - If the key matches but lacks the required permission, returns `InsufficientPermission`.
58/// - Otherwise returns `Allowed`.
59pub fn check_auth(
60    api_keys: &[ApiKeyConfig],
61    provided_key: Option<&str>,
62    permission: &str,
63) -> AuthResult {
64    if api_keys.is_empty() {
65        return AuthResult::Open;
66    }
67
68    let key = match provided_key {
69        Some(k) if !k.is_empty() => k,
70        _ => return AuthResult::MissingKey,
71    };
72
73    match api_keys.iter().find(|k| {
74        let a = k.key.as_bytes();
75        let b = key.as_bytes();
76        // NOTE: length precheck may leak key length via timing side-channel.
77        // This is accepted because: (1) Brain binds to 127.0.0.1 by default,
78        // so remote attackers cannot reach auth endpoints; (2) keys are
79        // minted with ~128 bits of entropy, making length knowledge
80        // inconsequential; (3) the timing delta (~50 ns) is below practical
81        // measurement thresholds even on localhost.
82        // See docs/deferred-followups-0.2.x.md for full analysis.
83        a.len() == b.len() && a.ct_eq(b).into()
84    }) {
85        None => AuthResult::InvalidKey,
86        Some(k) if !k.has_permission(permission) => AuthResult::InsufficientPermission,
87        Some(_) => AuthResult::Allowed,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn test_keys() -> Vec<ApiKeyConfig> {
96        vec![
97            ApiKeyConfig {
98                key: "rw-key".to_string(),
99                name: "Read-Write".to_string(),
100                permissions: vec!["read".to_string(), "write".to_string()],
101            },
102            ApiKeyConfig {
103                key: "ro-key".to_string(),
104                name: "Read-Only".to_string(),
105                permissions: vec!["read".to_string()],
106            },
107        ]
108    }
109
110    #[test]
111    fn test_empty_keys_allows_all() {
112        assert_eq!(check_auth(&[], Some("anything"), "write"), AuthResult::Open);
113        assert_eq!(check_auth(&[], None, "read"), AuthResult::Open);
114    }
115
116    #[test]
117    fn test_missing_key() {
118        let keys = test_keys();
119        assert_eq!(check_auth(&keys, None, "read"), AuthResult::MissingKey);
120        assert_eq!(check_auth(&keys, Some(""), "read"), AuthResult::MissingKey);
121    }
122
123    #[test]
124    fn test_invalid_key() {
125        let keys = test_keys();
126        assert_eq!(
127            check_auth(&keys, Some("bad-key"), "read"),
128            AuthResult::InvalidKey
129        );
130    }
131
132    #[test]
133    fn test_insufficient_permission() {
134        let keys = test_keys();
135        assert_eq!(
136            check_auth(&keys, Some("ro-key"), "write"),
137            AuthResult::InsufficientPermission
138        );
139    }
140
141    #[test]
142    fn test_valid_read() {
143        let keys = test_keys();
144        assert_eq!(
145            check_auth(&keys, Some("ro-key"), "read"),
146            AuthResult::Allowed
147        );
148        assert_eq!(
149            check_auth(&keys, Some("rw-key"), "read"),
150            AuthResult::Allowed
151        );
152    }
153
154    #[test]
155    fn test_valid_write() {
156        let keys = test_keys();
157        assert_eq!(
158            check_auth(&keys, Some("rw-key"), "write"),
159            AuthResult::Allowed
160        );
161    }
162}