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 token_limiter: PerIpRateLimiter,
178 #[cfg(feature = "http-axum")]
183 pub(crate) cimd_cache: Arc<crate::cimd::document::DocumentCache>,
184}
185
186impl AuthState {
187 pub async fn new(config: AuthConfig) -> Result<Self, AuthError> {
188 config.validate()?;
197
198 if !matches!(config.mode, AuthMode::OAuth) {
199 return Err(AuthError::Config(format!(
200 "AuthState requires {prefix}_AUTH_MODE=oauth",
201 prefix = config.env_prefix
202 )));
203 }
204
205 let public_url = config.public_url.clone().ok_or_else(|| {
206 AuthError::Config(format!(
207 "{prefix}_PUBLIC_URL is required when {prefix}_AUTH_MODE=oauth",
208 prefix = config.env_prefix
209 ))
210 })?;
211 let store = SqliteStore::open(config.sqlite_path.clone()).await?;
212 ensure_machine_clients_do_not_shadow_registrations(&config, &store).await?;
218 let signing_keys = SigningKeys::load_or_create(&config.key_path)?;
219 let providers = build_providers(&public_url, &config)?;
220 if !providers.contains_key(&config.default_provider) {
221 return Err(AuthError::Config(format!(
222 "{prefix}_AUTH_DEFAULT_PROVIDER `{provider}` is not a configured provider",
223 prefix = config.env_prefix,
224 provider = config.default_provider,
225 )));
226 }
227 info!(
228 crate_name = "soma-auth",
229 env_prefix = %config.env_prefix,
230 auth_mode = "oauth",
231 public_url = %public_url,
232 configured_providers = ?providers.keys().collect::<Vec<_>>(),
233 default_provider = %config.default_provider,
234 sqlite_path = %config.sqlite_path.display(),
235 key_path = %config.key_path.display(),
236 "auth state initialized"
237 );
238 if providers.len() > 1 {
250 warn!(
251 crate_name = "soma-auth",
252 env_prefix = %config.env_prefix,
253 configured_providers = ?providers.keys().collect::<Vec<_>>(),
254 "multiple OAuth providers configured — the email allowlist is shared across all \
255 of them, so admin access is only as strong as the weakest configured provider's \
256 identity verification; see docs/AUTH.md"
257 );
258 }
259
260 let authorize_limiter = PerIpRateLimiter::new(config.authorize_requests_per_minute);
261 let register_limiter = PerIpRateLimiter::new(config.register_requests_per_minute);
262 let token_limiter = PerIpRateLimiter::new(config.token_requests_per_minute);
263 let default_provider = config.default_provider.clone();
264 Ok(Self {
265 config: Arc::new(config),
266 store,
267 signing_keys: Arc::new(signing_keys),
268 providers: Arc::new(providers),
269 default_provider,
270 allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())),
271 authorize_limiter,
272 register_limiter,
273 token_limiter,
274 #[cfg(feature = "http-axum")]
275 cimd_cache: Arc::new(crate::cimd::document::DocumentCache::new()),
276 })
277 }
278
279 pub fn set_allowed_resource_urls(&self, resources: impl IntoIterator<Item = String>) {
285 self.set_allowed_resource_scopes(
286 resources
287 .into_iter()
288 .map(|resource| (resource, self.config.scopes_supported.to_vec())),
289 );
290 }
291
292 pub fn set_allowed_resource_scopes(
294 &self,
295 resources: impl IntoIterator<Item = (String, Vec<String>)>,
296 ) {
297 let mut allowed = self
298 .allowed_resource_scopes
299 .write()
300 .expect("allowed resource scope lock");
301 allowed.clear();
302 for (resource, scopes) in resources {
303 let resource = resource.trim().trim_end_matches('/').to_string();
304 if resource.is_empty() {
305 continue;
306 }
307 let scopes = scopes
308 .into_iter()
309 .map(|scope| scope.trim().to_string())
310 .filter(|scope| !scope.is_empty())
311 .collect::<BTreeSet<_>>();
312 allowed.insert(resource, scopes);
313 }
314 debug!(
315 resource_count = allowed.len(),
316 "oauth allowed protected resource scopes refreshed"
317 );
318 }
319
320 pub fn is_allowed_resource_url(&self, resource: &str) -> bool {
321 self.allowed_resource_scopes
322 .read()
323 .expect("allowed resource scope lock")
324 .contains_key(resource.trim().trim_end_matches('/'))
325 }
326
327 pub fn allowed_resource_scopes(&self, resource: &str) -> Option<Vec<String>> {
328 self.allowed_resource_scopes
329 .read()
330 .expect("allowed resource scope lock")
331 .get(resource.trim().trim_end_matches('/'))
332 .map(|scopes| scopes.iter().cloned().collect())
333 }
334
335 pub async fn check_authorize_rate_limit(&self, ip: IpAddr) -> Result<(), AuthError> {
341 if self.authorize_limiter.try_acquire(ip).await {
342 Ok(())
343 } else {
344 Err(AuthError::RateLimited {
345 message: "authorize rate limit exceeded".to_string(),
346 retry_after_ms: RATE_LIMIT_RETRY_AFTER_MS,
347 })
348 }
349 }
350
351 pub async fn check_register_rate_limit(&self, ip: IpAddr) -> Result<(), AuthError> {
355 if self.register_limiter.try_acquire(ip).await {
356 Ok(())
357 } else {
358 Err(AuthError::RateLimited {
359 message: "register rate limit exceeded".to_string(),
360 retry_after_ms: RATE_LIMIT_RETRY_AFTER_MS,
361 })
362 }
363 }
364
365 pub async fn check_token_rate_limit(&self, ip: IpAddr) -> Result<(), AuthError> {
367 if self.token_limiter.try_acquire(ip).await {
368 Ok(())
369 } else {
370 Err(AuthError::RateLimited {
371 message: "token endpoint rate limit exceeded".to_string(),
372 retry_after_ms: RATE_LIMIT_RETRY_AFTER_MS,
373 })
374 }
375 }
376
377 pub async fn consume_assertion_jti(
379 &self,
380 issuer: &str,
381 jti: &str,
382 issued_at: i64,
383 expires_at: i64,
384 ) -> Result<bool, AuthError> {
385 self.store
386 .consume_assertion_jti(issuer, jti, issued_at, expires_at, crate::util::now_unix())
387 .await
388 }
389
390 pub async fn resolve_allowed_emails(&self) -> Result<Vec<String>, AuthError> {
400 let mut emails = vec![self.config.admin_email.clone()];
401 for row in self.store.list_allowed_users().await? {
402 if !row.email.eq_ignore_ascii_case(&self.config.admin_email) {
403 emails.push(row.email);
404 }
405 }
406 Ok(emails)
407 }
408
409 pub async fn ensure_pending_oauth_state_capacity(&self) -> Result<(), AuthError> {
411 let count = self.store.count_pending_oauth_states().await?;
412 if count >= self.config.max_pending_oauth_states {
413 return Err(AuthError::RateLimited {
414 message: "too many pending authorization requests".to_string(),
415 retry_after_ms: 5_000,
416 });
417 }
418 Ok(())
419 }
420
421 pub fn provider(&self, id: &str) -> Result<Arc<dyn OAuthProvider>, AuthError> {
427 self.providers
428 .get(id)
429 .cloned()
430 .ok_or_else(|| AuthError::Validation(format!("unknown oauth provider `{id}`")))
431 }
432
433 pub fn provider_or_default(
436 &self,
437 id: Option<&str>,
438 ) -> Result<Arc<dyn OAuthProvider>, AuthError> {
439 self.provider(id.unwrap_or(self.default_provider.as_str()))
440 }
441
442 #[cfg(test)]
443 pub fn for_tests(
444 config: AuthConfig,
445 store: SqliteStore,
446 signing_keys: SigningKeys,
447 providers: BTreeMap<String, Arc<dyn OAuthProvider>>,
448 ) -> Self {
449 let authorize_limiter = PerIpRateLimiter::new(config.authorize_requests_per_minute);
450 let register_limiter = PerIpRateLimiter::new(config.register_requests_per_minute);
451 let token_limiter = PerIpRateLimiter::new(config.token_requests_per_minute);
452 let default_provider = config.default_provider.clone();
453 Self {
454 config: Arc::new(config),
455 store,
456 signing_keys: Arc::new(signing_keys),
457 providers: Arc::new(providers),
458 default_provider,
459 allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())),
460 authorize_limiter,
461 register_limiter,
462 token_limiter,
463 #[cfg(feature = "http-axum")]
464 cimd_cache: Arc::new(crate::cimd::document::DocumentCache::new()),
465 }
466 }
467
468 #[cfg(test)]
469 pub fn google_only_providers(
470 google: GoogleProvider,
471 ) -> BTreeMap<String, Arc<dyn OAuthProvider>> {
472 let mut providers: BTreeMap<String, Arc<dyn OAuthProvider>> = BTreeMap::new();
473 providers.insert("google".to_string(), Arc::new(google));
474 providers
475 }
476}
477
478fn build_providers(
479 public_url: &Url,
480 config: &AuthConfig,
481) -> Result<BTreeMap<String, Arc<dyn OAuthProvider>>, AuthError> {
482 let mut providers: BTreeMap<String, Arc<dyn OAuthProvider>> = BTreeMap::new();
483
484 if !config.google.client_id.is_empty() {
485 let redirect_uri = build_provider_redirect_uri(public_url, &config.google.callback_path);
486 let mut google = GoogleProvider::new(
487 config.google.client_id.clone(),
488 config.google.client_secret.clone(),
489 redirect_uri,
490 )?;
491 google.scopes.clone_from(&config.google.scopes);
492 providers.insert("google".to_string(), Arc::new(google));
493 }
494
495 if !config.authelia.client_id.is_empty() {
496 let issuer = config.authelia.issuer_url.clone().ok_or_else(|| {
497 AuthError::Config(format!(
498 "{}_AUTHELIA_ISSUER_URL is required when {}_AUTHELIA_CLIENT_ID is set",
499 config.env_prefix, config.env_prefix
500 ))
501 })?;
502 let redirect_uri = build_provider_redirect_uri(public_url, &config.authelia.callback_path);
503 let mut authelia = AutheliaProvider::new(
504 issuer,
505 config.authelia.client_id.clone(),
506 config.authelia.client_secret.clone(),
507 redirect_uri,
508 )?;
509 authelia.scopes.clone_from(&config.authelia.scopes);
510 providers.insert("authelia".to_string(), Arc::new(authelia));
511 }
512
513 if !config.github.client_id.is_empty() {
514 let redirect_uri = build_provider_redirect_uri(public_url, &config.github.callback_path);
515 let mut github = GitHubProvider::new(
516 config.github.client_id.clone(),
517 config.github.client_secret.clone(),
518 redirect_uri,
519 )?;
520 github.scopes.clone_from(&config.github.scopes);
521 providers.insert("github".to_string(), Arc::new(github));
522 }
523
524 if providers.is_empty() {
525 return Err(AuthError::Config(format!(
526 "at least one OAuth provider must be configured when {}_AUTH_MODE=oauth",
527 config.env_prefix
528 )));
529 }
530
531 Ok(providers)
532}
533
534async fn ensure_machine_clients_do_not_shadow_registrations(
549 config: &AuthConfig,
550 store: &SqliteStore,
551) -> Result<(), AuthError> {
552 for client in &config.machine_clients {
553 #[cfg(feature = "http-axum")]
558 if crate::cimd::document::is_cimd_client_id(&client.client_id) {
559 return Err(AuthError::Config(format!(
560 "machine client `{}` uses the Client ID Metadata Document \
561 `client_id` shape (`https://...`); the token endpoint would \
562 authenticate it as a machine client instead of fetching its \
563 metadata document. Give the machine client an opaque id",
564 client.client_id
565 )));
566 }
567 if store.find_client(&client.client_id).await?.is_some() {
568 return Err(AuthError::Config(format!(
569 "machine client `{}` collides with a registered OAuth client of the same \
570 `client_id` and would silently shadow it at the token endpoint. Rename the \
571 machine client or delete the registration",
572 client.client_id
573 )));
574 }
575 }
576 Ok(())
577}
578
579fn build_provider_redirect_uri(public_url: &Url, callback_path: &str) -> Url {
580 let mut redirect_uri = public_url.clone();
581 let base_path = redirect_uri.path().trim_end_matches('/');
582 let callback_path = callback_path.trim_start_matches('/');
583 let next_path = if base_path.is_empty() {
584 format!("/{callback_path}")
585 } else {
586 format!("{base_path}/{callback_path}")
587 };
588
589 redirect_uri.set_path(&next_path);
590 redirect_uri.set_query(None);
591 redirect_uri.set_fragment(None);
592 redirect_uri
593}
594
595#[cfg(test)]
596mod tests {
597 use std::path::Path;
598 use std::time::Duration;
599
600 use tempfile::tempdir;
601
602 use super::*;
603 use crate::config::{GitHubConfig, GoogleConfig, MachineClientConfig};
604 use crate::types::RegisteredClient;
605 use crate::util::now_unix;
606
607 async fn resolve_state(admin_email: &str) -> AuthState {
609 let dir = tempdir().expect("tempdir");
610 AuthState::new(AuthConfig {
611 mode: AuthMode::OAuth,
612 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
613 sqlite_path: dir.path().join("auth.db"),
614 key_path: dir.path().join("auth.pem"),
615 bootstrap_secret: None,
616 allowed_client_redirect_uris: Vec::new(),
617 admin_email: admin_email.to_string(),
618 google: GoogleConfig {
619 client_id: "client-id".to_string(),
620 client_secret: "client-secret".to_string(),
621 callback_path: "/auth/google/callback".to_string(),
622 scopes: vec![
623 "openid".to_string(),
624 "email".to_string(),
625 "profile".to_string(),
626 ],
627 },
628 access_token_ttl: Duration::from_secs(3600),
629 refresh_token_ttl: Duration::from_secs(3600),
630 auth_code_ttl: Duration::from_secs(300),
631 register_requests_per_minute: 10,
632 authorize_requests_per_minute: 20,
633 max_pending_oauth_states: 1024,
634 default_provider: "google".to_string(),
635 ..AuthConfig::default()
636 })
637 .await
638 .expect("auth state")
639 }
640
641 #[tokio::test]
647 async fn provider_map_keys_match_each_providers_provider_id() {
648 let dir = tempdir().expect("tempdir");
649 let state = AuthState::new(AuthConfig {
650 mode: AuthMode::OAuth,
651 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
652 sqlite_path: dir.path().join("auth.db"),
653 key_path: dir.path().join("auth.pem"),
654 bootstrap_secret: None,
655 allowed_client_redirect_uris: Vec::new(),
656 admin_email: "admin@example.com".to_string(),
657 google: GoogleConfig {
658 client_id: "client-id".to_string(),
659 client_secret: "client-secret".to_string(),
660 callback_path: "/auth/google/callback".to_string(),
661 scopes: vec![
662 "openid".to_string(),
663 "email".to_string(),
664 "profile".to_string(),
665 ],
666 },
667 github: GitHubConfig {
668 client_id: "gh-client".to_string(),
669 client_secret: "gh-secret".to_string(),
670 callback_path: "/auth/github/callback".to_string(),
671 scopes: vec!["read:user".to_string(), "user:email".to_string()],
672 },
673 access_token_ttl: Duration::from_secs(3600),
674 refresh_token_ttl: Duration::from_secs(3600),
675 auth_code_ttl: Duration::from_secs(300),
676 register_requests_per_minute: 10,
677 authorize_requests_per_minute: 20,
678 max_pending_oauth_states: 1024,
679 default_provider: "google".to_string(),
680 ..AuthConfig::default()
681 })
682 .await
683 .expect("auth state");
684
685 assert_eq!(
686 state.providers.len(),
687 2,
688 "expected both configured providers: {:?}",
689 state.providers.keys().collect::<Vec<_>>()
690 );
691 assert!(
692 state
693 .providers
694 .iter()
695 .all(|(key, provider)| key.as_str() == provider.provider_id()),
696 "provider map key must match provider_id() for every entry: {:?}",
697 state
698 .providers
699 .iter()
700 .map(|(key, provider)| (key.clone(), provider.provider_id()))
701 .collect::<Vec<_>>()
702 );
703 }
704
705 fn machine_client_config(dir: &Path, machine_clients: Vec<MachineClientConfig>) -> AuthConfig {
708 AuthConfig {
709 mode: AuthMode::OAuth,
710 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
711 sqlite_path: dir.join("auth.db"),
712 key_path: dir.join("auth.pem"),
713 admin_email: "admin@example.com".to_string(),
714 google: GoogleConfig {
715 client_id: "client-id".to_string(),
716 client_secret: "client-secret".to_string(),
717 callback_path: "/auth/google/callback".to_string(),
718 scopes: vec!["openid".to_string(), "email".to_string()],
719 },
720 default_provider: "google".to_string(),
721 machine_clients,
722 ..AuthConfig::default()
723 }
724 }
725
726 fn machine_client(client_id: &str) -> MachineClientConfig {
727 MachineClientConfig {
728 client_id: client_id.to_string(),
729 client_secret: Some("machine-secret".to_string()),
730 jwks: None,
731 scopes: vec!["lab".to_string()],
732 resources: vec!["https://lab.example.com/mcp".to_string()],
733 }
734 }
735
736 async fn seed_registered_client(sqlite_path: &Path, client_id: &str) {
739 let store = SqliteStore::open(sqlite_path.to_path_buf())
740 .await
741 .expect("seed store");
742 store
743 .register_client(RegisteredClient {
744 client_id: client_id.to_string(),
745 redirect_uris: vec!["https://client.example.com/callback".to_string()],
746 created_at: now_unix(),
747 token_endpoint_auth_method: "none".to_string(),
748 jwks: None,
749 })
750 .await
751 .expect("seed registered client");
752 }
753
754 #[tokio::test]
755 async fn auth_state_rejects_machine_client_shadowing_a_registered_client() {
756 let dir = tempdir().expect("tempdir");
757 let config = machine_client_config(dir.path(), vec![machine_client("shared-id")]);
758 seed_registered_client(&config.sqlite_path, "shared-id").await;
759
760 let Err(error) = AuthState::new(config).await else {
762 panic!("a machine client colliding with a registration must fail startup");
763 };
764 assert!(
765 matches!(&error, AuthError::Config(_)),
766 "expected a config error, got {error:?}"
767 );
768 assert!(
769 error.to_string().contains("shared-id"),
770 "the error must name the colliding client_id: {error}"
771 );
772 }
773
774 #[tokio::test]
775 async fn auth_state_accepts_machine_client_with_no_registered_collision() {
776 let dir = tempdir().expect("tempdir");
777 let config = machine_client_config(dir.path(), vec![machine_client("machine")]);
778 seed_registered_client(&config.sqlite_path, "registered-id").await;
779
780 let state = AuthState::new(config).await.expect("auth state");
781 assert_eq!(state.config.machine_clients.len(), 1);
782 }
783
784 #[cfg(feature = "http-axum")]
788 #[tokio::test]
789 async fn auth_state_rejects_cimd_shaped_machine_client_id() {
790 let dir = tempdir().expect("tempdir");
791 let client_id = "https://app.example.com/oauth/client-metadata.json";
792 let config = machine_client_config(dir.path(), vec![machine_client(client_id)]);
793
794 let Err(error) = AuthState::new(config).await else {
795 panic!("a CIMD-shaped machine client_id must fail startup");
796 };
797 assert!(
798 matches!(&error, AuthError::Config(_)),
799 "expected a config error, got {error:?}"
800 );
801 assert!(
802 error.to_string().contains(client_id),
803 "the error must name the offending client_id: {error}"
804 );
805 }
806
807 #[tokio::test]
808 async fn resolve_allowed_emails_returns_admin_when_table_is_empty() {
809 let state = resolve_state("admin@example.com").await;
810 let emails = state.resolve_allowed_emails().await.unwrap();
811 assert_eq!(emails, vec!["admin@example.com"]);
812 }
813
814 #[tokio::test]
815 async fn resolve_allowed_emails_includes_db_rows_after_admin() {
816 let state = resolve_state("admin@example.com").await;
817 state
818 .store
819 .add_allowed_user("alice@example.com", "admin", now_unix())
820 .await
821 .unwrap();
822 state
823 .store
824 .add_allowed_user("bob@example.com", "admin", now_unix() + 1)
825 .await
826 .unwrap();
827 let emails = state.resolve_allowed_emails().await.unwrap();
828 assert_eq!(
830 emails,
831 vec!["admin@example.com", "alice@example.com", "bob@example.com"]
832 );
833 }
834
835 #[tokio::test]
836 async fn resolve_allowed_emails_deduplicates_admin_present_in_db() {
837 let state = resolve_state("admin@example.com").await;
838 state
840 .store
841 .add_allowed_user("Admin@Example.COM", "self", now_unix())
842 .await
843 .unwrap();
844 state
845 .store
846 .add_allowed_user("other@example.com", "admin", now_unix() + 1)
847 .await
848 .unwrap();
849 let emails = state.resolve_allowed_emails().await.unwrap();
850 assert_eq!(emails, vec!["admin@example.com", "other@example.com"]);
852 }
853
854 #[tokio::test]
855 async fn auth_state_preserves_public_url_path_prefix_in_google_redirect_uri() {
856 let temp = tempdir().expect("tempdir");
857 let state = AuthState::new(AuthConfig {
858 mode: AuthMode::OAuth,
859 public_url: Some(Url::parse("https://lab.example.com/gateway").expect("public url")),
860 sqlite_path: temp.path().join("auth.db"),
861 key_path: temp.path().join("auth.pem"),
862 bootstrap_secret: None,
863 allowed_client_redirect_uris: Vec::new(),
864 admin_email: "admin@example.com".to_string(),
865 google: GoogleConfig {
866 client_id: "client-id".to_string(),
867 client_secret: "client-secret".to_string(),
868 callback_path: "/auth/google/callback".to_string(),
869 scopes: vec![
870 "openid".to_string(),
871 "email".to_string(),
872 "profile".to_string(),
873 ],
874 },
875 access_token_ttl: Duration::from_secs(3600),
876 refresh_token_ttl: Duration::from_secs(3600),
877 auth_code_ttl: Duration::from_secs(300),
878 register_requests_per_minute: 10,
879 authorize_requests_per_minute: 20,
880 max_pending_oauth_states: 1024,
881 default_provider: "google".to_string(),
882 ..AuthConfig::default()
883 })
884 .await
885 .expect("auth state");
886
887 assert_eq!(
888 state.provider("google").unwrap().callback_path(),
889 "/gateway/auth/google/callback"
890 );
891 }
892
893 #[tokio::test(flavor = "current_thread")]
894 async fn per_ip_rate_limiter_evicts_at_cap_instead_of_growing() {
895 let limiter = PerIpRateLimiter::with_max_buckets(60, 4);
896 for i in 0..4u8 {
897 assert!(limiter.try_acquire(IpAddr::from([10, 0, 0, i])).await);
898 }
899 assert_eq!(limiter.buckets.len(), 4);
900
901 let newcomer = IpAddr::from([10, 0, 0, 200]);
905 assert!(limiter.try_acquire(newcomer).await);
906 assert_eq!(limiter.buckets.len(), 4);
907 assert!(limiter.buckets.contains_key(&newcomer));
908 }
909
910 #[tokio::test(flavor = "current_thread")]
911 async fn per_ip_rate_limiter_stays_bounded_under_address_churn() {
912 let limiter = PerIpRateLimiter::with_max_buckets(60, 8);
913 for i in 0..100u32 {
914 let ip = IpAddr::from([10, 1, (i / 256) as u8, (i % 256) as u8]);
915 assert!(limiter.try_acquire(ip).await);
916 }
917 assert!(limiter.buckets.len() <= 8);
918 }
919}