shell_tunnel/security/
auth.rs1use 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#[derive(Debug, Clone)]
18pub struct AuthConfig {
19 pub enabled: bool,
21 pub header_name: String,
23 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 pub fn disabled() -> Self {
40 Self {
41 enabled: false,
42 ..Default::default()
43 }
44 }
45
46 pub fn with_prefix(prefix: impl Into<String>) -> Self {
48 Self {
49 prefix: prefix.into(),
50 ..Default::default()
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
62pub struct TokenRecord {
63 pub capabilities: CapabilitySet,
65 pub label: String,
67 pub created_at: SystemTime,
69}
70
71impl TokenRecord {
72 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 pub fn full_control(label: impl Into<String>) -> Self {
84 Self::new(CapabilitySet::wildcard(), label)
85 }
86}
87
88#[derive(Debug)]
90pub struct ApiKeyStore {
91 tokens: RwLock<HashMap<String, TokenRecord>>,
92 config: AuthConfig,
93}
94
95impl ApiKeyStore {
96 pub fn new(config: AuthConfig) -> Self {
98 Self {
99 tokens: RwLock::new(HashMap::new()),
100 config,
101 }
102 }
103
104 pub fn disabled() -> Self {
106 Self::new(AuthConfig::disabled())
107 }
108
109 pub fn add_key(&self, key: impl Into<String>) {
116 self.add_token(key, TokenRecord::full_control("legacy"));
117 }
118
119 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 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 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 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 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 pub fn count(&self) -> usize {
165 self.tokens.read().map(|t| t.len()).unwrap_or(0)
166 }
167
168 pub fn is_enabled(&self) -> bool {
170 self.config.enabled
171 }
172
173 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
189pub async fn auth_middleware(
191 State(store): State<std::sync::Arc<ApiKeyStore>>,
192 request: Request,
193 next: Next,
194) -> Result<Response, StatusCode> {
195 if !store.is_enabled() {
197 return Ok(next.run(request).await);
198 }
199
200 if request.uri().path() == "/health" {
202 return Ok(next.run(request).await);
203 }
204
205 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
224pub 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 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 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 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 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}