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    token_limiter: PerIpRateLimiter,
178    /// Single-flight, TTL-cached OAuth Client ID Metadata Document store for
179    /// `/authorize`'s CIMD path (`crate::cimd`). Gated behind `http-axum`
180    /// alongside `crate::cimd` itself, even though `AuthState` (this struct)
181    /// is otherwise usable without that feature.
182    #[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        // Run the full validator first — struct-literal callers (test
189        // fixtures, or a downstream consumer bypassing AuthConfigBuilder)
190        // otherwise skip every safety check `validate()` enforces (HTTPS-only
191        // Authelia issuer, callback-path collisions, GitHub scope
192        // requirements, etc.). `validate()` only asserts OAuth-mode-specific
193        // invariants when `mode == AuthMode::OAuth`, so the manual mode check
194        // immediately below is NOT redundant with it — it's the only thing
195        // that rejects a non-OAuth config reaching `AuthState::new` at all.
196        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        // Needs both halves of the pair, so it cannot live in
213        // `AuthConfig::validate`: the configured machine clients come from
214        // config, the registrations they could shadow live in the SQLite
215        // store that was only just opened. This is the first point where the
216        // two meet.
217        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        // Security posture note (see this plan's Global Constraints): the
239        // email allowlist is a single flat list shared across every
240        // configured provider, and being on it grants full admin scope
241        // regardless of which provider authenticated the user. With 2+
242        // providers configured, the deployment's effective admin-gate
243        // strength is that of its weakest provider's identity-verification
244        // signal (GitHub's non-re-verified "primary && verified" email flag
245        // is weaker than Google/Authelia's live per-login ID-token claim).
246        // `admin_email` is always non-empty in OAuth mode (enforced by
247        // `AuthConfig::validate`), so this warning fires on every startup
248        // where it's relevant — never silently.
249        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    /// Replace the extra OAuth resource audiences accepted by `/authorize` and `/token`.
280    ///
281    /// The canonical `{LAB_PUBLIC_URL}/mcp` resource is always accepted; callers use this
282    /// to publish Gateway-managed protected MCP resources such as
283    /// `https://mcp.example.com/syslog` or `https://syslog.example.com/mcp`.
284    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    /// Replace the extra OAuth resource audiences and the scopes each resource accepts.
293    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    /// Rate-limit guard for `/authorize` and `/browser_login` endpoints.
336    ///
337    /// Keyed per remote IP so one client cannot exhaust the global bucket
338    /// (lab-77y5.10). Uses `tokio::sync::Mutex` internally so contention does
339    /// not park a Tokio worker thread (lab-77y5.9).
340    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    /// Rate-limit guard for `/register` endpoint.
352    ///
353    /// Keyed per remote IP — see `check_authorize_rate_limit` for the rationale.
354    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    /// Rate-limit guard shared by `/token` and `/revoke`.
366    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    /// Consume a JWT assertion identifier exactly once.
378    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    /// Returns the merged email allowlist: admin first, then all `allowed_users` rows,
391    /// deduplicating case-insensitively so admin is never counted twice.
392    ///
393    /// This is the single source of truth used in both OAuth callback branches. A DB
394    /// error is surfaced as [`AuthError::Storage`] (fail-closed — server fault, not
395    /// user fault).
396    ///
397    /// Never log the returned emails directly — pass them only to
398    /// `check_email_allowlist`, which uses `fingerprint()` for safe diagnostics.
399    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    /// Rejects new OAuth state rows when the pending count exceeds `max_pending_oauth_states`.
410    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    /// Look up a specific configured provider by id. Returns
422    /// [`AuthError::Validation`] if `id` does not name a configured
423    /// provider — this is a request-shaped error (bad `?provider=` query
424    /// param, or a stale DB row naming a provider that has since been
425    /// unconfigured), not a server fault.
426    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    /// [`Self::provider`], falling back to [`Self::default_provider`] when
434    /// `id` is `None`.
435    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
534/// Refuse to start when a configured machine `client_id` could also be
535/// answered by the OAuth client registry.
536///
537/// `token_client_auth::authenticate_oauth_client` searches
538/// `config.machine_clients` first and returns on the first match, so a
539/// configured machine `client_id` that a registration also resolves silently
540/// wins on every `/token` path - including the `authorization_code` and
541/// `refresh_token` delegations, where it changes how an already-registered
542/// client must authenticate with no diagnostic anywhere. That precedence is
543/// deliberate and unchanged; resolving the ambiguity in silence is not, so a
544/// collision fails configuration instead of being picked in the dark.
545///
546/// A `client_id` is a public identifier, never a credential, so naming the
547/// offending id in the error is both safe and the entire point of the check.
548async 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        // A Client ID Metadata Document `client_id` is any `https://` URL,
554        // resolved by fetching that URL when the request arrives. There is no
555        // local registry to diff it against, so refusing the shape outright is
556        // the only startup-time defense against that half of the collision.
557        #[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    /// Builds a minimal `AuthState` for unit-testing `resolve_allowed_emails`.
608    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    /// `build_providers` hand-writes each provider's map key (e.g.
642    /// `"google".to_string()`) as a string literal, independently of
643    /// `OAuthProvider::provider_id()` on the value stored under that key —
644    /// two never-cross-checked sources of truth for the same fact. Assert
645    /// they actually agree for a multi-provider deployment.
646    #[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    /// Minimal OAuth config carrying `machine_clients`, for the startup
706    /// collision guard.
707    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    /// Write a DCR-registered client straight into the store the config under
737    /// test will open, so `AuthState::new` sees a pre-existing registration.
738    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        // `AuthState` is not `Debug`, so `expect_err` is unavailable here.
761        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    /// A CIMD `client_id` cannot be diffed against the local registry, so the
785    /// guard rejects the shape itself. Gated with the token endpoint that
786    /// does the shadowing.
787    #[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        // Admin is always first; DB rows follow in created_at ASC order.
829        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        // add_allowed_user lowercases; admin_email may differ in case → still deduped.
839        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        // "admin@example.com" from DB is deduped; "other@example.com" remains.
851        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        // A fifth previously-unseen IP evicts an existing bucket (none are
902        // idle yet, so the least-recently-used one goes) rather than
903        // growing the map past the cap.
904        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}