Skip to main content

smooth_operator/
auth.rs

1//! Authentication + role-based access control (Phase 12).
2//!
3//! This is the auth seam the management console (Next.js, increment 2) consumes
4//! through the admin HTTP API. It defines:
5//!
6//! - [`Role`] — `Admin >= Curator >= Basic`, a total order so a route can gate
7//!   on a *minimum* role.
8//! - [`Principal`] — the authenticated identity a request runs as (`user_id`,
9//!   `org_id`, `role`, optional `display_name`). Org-scoping everything to this
10//!   `org_id` is how the admin API stays multi-tenant-safe.
11//! - [`AuthVerifier`] — the one seam that turns a bearer token into a
12//!   [`Principal`]. Three impls cover the deployment shapes:
13//!   - [`JwtVerifier`] — **BYO** path: validates a JWT issued by the customer's
14//!     own IdP. SST OpenAuth (`@openauthjs/openauth` + `sst.aws.Auth`) issues
15//!     exactly these. HS256 (shared secret) and RS256 (public key) supported.
16//!   - [`SmooIdentityVerifier`] — **hosted** path: validates a Smoo-issued JWT
17//!     keyed to Smoo's issuer/audience (lom.smoo.ai wires Smoo's identity). The
18//!     live token-introspection variant is documented + stubbed (it needs a
19//!     network call to the auth server's `/introspect`).
20//!   - [`NoAuthVerifier`] — **dev only**: returns a fixed `Admin` principal.
21//!     Reachable *only* when `AUTH_MODE=none` is set explicitly, so it can never
22//!     be the silent production default.
23//!
24//! ## Secure-by-default
25//!
26//! [`AuthConfig::from_env`] selects the verifier from `AUTH_MODE`
27//! (`jwt` | `smoo` | `none`). The **default is `jwt`** — and if `jwt`/`smoo` is
28//! selected without a configured key the constructor returns an
29//! [`AuthError::Misconfigured`] error rather than silently falling back to
30//! no-auth. Only an explicit `AUTH_MODE=none` yields [`NoAuthVerifier`].
31//!
32//! ## Relationship to [`AccessContext`](crate::access_control::AccessContext)
33//!
34//! RBAC ([`Role`]) gates *which admin operations* a principal may perform;
35//! [`AccessContext`](crate::access_control::AccessContext) gates *which
36//! documents* a retrieval may return. A [`Principal`] maps to an
37//! [`AccessContext`] via [`Principal::access_context`] so the same identity
38//! drives both layers.
39
40use std::collections::HashSet;
41use std::fmt;
42use std::str::FromStr;
43use std::sync::{Arc, RwLock};
44use std::time::{Duration, Instant};
45
46use jsonwebtoken::jwk::{Jwk, JwkSet};
47use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
48use serde::{Deserialize, Serialize};
49
50use crate::access_control::AccessContext;
51
52/// A role in the org's RBAC model. Ordered so `Admin > Curator > Basic`, which
53/// lets a route gate on a *minimum* role with `principal.role >= min`.
54///
55/// - **Admin** — full org-wide read of chat history, indexing, document sets,
56///   and (future) write/config.
57/// - **Curator** — org-wide read of chat history + curation surfaces (indexing,
58///   document sets); the knowledge-curation persona.
59/// - **Basic** — an end user: may see only their *own* conversations.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Role {
63    /// Lowest privilege — sees only their own data.
64    Basic,
65    /// Curation persona — org-wide read of curation surfaces.
66    Curator,
67    /// Highest privilege — full org-wide access.
68    Admin,
69}
70
71impl Role {
72    /// Parse a role from a claim string (case-insensitive). Unknown / absent
73    /// values are an error so a token can never silently downgrade *or* upgrade.
74    ///
75    /// # Errors
76    /// Returns [`AuthError::MissingRole`] when the value isn't a known role.
77    pub fn parse(value: &str) -> Result<Self, AuthError> {
78        match value.trim().to_ascii_lowercase().as_str() {
79            "admin" => Ok(Role::Admin),
80            "curator" => Ok(Role::Curator),
81            "basic" | "user" => Ok(Role::Basic),
82            other => Err(AuthError::MissingRole(format!("unknown role '{other}'"))),
83        }
84    }
85
86    /// The wire/string form of this role.
87    #[must_use]
88    pub fn as_str(self) -> &'static str {
89        match self {
90            Role::Admin => "admin",
91            Role::Curator => "curator",
92            Role::Basic => "basic",
93        }
94    }
95}
96
97impl fmt::Display for Role {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103/// The authenticated identity a request runs as. Everything the admin API reads
104/// is scoped to [`org_id`](Principal::org_id); [`role`](Principal::role) gates
105/// which operations are allowed and whether reads are org-wide or self-only.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Principal {
109    /// Stable user id (the JWT `sub`).
110    pub user_id: String,
111    /// The organization this principal belongs to (the JWT `org` / `org_id`).
112    /// Every admin read is filtered to this org.
113    pub org_id: String,
114    /// The principal's role in the org.
115    pub role: Role,
116    /// Optional human-readable name (the JWT `name`).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub display_name: Option<String>,
119    /// The groups the principal belongs to (the JWT `groups` claim). These are
120    /// the entitlements the document-level ACL layer matches against: a
121    /// document scoped to group `github:owner/repo` is readable only by a
122    /// principal carrying that group. Empty when the token has no `groups`
123    /// claim (the principal then sees only org-public + user-scoped docs).
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub groups: Vec<String>,
126}
127
128impl Principal {
129    /// Construct a principal (mostly for tests + the no-auth path).
130    #[must_use]
131    pub fn new(
132        user_id: impl Into<String>,
133        org_id: impl Into<String>,
134        role: Role,
135        display_name: Option<String>,
136    ) -> Self {
137        Self {
138            user_id: user_id.into(),
139            org_id: org_id.into(),
140            role,
141            display_name,
142            groups: Vec::new(),
143        }
144    }
145
146    /// Attach group memberships to this principal (builder). The groups flow
147    /// into [`access_context`](Self::access_context) so the document-level ACL
148    /// layer can match a group-scoped document.
149    #[must_use]
150    pub fn with_groups<I, S>(mut self, groups: I) -> Self
151    where
152        I: IntoIterator<Item = S>,
153        S: Into<String>,
154    {
155        self.groups = groups.into_iter().map(Into::into).collect();
156        self
157    }
158
159    /// Whether this principal may act at `min` or above.
160    #[must_use]
161    pub fn has_role(&self, min: Role) -> bool {
162        self.role >= min
163    }
164
165    /// Map this principal to the document-level [`AccessContext`] used by the
166    /// knowledge-retrieval ACL layer. The user id **and** the principal's groups
167    /// carry through, so a retrieval as this principal can match a document
168    /// scoped to the user *or* to any group the principal belongs to (the JWT
169    /// `groups` claim — see [`Claims`]). The principal's [`org_id`](Self::org_id)
170    /// is also carried as the context's `organization_id`, so a multi-tenant
171    /// host adapter's `knowledge_for_access` can scope retrieval to this
172    /// principal's tenant (the built-in single-tenant ACL ignores it).
173    #[must_use]
174    pub fn access_context(&self) -> AccessContext {
175        AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
176            .with_organization_id(self.org_id.clone())
177    }
178}
179
180/// Why authentication / authorization failed. Maps cleanly to HTTP status in the
181/// admin API: [`Unauthenticated`](AuthError::Unauthenticated) /
182/// [`InvalidToken`](AuthError::InvalidToken) / [`MissingRole`](AuthError::MissingRole)
183/// → 401; [`Forbidden`](AuthError::Forbidden) → 403;
184/// [`Misconfigured`](AuthError::Misconfigured) is a server-config error surfaced
185/// at startup (never to a client).
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum AuthError {
188    /// No bearer token was presented.
189    Unauthenticated,
190    /// A token was presented but failed validation (bad signature, expired,
191    /// wrong issuer/audience, malformed).
192    InvalidToken(String),
193    /// The token validated but carried no usable role claim.
194    MissingRole(String),
195    /// The principal is authenticated but lacks the required role.
196    Forbidden {
197        /// The role the route requires.
198        required: Role,
199        /// The role the principal actually has.
200        actual: Role,
201    },
202    /// The verifier is misconfigured (e.g. `AUTH_MODE=jwt` with no key). A
203    /// startup/server error, never a client-facing one.
204    Misconfigured(String),
205}
206
207impl fmt::Display for AuthError {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            AuthError::Unauthenticated => f.write_str("missing bearer token"),
211            AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
212            AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
213            AuthError::Forbidden { required, actual } => {
214                write!(f, "forbidden: requires {required}, principal is {actual}")
215            }
216            AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
217        }
218    }
219}
220
221impl std::error::Error for AuthError {}
222
223/// The single auth seam: turn a bearer token into a [`Principal`].
224///
225/// Implemented by [`JwtVerifier`] (BYO), [`SmooIdentityVerifier`] (hosted), and
226/// [`NoAuthVerifier`] (dev). `Send + Sync` so a single verifier rides on the
227/// shared server state across connections.
228pub trait AuthVerifier: Send + Sync {
229    /// Validate `bearer_token` (the raw token, **without** the `Bearer ` prefix)
230    /// and return the authenticated [`Principal`].
231    ///
232    /// # Errors
233    /// Returns [`AuthError::InvalidToken`] / [`AuthError::MissingRole`] when the
234    /// token is present but unusable, or [`AuthError::Unauthenticated`] when it
235    /// is empty.
236    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
237
238    /// A short label for logs/metrics (never includes secrets).
239    fn mode(&self) -> &'static str;
240}
241
242/// The JWT claim shape both [`JwtVerifier`] and [`SmooIdentityVerifier`] decode.
243/// `org` is the canonical org claim with `org_id` accepted as an alias (SST
244/// OpenAuth and Smoo both emit one or the other).
245#[derive(Debug, Deserialize)]
246struct Claims {
247    sub: String,
248    #[serde(default)]
249    org: Option<String>,
250    #[serde(default)]
251    org_id: Option<String>,
252    #[serde(default)]
253    role: Option<String>,
254    #[serde(default)]
255    name: Option<String>,
256    /// Group memberships (entitlements) — the document-level ACL layer matches
257    /// these against a document's group allow-list. Optional; absent ⇒ no group
258    /// entitlements (the principal sees only org-public + user-scoped docs).
259    #[serde(default)]
260    groups: Vec<String>,
261}
262
263impl Claims {
264    /// Resolve the org id from `org` (preferred) or `org_id` (alias).
265    fn org_id(&self) -> Option<String> {
266        self.org.clone().or_else(|| self.org_id.clone())
267    }
268
269    /// Build a [`Principal`], failing if the role is absent/unknown or no org id
270    /// is present.
271    fn into_principal(self) -> Result<Principal, AuthError> {
272        let role = match &self.role {
273            Some(r) => Role::parse(r)?,
274            None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
275        };
276        let org_id = self
277            .org_id()
278            .ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
279        Ok(Principal {
280            user_id: self.sub,
281            org_id,
282            role,
283            display_name: self.name,
284            groups: self.groups,
285        })
286    }
287}
288
289/// The signing-key material a [`JwtVerifier`] validates against. Built from env
290/// by [`AuthConfig`]; never logged.
291enum VerifyKey {
292    /// HS256 shared secret.
293    Hs256(Box<DecodingKey>),
294    /// RS256 public key (PEM). Structural support — the gateway/IdP signs, we
295    /// verify with the public half.
296    Rs256(Box<DecodingKey>),
297}
298
299/// How a [`JwtVerifier`] resolves the verification key for a token.
300///
301/// - [`Static`](JwtBackend::Static) — a single fixed key (HS256 secret or RS256
302///   PEM) with a pre-built [`Validation`]. The original BYO path, unchanged.
303/// - [`Jwks`](JwtBackend::Jwks) — keys are pulled (and cached) from the issuer's
304///   published JWKS, selected per-token by `kid`. Supports **any** JWS algorithm
305///   the JWKS advertises (ES256/ES384/RS256/PS256/EdDSA/…), which is what lets
306///   `auth.smoo.ai`'s **ES256** tokens validate. See [`JwksVerifier`].
307enum JwtBackend {
308    Static {
309        key: VerifyKey,
310        validation: Validation,
311    },
312    Jwks(JwksVerifier),
313}
314
315/// Validates a JWT and extracts a [`Principal`]. The **BYO** path: SST OpenAuth
316/// (or any OIDC IdP) issues the token; this verifies signature + standard claims
317/// and maps `sub`→`user_id`, `org`/`org_id`→`org_id`, `role`→[`Role`],
318/// `name`→`display_name`.
319///
320/// Two backends (see [`JwtBackend`]): a **static** key (HS256/RS256) or a
321/// **JWKS**-backed multi-algorithm verifier that fetches + caches the issuer's
322/// keys and selects one per-token by `kid`.
323pub struct JwtVerifier {
324    backend: JwtBackend,
325}
326
327impl JwtVerifier {
328    /// An HS256 verifier over a shared secret. Optionally constrains `iss`/`aud`.
329    #[must_use]
330    pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
331        let mut validation = Validation::new(Algorithm::HS256);
332        configure_validation(&mut validation, issuer, audience);
333        Self {
334            backend: JwtBackend::Static {
335                key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
336                validation,
337            },
338        }
339    }
340
341    /// An RS256 verifier over a PEM-encoded public key. Optionally constrains
342    /// `iss`/`aud`. The static BYO path; for issuers that publish a JWKS (and
343    /// possibly rotate keys or sign with ES256) use [`JwtVerifier::jwks`].
344    ///
345    /// # Errors
346    /// Returns [`AuthError::Misconfigured`] if the PEM can't be parsed.
347    pub fn rs256(
348        public_key_pem: &[u8],
349        issuer: Option<String>,
350        audience: Option<String>,
351    ) -> Result<Self, AuthError> {
352        let key = DecodingKey::from_rsa_pem(public_key_pem)
353            .map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
354        let mut validation = Validation::new(Algorithm::RS256);
355        configure_validation(&mut validation, issuer, audience);
356        Ok(Self {
357            backend: JwtBackend::Static {
358                key: VerifyKey::Rs256(Box::new(key)),
359                validation,
360            },
361        })
362    }
363
364    /// A JWKS-backed verifier: keys are fetched + cached from `jwks_url` and
365    /// selected per-token by `kid`, so **any** advertised algorithm
366    /// (ES256/RS256/…) and key rotation work without a redeploy. Optionally
367    /// constrains `iss`/`aud`.
368    #[must_use]
369    pub fn jwks(
370        jwks_url: impl Into<String>,
371        issuer: Option<String>,
372        audience: Option<String>,
373    ) -> Self {
374        Self {
375            backend: JwtBackend::Jwks(JwksVerifier::from_url(jwks_url, issuer, audience)),
376        }
377    }
378
379    /// A JWKS-backed verifier over a caller-supplied [`JwksFetcher`] (lets tests
380    /// inject an in-memory [`JwkSet`] with no network). Optionally constrains
381    /// `iss`/`aud`.
382    #[must_use]
383    pub fn jwks_with_fetcher(
384        fetcher: Arc<dyn JwksFetcher>,
385        issuer: Option<String>,
386        audience: Option<String>,
387    ) -> Self {
388        Self {
389            backend: JwtBackend::Jwks(JwksVerifier::with_fetcher(fetcher, issuer, audience)),
390        }
391    }
392
393    /// Decode + validate, returning the [`Principal`]. Shared by
394    /// [`SmooIdentityVerifier`].
395    fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
396        match &self.backend {
397            JwtBackend::Static { key, validation } => {
398                if token.trim().is_empty() {
399                    return Err(AuthError::Unauthenticated);
400                }
401                let key = match key {
402                    VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
403                };
404                let data = decode::<Claims>(token, key, validation)
405                    .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
406                data.claims.into_principal()
407            }
408            JwtBackend::Jwks(v) => v.decode_principal(token),
409        }
410    }
411}
412
413/// Apply shared validation defaults: require `exp` + `sub`, and constrain
414/// `iss`/`aud` only when configured (otherwise `validate_aud` is turned off so a
415/// token without an `aud` claim isn't spuriously rejected).
416fn configure_validation(
417    validation: &mut Validation,
418    issuer: Option<String>,
419    audience: Option<String>,
420) {
421    validation.set_required_spec_claims(&["exp", "sub"]);
422    match audience {
423        Some(aud) => {
424            validation.validate_aud = true;
425            validation.aud = Some(HashSet::from([aud]));
426        }
427        // No configured audience ⇒ don't validate it (the default `true` would
428        // reject any token lacking an `aud` claim).
429        None => validation.validate_aud = false,
430    }
431    if let Some(iss) = issuer {
432        validation.iss = Some(HashSet::from([iss]));
433    }
434}
435
436// ---- JWKS-backed verification ------------------------------------------------
437
438/// How long a fetched [`JwkSet`] is served from cache before a refresh. Reads on
439/// the hot path are local-memory; the network round-trip happens at most once
440/// per this interval (plus on an unknown `kid` — see [`JwksKeyStore`]).
441const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(300);
442/// Floor between JWKS network fetches, so a stream of tokens carrying an unknown
443/// `kid` (or a malformed token) can't turn into a fetch-per-request storm.
444const DEFAULT_JWKS_MIN_REFRESH: Duration = Duration::from_secs(30);
445/// Timeout for the JWKS HTTP fetch — a hung issuer must not stall auth forever.
446const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
447
448/// Fetches a [`JwkSet`]. The seam that lets [`JwksKeyStore`] pull keys from an
449/// HTTP issuer in production ([`HttpJwksFetcher`]) and from an in-memory set in
450/// tests ([`StaticJwksFetcher`]) — so the verification logic is exercised with
451/// **no network**.
452///
453/// `fetch` is synchronous so [`AuthVerifier::verify`] can stay synchronous (no
454/// per-request `await`): the real HTTP impl runs its blocking call on a
455/// dedicated thread, and the result is cached, so the common path is a local
456/// read.
457pub trait JwksFetcher: Send + Sync {
458    /// Fetch the current [`JwkSet`] from the source.
459    ///
460    /// # Errors
461    /// Returns [`AuthError::InvalidToken`] on a network / parse failure (treated
462    /// like an unverifiable token) or [`AuthError::Misconfigured`] for a client
463    /// build error.
464    fn fetch(&self) -> Result<JwkSet, AuthError>;
465}
466
467/// An in-memory [`JwksFetcher`] — returns a fixed [`JwkSet`]. Used by tests (and
468/// any caller that already holds the keys) to drive the JWKS path offline.
469pub struct StaticJwksFetcher {
470    set: JwkSet,
471}
472
473impl StaticJwksFetcher {
474    /// Wrap an already-parsed [`JwkSet`].
475    #[must_use]
476    pub fn new(set: JwkSet) -> Self {
477        Self { set }
478    }
479
480    /// Parse a JWKS JSON document (`{"keys":[…]}`) into a fetcher.
481    ///
482    /// # Errors
483    /// Returns [`AuthError::InvalidToken`] if the JSON isn't a valid JWKS.
484    pub fn from_json(json: &str) -> Result<Self, AuthError> {
485        Ok(Self {
486            set: parse_jwks(json)?,
487        })
488    }
489}
490
491impl JwksFetcher for StaticJwksFetcher {
492    fn fetch(&self) -> Result<JwkSet, AuthError> {
493        Ok(self.set.clone())
494    }
495}
496
497/// The production [`JwksFetcher`]: an HTTP GET of the issuer's JWKS endpoint.
498///
499/// The blocking fetch runs on a freshly-spawned OS thread so it is safe to call
500/// from **anywhere** — including from inside a Tokio worker (where building a
501/// blocking reqwest client would otherwise panic) and from the synchronous
502/// [`AuthVerifier::verify`]. It only runs on a cache miss / TTL refresh, so it is
503/// off the hot path.
504struct HttpJwksFetcher {
505    url: String,
506    timeout: Duration,
507}
508
509impl HttpJwksFetcher {
510    fn new(url: impl Into<String>) -> Self {
511        Self {
512            url: url.into(),
513            timeout: JWKS_HTTP_TIMEOUT,
514        }
515    }
516}
517
518impl JwksFetcher for HttpJwksFetcher {
519    fn fetch(&self) -> Result<JwkSet, AuthError> {
520        let url = self.url.clone();
521        let timeout = self.timeout;
522        // A fresh OS thread has no ambient Tokio runtime, so constructing the
523        // blocking reqwest client here can never panic, regardless of the
524        // caller's context.
525        std::thread::spawn(move || -> Result<JwkSet, AuthError> {
526            install_ring_crypto_provider();
527            let client = reqwest::blocking::Client::builder()
528                .timeout(timeout)
529                .build()
530                .map_err(|e| AuthError::Misconfigured(format!("JWKS HTTP client build: {e}")))?;
531            let resp = client
532                .get(&url)
533                .send()
534                .map_err(|e| AuthError::InvalidToken(format!("JWKS fetch ({url}) failed: {e}")))?;
535            if !resp.status().is_success() {
536                return Err(AuthError::InvalidToken(format!(
537                    "JWKS fetch ({url}) returned HTTP {}",
538                    resp.status()
539                )));
540            }
541            let body = resp
542                .text()
543                .map_err(|e| AuthError::InvalidToken(format!("JWKS read ({url}) failed: {e}")))?;
544            parse_jwks(&body)
545        })
546        .join()
547        .map_err(|_| AuthError::Misconfigured("JWKS fetch thread panicked".to_string()))?
548    }
549}
550
551/// Parse a JWKS JSON document into a [`JwkSet`].
552fn parse_jwks(body: &str) -> Result<JwkSet, AuthError> {
553    serde_json::from_str::<JwkSet>(body)
554        .map_err(|e| AuthError::InvalidToken(format!("invalid JWKS JSON: {e}")))
555}
556
557/// Install the `ring` rustls [`CryptoProvider`](rustls::crypto::CryptoProvider)
558/// as the process default, once. The workspace graph carries both `ring` and
559/// `aws-lc-rs`, so rustls 0.23 can't auto-pick a provider; the JWKS HTTPS fetch
560/// needs one installed before its first TLS handshake. Idempotent + cheap.
561fn install_ring_crypto_provider() {
562    use std::sync::Once;
563    static ONCE: Once = Once::new();
564    ONCE.call_once(|| {
565        let _ = rustls::crypto::ring::default_provider().install_default();
566    });
567}
568
569/// The cached keyset + when it was last fetched.
570struct CachedJwks {
571    set: Arc<JwkSet>,
572    fetched_at: Option<Instant>,
573}
574
575/// A TTL-cached, rotation-aware [`JwkSet`] behind a [`JwksFetcher`].
576///
577/// - **Cache**: the parsed keyset is held in an [`RwLock`]; the hot path is a
578///   read lock + a `kid` lookup — no network, no `await`.
579/// - **TTL refresh**: when the cache is older than [`ttl`](Self::ttl) the next
580///   lookup refetches.
581/// - **Rotation (refresh-on-unknown-`kid`)**: a token whose `kid` isn't in the
582///   cache triggers a refetch, so a key the issuer just rotated in is picked up
583///   **without a redeploy**. A [`min_refresh`](Self::min_refresh) floor keeps a
584///   bad/unknown `kid` from turning into a fetch storm.
585struct JwksKeyStore {
586    fetcher: Arc<dyn JwksFetcher>,
587    cached: RwLock<CachedJwks>,
588    ttl: Duration,
589    min_refresh: Duration,
590}
591
592impl JwksKeyStore {
593    fn new(fetcher: Arc<dyn JwksFetcher>, ttl: Duration, min_refresh: Duration) -> Self {
594        Self {
595            fetcher,
596            cached: RwLock::new(CachedJwks {
597                set: Arc::new(JwkSet { keys: Vec::new() }),
598                fetched_at: None,
599            }),
600            ttl,
601            min_refresh,
602        }
603    }
604
605    /// Resolve the [`Jwk`] for `kid`, refreshing the cache on a stale TTL or an
606    /// unknown `kid` (rotation). With no `kid`, a single-key JWKS resolves to its
607    /// one key; an ambiguous (multi-key) JWKS requires a `kid`.
608    fn key_for(&self, kid: Option<&str>) -> Result<Jwk, AuthError> {
609        // Hot path: a fresh cache that already has the key.
610        {
611            let r = self.read_cache();
612            if r.fetched_at.is_some_and(|t| t.elapsed() < self.ttl) {
613                if let Some(jwk) = find_jwk(&r.set, kid) {
614                    return Ok(jwk);
615                }
616            }
617        }
618        // Stale TTL, never-fetched, or unknown kid → (rate-limited) refresh.
619        self.maybe_refresh()?;
620        let r = self.read_cache();
621        find_jwk(&r.set, kid).ok_or_else(|| match kid {
622            Some(k) => AuthError::InvalidToken(format!("no JWK matching kid '{k}' in issuer JWKS")),
623            None => AuthError::InvalidToken(
624                "token has no 'kid' and the issuer JWKS does not have exactly one key".to_string(),
625            ),
626        })
627    }
628
629    /// Refetch the JWKS unless the last fetch is more recent than `min_refresh`
630    /// (the storm guard). A `None` `fetched_at` (never fetched) always fetches.
631    fn maybe_refresh(&self) -> Result<(), AuthError> {
632        if let Some(t) = self.read_cache().fetched_at {
633            if t.elapsed() < self.min_refresh {
634                return Ok(());
635            }
636        }
637        let set = self.fetcher.fetch()?;
638        let mut w = self
639            .cached
640            .write()
641            .unwrap_or_else(std::sync::PoisonError::into_inner);
642        w.set = Arc::new(set);
643        w.fetched_at = Some(Instant::now());
644        Ok(())
645    }
646
647    fn read_cache(&self) -> std::sync::RwLockReadGuard<'_, CachedJwks> {
648        self.cached
649            .read()
650            .unwrap_or_else(std::sync::PoisonError::into_inner)
651    }
652}
653
654/// Find the [`Jwk`] for `kid` in a [`JwkSet`] (or the sole key when `kid` is
655/// `None`).
656fn find_jwk(set: &JwkSet, kid: Option<&str>) -> Option<Jwk> {
657    match kid {
658        Some(k) => set.find(k).cloned(),
659        None if set.keys.len() == 1 => set.keys.first().cloned(),
660        None => None,
661    }
662}
663
664/// Resolve the algorithm to validate with: the **JWK-declared** `alg` when the
665/// key carries one (pins verification to the issuer's intended algorithm,
666/// closing the JWS algorithm-confusion gap), otherwise the token header's `alg`
667/// (still constrained to the selected key's type by `DecodingKey::from_jwk`).
668fn resolve_jwk_alg(jwk: &Jwk, header_alg: Algorithm) -> Result<Algorithm, AuthError> {
669    match jwk.common.key_algorithm {
670        Some(ka) => Algorithm::from_str(&ka.to_string())
671            .map_err(|_| AuthError::InvalidToken(format!("unsupported JWK algorithm '{ka}'"))),
672        None => Ok(header_alg),
673    }
674}
675
676/// Validates a JWT against the issuer's **published JWKS** — fetched, cached, and
677/// rotation-aware (see [`JwksKeyStore`]). Selects the signing key per-token by
678/// `kid`, builds a [`DecodingKey`] from the matching [`Jwk`], and validates with
679/// the key's algorithm — so **any** JWS algorithm the issuer advertises works
680/// (ES256/ES384/RS256/PS256/EdDSA/…), not just a static RS256 PEM.
681///
682/// This is what makes `auth.smoo.ai` (the `smoo` issuer, **ES256**) verifiable.
683/// `verify` stays synchronous: the keyset is read from cache; the network fetch
684/// happens at most once per TTL (plus on a never-seen `kid`).
685pub struct JwksVerifier {
686    store: JwksKeyStore,
687    issuer: Option<String>,
688    audience: Option<String>,
689}
690
691impl JwksVerifier {
692    /// A verifier that pulls keys from `jwks_url` over HTTP (cached, TTL +
693    /// rotation refresh). Optionally constrains `iss`/`aud`.
694    #[must_use]
695    pub fn from_url(
696        jwks_url: impl Into<String>,
697        issuer: Option<String>,
698        audience: Option<String>,
699    ) -> Self {
700        Self::with_fetcher(Arc::new(HttpJwksFetcher::new(jwks_url)), issuer, audience)
701    }
702
703    /// A verifier over a caller-supplied [`JwksFetcher`] (tests inject an
704    /// in-memory [`JwkSet`]). Optionally constrains `iss`/`aud`.
705    #[must_use]
706    pub fn with_fetcher(
707        fetcher: Arc<dyn JwksFetcher>,
708        issuer: Option<String>,
709        audience: Option<String>,
710    ) -> Self {
711        Self::with_policy(
712            fetcher,
713            issuer,
714            audience,
715            DEFAULT_JWKS_TTL,
716            DEFAULT_JWKS_MIN_REFRESH,
717        )
718    }
719
720    /// Full constructor exposing the cache `ttl` + `min_refresh` floor (tests
721    /// drive rotation timing through this).
722    #[must_use]
723    pub fn with_policy(
724        fetcher: Arc<dyn JwksFetcher>,
725        issuer: Option<String>,
726        audience: Option<String>,
727        ttl: Duration,
728        min_refresh: Duration,
729    ) -> Self {
730        Self {
731            store: JwksKeyStore::new(fetcher, ttl, min_refresh),
732            issuer,
733            audience,
734        }
735    }
736
737    /// Decode + validate `token` against the cached JWKS, returning the
738    /// [`Principal`].
739    fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
740        if token.trim().is_empty() {
741            return Err(AuthError::Unauthenticated);
742        }
743        let header = decode_header(token)
744            .map_err(|e| AuthError::InvalidToken(format!("bad JWT header: {e}")))?;
745        let jwk = self.store.key_for(header.kid.as_deref())?;
746        let alg = resolve_jwk_alg(&jwk, header.alg)?;
747        let key = DecodingKey::from_jwk(&jwk)
748            .map_err(|e| AuthError::InvalidToken(format!("unusable JWK: {e}")))?;
749        let mut validation = Validation::new(alg);
750        configure_validation(&mut validation, self.issuer.clone(), self.audience.clone());
751        let data = decode::<Claims>(token, &key, &validation)
752            .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
753        data.claims.into_principal()
754    }
755}
756
757impl AuthVerifier for JwksVerifier {
758    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
759        self.decode_principal(bearer_token)
760    }
761
762    fn mode(&self) -> &'static str {
763        "jwks"
764    }
765}
766
767impl AuthVerifier for JwtVerifier {
768    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
769        self.decode_principal(bearer_token)
770    }
771
772    fn mode(&self) -> &'static str {
773        "jwt"
774    }
775}
776
777/// Validates a **Smoo-issued** token — the hosted path (lom.smoo.ai wires Smoo's
778/// identity). Implemented as JWT validation keyed to Smoo's issuer/audience,
779/// reusing [`JwtVerifier`]'s internals.
780///
781/// ## Live introspection (hosted, stubbed)
782///
783/// The fully-hosted variant would call Smoo's auth server `/introspect` endpoint
784/// (RFC 7662) to validate an opaque token and pull the principal. That requires
785/// a network round-trip + a client credential, so it is intentionally **not**
786/// implemented here: [`SmooIdentityVerifier::introspect`] documents the contract
787/// and returns [`AuthError::Misconfigured`] until the introspection client is
788/// wired. The JWT form below is the one exercised in tests + the default hosted
789/// deployment (Smoo signs a JWT; we verify it locally with Smoo's public key /
790/// shared secret — no per-request network call).
791pub struct SmooIdentityVerifier {
792    inner: JwtVerifier,
793}
794
795impl SmooIdentityVerifier {
796    /// A Smoo-identity verifier over an HS256 shared secret, keyed to Smoo's
797    /// issuer + audience.
798    #[must_use]
799    pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
800        Self {
801            inner: JwtVerifier::hs256(secret, Some(issuer), audience),
802        }
803    }
804
805    /// A Smoo-identity verifier over an RS256 public key, keyed to Smoo's
806    /// issuer + audience.
807    ///
808    /// # Errors
809    /// Returns [`AuthError::Misconfigured`] if the PEM can't be parsed.
810    pub fn rs256(
811        public_key_pem: &[u8],
812        issuer: String,
813        audience: Option<String>,
814    ) -> Result<Self, AuthError> {
815        Ok(Self {
816            inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
817        })
818    }
819
820    /// A Smoo-identity verifier backed by Smoo's **published JWKS** — the path
821    /// that makes real `auth.smoo.ai` tokens (signed **ES256**, `kty: EC`)
822    /// verifiable. Keys are fetched + cached from `jwks_url` (typically
823    /// `{issuer}/.well-known/jwks.json`) and selected per-token by `kid`, so key
824    /// rotation needs no redeploy and any advertised algorithm works. Keyed to
825    /// Smoo's issuer + audience.
826    #[must_use]
827    pub fn jwks(jwks_url: impl Into<String>, issuer: String, audience: Option<String>) -> Self {
828        Self {
829            inner: JwtVerifier::jwks(jwks_url, Some(issuer), audience),
830        }
831    }
832
833    /// A Smoo-identity verifier over a caller-supplied [`JwksFetcher`] (tests
834    /// inject an in-memory [`JwkSet`]; no network). Keyed to Smoo's issuer +
835    /// audience.
836    #[must_use]
837    pub fn jwks_with_fetcher(
838        fetcher: Arc<dyn JwksFetcher>,
839        issuer: String,
840        audience: Option<String>,
841    ) -> Self {
842        Self {
843            inner: JwtVerifier::jwks_with_fetcher(fetcher, Some(issuer), audience),
844        }
845    }
846
847    /// Live token introspection (RFC 7662) against Smoo's auth server.
848    ///
849    /// **Not implemented**: this is the opaque-token hosted variant, which needs
850    /// a network call to `{auth_server}/introspect` with a client credential and
851    /// a parse of the introspection response into a [`Principal`]. Wiring it is
852    /// the follow-up; until then this returns [`AuthError::Misconfigured`] so a
853    /// caller can never mistake the stub for a working validator.
854    ///
855    /// # Errors
856    /// Always returns [`AuthError::Misconfigured`] (stub).
857    pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
858        Err(AuthError::Misconfigured(
859            "live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
860             locally) or implement the /introspect client"
861                .to_string(),
862        ))
863    }
864}
865
866impl AuthVerifier for SmooIdentityVerifier {
867    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
868        self.inner.decode_principal(bearer_token)
869    }
870
871    fn mode(&self) -> &'static str {
872        "smoo"
873    }
874}
875
876/// **Dev-only** verifier: returns a fixed `Admin` principal for *any* token
877/// (including none). Reachable only via an explicit `AUTH_MODE=none`
878/// ([`AuthConfig::from_env`]) so it can never be the silent production default.
879pub struct NoAuthVerifier {
880    principal: Principal,
881}
882
883impl NoAuthVerifier {
884    /// A no-auth verifier returning an `Admin` principal in `org_id`.
885    #[must_use]
886    pub fn new(org_id: impl Into<String>) -> Self {
887        Self {
888            principal: Principal::new(
889                "dev-admin",
890                org_id,
891                Role::Admin,
892                Some("Dev Admin (AUTH_MODE=none)".to_string()),
893            ),
894        }
895    }
896}
897
898impl Default for NoAuthVerifier {
899    fn default() -> Self {
900        Self::new("dev-org")
901    }
902}
903
904impl AuthVerifier for NoAuthVerifier {
905    fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
906        Ok(self.principal.clone())
907    }
908
909    fn mode(&self) -> &'static str {
910        "none"
911    }
912}
913
914/// **Local single-user** verifier — the auth for the *local deployment flavor*.
915///
916/// Holds one shared secret (the local daemon auto-provisions it). The presented
917/// token must equal the secret, compared in **constant time**; on match the
918/// connection runs as a fixed local `Admin` principal, and on mismatch/empty it
919/// **fails closed**. This gates stray local processes from connecting to the
920/// loopback/tailnet server without dragging in the multi-tenant JWT/IdP
921/// machinery — exactly the posture a single-user always-on daemon wants.
922///
923/// The token rides in the **same slot** a JWT would: the `/ws` `?token=` query
924/// param (reference server) or the `send_message` `token` field (Lambda), so all
925/// existing transport plumbing is reused.
926pub struct LocalTokenVerifier {
927    secret: String,
928    principal: Principal,
929}
930
931impl LocalTokenVerifier {
932    /// A verifier over `secret`; matched connections run as a local `Admin`.
933    #[must_use]
934    pub fn new(secret: impl Into<String>) -> Self {
935        Self {
936            secret: secret.into(),
937            principal: Principal::new(
938                "local",
939                "local",
940                Role::Admin,
941                Some("Local user".to_string()),
942            ),
943        }
944    }
945}
946
947impl AuthVerifier for LocalTokenVerifier {
948    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
949        if bearer_token.is_empty() {
950            return Err(AuthError::Unauthenticated);
951        }
952        if local_token_eq(bearer_token.as_bytes(), self.secret.as_bytes()) {
953            Ok(self.principal.clone())
954        } else {
955            Err(AuthError::InvalidToken("local token mismatch".to_string()))
956        }
957    }
958
959    fn mode(&self) -> &'static str {
960        "local-token"
961    }
962}
963
964/// Length-aware constant-time byte comparison, so the local-token check leaks
965/// neither length nor content through timing.
966fn local_token_eq(a: &[u8], b: &[u8]) -> bool {
967    if a.len() != b.len() {
968        return false;
969    }
970    let mut diff = 0u8;
971    for (x, y) in a.iter().zip(b) {
972        diff |= x ^ y;
973    }
974    diff == 0
975}
976
977/// **Tokenless trusted-upstream** verifier — `AUTH_MODE=trusted`.
978///
979/// For the **proxied-integration** deployment shape: an existing application's
980/// backend has *already* authenticated the user and proxies smooth-operator over
981/// a trusted/internal network. That upstream forwards the user's identity
982/// (`sub` / `org` / `role` / `groups`); smooth-operator **trusts** it **without
983/// any signature verification** — the upstream owns identity *and* token
984/// lifetime, so there is no signature to check and no `exp` to enforce.
985///
986/// ## Wire format — identity in the same slot a token would ride
987///
988/// The forwarded identity rides in the **exact same slot** a JWT would: the
989/// `/ws` `?token=` query param (reference server) or the `send_message` `token`
990/// field (Lambda). So *all* the existing transport plumbing is reused — the only
991/// difference from [`JwtVerifier`] is **trust, don't verify**.
992///
993/// The value is **`base64url(JSON)`** of the [`Claims`] shape, e.g.
994/// `base64url({"sub":"u1","org":"acme","role":"basic","groups":["github:acme/secret"]})`.
995/// base64url is used (not raw JSON) so the blob survives the query-string and
996/// JSON-string transports cleanly without escaping. No padding is required
997/// (`URL_SAFE_NO_PAD` is accepted; padded `URL_SAFE` is also tolerated).
998///
999/// ## Security boundary — this is **trust without verification**
1000///
1001/// `AUTH_MODE=trusted` is **only safe when smooth-operator is not directly
1002/// reachable by clients** — it must be fronted by your authenticated
1003/// backend/proxy on a trusted network. A client that *can* reach `/ws` directly
1004/// could forge any identity (any org, any groups). [`AuthConfig::from_env`]
1005/// emits a loud startup `tracing::warn!` to that effect whenever this mode is
1006/// selected.
1007///
1008/// ## Fail closed — never silently no-auth-admin
1009///
1010/// Absent / empty / malformed trusted identity yields an [`AuthError`], which the
1011/// connect path ([`crate::access_control::AccessContext::anonymous`]) maps to an
1012/// **anonymous** connection (org-public only) — exactly like the no-token path.
1013/// Trusted mode **never** degrades to an admin / all-access principal on bad
1014/// input.
1015pub struct TrustedIdentityVerifier;
1016
1017impl TrustedIdentityVerifier {
1018    /// Construct the trusted-identity verifier (stateless).
1019    #[must_use]
1020    pub fn new() -> Self {
1021        Self
1022    }
1023
1024    /// Decode `base64url(JSON)` identity → [`Claims`] → [`Principal`], **without**
1025    /// any signature or `exp` check.
1026    fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
1027        use base64::Engine as _;
1028
1029        let forwarded = forwarded.trim();
1030        if forwarded.is_empty() {
1031            return Err(AuthError::Unauthenticated);
1032        }
1033        // Accept unpadded URL-safe (the canonical encoding) and fall back to the
1034        // padded variant so a caller that pads isn't spuriously rejected.
1035        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1036            .decode(forwarded)
1037            .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
1038            .map_err(|e| {
1039                AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
1040            })?;
1041        let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
1042            AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
1043        })?;
1044        // Reuse the exact same Claims→Principal mapping as the JWT path: missing
1045        // `role` / `org` are still hard errors (which fail closed to anonymous),
1046        // so a blob that omits them can never become an admin.
1047        claims.into_principal()
1048    }
1049}
1050
1051impl Default for TrustedIdentityVerifier {
1052    fn default() -> Self {
1053        Self::new()
1054    }
1055}
1056
1057impl AuthVerifier for TrustedIdentityVerifier {
1058    fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
1059        Self::decode_trusted(forwarded_identity)
1060    }
1061
1062    fn mode(&self) -> &'static str {
1063        "trusted"
1064    }
1065}
1066
1067/// Builds the configured [`AuthVerifier`] from the environment — secure by
1068/// default.
1069///
1070/// ## Environment
1071///
1072/// | var | default | meaning |
1073/// | --- | --- | --- |
1074/// | `AUTH_MODE` | `jwt` | `jwt` (BYO) \| `smoo` (hosted) \| `trusted` (proxied, tokenless — see below) \| `none` (dev only). |
1075/// | `AUTH_JWT_HS256_SECRET` | — | HS256 shared secret. |
1076/// | `AUTH_JWT_RS256_PUBLIC_KEY` | — | Static RS256 PEM public key. |
1077/// | `AUTH_JWT_JWKS_URL` | — | JWKS endpoint to fetch signing keys from (any algorithm — ES256/RS256/…). |
1078/// | `AUTH_JWT_ISSUER` | — | Required `iss` (optional). Also the JWKS auto-derivation root (`{issuer}/.well-known/jwks.json`). |
1079/// | `AUTH_JWT_AUDIENCE` | — | Required `aud` (optional). |
1080/// | `AUTH_DEV_ORG_ID` | `dev-org` | Org id for the `none`-mode admin principal. |
1081///
1082/// ## Key-source precedence (`jwt` and `smoo`)
1083///
1084/// 1. **Static `AUTH_JWT_RS256_PUBLIC_KEY`** (RS256 PEM) — the BYO path, unchanged.
1085/// 2. **Static `AUTH_JWT_HS256_SECRET`** (HS256 shared secret).
1086/// 3. **JWKS** — `AUTH_JWT_JWKS_URL` if set, else derived from the issuer as
1087///    `{AUTH_JWT_ISSUER}/.well-known/jwks.json`. This is the **ES256-capable**
1088///    path: keys are fetched + cached and selected per-token by `kid`, so
1089///    `auth.smoo.ai`'s ES256 tokens verify and key rotation needs no redeploy.
1090///
1091/// So `AUTH_MODE=smoo` now needs only `AUTH_JWT_ISSUER` (+ optionally
1092/// `AUTH_JWT_AUDIENCE`) — no static public key required.
1093///
1094/// **Explicitly** setting `AUTH_MODE=jwt`/`smoo` with **no** usable key source
1095/// (no static key, no JWKS URL, and — for `jwt` — no issuer to derive one) is a
1096/// hard [`AuthError::Misconfigured`] error — not a silent fall-through to no-auth.
1097/// Leaving `AUTH_MODE` **unset** with no key source boots the server with the
1098/// admin API **disabled** ([`AdminDisabledVerifier`]) so `/ws` serves without
1099/// forcing auth config; `/admin` then returns 401 until configured (or
1100/// `AUTH_MODE=none` for dev).
1101///
1102/// A verifier that rejects every request. The default when neither `AUTH_MODE`
1103/// nor a key is configured: the server still boots (so `/ws` serves) but the
1104/// `/admin` API is disabled until an operator sets `AUTH_MODE` + a key, or
1105/// `AUTH_MODE=none` for local dev. Secure-by-default without hard-failing the
1106/// whole service over admin config.
1107#[derive(Debug, Clone, Copy, Default)]
1108pub struct AdminDisabledVerifier;
1109
1110impl AuthVerifier for AdminDisabledVerifier {
1111    fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
1112        Err(AuthError::InvalidToken(
1113            "admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
1114                .to_string(),
1115        ))
1116    }
1117
1118    fn mode(&self) -> &'static str {
1119        "disabled"
1120    }
1121}
1122
1123pub struct AuthConfig;
1124
1125impl AuthConfig {
1126    /// Build the verifier the env selects. Reads keys from env (never logs them).
1127    ///
1128    /// # Errors
1129    /// Returns [`AuthError::Misconfigured`] for an unknown `AUTH_MODE`, or for
1130    /// `jwt`/`smoo` without a usable key.
1131    pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
1132        let raw_mode = std::env::var("AUTH_MODE")
1133            .ok()
1134            .map(|s| s.trim().to_ascii_lowercase())
1135            .filter(|s| !s.is_empty());
1136        let mode_explicit = raw_mode.is_some();
1137        let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
1138
1139        let issuer = env_nonempty("AUTH_JWT_ISSUER");
1140        let audience = env_nonempty("AUTH_JWT_AUDIENCE");
1141
1142        match mode.as_str() {
1143            "none" => {
1144                let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
1145                Ok(Box::new(NoAuthVerifier::new(org)))
1146            }
1147            "trusted" => {
1148                // Reached ONLY by an explicit `AUTH_MODE=trusted`. Identity is
1149                // taken from the upstream caller WITHOUT verification, so warn
1150                // loudly at startup that this is only safe behind a trusted proxy.
1151                tracing::warn!(
1152                    "AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
1153                     verification; ONLY safe when smooth-operator is not directly reachable by \
1154                     clients (front it with your authenticated backend/proxy). Bad/absent \
1155                     identity fails closed to anonymous (org-public only), never admin."
1156                );
1157                Ok(Box::new(TrustedIdentityVerifier::new()))
1158            }
1159            "jwt" => match Self::build_jwt(issuer, audience) {
1160                Ok(v) => Ok(Box::new(v)),
1161                // Default mode (AUTH_MODE unset) with no key: boot with the admin
1162                // API disabled rather than hard-failing the whole server.
1163                Err(AuthError::Misconfigured(_)) if !mode_explicit => {
1164                    tracing::warn!(
1165                        "admin API disabled: no AUTH_MODE/key configured — /ws serves, /admin returns 401. Set AUTH_MODE=jwt + a key (or AUTH_MODE=none for dev) to enable it."
1166                    );
1167                    Ok(Box::new(AdminDisabledVerifier))
1168                }
1169                // Explicitly choosing AUTH_MODE=jwt with no key stays a loud startup error.
1170                Err(e) => Err(e),
1171            },
1172            "smoo" => {
1173                let iss = issuer.ok_or_else(|| {
1174                    AuthError::Misconfigured(
1175                        "AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
1176                    )
1177                })?;
1178                if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
1179                    Ok(Box::new(SmooIdentityVerifier::rs256(
1180                        pem.as_bytes(),
1181                        iss,
1182                        audience,
1183                    )?))
1184                } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
1185                    Ok(Box::new(SmooIdentityVerifier::hs256(
1186                        secret.as_bytes(),
1187                        iss,
1188                        audience,
1189                    )))
1190                } else {
1191                    // No static key: verify against Smoo's published JWKS. Smoo
1192                    // (`auth.smoo.ai`) signs with ES256 (`kty: EC`), which a
1193                    // static RS256 PEM can't validate — JWKS is the working path.
1194                    // The issuer is always present here (required above), so the
1195                    // JWKS URL is always derivable.
1196                    let url = jwks_source(Some(&iss)).expect("issuer is present for smoo mode");
1197                    Ok(Box::new(SmooIdentityVerifier::jwks(url, iss, audience)))
1198                }
1199            }
1200            other => Err(AuthError::Misconfigured(format!(
1201                "unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
1202            ))),
1203        }
1204    }
1205
1206    /// Build a [`JwtVerifier`] from env. Key-source precedence: static RS256 PEM
1207    /// → static HS256 secret → JWKS (`AUTH_JWT_JWKS_URL`, else
1208    /// `{AUTH_JWT_ISSUER}/.well-known/jwks.json`).
1209    fn build_jwt(
1210        issuer: Option<String>,
1211        audience: Option<String>,
1212    ) -> Result<JwtVerifier, AuthError> {
1213        if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
1214            JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
1215        } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
1216            Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
1217        } else if let Some(url) = jwks_source(issuer.as_deref()) {
1218            // Any OIDC issuer that publishes a JWKS works (ES256/RS256/…), with
1219            // no static key in env.
1220            Ok(JwtVerifier::jwks(url, issuer, audience))
1221        } else {
1222            Err(AuthError::Misconfigured(
1223                "AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY, AUTH_JWT_HS256_SECRET, \
1224                 AUTH_JWT_JWKS_URL, or AUTH_JWT_ISSUER (to derive the JWKS URL) \
1225                 (refusing to fall back to no-auth)"
1226                    .to_string(),
1227            ))
1228        }
1229    }
1230}
1231
1232/// Resolve the JWKS endpoint: an explicit `AUTH_JWT_JWKS_URL` wins, otherwise
1233/// derive `{issuer}/.well-known/jwks.json` from the configured issuer (the
1234/// standard OIDC location `auth.smoo.ai` serves). `None` when neither is set.
1235fn jwks_source(issuer: Option<&str>) -> Option<String> {
1236    if let Some(url) = env_nonempty("AUTH_JWT_JWKS_URL") {
1237        return Some(url);
1238    }
1239    issuer.map(|iss| format!("{}/.well-known/jwks.json", iss.trim_end_matches('/')))
1240}
1241
1242/// Read an env var, returning `None` when absent or empty/whitespace.
1243fn env_nonempty(key: &str) -> Option<String> {
1244    std::env::var(key)
1245        .ok()
1246        .map(|s| s.trim().to_string())
1247        .filter(|s| !s.is_empty())
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use jsonwebtoken::{encode, EncodingKey, Header};
1254    use serde_json::json;
1255
1256    const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
1257
1258    /// Sign an HS256 token with the given claims object.
1259    fn sign(claims: serde_json::Value) -> String {
1260        encode(
1261            &Header::new(Algorithm::HS256),
1262            &claims,
1263            &EncodingKey::from_secret(SECRET),
1264        )
1265        .expect("sign")
1266    }
1267
1268    /// A far-future expiry so tokens are valid.
1269    fn future_exp() -> i64 {
1270        (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
1271    }
1272
1273    // ---- JWKS / ES256 fixtures -------------------------------------------
1274    //
1275    // A locally-generated EC P-256 (ES256) keypair + the matching public JWK,
1276    // and an RSA-2048 keypair for the static-RS256 regression. All offline: the
1277    // JWKS path is driven through an injected `JwksFetcher`, never the network.
1278
1279    /// PKCS#8 EC P-256 private key (test-only; generated with openssl).
1280    const EC_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1281MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgS73a4tqPSek9+32c\n\
1282x0FaP0T8bhMiC5yIvyBGW9qk68ehRANCAAQ7175zcp6KZfPVpFG4a8RI0dtVKNtr\n\
1283YIF2/Pl3nm1Pb1imLIy4WnLa+vr0nqcC0612yaRg4KWjYj6XdDO9gP+Y\n\
1284-----END PRIVATE KEY-----\n";
1285    /// `kid` advertised for the EC public key in the test JWKS.
1286    const EC_KID: &str = "test-ec-1";
1287    /// base64url EC public point coords matching `EC_PRIV_PEM`.
1288    const EC_X: &str = "O9e-c3KeimXz1aRRuGvESNHbVSjba2CBdvz5d55tT28";
1289    const EC_Y: &str = "WKYsjLhactr6-vSepwLTrXbJpGDgpaNiPpd0M72A_5g";
1290
1291    /// RSA-2048 keypair for the static-RS256 regression (test-only).
1292    const RSA_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
1293MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0MeIERxU2bLpDNQaSis\n\
1294nz93wtxbYL3aTVEiHSGCyDysrpIAFQxD8IjXn0lLnf/OlR0IWjBH/6ARsXucXemG\n\
1295jzZBCpHbna0PAnNXUOOPM88gev/XN9p+MxWPDHnyd1ZtyxAHc5xo0a596Gq3HE9C\n\
1296QL53nMIYEOBOP5VeUQS68G7DGo+dTQgXrFb98fsqYS3xqeLoYWI+tHYEkzY4DFxb\n\
1297jdvBvBN65N84pYnk7Pd/vbITvVaDC7pev1E5wvh4Iu/zZy0LBnQPgcMEumcc5cZQ\n\
12986Filt8q83ReOIWpmQfNryxgdz7okUvOZSzkYLJscwjkdyBDOcaKxT5O323dd1xm8\n\
12996QIDAQAB\n\
1300-----END PUBLIC KEY-----\n";
1301    const RSA_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1302MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDQx4gRHFTZsuk\n\
1303M1BpKKyfP3fC3FtgvdpNUSIdIYLIPKyukgAVDEPwiNefSUud/86VHQhaMEf/oBGx\n\
1304e5xd6YaPNkEKkdudrQ8Cc1dQ448zzyB6/9c32n4zFY8MefJ3Vm3LEAdznGjRrn3o\n\
1305arccT0JAvnecwhgQ4E4/lV5RBLrwbsMaj51NCBesVv3x+yphLfGp4uhhYj60dgST\n\
1306NjgMXFuN28G8E3rk3zilieTs93+9shO9VoMLul6/UTnC+Hgi7/NnLQsGdA+BwwS6\n\
1307ZxzlxlDoWKW3yrzdF44hamZB82vLGB3PuiRS85lLORgsmxzCOR3IEM5xorFPk7fb\n\
1308d13XGbzpAgMBAAECggEACKe7+SAvicvfsPqZUN/9rt1oWJnd7w7bU1wKUBJBMtEF\n\
1309soNEP6qYhFv8etIL6QgCxzdPPHgxaNJWlnBtQPht/4EfJvHKM1YNeUVVlH9RxLEk\n\
1310tm8Kwi4MNAV7nsj1B3csTLj8K5K+TrUWXawFS9rzi90lfixYVr8qmMTtNlgoVSnv\n\
1311vNsIbEIoqNu4SwIAAmuXTsVoaUcgo8L+UDtTn3LXl4X5Daz6Z54whloMr+YjdoxL\n\
1312exLSN9Z4sirhoDpUMl9ckmu57stObY2IHsJeMNzmhg8u535GrlyPs+JHYs6lIzWX\n\
1313O4UT8VOwnkOcudCTL3l8sITJmArzkjSMqSzsiPb65QKBgQD+pLZHfYwfR72aQnLE\n\
1314Ypwo1SNZBWy2SDeszSgnzTr9u8kPChIgUTmRam7f6++hPe49S0n/BwTm3SXxKZQ+\n\
1315yySyW9ikmR4qzNhMywL8ViKNcGtuKSrad+KA3Ur4Oq3RzmVDYPMoJ0yiaQW19Yfy\n\
1316R+L5Y0x9drUWH4vqYqk4FJKg2wKBgQDETWuYq74omGHyNMAXWdAcsW+HA+A21HA2\n\
13174jK8X1e8Qdo/ddBZjgr7satzhBYdAa5VOS6unL//Al8eYNHmnvLqLFmReUye7Mp+\n\
1318c+LxIUzta0M6q4Nnq69ctvMq9WFG/Lj7pUxzuBDk6Q3X/8tu25DoBzmv/iQDP4eY\n\
1319F9FB4ZcSiwKBgH2GUFx5ZQNeZ/aM3uoz+eqe9mfBps9MVjWWhD7qijPdx8TkH/9S\n\
1320SuCF6NX1BhEj6DbK0FUo7p+nUDbLWkqB9Tr+z5KD8D0E8XMZeAVPqIS0cCDDpl4/\n\
1321TqZbb8NhmaGc7ooCVprqlHpS7v+9YyBpk1eAPYpzY9zd/Ci0Ldp5ObaVAoGAOVFh\n\
13222XJMVA4qi05byHWxDq/AoOvAzEG7gksKBXbRZ2bTEzSTYZLYIiX+qfwneNDE1p2b\n\
1323w+CBLzTCEVyz7WL8CuRoQtHoTX9WoRW1bjMLA0gOmVL7S4oV6jyBREnh3Zhtaw0Z\n\
1324BbD5Pd3O7QMDo5r49McnUPwkB87FCOPrdhEoy4ECgYBCBhrsUic64os42vqIdNc9\n\
1325y7LwxQbJgj1EELIx1ErXtbWkhqSCYJ4dOOuRn2koc0SXk0Q0fnbQck+8bc4R6FXp\n\
1326dbzmuAQrASyqJ4cWmKhJyKgZzMfelJVVTnM/5H+mFMSZweNWNN5jn1VbWJNgrZpj\n\
1327fabZgkSUBnZ7xCln6zeeWQ==\n\
1328-----END PRIVATE KEY-----\n";
1329
1330    /// A JWKS JSON document carrying the EC public key (`kid = test-ec-1`).
1331    fn ec_jwks_json() -> String {
1332        format!(
1333            r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"{EC_X}","y":"{EC_Y}","alg":"ES256","use":"sig","kid":"{EC_KID}"}}]}}"#
1334        )
1335    }
1336
1337    /// Sign an ES256 token with `EC_PRIV_PEM`, stamping the given `kid` header.
1338    fn sign_es256(claims: serde_json::Value, kid: &str) -> String {
1339        let mut header = Header::new(Algorithm::ES256);
1340        header.kid = Some(kid.to_string());
1341        let key = EncodingKey::from_ec_pem(EC_PRIV_PEM.as_bytes()).expect("ec encoding key");
1342        encode(&header, &claims, &key).expect("sign es256")
1343    }
1344
1345    /// Sign an RS256 token with `RSA_PRIV_PEM`.
1346    fn sign_rs256(claims: serde_json::Value) -> String {
1347        let key = EncodingKey::from_rsa_pem(RSA_PRIV_PEM.as_bytes()).expect("rsa encoding key");
1348        encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign rs256")
1349    }
1350
1351    // (a) An ES256 token verifies against a JWKS holding its EC public key.
1352    #[test]
1353    fn jwks_verifier_validates_es256_token() {
1354        let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1355        let v = JwksVerifier::with_fetcher(
1356            fetcher,
1357            Some("https://auth.smoo.ai".to_string()),
1358            Some("smoo-api".to_string()),
1359        );
1360        let token = sign_es256(
1361            json!({
1362                "sub": "user-es",
1363                "org": "org-es",
1364                "role": "admin",
1365                "name": "EC User",
1366                "iss": "https://auth.smoo.ai",
1367                "aud": "smoo-api",
1368                "exp": future_exp(),
1369            }),
1370            EC_KID,
1371        );
1372        let p = v.verify(&token).expect("verify es256");
1373        assert_eq!(p.user_id, "user-es");
1374        assert_eq!(p.org_id, "org-es");
1375        assert_eq!(p.role, Role::Admin);
1376        assert_eq!(p.display_name.as_deref(), Some("EC User"));
1377        assert_eq!(v.mode(), "jwks");
1378    }
1379
1380    // (a') The SmooIdentityVerifier (AUTH_MODE=smoo) validates real-shaped ES256
1381    // tokens through the JWKS path — the actual auth.smoo.ai scenario.
1382    #[test]
1383    fn smoo_identity_verifier_validates_es256_via_jwks() {
1384        let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1385        let v = SmooIdentityVerifier::jwks_with_fetcher(
1386            fetcher,
1387            "https://auth.smoo.ai".to_string(),
1388            Some("smoo-api".to_string()),
1389        );
1390        let token = sign_es256(
1391            json!({
1392                "sub": "smoo-user",
1393                "org": "smoo-org",
1394                "role": "curator",
1395                "iss": "https://auth.smoo.ai",
1396                "aud": "smoo-api",
1397                "exp": future_exp(),
1398            }),
1399            EC_KID,
1400        );
1401        let p = v.verify(&token).expect("smoo verify es256");
1402        assert_eq!(p.user_id, "smoo-user");
1403        assert_eq!(p.role, Role::Curator);
1404        assert_eq!(v.mode(), "smoo");
1405    }
1406
1407    // (b) The existing static-RS256 path still verifies — behavior-preserving.
1408    #[test]
1409    fn static_rs256_path_still_verifies() {
1410        let v = JwtVerifier::rs256(RSA_PUB_PEM.as_bytes(), None, None).expect("rs256 verifier");
1411        let token = sign_rs256(json!({
1412            "sub": "rsa-user",
1413            "org": "rsa-org",
1414            "role": "basic",
1415            "exp": future_exp(),
1416        }));
1417        let p = v.verify(&token).expect("verify rs256");
1418        assert_eq!(p.user_id, "rsa-user");
1419        assert_eq!(p.role, Role::Basic);
1420        assert_eq!(v.mode(), "jwt");
1421    }
1422
1423    // (c) An unknown `kid` triggers a JWKS refresh — a key the issuer rotates in
1424    // is picked up without a redeploy (and an absent key fails cleanly).
1425    #[test]
1426    fn unknown_kid_triggers_jwks_refresh() {
1427        use std::sync::atomic::{AtomicUsize, Ordering};
1428
1429        struct CountingFetcher {
1430            set: Mutex<JwkSet>,
1431            calls: AtomicUsize,
1432        }
1433        impl JwksFetcher for CountingFetcher {
1434            fn fetch(&self) -> Result<JwkSet, AuthError> {
1435                self.calls.fetch_add(1, Ordering::SeqCst);
1436                Ok(self.set.lock().unwrap().clone())
1437            }
1438        }
1439
1440        // Start with an empty JWKS (the key has not been published yet).
1441        let fetcher = Arc::new(CountingFetcher {
1442            set: Mutex::new(JwkSet { keys: Vec::new() }),
1443            calls: AtomicUsize::new(0),
1444        });
1445        // min_refresh = 0 so the unknown-kid path refreshes immediately in-test.
1446        let v = JwksVerifier::with_policy(
1447            fetcher.clone(),
1448            Some("iss-rot".to_string()),
1449            None,
1450            Duration::from_secs(3600),
1451            Duration::ZERO,
1452        );
1453        let token = sign_es256(
1454            json!({
1455                "sub": "rot-user",
1456                "org": "rot-org",
1457                "role": "basic",
1458                "iss": "iss-rot",
1459                "exp": future_exp(),
1460            }),
1461            EC_KID,
1462        );
1463
1464        // First attempt: the key isn't in the JWKS yet → fails cleanly, but a
1465        // fetch was attempted.
1466        assert!(v.verify(&token).is_err());
1467        let after_first = fetcher.calls.load(Ordering::SeqCst);
1468        assert!(after_first >= 1, "an initial fetch must have happened");
1469
1470        // The issuer rotates the EC key in.
1471        *fetcher.set.lock().unwrap() = parse_jwks(&ec_jwks_json()).expect("jwks");
1472
1473        // Next verify: the unknown-kid cache miss forces a refresh → the rotated
1474        // key is found → the token verifies.
1475        let p = v.verify(&token).expect("verify after rotation");
1476        assert_eq!(p.user_id, "rot-user");
1477        assert!(
1478            fetcher.calls.load(Ordering::SeqCst) > after_first,
1479            "rotation must have triggered a refetch"
1480        );
1481    }
1482
1483    // (d) Wrong issuer / audience are rejected even with a valid signature.
1484    #[test]
1485    fn jwks_rejects_wrong_issuer() {
1486        let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1487        let v = JwksVerifier::with_fetcher(
1488            fetcher,
1489            Some("https://auth.smoo.ai".to_string()),
1490            Some("smoo-api".to_string()),
1491        );
1492        let token = sign_es256(
1493            json!({
1494                "sub": "u", "org": "o", "role": "basic",
1495                "iss": "https://evil.example", "aud": "smoo-api", "exp": future_exp(),
1496            }),
1497            EC_KID,
1498        );
1499        assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
1500    }
1501
1502    #[test]
1503    fn jwks_rejects_wrong_audience() {
1504        let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1505        let v = JwksVerifier::with_fetcher(
1506            fetcher,
1507            Some("https://auth.smoo.ai".to_string()),
1508            Some("smoo-api".to_string()),
1509        );
1510        let token = sign_es256(
1511            json!({
1512                "sub": "u", "org": "o", "role": "basic",
1513                "iss": "https://auth.smoo.ai", "aud": "wrong-api", "exp": future_exp(),
1514            }),
1515            EC_KID,
1516        );
1517        assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
1518    }
1519
1520    // The JWKS source derivation: explicit URL wins; else issuer-derived.
1521    #[test]
1522    fn jwks_source_precedence() {
1523        let _g = ENV_LOCK.lock().unwrap();
1524        clear_auth_env();
1525        // No URL set → derive from issuer.
1526        assert_eq!(
1527            jwks_source(Some("https://auth.smoo.ai")),
1528            Some("https://auth.smoo.ai/.well-known/jwks.json".to_string())
1529        );
1530        assert_eq!(jwks_source(None), None);
1531        // Explicit URL wins over the issuer derivation.
1532        std::env::set_var("AUTH_JWT_JWKS_URL", "https://keys.example/jwks");
1533        assert_eq!(
1534            jwks_source(Some("https://auth.smoo.ai")),
1535            Some("https://keys.example/jwks".to_string())
1536        );
1537        clear_auth_env();
1538    }
1539
1540    // AUTH_MODE=smoo with only an issuer (no static key) builds the JWKS-backed
1541    // verifier — the chart can drop AUTH_JWT_RS256_PUBLIC_KEY.
1542    #[test]
1543    fn from_env_smoo_with_issuer_only_builds_jwks() {
1544        let _g = ENV_LOCK.lock().unwrap();
1545        clear_auth_env();
1546        std::env::set_var("AUTH_MODE", "smoo");
1547        std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
1548        let v = AuthConfig::from_env().expect("smoo builds from issuer alone");
1549        assert_eq!(v.mode(), "smoo");
1550        clear_auth_env();
1551    }
1552
1553    // ---- Role ordering ---------------------------------------------------
1554
1555    #[test]
1556    fn role_ordering_admin_ge_curator_ge_basic() {
1557        assert!(Role::Admin >= Role::Curator);
1558        assert!(Role::Curator >= Role::Basic);
1559        assert!(Role::Admin > Role::Basic);
1560        assert!(Role::Admin >= Role::Admin);
1561        // And the inverse never holds.
1562        assert!(Role::Basic < Role::Curator);
1563        assert!(Role::Curator < Role::Admin);
1564    }
1565
1566    #[test]
1567    fn role_has_role_gate() {
1568        let admin = Principal::new("u", "o", Role::Admin, None);
1569        let basic = Principal::new("u", "o", Role::Basic, None);
1570        assert!(admin.has_role(Role::Curator));
1571        assert!(admin.has_role(Role::Basic));
1572        assert!(!basic.has_role(Role::Curator));
1573        assert!(basic.has_role(Role::Basic));
1574    }
1575
1576    #[test]
1577    fn role_parse_known_and_unknown() {
1578        assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1579        assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
1580        assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
1581        assert_eq!(Role::parse("user").unwrap(), Role::Basic);
1582        assert!(matches!(
1583            Role::parse("superuser"),
1584            Err(AuthError::MissingRole(_))
1585        ));
1586    }
1587
1588    // ---- JwtVerifier round-trip ------------------------------------------
1589
1590    #[test]
1591    fn jwt_verifier_round_trip_extracts_principal() {
1592        let verifier = JwtVerifier::hs256(SECRET, None, None);
1593        let token = sign(json!({
1594            "sub": "user-123",
1595            "org": "org-abc",
1596            "role": "curator",
1597            "name": "Ada Lovelace",
1598            "exp": future_exp(),
1599        }));
1600        let p = verifier.verify(&token).expect("verify");
1601        assert_eq!(p.user_id, "user-123");
1602        assert_eq!(p.org_id, "org-abc");
1603        assert_eq!(p.role, Role::Curator);
1604        assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
1605    }
1606
1607    #[test]
1608    fn jwt_verifier_parses_groups_claim_into_access_context() {
1609        // A token carrying a `groups` claim must surface those groups on the
1610        // Principal AND in the derived AccessContext — this is what lets a user
1611        // match a `github:owner/repo` document ACL on the chat retrieval path.
1612        let verifier = JwtVerifier::hs256(SECRET, None, None);
1613        let token = sign(json!({
1614            "sub": "user-7",
1615            "org": "org-x",
1616            "role": "basic",
1617            "groups": ["github:acme/secret", "eng"],
1618            "exp": future_exp(),
1619        }));
1620        let p = verifier.verify(&token).expect("verify");
1621        assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1622
1623        let ctx = p.access_context();
1624        assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
1625        assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
1626        // The principal's org is carried so a multi-tenant host adapter can scope
1627        // RAG to this tenant.
1628        assert_eq!(ctx.organization_id.as_deref(), Some("org-x"));
1629        // And it can read a doc scoped to one of its groups.
1630        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1631        assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
1632    }
1633
1634    #[test]
1635    fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
1636        // No `groups` claim ⇒ empty groups ⇒ the principal cannot match a
1637        // group-scoped (private-repo) document.
1638        let verifier = JwtVerifier::hs256(SECRET, None, None);
1639        let token = sign(json!({
1640            "sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
1641        }));
1642        let p = verifier.verify(&token).expect("verify");
1643        assert!(p.groups.is_empty());
1644        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1645        assert!(
1646            !p.access_context().can_access(&acl),
1647            "LEAK: a principal with no groups must NOT read a group-scoped doc"
1648        );
1649    }
1650
1651    #[test]
1652    fn jwt_verifier_accepts_org_id_alias() {
1653        let verifier = JwtVerifier::hs256(SECRET, None, None);
1654        let token = sign(json!({
1655            "sub": "u",
1656            "org_id": "org-from-alias",
1657            "role": "admin",
1658            "exp": future_exp(),
1659        }));
1660        let p = verifier.verify(&token).expect("verify");
1661        assert_eq!(p.org_id, "org-from-alias");
1662        assert_eq!(p.role, Role::Admin);
1663        assert!(p.display_name.is_none());
1664    }
1665
1666    #[test]
1667    fn jwt_verifier_rejects_expired() {
1668        let verifier = JwtVerifier::hs256(SECRET, None, None);
1669        let token = sign(json!({
1670            "sub": "u",
1671            "org": "o",
1672            "role": "admin",
1673            "exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
1674        }));
1675        let err = verifier.verify(&token).expect_err("must reject expired");
1676        assert!(matches!(err, AuthError::InvalidToken(_)));
1677    }
1678
1679    #[test]
1680    fn jwt_verifier_rejects_wrong_secret() {
1681        let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
1682        let token = sign(json!({
1683            "sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
1684        }));
1685        let err = verifier.verify(&token).expect_err("must reject bad sig");
1686        assert!(matches!(err, AuthError::InvalidToken(_)));
1687    }
1688
1689    #[test]
1690    fn jwt_verifier_rejects_missing_role() {
1691        let verifier = JwtVerifier::hs256(SECRET, None, None);
1692        let token = sign(json!({
1693            "sub": "u", "org": "o", "exp": future_exp(),
1694        }));
1695        let err = verifier.verify(&token).expect_err("must reject no role");
1696        assert!(matches!(err, AuthError::MissingRole(_)));
1697    }
1698
1699    #[test]
1700    fn jwt_verifier_rejects_unknown_role() {
1701        let verifier = JwtVerifier::hs256(SECRET, None, None);
1702        let token = sign(json!({
1703            "sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
1704        }));
1705        let err = verifier.verify(&token).expect_err("must reject bad role");
1706        assert!(matches!(err, AuthError::MissingRole(_)));
1707    }
1708
1709    #[test]
1710    fn jwt_verifier_rejects_missing_org() {
1711        let verifier = JwtVerifier::hs256(SECRET, None, None);
1712        let token = sign(json!({
1713            "sub": "u", "role": "admin", "exp": future_exp(),
1714        }));
1715        let err = verifier.verify(&token).expect_err("must reject no org");
1716        assert!(matches!(err, AuthError::InvalidToken(_)));
1717    }
1718
1719    #[test]
1720    fn jwt_verifier_rejects_empty_token() {
1721        let verifier = JwtVerifier::hs256(SECRET, None, None);
1722        assert_eq!(
1723            verifier.verify("   ").expect_err("empty"),
1724            AuthError::Unauthenticated
1725        );
1726    }
1727
1728    #[test]
1729    fn jwt_verifier_rejects_garbage() {
1730        let verifier = JwtVerifier::hs256(SECRET, None, None);
1731        let err = verifier.verify("not.a.jwt").expect_err("garbage");
1732        assert!(matches!(err, AuthError::InvalidToken(_)));
1733    }
1734
1735    #[test]
1736    fn jwt_verifier_enforces_audience_when_configured() {
1737        let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
1738        // Right audience → ok.
1739        let ok = sign(json!({
1740            "sub": "u", "org": "o", "role": "admin",
1741            "aud": "expected-aud", "exp": future_exp(),
1742        }));
1743        assert!(verifier.verify(&ok).is_ok());
1744        // Wrong audience → rejected.
1745        let bad = sign(json!({
1746            "sub": "u", "org": "o", "role": "admin",
1747            "aud": "other-aud", "exp": future_exp(),
1748        }));
1749        assert!(matches!(
1750            verifier.verify(&bad),
1751            Err(AuthError::InvalidToken(_))
1752        ));
1753    }
1754
1755    // ---- SmooIdentityVerifier --------------------------------------------
1756
1757    #[test]
1758    fn smoo_verifier_validates_issuer_keyed_token() {
1759        let verifier =
1760            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1761        let token = sign(json!({
1762            "sub": "u", "org": "o", "role": "admin",
1763            "iss": "https://auth.smoo.ai", "exp": future_exp(),
1764        }));
1765        let p = verifier.verify(&token).expect("verify");
1766        assert_eq!(p.role, Role::Admin);
1767        assert_eq!(verifier.mode(), "smoo");
1768    }
1769
1770    #[test]
1771    fn smoo_verifier_rejects_wrong_issuer() {
1772        let verifier =
1773            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1774        let token = sign(json!({
1775            "sub": "u", "org": "o", "role": "admin",
1776            "iss": "https://evil.example", "exp": future_exp(),
1777        }));
1778        assert!(matches!(
1779            verifier.verify(&token),
1780            Err(AuthError::InvalidToken(_))
1781        ));
1782    }
1783
1784    #[test]
1785    fn smoo_introspect_is_stubbed_misconfigured() {
1786        let verifier =
1787            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1788        assert!(matches!(
1789            verifier.introspect("opaque-token"),
1790            Err(AuthError::Misconfigured(_))
1791        ));
1792    }
1793
1794    // ---- NoAuthVerifier --------------------------------------------------
1795
1796    #[test]
1797    fn no_auth_returns_fixed_admin() {
1798        let verifier = NoAuthVerifier::new("dev-org");
1799        let p = verifier.verify("anything-or-nothing").expect("no-auth");
1800        assert_eq!(p.role, Role::Admin);
1801        assert_eq!(p.org_id, "dev-org");
1802        assert_eq!(verifier.mode(), "none");
1803    }
1804
1805    // ---- LocalTokenVerifier ----------------------------------------------
1806
1807    #[test]
1808    fn local_token_accepts_exact_secret_as_local_admin() {
1809        let v = LocalTokenVerifier::new("s3cret-local");
1810        let p = v.verify("s3cret-local").expect("matching token");
1811        assert_eq!(p.role, Role::Admin);
1812        assert_eq!(p.user_id, "local");
1813        assert_eq!(p.org_id, "local");
1814        assert_eq!(v.mode(), "local-token");
1815    }
1816
1817    #[test]
1818    fn local_token_fails_closed_on_wrong_or_empty() {
1819        let v = LocalTokenVerifier::new("s3cret-local");
1820        assert!(matches!(v.verify(""), Err(AuthError::Unauthenticated)));
1821        assert!(matches!(v.verify("nope"), Err(AuthError::InvalidToken(_))));
1822        assert!(matches!(
1823            v.verify("s3cret"),
1824            Err(AuthError::InvalidToken(_))
1825        ));
1826    }
1827
1828    // ---- AuthConfig::from_env — secure by default ------------------------
1829    //
1830    // These mutate process env, so they run serially under a shared lock to
1831    // avoid cross-test interference.
1832
1833    use std::sync::Mutex;
1834    static ENV_LOCK: Mutex<()> = Mutex::new(());
1835
1836    fn clear_auth_env() {
1837        for k in [
1838            "AUTH_MODE",
1839            "AUTH_JWT_HS256_SECRET",
1840            "AUTH_JWT_RS256_PUBLIC_KEY",
1841            "AUTH_JWT_JWKS_URL",
1842            "AUTH_JWT_ISSUER",
1843            "AUTH_JWT_AUDIENCE",
1844            "AUTH_DEV_ORG_ID",
1845        ] {
1846            std::env::remove_var(k);
1847        }
1848    }
1849
1850    #[test]
1851    fn from_env_default_disables_admin_without_key() {
1852        let _g = ENV_LOCK.lock().unwrap();
1853        clear_auth_env();
1854        // No AUTH_MODE, no key → the server BOOTS (so /ws serves) with the admin
1855        // API disabled — it does NOT silently fall back to no-auth, and it does
1856        // NOT hard-fail the whole service.
1857        let v = AuthConfig::from_env().expect("default boots with admin disabled");
1858        assert_eq!(v.mode(), "disabled");
1859        // Every admin request is rejected until auth is configured.
1860        assert!(matches!(
1861            v.verify("anything"),
1862            Err(AuthError::InvalidToken(_))
1863        ));
1864        clear_auth_env();
1865    }
1866
1867    #[test]
1868    fn from_env_explicit_jwt_without_key_hard_errors() {
1869        let _g = ENV_LOCK.lock().unwrap();
1870        clear_auth_env();
1871        // EXPLICITLY asking for jwt with no key is a loud startup error (an
1872        // operator who set AUTH_MODE=jwt and forgot the key must be told).
1873        std::env::set_var("AUTH_MODE", "jwt");
1874        match AuthConfig::from_env() {
1875            Err(AuthError::Misconfigured(_)) => {}
1876            Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
1877            Err(other) => panic!("expected Misconfigured, got {other}"),
1878        }
1879        clear_auth_env();
1880    }
1881
1882    #[test]
1883    fn from_env_jwt_with_hs256_secret_builds() {
1884        let _g = ENV_LOCK.lock().unwrap();
1885        clear_auth_env();
1886        std::env::set_var("AUTH_MODE", "jwt");
1887        std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1888        let v = AuthConfig::from_env().expect("builds");
1889        assert_eq!(v.mode(), "jwt");
1890        clear_auth_env();
1891    }
1892
1893    #[test]
1894    fn from_env_none_only_when_explicit() {
1895        let _g = ENV_LOCK.lock().unwrap();
1896        clear_auth_env();
1897        std::env::set_var("AUTH_MODE", "none");
1898        std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
1899        let v = AuthConfig::from_env().expect("none builds");
1900        assert_eq!(v.mode(), "none");
1901        let p = v.verify("").expect("no-auth principal");
1902        assert_eq!(p.role, Role::Admin);
1903        assert_eq!(p.org_id, "explicit-dev-org");
1904        clear_auth_env();
1905    }
1906
1907    #[test]
1908    fn from_env_unknown_mode_errors() {
1909        let _g = ENV_LOCK.lock().unwrap();
1910        clear_auth_env();
1911        std::env::set_var("AUTH_MODE", "banana");
1912        assert!(matches!(
1913            AuthConfig::from_env(),
1914            Err(AuthError::Misconfigured(_))
1915        ));
1916        clear_auth_env();
1917    }
1918
1919    // ---- TrustedIdentityVerifier (AUTH_MODE=trusted) ---------------------
1920    //
1921    // Tokenless proxied-integration mode: the upstream forwards identity as a
1922    // base64url(JSON) blob in the same slot a token would ride. NO signature,
1923    // NO exp; reuses the Claims→Principal mapping. MUST fail closed (error →
1924    // anonymous at the connect path), NEVER admin, on bad input.
1925
1926    /// Encode a claims object as the `base64url(JSON)` blob the trusted upstream
1927    /// would forward (unpadded URL-safe — the canonical form).
1928    fn forward(claims: serde_json::Value) -> String {
1929        use base64::Engine as _;
1930        let json = serde_json::to_vec(&claims).expect("serialize claims");
1931        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
1932    }
1933
1934    #[test]
1935    fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
1936        let verifier = TrustedIdentityVerifier::new();
1937        // No `exp` here on purpose — the upstream owns lifetime; trusted mode
1938        // must NOT require it.
1939        let blob = forward(json!({
1940            "sub": "user-42",
1941            "org": "acme",
1942            "role": "curator",
1943            "name": "Grace Hopper",
1944            "groups": ["github:acme/secret", "eng"],
1945        }));
1946        let p = verifier.verify(&blob).expect("trusted verify");
1947        assert_eq!(p.user_id, "user-42");
1948        assert_eq!(p.org_id, "acme");
1949        assert_eq!(p.role, Role::Curator);
1950        assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
1951        assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1952        assert_eq!(verifier.mode(), "trusted");
1953
1954        // The groups carry into the AccessContext so the SAME ACL enforcement a
1955        // JWT drives applies to a forwarded identity.
1956        let ctx = p.access_context();
1957        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1958        assert!(
1959            ctx.can_access(&acl),
1960            "forwarded group must drive ACL access"
1961        );
1962    }
1963
1964    #[test]
1965    fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
1966        use base64::Engine as _;
1967        let verifier = TrustedIdentityVerifier::new();
1968        // `org_id` alias + PADDED url-safe base64 must both be accepted.
1969        let json = serde_json::to_vec(&json!({
1970            "sub": "u", "org_id": "org-alias", "role": "admin",
1971        }))
1972        .unwrap();
1973        let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
1974        let p = verifier.verify(&blob).expect("padded + alias");
1975        assert_eq!(p.org_id, "org-alias");
1976        assert_eq!(p.role, Role::Admin);
1977    }
1978
1979    #[test]
1980    fn trusted_verifier_empty_is_unauthenticated_not_admin() {
1981        let verifier = TrustedIdentityVerifier::new();
1982        // Absent/empty forwarded identity ⇒ Unauthenticated error (which the
1983        // connect path maps to anonymous), NOT a fabricated admin principal.
1984        assert_eq!(
1985            verifier.verify("   ").expect_err("empty must error"),
1986            AuthError::Unauthenticated
1987        );
1988    }
1989
1990    #[test]
1991    fn trusted_verifier_malformed_base64_errors_never_admin() {
1992        let verifier = TrustedIdentityVerifier::new();
1993        let err = verifier
1994            .verify("!!!not base64!!!")
1995            .expect_err("malformed base64 must error");
1996        assert!(matches!(err, AuthError::InvalidToken(_)));
1997    }
1998
1999    #[test]
2000    fn trusted_verifier_malformed_json_errors_never_admin() {
2001        use base64::Engine as _;
2002        let verifier = TrustedIdentityVerifier::new();
2003        // Valid base64url but the bytes aren't claims JSON.
2004        let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
2005        let err = verifier.verify(&blob).expect_err("non-json must error");
2006        assert!(matches!(err, AuthError::InvalidToken(_)));
2007    }
2008
2009    #[test]
2010    fn trusted_verifier_missing_role_errors_never_admin() {
2011        // A forwarded identity with NO role must NOT silently become admin — it
2012        // is a MissingRole error (→ anonymous at the connect path).
2013        let verifier = TrustedIdentityVerifier::new();
2014        let blob = forward(json!({ "sub": "u", "org": "o" }));
2015        let err = verifier.verify(&blob).expect_err("no role must error");
2016        assert!(matches!(err, AuthError::MissingRole(_)));
2017    }
2018
2019    #[test]
2020    fn trusted_verifier_missing_org_errors_never_admin() {
2021        let verifier = TrustedIdentityVerifier::new();
2022        let blob = forward(json!({ "sub": "u", "role": "admin" }));
2023        let err = verifier.verify(&blob).expect_err("no org must error");
2024        assert!(matches!(err, AuthError::InvalidToken(_)));
2025    }
2026
2027    #[test]
2028    fn from_env_trusted_only_when_explicit() {
2029        let _g = ENV_LOCK.lock().unwrap();
2030        clear_auth_env();
2031        // trusted is reached ONLY by explicit AUTH_MODE=trusted — no key needed
2032        // (there is nothing to verify), and it never requires AUTH_JWT_* config.
2033        std::env::set_var("AUTH_MODE", "trusted");
2034        let v = AuthConfig::from_env().expect("trusted builds");
2035        assert_eq!(v.mode(), "trusted");
2036        // A forwarded identity is honored...
2037        let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
2038        assert_eq!(
2039            v.verify(&blob).expect("trusted principal").role,
2040            Role::Basic
2041        );
2042        // ...and bad input is an error (→ anonymous at the connect path), never admin.
2043        assert!(v.verify("garbage").is_err());
2044        clear_auth_env();
2045    }
2046
2047    #[test]
2048    fn from_env_unset_does_not_select_trusted() {
2049        let _g = ENV_LOCK.lock().unwrap();
2050        clear_auth_env();
2051        // Secure-by-default unset case is UNCHANGED: no AUTH_MODE ⇒ admin-disabled,
2052        // NOT trusted. trusted is only ever reached by an explicit opt-in.
2053        let v = AuthConfig::from_env().expect("default boots");
2054        assert_eq!(v.mode(), "disabled");
2055        assert_ne!(v.mode(), "trusted");
2056        clear_auth_env();
2057    }
2058
2059    #[test]
2060    fn from_env_smoo_requires_issuer() {
2061        let _g = ENV_LOCK.lock().unwrap();
2062        clear_auth_env();
2063        std::env::set_var("AUTH_MODE", "smoo");
2064        // No issuer → misconfig (there is nothing to key the JWKS or validate
2065        // `iss` against).
2066        assert!(matches!(
2067            AuthConfig::from_env(),
2068            Err(AuthError::Misconfigured(_))
2069        ));
2070        // Issuer alone now builds the JWKS-backed verifier (no static key
2071        // required — this is the ES256 path for auth.smoo.ai).
2072        std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
2073        let v = AuthConfig::from_env().expect("smoo builds from issuer (JWKS)");
2074        assert_eq!(v.mode(), "smoo");
2075        // A static key still works and takes precedence over JWKS.
2076        std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
2077        let v = AuthConfig::from_env().expect("smoo builds with static key");
2078        assert_eq!(v.mode(), "smoo");
2079        clear_auth_env();
2080    }
2081}