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 let mut bytes = [0u8; 16];
259 getrandom::fill(&mut bytes).expect("the OS entropy source is unavailable");
260 let (a, b) = bytes.split_at(8);
261 format!(
262 "st_{:016x}_{:016x}",
263 u64::from_be_bytes(a.try_into().expect("split_at(8) yields 8 bytes")),
264 u64::from_be_bytes(b.try_into().expect("split_at(8) yields 8 bytes"))
265 )
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_auth_config_default() {
274 let config = AuthConfig::default();
275 assert!(config.enabled);
276 assert_eq!(config.prefix, "Bearer ");
277 }
278
279 #[test]
280 fn test_auth_config_disabled() {
281 let config = AuthConfig::disabled();
282 assert!(!config.enabled);
283 }
284
285 #[test]
286 fn test_api_key_store_add_remove() {
287 let store = ApiKeyStore::default();
288
289 store.add_key("test-key-123");
290 assert!(store.is_valid("test-key-123"));
291 assert!(!store.is_valid("invalid-key"));
292 assert_eq!(store.count(), 1);
293
294 assert!(store.remove_key("test-key-123"));
295 assert!(!store.is_valid("test-key-123"));
296 assert_eq!(store.count(), 0);
297 }
298
299 #[test]
300 fn test_api_key_store_extract() {
301 let store = ApiKeyStore::default();
302
303 let key = store.extract_key("Bearer my-secret-key");
304 assert_eq!(key, Some("my-secret-key".to_string()));
305
306 let no_key = store.extract_key("Basic credentials");
307 assert!(no_key.is_none());
308 }
309
310 #[test]
311 fn test_api_key_store_disabled() {
312 let store = ApiKeyStore::disabled();
313 assert!(!store.is_enabled());
314 }
315
316 #[test]
317 fn test_generate_api_key() {
318 let key1 = generate_api_key();
319 let key2 = generate_api_key();
320
321 assert!(key1.starts_with("st_"));
322 assert!(key2.starts_with("st_"));
323 assert_ne!(key1, key2);
324 }
325
326 #[test]
327 fn the_secret_half_is_not_a_function_of_the_printed_half() {
328 let key = generate_api_key();
335 let mut halves = key.trim_start_matches("st_").split('_');
336 let printed = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
337 let secret = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
338 assert_ne!(
339 secret,
340 printed.wrapping_mul(0x5DEECE66D).wrapping_add(0xB),
341 "the second half of {key} is derived from the first"
342 );
343 }
344
345 #[test]
346 fn keys_generated_back_to_back_are_all_distinct() {
347 let keys: std::collections::HashSet<String> =
351 (0..1000).map(|_| generate_api_key()).collect();
352 assert_eq!(keys.len(), 1000);
353 }
354
355 #[test]
356 fn test_api_key_store_multiple_keys() {
357 let store = ApiKeyStore::default();
358
359 store.add_key("key1");
360 store.add_key("key2");
361 store.add_key("key3");
362
363 assert_eq!(store.count(), 3);
364 assert!(store.is_valid("key1"));
365 assert!(store.is_valid("key2"));
366 assert!(store.is_valid("key3"));
367 }
368
369 #[test]
370 fn test_legacy_key_maps_to_full_control() {
371 let store = ApiKeyStore::default();
374 store.add_key("legacy-key");
375
376 let caps = store.capabilities("legacy-key").expect("token registered");
377 assert!(caps.is_wildcard());
378 assert!(caps.satisfies("exec"));
379 assert!(caps.satisfies("session.manage"));
380 }
381
382 #[test]
383 fn test_add_key_with_capabilities() {
384 let store = ApiKeyStore::default();
385 let caps: CapabilitySet = ["exec", "session.read"].into_iter().collect();
386 store.add_key_with_capabilities("fine-grained", caps, "operator");
387
388 assert!(store.is_valid("fine-grained"));
389 let caps = store
390 .capabilities("fine-grained")
391 .expect("token registered");
392 assert!(caps.satisfies("exec"));
393 assert!(caps.satisfies("session.read"));
394 assert!(!caps.is_wildcard());
396 assert!(!caps.satisfies("session.manage"));
397 }
398
399 #[test]
400 fn test_capabilities_of_unknown_key_is_none() {
401 let store = ApiKeyStore::default();
402 assert!(store.capabilities("nope").is_none());
403 }
404
405 #[test]
406 fn test_token_record_full_control() {
407 let record = TokenRecord::full_control("legacy");
408 assert!(record.capabilities.is_wildcard());
409 assert_eq!(record.label, "legacy");
410 }
411}