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    /// Identifier for the audit trail.
64    ///
65    /// Assigned at registration and unrelated to the token's value, so a trail
66    /// can name the caller without containing the credential. Per-process:
67    /// tokens are not persisted, so neither is this.
68    pub id: String,
69    /// Capabilities this token grants (spec §2 mechanism).
70    pub capabilities: CapabilitySet,
71    /// Human-readable label for provenance (e.g. `"legacy"`, `"operator"`).
72    pub label: String,
73    /// When the token was registered.
74    pub created_at: SystemTime,
75}
76
77impl TokenRecord {
78    /// Create a token record with the given capabilities and label.
79    pub fn new(capabilities: CapabilitySet, label: impl Into<String>) -> Self {
80        Self {
81            id: generate_token_id(),
82            capabilities,
83            label: label.into(),
84            created_at: SystemTime::now(),
85        }
86    }
87
88    /// Create a full-control (wildcard) token — the legacy-key mapping target
89    /// and the `full-control` preset (spec §4, §6).
90    pub fn full_control(label: impl Into<String>) -> Self {
91        Self::new(CapabilitySet::wildcard(), label)
92    }
93}
94
95/// Thread-safe token store, keyed by opaque bearer-token string.
96#[derive(Debug)]
97pub struct ApiKeyStore {
98    tokens: RwLock<HashMap<String, TokenRecord>>,
99    config: AuthConfig,
100}
101
102impl ApiKeyStore {
103    /// Create a new token store.
104    pub fn new(config: AuthConfig) -> Self {
105        Self {
106            tokens: RwLock::new(HashMap::new()),
107            config,
108        }
109    }
110
111    /// Create a store with authentication disabled.
112    pub fn disabled() -> Self {
113        Self::new(AuthConfig::disabled())
114    }
115
116    /// Add a legacy full-control API key.
117    ///
118    /// Backward-compatibility path (spec §4): a bare key with no declared
119    /// capabilities maps to a `full-control` token holding the wildcard, so any
120    /// existing `--api-key` / `--require-auth` consumer is unaffected and can
121    /// never trigger a 403.
122    pub fn add_key(&self, key: impl Into<String>) {
123        self.add_token(key, TokenRecord::full_control("legacy"));
124    }
125
126    /// Register a token string with an explicit capability record.
127    pub fn add_token(&self, key: impl Into<String>, record: TokenRecord) {
128        if let Ok(mut tokens) = self.tokens.write() {
129            tokens.insert(key.into(), record);
130        }
131    }
132
133    /// Register a token with the given capabilities and label.
134    pub fn add_key_with_capabilities(
135        &self,
136        key: impl Into<String>,
137        capabilities: CapabilitySet,
138        label: impl Into<String>,
139    ) {
140        self.add_token(key, TokenRecord::new(capabilities, label));
141    }
142
143    /// Remove a token.
144    pub fn remove_key(&self, key: &str) -> bool {
145        self.tokens
146            .write()
147            .map(|mut tokens| tokens.remove(key).is_some())
148            .unwrap_or(false)
149    }
150
151    /// Check if a token is registered (valid).
152    pub fn is_valid(&self, key: &str) -> bool {
153        self.tokens
154            .read()
155            .map(|tokens| tokens.contains_key(key))
156            .unwrap_or(false)
157    }
158
159    /// Look up the capabilities a token grants, if it is registered.
160    ///
161    /// This is the store surface the scope-aware middleware consumes
162    /// (spec §5 step 2): token → `TokenRecord` → capability check.
163    pub fn capabilities(&self, key: &str) -> Option<CapabilitySet> {
164        self.tokens
165            .read()
166            .ok()
167            .and_then(|tokens| tokens.get(key).map(|record| record.capabilities.clone()))
168    }
169
170    /// Identify a token for the audit trail, without revealing it.
171    pub fn identity(&self, key: &str) -> Option<crate::audit::Identity> {
172        self.tokens.read().ok().and_then(|tokens| {
173            tokens.get(key).map(|record| crate::audit::Identity {
174                token_id: record.id.clone(),
175                label: record.label.clone(),
176            })
177        })
178    }
179
180    /// Get the number of registered tokens.
181    pub fn count(&self) -> usize {
182        self.tokens.read().map(|t| t.len()).unwrap_or(0)
183    }
184
185    /// Check if authentication is enabled.
186    pub fn is_enabled(&self) -> bool {
187        self.config.enabled
188    }
189
190    /// Extract API key from authorization header.
191    pub fn extract_key(&self, header_value: &str) -> Option<String> {
192        if header_value.starts_with(&self.config.prefix) {
193            Some(header_value[self.config.prefix.len()..].to_string())
194        } else {
195            None
196        }
197    }
198}
199
200impl Default for ApiKeyStore {
201    fn default() -> Self {
202        Self::new(AuthConfig::default())
203    }
204}
205
206/// Authentication middleware for axum.
207pub async fn auth_middleware(
208    State(store): State<std::sync::Arc<ApiKeyStore>>,
209    request: Request,
210    next: Next,
211) -> Result<Response, StatusCode> {
212    // Skip auth if disabled
213    if !store.is_enabled() {
214        return Ok(next.run(request).await);
215    }
216
217    // Skip auth for health endpoint
218    if request.uri().path() == "/health" {
219        return Ok(next.run(request).await);
220    }
221
222    // Extract and validate API key
223    let auth_header = request
224        .headers()
225        .get(AUTHORIZATION)
226        .and_then(|v| v.to_str().ok());
227
228    match auth_header {
229        Some(header) => {
230            if let Some(key) = store.extract_key(header) {
231                if store.is_valid(&key) {
232                    return Ok(next.run(request).await);
233                }
234            }
235            Err(StatusCode::UNAUTHORIZED)
236        }
237        None => Err(StatusCode::UNAUTHORIZED),
238    }
239}
240
241/// Short identifier for an audit trail entry.
242///
243/// Random rather than derived from the token: a derived value would let anyone
244/// holding the log test guesses against it.
245fn generate_token_id() -> String {
246    let full = generate_api_key();
247    format!("tok_{}", &full[full.len().saturating_sub(12)..])
248}
249
250/// Generate a random API key.
251pub fn generate_api_key() -> String {
252    use std::time::{SystemTime, UNIX_EPOCH};
253
254    let timestamp = SystemTime::now()
255        .duration_since(UNIX_EPOCH)
256        .map(|d| d.as_nanos())
257        .unwrap_or(0);
258
259    // Simple but unique key generation
260    // Format: st_<timestamp_hex>_<random_hex>
261    let random: u64 = (timestamp as u64)
262        .wrapping_mul(0x5DEECE66D)
263        .wrapping_add(0xB);
264    format!("st_{:x}_{:016x}", timestamp as u64, random)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_auth_config_default() {
273        let config = AuthConfig::default();
274        assert!(config.enabled);
275        assert_eq!(config.prefix, "Bearer ");
276    }
277
278    #[test]
279    fn test_auth_config_disabled() {
280        let config = AuthConfig::disabled();
281        assert!(!config.enabled);
282    }
283
284    #[test]
285    fn test_api_key_store_add_remove() {
286        let store = ApiKeyStore::default();
287
288        store.add_key("test-key-123");
289        assert!(store.is_valid("test-key-123"));
290        assert!(!store.is_valid("invalid-key"));
291        assert_eq!(store.count(), 1);
292
293        assert!(store.remove_key("test-key-123"));
294        assert!(!store.is_valid("test-key-123"));
295        assert_eq!(store.count(), 0);
296    }
297
298    #[test]
299    fn test_api_key_store_extract() {
300        let store = ApiKeyStore::default();
301
302        let key = store.extract_key("Bearer my-secret-key");
303        assert_eq!(key, Some("my-secret-key".to_string()));
304
305        let no_key = store.extract_key("Basic credentials");
306        assert!(no_key.is_none());
307    }
308
309    #[test]
310    fn test_api_key_store_disabled() {
311        let store = ApiKeyStore::disabled();
312        assert!(!store.is_enabled());
313    }
314
315    #[test]
316    fn test_generate_api_key() {
317        let key1 = generate_api_key();
318        let key2 = generate_api_key();
319
320        assert!(key1.starts_with("st_"));
321        assert!(key2.starts_with("st_"));
322        // Keys should be unique (unless generated in same nanosecond)
323        assert_ne!(key1, key2);
324    }
325
326    #[test]
327    fn test_api_key_store_multiple_keys() {
328        let store = ApiKeyStore::default();
329
330        store.add_key("key1");
331        store.add_key("key2");
332        store.add_key("key3");
333
334        assert_eq!(store.count(), 3);
335        assert!(store.is_valid("key1"));
336        assert!(store.is_valid("key2"));
337        assert!(store.is_valid("key3"));
338    }
339
340    #[test]
341    fn test_legacy_key_maps_to_full_control() {
342        // spec §4: a bare `add_key` maps to a wildcard token so legacy
343        // consumers can never trigger a 403.
344        let store = ApiKeyStore::default();
345        store.add_key("legacy-key");
346
347        let caps = store.capabilities("legacy-key").expect("token registered");
348        assert!(caps.is_wildcard());
349        assert!(caps.satisfies("exec"));
350        assert!(caps.satisfies("session.manage"));
351    }
352
353    #[test]
354    fn test_add_key_with_capabilities() {
355        let store = ApiKeyStore::default();
356        let caps: CapabilitySet = ["exec", "session.read"].into_iter().collect();
357        store.add_key_with_capabilities("fine-grained", caps, "operator");
358
359        assert!(store.is_valid("fine-grained"));
360        let caps = store
361            .capabilities("fine-grained")
362            .expect("token registered");
363        assert!(caps.satisfies("exec"));
364        assert!(caps.satisfies("session.read"));
365        // Fine-grained token is NOT wildcard and lacks unlisted capabilities.
366        assert!(!caps.is_wildcard());
367        assert!(!caps.satisfies("session.manage"));
368    }
369
370    #[test]
371    fn test_capabilities_of_unknown_key_is_none() {
372        let store = ApiKeyStore::default();
373        assert!(store.capabilities("nope").is_none());
374    }
375
376    #[test]
377    fn test_token_record_full_control() {
378        let record = TokenRecord::full_control("legacy");
379        assert!(record.capabilities.is_wildcard());
380        assert_eq!(record.label, "legacy");
381    }
382}