Skip to main content

soma_auth/
state.rs

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
23/// Hard cap on distinct per-IP buckets held in memory. Without a cap an
24/// attacker rotating IPv6 source addresses grows the map without bound
25/// (pattern ported from labby-auth's bounded limiter).
26const RATE_LIMIT_MAX_IP_BUCKETS: usize = 4096;
27
28/// Buckets untouched for this long are eligible for eviction. Any bucket
29/// idle this long has fully refilled, so dropping it loses no state.
30const RATE_LIMIT_BUCKET_IDLE_SECS: u64 = 600;
31
32/// Per-request parameters for rate-limiting. Each bucket is independent.
33struct RateLimiterInner {
34    /// Tokens available in the bucket.
35    tokens: f64,
36    /// Maximum tokens, equal to the full per-minute burst allowance.
37    max_tokens: f64,
38    /// Refill rate in tokens per second.
39    refill_rate: f64,
40    /// Last refill time.
41    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/// Per-IP token-bucket rate limiter.
71///
72/// Uses a `DashMap` of `tokio::sync::Mutex<RateLimiterInner>` so:
73/// - different IPs can be checked concurrently without serializing on a global lock
74///   (lab-77y5.10 — one IP cannot exhaust the global bucket),
75/// - the per-bucket lock is a `tokio::sync::Mutex` so contention does not park a
76///   Tokio worker thread (lab-77y5.9).
77///
78/// Cheap to clone (all state is behind `Arc`).
79#[derive(Clone)]
80struct PerIpRateLimiter {
81    requests_per_minute: u32,
82    /// Per-IP buckets. Bounded: when the map reaches `max_buckets`, idle
83    /// buckets are swept and, failing that, the least-recently-used bucket
84    /// is evicted before a new one is inserted.
85    buckets: Arc<DashMap<IpAddr, Mutex<RateLimiterInner>>>,
86    /// Cap on `buckets` (constant in production; overridable in tests).
87    max_buckets: usize,
88    /// Serializes slow-path bucket creation so a burst of previously-unseen
89    /// IPs cannot race past the `max_buckets` cap.
90    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    /// Try to consume one token for `ip`. Returns `true` if allowed.
108    async fn try_acquire(&self, ip: IpAddr) -> bool {
109        // Fast path: bucket already exists.
110        if let Some(bucket) = self.buckets.get(&ip) {
111            return bucket.value().lock().await.try_acquire();
112        }
113        // Slow path: create the bucket under the maintenance lock so
114        // concurrent new IPs cannot collectively exceed the cap.
115        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        // Safe expect: inserted above (or by a racing task) and only
126        // `evict_one` removes entries, which runs under the same lock.
127        self.buckets
128            .get(&ip)
129            .expect("bucket just inserted")
130            .value()
131            .lock()
132            .await
133            .try_acquire()
134    }
135
136    /// Make room for one new bucket: drop every idle bucket, and if none
137    /// were idle, drop the least-recently-used one. Buckets whose mutex is
138    /// currently held are in active use and are never candidates. Must be
139    /// called while holding `maintenance`.
140    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    /// Single-flight, TTL-cached OAuth Client ID Metadata Document store for
178    /// `/authorize`'s CIMD path (`crate::cimd`). Gated behind `http-axum`
179    /// alongside `crate::cimd` itself, even though `AuthState` (this struct)
180    /// is otherwise usable without that feature.
181    #[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        // Run the full validator first — struct-literal callers (test
188        // fixtures, or a downstream consumer bypassing AuthConfigBuilder)
189        // otherwise skip every safety check `validate()` enforces (HTTPS-only
190        // Authelia issuer, callback-path collisions, GitHub scope
191        // requirements, etc.). `validate()` only asserts OAuth-mode-specific
192        // invariants when `mode == AuthMode::OAuth`, so the manual mode check
193        // immediately below is NOT redundant with it — it's the only thing
194        // that rejects a non-OAuth config reaching `AuthState::new` at all.
195        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        // Security posture note (see this plan's Global Constraints): the
232        // email allowlist is a single flat list shared across every
233        // configured provider, and being on it grants full admin scope
234        // regardless of which provider authenticated the user. With 2+
235        // providers configured, the deployment's effective admin-gate
236        // strength is that of its weakest provider's identity-verification
237        // signal (GitHub's non-re-verified "primary && verified" email flag
238        // is weaker than Google/Authelia's live per-login ID-token claim).
239        // `admin_email` is always non-empty in OAuth mode (enforced by
240        // `AuthConfig::validate`), so this warning fires on every startup
241        // where it's relevant — never silently.
242        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    /// Replace the extra OAuth resource audiences accepted by `/authorize` and `/token`.
271    ///
272    /// The canonical `{LAB_PUBLIC_URL}/mcp` resource is always accepted; callers use this
273    /// to publish Gateway-managed protected MCP resources such as
274    /// `https://mcp.example.com/syslog` or `https://syslog.example.com/mcp`.
275    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    /// Replace the extra OAuth resource audiences and the scopes each resource accepts.
284    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    /// Rate-limit guard for `/authorize` and `/browser_login` endpoints.
327    ///
328    /// Keyed per remote IP so one client cannot exhaust the global bucket
329    /// (lab-77y5.10). Uses `tokio::sync::Mutex` internally so contention does
330    /// not park a Tokio worker thread (lab-77y5.9).
331    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    /// Rate-limit guard for `/register` endpoint.
343    ///
344    /// Keyed per remote IP — see `check_authorize_rate_limit` for the rationale.
345    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    /// Returns the merged email allowlist: admin first, then all `allowed_users` rows,
357    /// deduplicating case-insensitively so admin is never counted twice.
358    ///
359    /// This is the single source of truth used in both OAuth callback branches. A DB
360    /// error is surfaced as [`AuthError::Storage`] (fail-closed — server fault, not
361    /// user fault).
362    ///
363    /// Never log the returned emails directly — pass them only to
364    /// `check_email_allowlist`, which uses `fingerprint()` for safe diagnostics.
365    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    /// Rejects new OAuth state rows when the pending count exceeds `max_pending_oauth_states`.
376    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    /// Look up a specific configured provider by id. Returns
388    /// [`AuthError::Validation`] if `id` does not name a configured
389    /// provider — this is a request-shaped error (bad `?provider=` query
390    /// param, or a stale DB row naming a provider that has since been
391    /// unconfigured), not a server fault.
392    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    /// [`Self::provider`], falling back to [`Self::default_provider`] when
400    /// `id` is `None`.
401    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    /// Builds a minimal `AuthState` for unit-testing `resolve_allowed_emails`.
525    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    /// `build_providers` hand-writes each provider's map key (e.g.
559    /// `"google".to_string()`) as a string literal, independently of
560    /// `OAuthProvider::provider_id()` on the value stored under that key —
561    /// two never-cross-checked sources of truth for the same fact. Assert
562    /// they actually agree for a multi-provider deployment.
563    #[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        // Admin is always first; DB rows follow in created_at ASC order.
644        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        // add_allowed_user lowercases; admin_email may differ in case → still deduped.
654        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        // "admin@example.com" from DB is deduped; "other@example.com" remains.
666        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        // A fifth previously-unseen IP evicts an existing bucket (none are
717        // idle yet, so the least-recently-used one goes) rather than
718        // growing the map past the cap.
719        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}