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 id: String,
69 pub capabilities: CapabilitySet,
71 pub label: String,
73 pub created_at: SystemTime,
75}
76
77impl TokenRecord {
78 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 pub fn full_control(label: impl Into<String>) -> Self {
91 Self::new(CapabilitySet::wildcard(), label)
92 }
93}
94
95#[derive(Debug)]
97pub struct ApiKeyStore {
98 tokens: RwLock<HashMap<String, TokenRecord>>,
99 config: AuthConfig,
100}
101
102impl ApiKeyStore {
103 pub fn new(config: AuthConfig) -> Self {
105 Self {
106 tokens: RwLock::new(HashMap::new()),
107 config,
108 }
109 }
110
111 pub fn disabled() -> Self {
113 Self::new(AuthConfig::disabled())
114 }
115
116 pub fn add_key(&self, key: impl Into<String>) {
123 self.add_token(key, TokenRecord::full_control("legacy"));
124 }
125
126 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 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 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 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 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 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 pub fn count(&self) -> usize {
182 self.tokens.read().map(|t| t.len()).unwrap_or(0)
183 }
184
185 pub fn is_enabled(&self) -> bool {
187 self.config.enabled
188 }
189
190 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
206pub async fn auth_middleware(
208 State(store): State<std::sync::Arc<ApiKeyStore>>,
209 request: Request,
210 next: Next,
211) -> Result<Response, StatusCode> {
212 if !store.is_enabled() {
214 return Ok(next.run(request).await);
215 }
216
217 if request.uri().path() == "/health" {
219 return Ok(next.run(request).await);
220 }
221
222 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
241fn generate_token_id() -> String {
246 let full = generate_api_key();
247 format!("tok_{}", &full[full.len().saturating_sub(12)..])
248}
249
250pub 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 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 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 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 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}