1use std::collections::{BTreeMap, BTreeSet};
2use std::net::IpAddr;
3use std::sync::Arc;
4use std::sync::RwLock;
5use std::time::Instant;
6
7use dashmap::DashMap;
8use tokio::sync::Mutex;
9use tracing::{debug, info, warn};
10use url::Url;
11
12use crate::authelia::AutheliaProvider;
13use crate::config::{AuthConfig, AuthMode};
14use crate::error::AuthError;
15use crate::github::GitHubProvider;
16use crate::google::GoogleProvider;
17use crate::jwt::SigningKeys;
18use crate::oauth_provider::OAuthProvider;
19use crate::sqlite::SqliteStore;
20
21const RATE_LIMIT_RETRY_AFTER_MS: u64 = 60_000;
22
23const RATE_LIMIT_MAX_IP_BUCKETS: usize = 4096;
27
28const RATE_LIMIT_BUCKET_IDLE_SECS: u64 = 600;
31
32struct RateLimiterInner {
34 tokens: f64,
36 max_tokens: f64,
38 refill_rate: f64,
40 last_refill: Instant,
42}
43
44impl RateLimiterInner {
45 fn new(requests_per_minute: u32) -> Self {
46 let rate = requests_per_minute as f64 / 60.0;
47 let max_tokens = requests_per_minute.max(1) as f64;
48 Self {
49 tokens: max_tokens,
50 max_tokens,
51 refill_rate: rate,
52 last_refill: Instant::now(),
53 }
54 }
55
56 fn try_acquire(&mut self) -> bool {
57 let now = Instant::now();
58 let elapsed = now.duration_since(self.last_refill).as_secs_f64();
59 self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
60 self.last_refill = now;
61 if self.tokens >= 1.0 {
62 self.tokens -= 1.0;
63 true
64 } else {
65 false
66 }
67 }
68}
69
70#[derive(Clone)]
80struct PerIpRateLimiter {
81 requests_per_minute: u32,
82 buckets: Arc<DashMap<IpAddr, Mutex<RateLimiterInner>>>,
86 max_buckets: usize,
88 maintenance: Arc<Mutex<()>>,
91}
92
93impl PerIpRateLimiter {
94 fn new(requests_per_minute: u32) -> Self {
95 Self::with_max_buckets(requests_per_minute, RATE_LIMIT_MAX_IP_BUCKETS)
96 }
97
98 fn with_max_buckets(requests_per_minute: u32, max_buckets: usize) -> Self {
99 Self {
100 requests_per_minute,
101 buckets: Arc::new(DashMap::new()),
102 max_buckets: max_buckets.max(1),
103 maintenance: Arc::new(Mutex::new(())),
104 }
105 }
106
107 async fn try_acquire(&self, ip: IpAddr) -> bool {
109 if let Some(bucket) = self.buckets.get(&ip) {
111 return bucket.value().lock().await.try_acquire();
112 }
113 let _guard = self.maintenance.lock().await;
116 if !self.buckets.contains_key(&ip) {
117 if self.buckets.len() >= self.max_buckets {
118 self.evict_one();
119 }
120 self.buckets.insert(
121 ip,
122 Mutex::new(RateLimiterInner::new(self.requests_per_minute)),
123 );
124 }
125 self.buckets
128 .get(&ip)
129 .expect("bucket just inserted")
130 .value()
131 .lock()
132 .await
133 .try_acquire()
134 }
135
136 fn evict_one(&self) {
141 let now = Instant::now();
142 let mut stale: Vec<IpAddr> = Vec::new();
143 let mut oldest: Option<(IpAddr, Instant)> = None;
144 for entry in self.buckets.iter() {
145 let Ok(inner) = entry.value().try_lock() else {
146 continue;
147 };
148 let last_used = inner.last_refill;
149 if now.duration_since(last_used).as_secs() >= RATE_LIMIT_BUCKET_IDLE_SECS {
150 stale.push(*entry.key());
151 } else if oldest.is_none_or(|(_, t)| last_used < t) {
152 oldest = Some((*entry.key(), last_used));
153 }
154 }
155 if stale.is_empty() {
156 if let Some((ip, _)) = oldest {
157 self.buckets.remove(&ip);
158 }
159 return;
160 }
161 for ip in stale {
162 self.buckets.remove(&ip);
163 }
164 }
165}
166
167#[derive(Clone)]
168pub struct AuthState {
169 pub config: Arc<AuthConfig>,
170 pub store: SqliteStore,
171 pub signing_keys: Arc<SigningKeys>,
172 pub providers: Arc<BTreeMap<String, Arc<dyn OAuthProvider>>>,
173 pub default_provider: String,
174 allowed_resource_scopes: Arc<RwLock<BTreeMap<String, BTreeSet<String>>>>,
175 authorize_limiter: PerIpRateLimiter,
176 register_limiter: PerIpRateLimiter,
177 #[cfg(feature = "http-axum")]
182 pub(crate) cimd_cache: Arc<crate::cimd::document::DocumentCache>,
183}
184
185impl AuthState {
186 pub async fn new(config: AuthConfig) -> Result<Self, AuthError> {
187 config.validate()?;
196
197 if !matches!(config.mode, AuthMode::OAuth) {
198 return Err(AuthError::Config(format!(
199 "AuthState requires {prefix}_AUTH_MODE=oauth",
200 prefix = config.env_prefix
201 )));
202 }
203
204 let public_url = config.public_url.clone().ok_or_else(|| {
205 AuthError::Config(format!(
206 "{prefix}_PUBLIC_URL is required when {prefix}_AUTH_MODE=oauth",
207 prefix = config.env_prefix
208 ))
209 })?;
210 let store = SqliteStore::open(config.sqlite_path.clone()).await?;
211 let signing_keys = SigningKeys::load_or_create(&config.key_path)?;
212 let providers = build_providers(&public_url, &config)?;
213 if !providers.contains_key(&config.default_provider) {
214 return Err(AuthError::Config(format!(
215 "{prefix}_AUTH_DEFAULT_PROVIDER `{provider}` is not a configured provider",
216 prefix = config.env_prefix,
217 provider = config.default_provider,
218 )));
219 }
220 info!(
221 crate_name = "soma-auth",
222 env_prefix = %config.env_prefix,
223 auth_mode = "oauth",
224 public_url = %public_url,
225 configured_providers = ?providers.keys().collect::<Vec<_>>(),
226 default_provider = %config.default_provider,
227 sqlite_path = %config.sqlite_path.display(),
228 key_path = %config.key_path.display(),
229 "auth state initialized"
230 );
231 if providers.len() > 1 {
243 warn!(
244 crate_name = "soma-auth",
245 env_prefix = %config.env_prefix,
246 configured_providers = ?providers.keys().collect::<Vec<_>>(),
247 "multiple OAuth providers configured — the email allowlist is shared across all \
248 of them, so admin access is only as strong as the weakest configured provider's \
249 identity verification; see docs/AUTH.md"
250 );
251 }
252
253 let authorize_limiter = PerIpRateLimiter::new(config.authorize_requests_per_minute);
254 let register_limiter = PerIpRateLimiter::new(config.register_requests_per_minute);
255 let default_provider = config.default_provider.clone();
256 Ok(Self {
257 config: Arc::new(config),
258 store,
259 signing_keys: Arc::new(signing_keys),
260 providers: Arc::new(providers),
261 default_provider,
262 allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())),
263 authorize_limiter,
264 register_limiter,
265 #[cfg(feature = "http-axum")]
266 cimd_cache: Arc::new(crate::cimd::document::DocumentCache::new()),
267 })
268 }
269
270 pub fn set_allowed_resource_urls(&self, resources: impl IntoIterator<Item = String>) {
276 self.set_allowed_resource_scopes(
277 resources
278 .into_iter()
279 .map(|resource| (resource, self.config.scopes_supported.to_vec())),
280 );
281 }
282
283 pub fn set_allowed_resource_scopes(
285 &self,
286 resources: impl IntoIterator<Item = (String, Vec<String>)>,
287 ) {
288 let mut allowed = self
289 .allowed_resource_scopes
290 .write()
291 .expect("allowed resource scope lock");
292 allowed.clear();
293 for (resource, scopes) in resources {
294 let resource = resource.trim().trim_end_matches('/').to_string();
295 if resource.is_empty() {
296 continue;
297 }
298 let scopes = scopes
299 .into_iter()
300 .map(|scope| scope.trim().to_string())
301 .filter(|scope| !scope.is_empty())
302 .collect::<BTreeSet<_>>();
303 allowed.insert(resource, scopes);
304 }
305 debug!(
306 resource_count = allowed.len(),
307 "oauth allowed protected resource scopes refreshed"
308 );
309 }
310
311 pub fn is_allowed_resource_url(&self, resource: &str) -> bool {
312 self.allowed_resource_scopes
313 .read()
314 .expect("allowed resource scope lock")
315 .contains_key(resource.trim().trim_end_matches('/'))
316 }
317
318 pub fn allowed_resource_scopes(&self, resource: &str) -> Option<Vec<String>> {
319 self.allowed_resource_scopes
320 .read()
321 .expect("allowed resource scope lock")
322 .get(resource.trim().trim_end_matches('/'))
323 .map(|scopes| scopes.iter().cloned().collect())
324 }
325
326 pub async fn check_authorize_rate_limit(&self, ip: IpAddr) -> Result<(), AuthError> {
332 if self.authorize_limiter.try_acquire(ip).await {
333 Ok(())
334 } else {
335 Err(AuthError::RateLimited {
336 message: "authorize rate limit exceeded".to_string(),
337 retry_after_ms: RATE_LIMIT_RETRY_AFTER_MS,
338 })
339 }
340 }
341
342 pub async fn check_register_rate_limit(&self, ip: IpAddr) -> Result<(), AuthError> {
346 if self.register_limiter.try_acquire(ip).await {
347 Ok(())
348 } else {
349 Err(AuthError::RateLimited {
350 message: "register rate limit exceeded".to_string(),
351 retry_after_ms: RATE_LIMIT_RETRY_AFTER_MS,
352 })
353 }
354 }
355
356 pub async fn resolve_allowed_emails(&self) -> Result<Vec<String>, AuthError> {
366 let mut emails = vec![self.config.admin_email.clone()];
367 for row in self.store.list_allowed_users().await? {
368 if !row.email.eq_ignore_ascii_case(&self.config.admin_email) {
369 emails.push(row.email);
370 }
371 }
372 Ok(emails)
373 }
374
375 pub async fn ensure_pending_oauth_state_capacity(&self) -> Result<(), AuthError> {
377 let count = self.store.count_pending_oauth_states().await?;
378 if count >= self.config.max_pending_oauth_states {
379 return Err(AuthError::RateLimited {
380 message: "too many pending authorization requests".to_string(),
381 retry_after_ms: 5_000,
382 });
383 }
384 Ok(())
385 }
386
387 pub fn provider(&self, id: &str) -> Result<Arc<dyn OAuthProvider>, AuthError> {
393 self.providers
394 .get(id)
395 .cloned()
396 .ok_or_else(|| AuthError::Validation(format!("unknown oauth provider `{id}`")))
397 }
398
399 pub fn provider_or_default(
402 &self,
403 id: Option<&str>,
404 ) -> Result<Arc<dyn OAuthProvider>, AuthError> {
405 self.provider(id.unwrap_or(self.default_provider.as_str()))
406 }
407
408 #[cfg(test)]
409 pub fn for_tests(
410 config: AuthConfig,
411 store: SqliteStore,
412 signing_keys: SigningKeys,
413 providers: BTreeMap<String, Arc<dyn OAuthProvider>>,
414 ) -> Self {
415 let authorize_limiter = PerIpRateLimiter::new(config.authorize_requests_per_minute);
416 let register_limiter = PerIpRateLimiter::new(config.register_requests_per_minute);
417 let default_provider = config.default_provider.clone();
418 Self {
419 config: Arc::new(config),
420 store,
421 signing_keys: Arc::new(signing_keys),
422 providers: Arc::new(providers),
423 default_provider,
424 allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())),
425 authorize_limiter,
426 register_limiter,
427 #[cfg(feature = "http-axum")]
428 cimd_cache: Arc::new(crate::cimd::document::DocumentCache::new()),
429 }
430 }
431
432 #[cfg(test)]
433 pub fn google_only_providers(
434 google: GoogleProvider,
435 ) -> BTreeMap<String, Arc<dyn OAuthProvider>> {
436 let mut providers: BTreeMap<String, Arc<dyn OAuthProvider>> = BTreeMap::new();
437 providers.insert("google".to_string(), Arc::new(google));
438 providers
439 }
440}
441
442fn build_providers(
443 public_url: &Url,
444 config: &AuthConfig,
445) -> Result<BTreeMap<String, Arc<dyn OAuthProvider>>, AuthError> {
446 let mut providers: BTreeMap<String, Arc<dyn OAuthProvider>> = BTreeMap::new();
447
448 if !config.google.client_id.is_empty() {
449 let redirect_uri = build_provider_redirect_uri(public_url, &config.google.callback_path);
450 let mut google = GoogleProvider::new(
451 config.google.client_id.clone(),
452 config.google.client_secret.clone(),
453 redirect_uri,
454 )?;
455 google.scopes.clone_from(&config.google.scopes);
456 providers.insert("google".to_string(), Arc::new(google));
457 }
458
459 if !config.authelia.client_id.is_empty() {
460 let issuer = config.authelia.issuer_url.clone().ok_or_else(|| {
461 AuthError::Config(format!(
462 "{}_AUTHELIA_ISSUER_URL is required when {}_AUTHELIA_CLIENT_ID is set",
463 config.env_prefix, config.env_prefix
464 ))
465 })?;
466 let redirect_uri = build_provider_redirect_uri(public_url, &config.authelia.callback_path);
467 let mut authelia = AutheliaProvider::new(
468 issuer,
469 config.authelia.client_id.clone(),
470 config.authelia.client_secret.clone(),
471 redirect_uri,
472 )?;
473 authelia.scopes.clone_from(&config.authelia.scopes);
474 providers.insert("authelia".to_string(), Arc::new(authelia));
475 }
476
477 if !config.github.client_id.is_empty() {
478 let redirect_uri = build_provider_redirect_uri(public_url, &config.github.callback_path);
479 let mut github = GitHubProvider::new(
480 config.github.client_id.clone(),
481 config.github.client_secret.clone(),
482 redirect_uri,
483 )?;
484 github.scopes.clone_from(&config.github.scopes);
485 providers.insert("github".to_string(), Arc::new(github));
486 }
487
488 if providers.is_empty() {
489 return Err(AuthError::Config(format!(
490 "at least one OAuth provider must be configured when {}_AUTH_MODE=oauth",
491 config.env_prefix
492 )));
493 }
494
495 Ok(providers)
496}
497
498fn build_provider_redirect_uri(public_url: &Url, callback_path: &str) -> Url {
499 let mut redirect_uri = public_url.clone();
500 let base_path = redirect_uri.path().trim_end_matches('/');
501 let callback_path = callback_path.trim_start_matches('/');
502 let next_path = if base_path.is_empty() {
503 format!("/{callback_path}")
504 } else {
505 format!("{base_path}/{callback_path}")
506 };
507
508 redirect_uri.set_path(&next_path);
509 redirect_uri.set_query(None);
510 redirect_uri.set_fragment(None);
511 redirect_uri
512}
513
514#[cfg(test)]
515mod tests {
516 use std::time::Duration;
517
518 use tempfile::tempdir;
519
520 use super::*;
521 use crate::config::{GitHubConfig, GoogleConfig};
522 use crate::util::now_unix;
523
524 async fn resolve_state(admin_email: &str) -> AuthState {
526 let dir = tempdir().expect("tempdir");
527 AuthState::new(AuthConfig {
528 mode: AuthMode::OAuth,
529 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
530 sqlite_path: dir.path().join("auth.db"),
531 key_path: dir.path().join("auth.pem"),
532 bootstrap_secret: None,
533 allowed_client_redirect_uris: Vec::new(),
534 admin_email: admin_email.to_string(),
535 google: GoogleConfig {
536 client_id: "client-id".to_string(),
537 client_secret: "client-secret".to_string(),
538 callback_path: "/auth/google/callback".to_string(),
539 scopes: vec![
540 "openid".to_string(),
541 "email".to_string(),
542 "profile".to_string(),
543 ],
544 },
545 access_token_ttl: Duration::from_secs(3600),
546 refresh_token_ttl: Duration::from_secs(3600),
547 auth_code_ttl: Duration::from_secs(300),
548 register_requests_per_minute: 10,
549 authorize_requests_per_minute: 20,
550 max_pending_oauth_states: 1024,
551 default_provider: "google".to_string(),
552 ..AuthConfig::default()
553 })
554 .await
555 .expect("auth state")
556 }
557
558 #[tokio::test]
564 async fn provider_map_keys_match_each_providers_provider_id() {
565 let dir = tempdir().expect("tempdir");
566 let state = AuthState::new(AuthConfig {
567 mode: AuthMode::OAuth,
568 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
569 sqlite_path: dir.path().join("auth.db"),
570 key_path: dir.path().join("auth.pem"),
571 bootstrap_secret: None,
572 allowed_client_redirect_uris: Vec::new(),
573 admin_email: "admin@example.com".to_string(),
574 google: GoogleConfig {
575 client_id: "client-id".to_string(),
576 client_secret: "client-secret".to_string(),
577 callback_path: "/auth/google/callback".to_string(),
578 scopes: vec![
579 "openid".to_string(),
580 "email".to_string(),
581 "profile".to_string(),
582 ],
583 },
584 github: GitHubConfig {
585 client_id: "gh-client".to_string(),
586 client_secret: "gh-secret".to_string(),
587 callback_path: "/auth/github/callback".to_string(),
588 scopes: vec!["read:user".to_string(), "user:email".to_string()],
589 },
590 access_token_ttl: Duration::from_secs(3600),
591 refresh_token_ttl: Duration::from_secs(3600),
592 auth_code_ttl: Duration::from_secs(300),
593 register_requests_per_minute: 10,
594 authorize_requests_per_minute: 20,
595 max_pending_oauth_states: 1024,
596 default_provider: "google".to_string(),
597 ..AuthConfig::default()
598 })
599 .await
600 .expect("auth state");
601
602 assert_eq!(
603 state.providers.len(),
604 2,
605 "expected both configured providers: {:?}",
606 state.providers.keys().collect::<Vec<_>>()
607 );
608 assert!(
609 state
610 .providers
611 .iter()
612 .all(|(key, provider)| key.as_str() == provider.provider_id()),
613 "provider map key must match provider_id() for every entry: {:?}",
614 state
615 .providers
616 .iter()
617 .map(|(key, provider)| (key.clone(), provider.provider_id()))
618 .collect::<Vec<_>>()
619 );
620 }
621
622 #[tokio::test]
623 async fn resolve_allowed_emails_returns_admin_when_table_is_empty() {
624 let state = resolve_state("admin@example.com").await;
625 let emails = state.resolve_allowed_emails().await.unwrap();
626 assert_eq!(emails, vec!["admin@example.com"]);
627 }
628
629 #[tokio::test]
630 async fn resolve_allowed_emails_includes_db_rows_after_admin() {
631 let state = resolve_state("admin@example.com").await;
632 state
633 .store
634 .add_allowed_user("alice@example.com", "admin", now_unix())
635 .await
636 .unwrap();
637 state
638 .store
639 .add_allowed_user("bob@example.com", "admin", now_unix() + 1)
640 .await
641 .unwrap();
642 let emails = state.resolve_allowed_emails().await.unwrap();
643 assert_eq!(
645 emails,
646 vec!["admin@example.com", "alice@example.com", "bob@example.com"]
647 );
648 }
649
650 #[tokio::test]
651 async fn resolve_allowed_emails_deduplicates_admin_present_in_db() {
652 let state = resolve_state("admin@example.com").await;
653 state
655 .store
656 .add_allowed_user("Admin@Example.COM", "self", now_unix())
657 .await
658 .unwrap();
659 state
660 .store
661 .add_allowed_user("other@example.com", "admin", now_unix() + 1)
662 .await
663 .unwrap();
664 let emails = state.resolve_allowed_emails().await.unwrap();
665 assert_eq!(emails, vec!["admin@example.com", "other@example.com"]);
667 }
668
669 #[tokio::test]
670 async fn auth_state_preserves_public_url_path_prefix_in_google_redirect_uri() {
671 let temp = tempdir().expect("tempdir");
672 let state = AuthState::new(AuthConfig {
673 mode: AuthMode::OAuth,
674 public_url: Some(Url::parse("https://lab.example.com/gateway").expect("public url")),
675 sqlite_path: temp.path().join("auth.db"),
676 key_path: temp.path().join("auth.pem"),
677 bootstrap_secret: None,
678 allowed_client_redirect_uris: Vec::new(),
679 admin_email: "admin@example.com".to_string(),
680 google: GoogleConfig {
681 client_id: "client-id".to_string(),
682 client_secret: "client-secret".to_string(),
683 callback_path: "/auth/google/callback".to_string(),
684 scopes: vec![
685 "openid".to_string(),
686 "email".to_string(),
687 "profile".to_string(),
688 ],
689 },
690 access_token_ttl: Duration::from_secs(3600),
691 refresh_token_ttl: Duration::from_secs(3600),
692 auth_code_ttl: Duration::from_secs(300),
693 register_requests_per_minute: 10,
694 authorize_requests_per_minute: 20,
695 max_pending_oauth_states: 1024,
696 default_provider: "google".to_string(),
697 ..AuthConfig::default()
698 })
699 .await
700 .expect("auth state");
701
702 assert_eq!(
703 state.provider("google").unwrap().callback_path(),
704 "/gateway/auth/google/callback"
705 );
706 }
707
708 #[tokio::test(flavor = "current_thread")]
709 async fn per_ip_rate_limiter_evicts_at_cap_instead_of_growing() {
710 let limiter = PerIpRateLimiter::with_max_buckets(60, 4);
711 for i in 0..4u8 {
712 assert!(limiter.try_acquire(IpAddr::from([10, 0, 0, i])).await);
713 }
714 assert_eq!(limiter.buckets.len(), 4);
715
716 let newcomer = IpAddr::from([10, 0, 0, 200]);
720 assert!(limiter.try_acquire(newcomer).await);
721 assert_eq!(limiter.buckets.len(), 4);
722 assert!(limiter.buckets.contains_key(&newcomer));
723 }
724
725 #[tokio::test(flavor = "current_thread")]
726 async fn per_ip_rate_limiter_stays_bounded_under_address_churn() {
727 let limiter = PerIpRateLimiter::with_max_buckets(60, 8);
728 for i in 0..100u32 {
729 let ip = IpAddr::from([10, 1, (i / 256) as u8, (i % 256) as u8]);
730 assert!(limiter.try_acquire(ip).await);
731 }
732 assert!(limiter.buckets.len() <= 8);
733 }
734}