Skip to main content

shell_tunnel/security/
auth.rs

1//! API Key authentication.
2
3use std::collections::HashMap;
4use std::sync::RwLock;
5use std::time::SystemTime;
6
7use axum::{
8    extract::{Request, State},
9    http::{header::AUTHORIZATION, StatusCode},
10    middleware::Next,
11    response::Response,
12};
13
14use super::capability::CapabilitySet;
15
16/// API key configuration.
17#[derive(Debug, Clone)]
18pub struct AuthConfig {
19    /// Whether authentication is enabled.
20    pub enabled: bool,
21    /// Header name for API key (default: "Authorization").
22    pub header_name: String,
23    /// Prefix for the API key (default: "Bearer ").
24    pub prefix: String,
25}
26
27impl Default for AuthConfig {
28    fn default() -> Self {
29        Self {
30            enabled: true,
31            header_name: AUTHORIZATION.to_string(),
32            prefix: "Bearer ".to_string(),
33        }
34    }
35}
36
37impl AuthConfig {
38    /// Create a disabled auth config (for development).
39    pub fn disabled() -> Self {
40        Self {
41            enabled: false,
42            ..Default::default()
43        }
44    }
45
46    /// Create auth config with custom prefix.
47    pub fn with_prefix(prefix: impl Into<String>) -> Self {
48        Self {
49            prefix: prefix.into(),
50            ..Default::default()
51        }
52    }
53}
54
55/// A registered token: the capabilities it grants plus provenance metadata.
56///
57/// The store maps an opaque bearer-token string to one of these records
58/// (spec §9). The token string itself stays the wire credential
59/// (`Authorization: Bearer <token>`); the record is the server-side authority
60/// on what that token may do.
61#[derive(Debug, Clone)]
62pub struct TokenRecord {
63    /// Capabilities this token grants (spec §2 mechanism).
64    pub capabilities: CapabilitySet,
65    /// Human-readable label for provenance (e.g. `"legacy"`, `"operator"`).
66    pub label: String,
67    /// When the token was registered.
68    pub created_at: SystemTime,
69}
70
71impl TokenRecord {
72    /// Create a token record with the given capabilities and label.
73    pub fn new(capabilities: CapabilitySet, label: impl Into<String>) -> Self {
74        Self {
75            capabilities,
76            label: label.into(),
77            created_at: SystemTime::now(),
78        }
79    }
80
81    /// Create a full-control (wildcard) token — the legacy-key mapping target
82    /// and the `full-control` preset (spec §4, §6).
83    pub fn full_control(label: impl Into<String>) -> Self {
84        Self::new(CapabilitySet::wildcard(), label)
85    }
86}
87
88/// Thread-safe token store, keyed by opaque bearer-token string.
89#[derive(Debug)]
90pub struct ApiKeyStore {
91    tokens: RwLock<HashMap<String, TokenRecord>>,
92    config: AuthConfig,
93}
94
95impl ApiKeyStore {
96    /// Create a new token store.
97    pub fn new(config: AuthConfig) -> Self {
98        Self {
99            tokens: RwLock::new(HashMap::new()),
100            config,
101        }
102    }
103
104    /// Create a store with authentication disabled.
105    pub fn disabled() -> Self {
106        Self::new(AuthConfig::disabled())
107    }
108
109    /// Add a legacy full-control API key.
110    ///
111    /// Backward-compatibility path (spec §4): a bare key with no declared
112    /// capabilities maps to a `full-control` token holding the wildcard, so any
113    /// existing `--api-key` / `--require-auth` consumer is unaffected and can
114    /// never trigger a 403.
115    pub fn add_key(&self, key: impl Into<String>) {
116        self.add_token(key, TokenRecord::full_control("legacy"));
117    }
118
119    /// Register a token string with an explicit capability record.
120    pub fn add_token(&self, key: impl Into<String>, record: TokenRecord) {
121        if let Ok(mut tokens) = self.tokens.write() {
122            tokens.insert(key.into(), record);
123        }
124    }
125
126    /// Register a token with the given capabilities and label.
127    pub fn add_key_with_capabilities(
128        &self,
129        key: impl Into<String>,
130        capabilities: CapabilitySet,
131        label: impl Into<String>,
132    ) {
133        self.add_token(key, TokenRecord::new(capabilities, label));
134    }
135
136    /// Remove a token.
137    pub fn remove_key(&self, key: &str) -> bool {
138        self.tokens
139            .write()
140            .map(|mut tokens| tokens.remove(key).is_some())
141            .unwrap_or(false)
142    }
143
144    /// Check if a token is registered (valid).
145    pub fn is_valid(&self, key: &str) -> bool {
146        self.tokens
147            .read()
148            .map(|tokens| tokens.contains_key(key))
149            .unwrap_or(false)
150    }
151
152    /// Look up the capabilities a token grants, if it is registered.
153    ///
154    /// This is the store surface the scope-aware middleware consumes
155    /// (spec §5 step 2): token → `TokenRecord` → capability check.
156    pub fn capabilities(&self, key: &str) -> Option<CapabilitySet> {
157        self.tokens
158            .read()
159            .ok()
160            .and_then(|tokens| tokens.get(key).map(|record| record.capabilities.clone()))
161    }
162
163    /// Get the number of registered tokens.
164    pub fn count(&self) -> usize {
165        self.tokens.read().map(|t| t.len()).unwrap_or(0)
166    }
167
168    /// Check if authentication is enabled.
169    pub fn is_enabled(&self) -> bool {
170        self.config.enabled
171    }
172
173    /// Extract API key from authorization header.
174    pub fn extract_key(&self, header_value: &str) -> Option<String> {
175        if header_value.starts_with(&self.config.prefix) {
176            Some(header_value[self.config.prefix.len()..].to_string())
177        } else {
178            None
179        }
180    }
181}
182
183impl Default for ApiKeyStore {
184    fn default() -> Self {
185        Self::new(AuthConfig::default())
186    }
187}
188
189/// Authentication middleware for axum.
190pub async fn auth_middleware(
191    State(store): State<std::sync::Arc<ApiKeyStore>>,
192    request: Request,
193    next: Next,
194) -> Result<Response, StatusCode> {
195    // Skip auth if disabled
196    if !store.is_enabled() {
197        return Ok(next.run(request).await);
198    }
199
200    // Skip auth for health endpoint
201    if request.uri().path() == "/health" {
202        return Ok(next.run(request).await);
203    }
204
205    // Extract and validate API key
206    let auth_header = request
207        .headers()
208        .get(AUTHORIZATION)
209        .and_then(|v| v.to_str().ok());
210
211    match auth_header {
212        Some(header) => {
213            if let Some(key) = store.extract_key(header) {
214                if store.is_valid(&key) {
215                    return Ok(next.run(request).await);
216                }
217            }
218            Err(StatusCode::UNAUTHORIZED)
219        }
220        None => Err(StatusCode::UNAUTHORIZED),
221    }
222}
223
224/// Generate a random API key.
225pub fn generate_api_key() -> String {
226    use std::time::{SystemTime, UNIX_EPOCH};
227
228    let timestamp = SystemTime::now()
229        .duration_since(UNIX_EPOCH)
230        .map(|d| d.as_nanos())
231        .unwrap_or(0);
232
233    // Simple but unique key generation
234    // Format: st_<timestamp_hex>_<random_hex>
235    let random: u64 = (timestamp as u64)
236        .wrapping_mul(0x5DEECE66D)
237        .wrapping_add(0xB);
238    format!("st_{:x}_{:016x}", timestamp as u64, random)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_auth_config_default() {
247        let config = AuthConfig::default();
248        assert!(config.enabled);
249        assert_eq!(config.prefix, "Bearer ");
250    }
251
252    #[test]
253    fn test_auth_config_disabled() {
254        let config = AuthConfig::disabled();
255        assert!(!config.enabled);
256    }
257
258    #[test]
259    fn test_api_key_store_add_remove() {
260        let store = ApiKeyStore::default();
261
262        store.add_key("test-key-123");
263        assert!(store.is_valid("test-key-123"));
264        assert!(!store.is_valid("invalid-key"));
265        assert_eq!(store.count(), 1);
266
267        assert!(store.remove_key("test-key-123"));
268        assert!(!store.is_valid("test-key-123"));
269        assert_eq!(store.count(), 0);
270    }
271
272    #[test]
273    fn test_api_key_store_extract() {
274        let store = ApiKeyStore::default();
275
276        let key = store.extract_key("Bearer my-secret-key");
277        assert_eq!(key, Some("my-secret-key".to_string()));
278
279        let no_key = store.extract_key("Basic credentials");
280        assert!(no_key.is_none());
281    }
282
283    #[test]
284    fn test_api_key_store_disabled() {
285        let store = ApiKeyStore::disabled();
286        assert!(!store.is_enabled());
287    }
288
289    #[test]
290    fn test_generate_api_key() {
291        let key1 = generate_api_key();
292        let key2 = generate_api_key();
293
294        assert!(key1.starts_with("st_"));
295        assert!(key2.starts_with("st_"));
296        // Keys should be unique (unless generated in same nanosecond)
297        assert_ne!(key1, key2);
298    }
299
300    #[test]
301    fn test_api_key_store_multiple_keys() {
302        let store = ApiKeyStore::default();
303
304        store.add_key("key1");
305        store.add_key("key2");
306        store.add_key("key3");
307
308        assert_eq!(store.count(), 3);
309        assert!(store.is_valid("key1"));
310        assert!(store.is_valid("key2"));
311        assert!(store.is_valid("key3"));
312    }
313
314    #[test]
315    fn test_legacy_key_maps_to_full_control() {
316        // spec §4: a bare `add_key` maps to a wildcard token so legacy
317        // consumers can never trigger a 403.
318        let store = ApiKeyStore::default();
319        store.add_key("legacy-key");
320
321        let caps = store.capabilities("legacy-key").expect("token registered");
322        assert!(caps.is_wildcard());
323        assert!(caps.satisfies("exec"));
324        assert!(caps.satisfies("session.manage"));
325    }
326
327    #[test]
328    fn test_add_key_with_capabilities() {
329        let store = ApiKeyStore::default();
330        let caps: CapabilitySet = ["exec", "session.read"].into_iter().collect();
331        store.add_key_with_capabilities("fine-grained", caps, "operator");
332
333        assert!(store.is_valid("fine-grained"));
334        let caps = store
335            .capabilities("fine-grained")
336            .expect("token registered");
337        assert!(caps.satisfies("exec"));
338        assert!(caps.satisfies("session.read"));
339        // Fine-grained token is NOT wildcard and lacks unlisted capabilities.
340        assert!(!caps.is_wildcard());
341        assert!(!caps.satisfies("session.manage"));
342    }
343
344    #[test]
345    fn test_capabilities_of_unknown_key_is_none() {
346        let store = ApiKeyStore::default();
347        assert!(store.capabilities("nope").is_none());
348    }
349
350    #[test]
351    fn test_token_record_full_control() {
352        let record = TokenRecord::full_control("legacy");
353        assert!(record.capabilities.is_wildcard());
354        assert_eq!(record.label, "legacy");
355    }
356}