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;
42
43use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
44use serde::{Deserialize, Serialize};
45
46use crate::access_control::AccessContext;
47
48/// A role in the org's RBAC model. Ordered so `Admin > Curator > Basic`, which
49/// lets a route gate on a *minimum* role with `principal.role >= min`.
50///
51/// - **Admin** — full org-wide read of chat history, indexing, document sets,
52///   and (future) write/config.
53/// - **Curator** — org-wide read of chat history + curation surfaces (indexing,
54///   document sets); the knowledge-curation persona.
55/// - **Basic** — an end user: may see only their *own* conversations.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Role {
59    /// Lowest privilege — sees only their own data.
60    Basic,
61    /// Curation persona — org-wide read of curation surfaces.
62    Curator,
63    /// Highest privilege — full org-wide access.
64    Admin,
65}
66
67impl Role {
68    /// Parse a role from a claim string (case-insensitive). Unknown / absent
69    /// values are an error so a token can never silently downgrade *or* upgrade.
70    ///
71    /// # Errors
72    /// Returns [`AuthError::MissingRole`] when the value isn't a known role.
73    pub fn parse(value: &str) -> Result<Self, AuthError> {
74        match value.trim().to_ascii_lowercase().as_str() {
75            "admin" => Ok(Role::Admin),
76            "curator" => Ok(Role::Curator),
77            "basic" | "user" => Ok(Role::Basic),
78            other => Err(AuthError::MissingRole(format!("unknown role '{other}'"))),
79        }
80    }
81
82    /// The wire/string form of this role.
83    #[must_use]
84    pub fn as_str(self) -> &'static str {
85        match self {
86            Role::Admin => "admin",
87            Role::Curator => "curator",
88            Role::Basic => "basic",
89        }
90    }
91}
92
93impl fmt::Display for Role {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99/// The authenticated identity a request runs as. Everything the admin API reads
100/// is scoped to [`org_id`](Principal::org_id); [`role`](Principal::role) gates
101/// which operations are allowed and whether reads are org-wide or self-only.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct Principal {
105    /// Stable user id (the JWT `sub`).
106    pub user_id: String,
107    /// The organization this principal belongs to (the JWT `org` / `org_id`).
108    /// Every admin read is filtered to this org.
109    pub org_id: String,
110    /// The principal's role in the org.
111    pub role: Role,
112    /// Optional human-readable name (the JWT `name`).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub display_name: Option<String>,
115    /// The groups the principal belongs to (the JWT `groups` claim). These are
116    /// the entitlements the document-level ACL layer matches against: a
117    /// document scoped to group `github:owner/repo` is readable only by a
118    /// principal carrying that group. Empty when the token has no `groups`
119    /// claim (the principal then sees only org-public + user-scoped docs).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub groups: Vec<String>,
122}
123
124impl Principal {
125    /// Construct a principal (mostly for tests + the no-auth path).
126    #[must_use]
127    pub fn new(
128        user_id: impl Into<String>,
129        org_id: impl Into<String>,
130        role: Role,
131        display_name: Option<String>,
132    ) -> Self {
133        Self {
134            user_id: user_id.into(),
135            org_id: org_id.into(),
136            role,
137            display_name,
138            groups: Vec::new(),
139        }
140    }
141
142    /// Attach group memberships to this principal (builder). The groups flow
143    /// into [`access_context`](Self::access_context) so the document-level ACL
144    /// layer can match a group-scoped document.
145    #[must_use]
146    pub fn with_groups<I, S>(mut self, groups: I) -> Self
147    where
148        I: IntoIterator<Item = S>,
149        S: Into<String>,
150    {
151        self.groups = groups.into_iter().map(Into::into).collect();
152        self
153    }
154
155    /// Whether this principal may act at `min` or above.
156    #[must_use]
157    pub fn has_role(&self, min: Role) -> bool {
158        self.role >= min
159    }
160
161    /// Map this principal to the document-level [`AccessContext`] used by the
162    /// knowledge-retrieval ACL layer. Both the user id **and** the principal's
163    /// groups carry through, so a retrieval as this principal can match a
164    /// document scoped to the user *or* to any group the principal belongs to
165    /// (the JWT `groups` claim — see [`Claims`]).
166    #[must_use]
167    pub fn access_context(&self) -> AccessContext {
168        AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
169    }
170}
171
172/// Why authentication / authorization failed. Maps cleanly to HTTP status in the
173/// admin API: [`Unauthenticated`](AuthError::Unauthenticated) /
174/// [`InvalidToken`](AuthError::InvalidToken) / [`MissingRole`](AuthError::MissingRole)
175/// → 401; [`Forbidden`](AuthError::Forbidden) → 403;
176/// [`Misconfigured`](AuthError::Misconfigured) is a server-config error surfaced
177/// at startup (never to a client).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum AuthError {
180    /// No bearer token was presented.
181    Unauthenticated,
182    /// A token was presented but failed validation (bad signature, expired,
183    /// wrong issuer/audience, malformed).
184    InvalidToken(String),
185    /// The token validated but carried no usable role claim.
186    MissingRole(String),
187    /// The principal is authenticated but lacks the required role.
188    Forbidden {
189        /// The role the route requires.
190        required: Role,
191        /// The role the principal actually has.
192        actual: Role,
193    },
194    /// The verifier is misconfigured (e.g. `AUTH_MODE=jwt` with no key). A
195    /// startup/server error, never a client-facing one.
196    Misconfigured(String),
197}
198
199impl fmt::Display for AuthError {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            AuthError::Unauthenticated => f.write_str("missing bearer token"),
203            AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
204            AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
205            AuthError::Forbidden { required, actual } => {
206                write!(f, "forbidden: requires {required}, principal is {actual}")
207            }
208            AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
209        }
210    }
211}
212
213impl std::error::Error for AuthError {}
214
215/// The single auth seam: turn a bearer token into a [`Principal`].
216///
217/// Implemented by [`JwtVerifier`] (BYO), [`SmooIdentityVerifier`] (hosted), and
218/// [`NoAuthVerifier`] (dev). `Send + Sync` so a single verifier rides on the
219/// shared server state across connections.
220pub trait AuthVerifier: Send + Sync {
221    /// Validate `bearer_token` (the raw token, **without** the `Bearer ` prefix)
222    /// and return the authenticated [`Principal`].
223    ///
224    /// # Errors
225    /// Returns [`AuthError::InvalidToken`] / [`AuthError::MissingRole`] when the
226    /// token is present but unusable, or [`AuthError::Unauthenticated`] when it
227    /// is empty.
228    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
229
230    /// A short label for logs/metrics (never includes secrets).
231    fn mode(&self) -> &'static str;
232}
233
234/// The JWT claim shape both [`JwtVerifier`] and [`SmooIdentityVerifier`] decode.
235/// `org` is the canonical org claim with `org_id` accepted as an alias (SST
236/// OpenAuth and Smoo both emit one or the other).
237#[derive(Debug, Deserialize)]
238struct Claims {
239    sub: String,
240    #[serde(default)]
241    org: Option<String>,
242    #[serde(default)]
243    org_id: Option<String>,
244    #[serde(default)]
245    role: Option<String>,
246    #[serde(default)]
247    name: Option<String>,
248    /// Group memberships (entitlements) — the document-level ACL layer matches
249    /// these against a document's group allow-list. Optional; absent ⇒ no group
250    /// entitlements (the principal sees only org-public + user-scoped docs).
251    #[serde(default)]
252    groups: Vec<String>,
253}
254
255impl Claims {
256    /// Resolve the org id from `org` (preferred) or `org_id` (alias).
257    fn org_id(&self) -> Option<String> {
258        self.org.clone().or_else(|| self.org_id.clone())
259    }
260
261    /// Build a [`Principal`], failing if the role is absent/unknown or no org id
262    /// is present.
263    fn into_principal(self) -> Result<Principal, AuthError> {
264        let role = match &self.role {
265            Some(r) => Role::parse(r)?,
266            None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
267        };
268        let org_id = self
269            .org_id()
270            .ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
271        Ok(Principal {
272            user_id: self.sub,
273            org_id,
274            role,
275            display_name: self.name,
276            groups: self.groups,
277        })
278    }
279}
280
281/// The signing-key material a [`JwtVerifier`] validates against. Built from env
282/// by [`AuthConfig`]; never logged.
283enum VerifyKey {
284    /// HS256 shared secret.
285    Hs256(Box<DecodingKey>),
286    /// RS256 public key (PEM). Structural support — the gateway/IdP signs, we
287    /// verify with the public half.
288    Rs256(Box<DecodingKey>),
289}
290
291/// Validates a JWT and extracts a [`Principal`]. The **BYO** path: SST OpenAuth
292/// (or any OIDC IdP) issues the token; this verifies signature + standard claims
293/// and maps `sub`→`user_id`, `org`/`org_id`→`org_id`, `role`→[`Role`],
294/// `name`→`display_name`.
295pub struct JwtVerifier {
296    key: VerifyKey,
297    validation: Validation,
298}
299
300impl JwtVerifier {
301    /// An HS256 verifier over a shared secret. Optionally constrains `iss`/`aud`.
302    #[must_use]
303    pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
304        let mut validation = Validation::new(Algorithm::HS256);
305        configure_validation(&mut validation, issuer, audience);
306        Self {
307            key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
308            validation,
309        }
310    }
311
312    /// An RS256 verifier over a PEM-encoded public key. Optionally constrains
313    /// `iss`/`aud`. (Structural RS256 support — a JWKS-url variant would fetch +
314    /// cache keys; see [`AuthConfig`].)
315    ///
316    /// # Errors
317    /// Returns [`AuthError::Misconfigured`] if the PEM can't be parsed.
318    pub fn rs256(
319        public_key_pem: &[u8],
320        issuer: Option<String>,
321        audience: Option<String>,
322    ) -> Result<Self, AuthError> {
323        let key = DecodingKey::from_rsa_pem(public_key_pem)
324            .map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
325        let mut validation = Validation::new(Algorithm::RS256);
326        configure_validation(&mut validation, issuer, audience);
327        Ok(Self {
328            key: VerifyKey::Rs256(Box::new(key)),
329            validation,
330        })
331    }
332
333    /// Decode + validate, returning the [`Principal`]. Shared by
334    /// [`SmooIdentityVerifier`].
335    fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
336        if token.trim().is_empty() {
337            return Err(AuthError::Unauthenticated);
338        }
339        let key = match &self.key {
340            VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
341        };
342        let data = decode::<Claims>(token, key, &self.validation)
343            .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
344        data.claims.into_principal()
345    }
346}
347
348/// Apply shared validation defaults: require `exp` + `sub`, and constrain
349/// `iss`/`aud` only when configured (otherwise `validate_aud` is turned off so a
350/// token without an `aud` claim isn't spuriously rejected).
351fn configure_validation(
352    validation: &mut Validation,
353    issuer: Option<String>,
354    audience: Option<String>,
355) {
356    validation.set_required_spec_claims(&["exp", "sub"]);
357    match audience {
358        Some(aud) => {
359            validation.validate_aud = true;
360            validation.aud = Some(HashSet::from([aud]));
361        }
362        // No configured audience ⇒ don't validate it (the default `true` would
363        // reject any token lacking an `aud` claim).
364        None => validation.validate_aud = false,
365    }
366    if let Some(iss) = issuer {
367        validation.iss = Some(HashSet::from([iss]));
368    }
369}
370
371impl AuthVerifier for JwtVerifier {
372    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
373        self.decode_principal(bearer_token)
374    }
375
376    fn mode(&self) -> &'static str {
377        "jwt"
378    }
379}
380
381/// Validates a **Smoo-issued** token — the hosted path (lom.smoo.ai wires Smoo's
382/// identity). Implemented as JWT validation keyed to Smoo's issuer/audience,
383/// reusing [`JwtVerifier`]'s internals.
384///
385/// ## Live introspection (hosted, stubbed)
386///
387/// The fully-hosted variant would call Smoo's auth server `/introspect` endpoint
388/// (RFC 7662) to validate an opaque token and pull the principal. That requires
389/// a network round-trip + a client credential, so it is intentionally **not**
390/// implemented here: [`SmooIdentityVerifier::introspect`] documents the contract
391/// and returns [`AuthError::Misconfigured`] until the introspection client is
392/// wired. The JWT form below is the one exercised in tests + the default hosted
393/// deployment (Smoo signs a JWT; we verify it locally with Smoo's public key /
394/// shared secret — no per-request network call).
395pub struct SmooIdentityVerifier {
396    inner: JwtVerifier,
397}
398
399impl SmooIdentityVerifier {
400    /// A Smoo-identity verifier over an HS256 shared secret, keyed to Smoo's
401    /// issuer + audience.
402    #[must_use]
403    pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
404        Self {
405            inner: JwtVerifier::hs256(secret, Some(issuer), audience),
406        }
407    }
408
409    /// A Smoo-identity verifier over an RS256 public key, keyed to Smoo's
410    /// issuer + audience.
411    ///
412    /// # Errors
413    /// Returns [`AuthError::Misconfigured`] if the PEM can't be parsed.
414    pub fn rs256(
415        public_key_pem: &[u8],
416        issuer: String,
417        audience: Option<String>,
418    ) -> Result<Self, AuthError> {
419        Ok(Self {
420            inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
421        })
422    }
423
424    /// Live token introspection (RFC 7662) against Smoo's auth server.
425    ///
426    /// **Not implemented**: this is the opaque-token hosted variant, which needs
427    /// a network call to `{auth_server}/introspect` with a client credential and
428    /// a parse of the introspection response into a [`Principal`]. Wiring it is
429    /// the follow-up; until then this returns [`AuthError::Misconfigured`] so a
430    /// caller can never mistake the stub for a working validator.
431    ///
432    /// # Errors
433    /// Always returns [`AuthError::Misconfigured`] (stub).
434    pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
435        Err(AuthError::Misconfigured(
436            "live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
437             locally) or implement the /introspect client"
438                .to_string(),
439        ))
440    }
441}
442
443impl AuthVerifier for SmooIdentityVerifier {
444    fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
445        self.inner.decode_principal(bearer_token)
446    }
447
448    fn mode(&self) -> &'static str {
449        "smoo"
450    }
451}
452
453/// **Dev-only** verifier: returns a fixed `Admin` principal for *any* token
454/// (including none). Reachable only via an explicit `AUTH_MODE=none`
455/// ([`AuthConfig::from_env`]) so it can never be the silent production default.
456pub struct NoAuthVerifier {
457    principal: Principal,
458}
459
460impl NoAuthVerifier {
461    /// A no-auth verifier returning an `Admin` principal in `org_id`.
462    #[must_use]
463    pub fn new(org_id: impl Into<String>) -> Self {
464        Self {
465            principal: Principal::new(
466                "dev-admin",
467                org_id,
468                Role::Admin,
469                Some("Dev Admin (AUTH_MODE=none)".to_string()),
470            ),
471        }
472    }
473}
474
475impl Default for NoAuthVerifier {
476    fn default() -> Self {
477        Self::new("dev-org")
478    }
479}
480
481impl AuthVerifier for NoAuthVerifier {
482    fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
483        Ok(self.principal.clone())
484    }
485
486    fn mode(&self) -> &'static str {
487        "none"
488    }
489}
490
491/// **Tokenless trusted-upstream** verifier — `AUTH_MODE=trusted`.
492///
493/// For the **proxied-integration** deployment shape: an existing application's
494/// backend has *already* authenticated the user and proxies smooth-operator over
495/// a trusted/internal network. That upstream forwards the user's identity
496/// (`sub` / `org` / `role` / `groups`); smooth-operator **trusts** it **without
497/// any signature verification** — the upstream owns identity *and* token
498/// lifetime, so there is no signature to check and no `exp` to enforce.
499///
500/// ## Wire format — identity in the same slot a token would ride
501///
502/// The forwarded identity rides in the **exact same slot** a JWT would: the
503/// `/ws` `?token=` query param (reference server) or the `send_message` `token`
504/// field (Lambda). So *all* the existing transport plumbing is reused — the only
505/// difference from [`JwtVerifier`] is **trust, don't verify**.
506///
507/// The value is **`base64url(JSON)`** of the [`Claims`] shape, e.g.
508/// `base64url({"sub":"u1","org":"acme","role":"basic","groups":["github:acme/secret"]})`.
509/// base64url is used (not raw JSON) so the blob survives the query-string and
510/// JSON-string transports cleanly without escaping. No padding is required
511/// (`URL_SAFE_NO_PAD` is accepted; padded `URL_SAFE` is also tolerated).
512///
513/// ## Security boundary — this is **trust without verification**
514///
515/// `AUTH_MODE=trusted` is **only safe when smooth-operator is not directly
516/// reachable by clients** — it must be fronted by your authenticated
517/// backend/proxy on a trusted network. A client that *can* reach `/ws` directly
518/// could forge any identity (any org, any groups). [`AuthConfig::from_env`]
519/// emits a loud startup `tracing::warn!` to that effect whenever this mode is
520/// selected.
521///
522/// ## Fail closed — never silently no-auth-admin
523///
524/// Absent / empty / malformed trusted identity yields an [`AuthError`], which the
525/// connect path ([`crate::access_control::AccessContext::anonymous`]) maps to an
526/// **anonymous** connection (org-public only) — exactly like the no-token path.
527/// Trusted mode **never** degrades to an admin / all-access principal on bad
528/// input.
529pub struct TrustedIdentityVerifier;
530
531impl TrustedIdentityVerifier {
532    /// Construct the trusted-identity verifier (stateless).
533    #[must_use]
534    pub fn new() -> Self {
535        Self
536    }
537
538    /// Decode `base64url(JSON)` identity → [`Claims`] → [`Principal`], **without**
539    /// any signature or `exp` check.
540    fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
541        use base64::Engine as _;
542
543        let forwarded = forwarded.trim();
544        if forwarded.is_empty() {
545            return Err(AuthError::Unauthenticated);
546        }
547        // Accept unpadded URL-safe (the canonical encoding) and fall back to the
548        // padded variant so a caller that pads isn't spuriously rejected.
549        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
550            .decode(forwarded)
551            .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
552            .map_err(|e| {
553                AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
554            })?;
555        let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
556            AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
557        })?;
558        // Reuse the exact same Claims→Principal mapping as the JWT path: missing
559        // `role` / `org` are still hard errors (which fail closed to anonymous),
560        // so a blob that omits them can never become an admin.
561        claims.into_principal()
562    }
563}
564
565impl Default for TrustedIdentityVerifier {
566    fn default() -> Self {
567        Self::new()
568    }
569}
570
571impl AuthVerifier for TrustedIdentityVerifier {
572    fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
573        Self::decode_trusted(forwarded_identity)
574    }
575
576    fn mode(&self) -> &'static str {
577        "trusted"
578    }
579}
580
581/// Builds the configured [`AuthVerifier`] from the environment — secure by
582/// default.
583///
584/// ## Environment
585///
586/// | var | default | meaning |
587/// | --- | --- | --- |
588/// | `AUTH_MODE` | `jwt` | `jwt` (BYO) \| `smoo` (hosted) \| `trusted` (proxied, tokenless — see below) \| `none` (dev only). |
589/// | `AUTH_JWT_HS256_SECRET` | — | HS256 shared secret. |
590/// | `AUTH_JWT_RS256_PUBLIC_KEY` | — | RS256 PEM public key (takes precedence over HS256). |
591/// | `AUTH_JWT_ISSUER` | — | Required `iss` (optional). |
592/// | `AUTH_JWT_AUDIENCE` | — | Required `aud` (optional). |
593/// | `AUTH_DEV_ORG_ID` | `dev-org` | Org id for the `none`-mode admin principal. |
594///
595/// **Explicitly** setting `AUTH_MODE=jwt`/`smoo` with **no** key is a hard
596/// [`AuthError::Misconfigured`] error — not a silent fall-through to no-auth.
597/// Leaving `AUTH_MODE` **unset** with no key boots the server with the admin API
598/// **disabled** ([`AdminDisabledVerifier`]) so `/ws` serves without forcing auth
599/// config; `/admin` then returns 401 until configured (or `AUTH_MODE=none` for dev).
600///
601/// A verifier that rejects every request. The default when neither `AUTH_MODE`
602/// nor a key is configured: the server still boots (so `/ws` serves) but the
603/// `/admin` API is disabled until an operator sets `AUTH_MODE` + a key, or
604/// `AUTH_MODE=none` for local dev. Secure-by-default without hard-failing the
605/// whole service over admin config.
606#[derive(Debug, Clone, Copy, Default)]
607pub struct AdminDisabledVerifier;
608
609impl AuthVerifier for AdminDisabledVerifier {
610    fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
611        Err(AuthError::InvalidToken(
612            "admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
613                .to_string(),
614        ))
615    }
616
617    fn mode(&self) -> &'static str {
618        "disabled"
619    }
620}
621
622pub struct AuthConfig;
623
624impl AuthConfig {
625    /// Build the verifier the env selects. Reads keys from env (never logs them).
626    ///
627    /// # Errors
628    /// Returns [`AuthError::Misconfigured`] for an unknown `AUTH_MODE`, or for
629    /// `jwt`/`smoo` without a usable key.
630    pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
631        let raw_mode = std::env::var("AUTH_MODE")
632            .ok()
633            .map(|s| s.trim().to_ascii_lowercase())
634            .filter(|s| !s.is_empty());
635        let mode_explicit = raw_mode.is_some();
636        let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
637
638        let issuer = env_nonempty("AUTH_JWT_ISSUER");
639        let audience = env_nonempty("AUTH_JWT_AUDIENCE");
640
641        match mode.as_str() {
642            "none" => {
643                let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
644                Ok(Box::new(NoAuthVerifier::new(org)))
645            }
646            "trusted" => {
647                // Reached ONLY by an explicit `AUTH_MODE=trusted`. Identity is
648                // taken from the upstream caller WITHOUT verification, so warn
649                // loudly at startup that this is only safe behind a trusted proxy.
650                tracing::warn!(
651                    "AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
652                     verification; ONLY safe when smooth-operator is not directly reachable by \
653                     clients (front it with your authenticated backend/proxy). Bad/absent \
654                     identity fails closed to anonymous (org-public only), never admin."
655                );
656                Ok(Box::new(TrustedIdentityVerifier::new()))
657            }
658            "jwt" => match Self::build_jwt(issuer, audience) {
659                Ok(v) => Ok(Box::new(v)),
660                // Default mode (AUTH_MODE unset) with no key: boot with the admin
661                // API disabled rather than hard-failing the whole server.
662                Err(AuthError::Misconfigured(_)) if !mode_explicit => {
663                    tracing::warn!(
664                        "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."
665                    );
666                    Ok(Box::new(AdminDisabledVerifier))
667                }
668                // Explicitly choosing AUTH_MODE=jwt with no key stays a loud startup error.
669                Err(e) => Err(e),
670            },
671            "smoo" => {
672                let iss = issuer.ok_or_else(|| {
673                    AuthError::Misconfigured(
674                        "AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
675                    )
676                })?;
677                if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
678                    Ok(Box::new(SmooIdentityVerifier::rs256(
679                        pem.as_bytes(),
680                        iss,
681                        audience,
682                    )?))
683                } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
684                    Ok(Box::new(SmooIdentityVerifier::hs256(
685                        secret.as_bytes(),
686                        iss,
687                        audience,
688                    )))
689                } else {
690                    Err(AuthError::Misconfigured(
691                        "AUTH_MODE=smoo requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET"
692                            .to_string(),
693                    ))
694                }
695            }
696            other => Err(AuthError::Misconfigured(format!(
697                "unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
698            ))),
699        }
700    }
701
702    /// Build a [`JwtVerifier`] from env, preferring RS256 (PEM) over HS256.
703    fn build_jwt(
704        issuer: Option<String>,
705        audience: Option<String>,
706    ) -> Result<JwtVerifier, AuthError> {
707        if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
708            JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
709        } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
710            Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
711        } else {
712            Err(AuthError::Misconfigured(
713                "AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET \
714                 (refusing to fall back to no-auth)"
715                    .to_string(),
716            ))
717        }
718    }
719}
720
721/// Read an env var, returning `None` when absent or empty/whitespace.
722fn env_nonempty(key: &str) -> Option<String> {
723    std::env::var(key)
724        .ok()
725        .map(|s| s.trim().to_string())
726        .filter(|s| !s.is_empty())
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use jsonwebtoken::{encode, EncodingKey, Header};
733    use serde_json::json;
734
735    const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
736
737    /// Sign an HS256 token with the given claims object.
738    fn sign(claims: serde_json::Value) -> String {
739        encode(
740            &Header::new(Algorithm::HS256),
741            &claims,
742            &EncodingKey::from_secret(SECRET),
743        )
744        .expect("sign")
745    }
746
747    /// A far-future expiry so tokens are valid.
748    fn future_exp() -> i64 {
749        (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
750    }
751
752    // ---- Role ordering ---------------------------------------------------
753
754    #[test]
755    fn role_ordering_admin_ge_curator_ge_basic() {
756        assert!(Role::Admin >= Role::Curator);
757        assert!(Role::Curator >= Role::Basic);
758        assert!(Role::Admin > Role::Basic);
759        assert!(Role::Admin >= Role::Admin);
760        // And the inverse never holds.
761        assert!(Role::Basic < Role::Curator);
762        assert!(Role::Curator < Role::Admin);
763    }
764
765    #[test]
766    fn role_has_role_gate() {
767        let admin = Principal::new("u", "o", Role::Admin, None);
768        let basic = Principal::new("u", "o", Role::Basic, None);
769        assert!(admin.has_role(Role::Curator));
770        assert!(admin.has_role(Role::Basic));
771        assert!(!basic.has_role(Role::Curator));
772        assert!(basic.has_role(Role::Basic));
773    }
774
775    #[test]
776    fn role_parse_known_and_unknown() {
777        assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
778        assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
779        assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
780        assert_eq!(Role::parse("user").unwrap(), Role::Basic);
781        assert!(matches!(
782            Role::parse("superuser"),
783            Err(AuthError::MissingRole(_))
784        ));
785    }
786
787    // ---- JwtVerifier round-trip ------------------------------------------
788
789    #[test]
790    fn jwt_verifier_round_trip_extracts_principal() {
791        let verifier = JwtVerifier::hs256(SECRET, None, None);
792        let token = sign(json!({
793            "sub": "user-123",
794            "org": "org-abc",
795            "role": "curator",
796            "name": "Ada Lovelace",
797            "exp": future_exp(),
798        }));
799        let p = verifier.verify(&token).expect("verify");
800        assert_eq!(p.user_id, "user-123");
801        assert_eq!(p.org_id, "org-abc");
802        assert_eq!(p.role, Role::Curator);
803        assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
804    }
805
806    #[test]
807    fn jwt_verifier_parses_groups_claim_into_access_context() {
808        // A token carrying a `groups` claim must surface those groups on the
809        // Principal AND in the derived AccessContext — this is what lets a user
810        // match a `github:owner/repo` document ACL on the chat retrieval path.
811        let verifier = JwtVerifier::hs256(SECRET, None, None);
812        let token = sign(json!({
813            "sub": "user-7",
814            "org": "org-x",
815            "role": "basic",
816            "groups": ["github:acme/secret", "eng"],
817            "exp": future_exp(),
818        }));
819        let p = verifier.verify(&token).expect("verify");
820        assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
821
822        let ctx = p.access_context();
823        assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
824        assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
825        // And it can read a doc scoped to one of its groups.
826        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
827        assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
828    }
829
830    #[test]
831    fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
832        // No `groups` claim ⇒ empty groups ⇒ the principal cannot match a
833        // group-scoped (private-repo) document.
834        let verifier = JwtVerifier::hs256(SECRET, None, None);
835        let token = sign(json!({
836            "sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
837        }));
838        let p = verifier.verify(&token).expect("verify");
839        assert!(p.groups.is_empty());
840        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
841        assert!(
842            !p.access_context().can_access(&acl),
843            "LEAK: a principal with no groups must NOT read a group-scoped doc"
844        );
845    }
846
847    #[test]
848    fn jwt_verifier_accepts_org_id_alias() {
849        let verifier = JwtVerifier::hs256(SECRET, None, None);
850        let token = sign(json!({
851            "sub": "u",
852            "org_id": "org-from-alias",
853            "role": "admin",
854            "exp": future_exp(),
855        }));
856        let p = verifier.verify(&token).expect("verify");
857        assert_eq!(p.org_id, "org-from-alias");
858        assert_eq!(p.role, Role::Admin);
859        assert!(p.display_name.is_none());
860    }
861
862    #[test]
863    fn jwt_verifier_rejects_expired() {
864        let verifier = JwtVerifier::hs256(SECRET, None, None);
865        let token = sign(json!({
866            "sub": "u",
867            "org": "o",
868            "role": "admin",
869            "exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
870        }));
871        let err = verifier.verify(&token).expect_err("must reject expired");
872        assert!(matches!(err, AuthError::InvalidToken(_)));
873    }
874
875    #[test]
876    fn jwt_verifier_rejects_wrong_secret() {
877        let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
878        let token = sign(json!({
879            "sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
880        }));
881        let err = verifier.verify(&token).expect_err("must reject bad sig");
882        assert!(matches!(err, AuthError::InvalidToken(_)));
883    }
884
885    #[test]
886    fn jwt_verifier_rejects_missing_role() {
887        let verifier = JwtVerifier::hs256(SECRET, None, None);
888        let token = sign(json!({
889            "sub": "u", "org": "o", "exp": future_exp(),
890        }));
891        let err = verifier.verify(&token).expect_err("must reject no role");
892        assert!(matches!(err, AuthError::MissingRole(_)));
893    }
894
895    #[test]
896    fn jwt_verifier_rejects_unknown_role() {
897        let verifier = JwtVerifier::hs256(SECRET, None, None);
898        let token = sign(json!({
899            "sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
900        }));
901        let err = verifier.verify(&token).expect_err("must reject bad role");
902        assert!(matches!(err, AuthError::MissingRole(_)));
903    }
904
905    #[test]
906    fn jwt_verifier_rejects_missing_org() {
907        let verifier = JwtVerifier::hs256(SECRET, None, None);
908        let token = sign(json!({
909            "sub": "u", "role": "admin", "exp": future_exp(),
910        }));
911        let err = verifier.verify(&token).expect_err("must reject no org");
912        assert!(matches!(err, AuthError::InvalidToken(_)));
913    }
914
915    #[test]
916    fn jwt_verifier_rejects_empty_token() {
917        let verifier = JwtVerifier::hs256(SECRET, None, None);
918        assert_eq!(
919            verifier.verify("   ").expect_err("empty"),
920            AuthError::Unauthenticated
921        );
922    }
923
924    #[test]
925    fn jwt_verifier_rejects_garbage() {
926        let verifier = JwtVerifier::hs256(SECRET, None, None);
927        let err = verifier.verify("not.a.jwt").expect_err("garbage");
928        assert!(matches!(err, AuthError::InvalidToken(_)));
929    }
930
931    #[test]
932    fn jwt_verifier_enforces_audience_when_configured() {
933        let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
934        // Right audience → ok.
935        let ok = sign(json!({
936            "sub": "u", "org": "o", "role": "admin",
937            "aud": "expected-aud", "exp": future_exp(),
938        }));
939        assert!(verifier.verify(&ok).is_ok());
940        // Wrong audience → rejected.
941        let bad = sign(json!({
942            "sub": "u", "org": "o", "role": "admin",
943            "aud": "other-aud", "exp": future_exp(),
944        }));
945        assert!(matches!(
946            verifier.verify(&bad),
947            Err(AuthError::InvalidToken(_))
948        ));
949    }
950
951    // ---- SmooIdentityVerifier --------------------------------------------
952
953    #[test]
954    fn smoo_verifier_validates_issuer_keyed_token() {
955        let verifier =
956            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
957        let token = sign(json!({
958            "sub": "u", "org": "o", "role": "admin",
959            "iss": "https://auth.smoo.ai", "exp": future_exp(),
960        }));
961        let p = verifier.verify(&token).expect("verify");
962        assert_eq!(p.role, Role::Admin);
963        assert_eq!(verifier.mode(), "smoo");
964    }
965
966    #[test]
967    fn smoo_verifier_rejects_wrong_issuer() {
968        let verifier =
969            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
970        let token = sign(json!({
971            "sub": "u", "org": "o", "role": "admin",
972            "iss": "https://evil.example", "exp": future_exp(),
973        }));
974        assert!(matches!(
975            verifier.verify(&token),
976            Err(AuthError::InvalidToken(_))
977        ));
978    }
979
980    #[test]
981    fn smoo_introspect_is_stubbed_misconfigured() {
982        let verifier =
983            SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
984        assert!(matches!(
985            verifier.introspect("opaque-token"),
986            Err(AuthError::Misconfigured(_))
987        ));
988    }
989
990    // ---- NoAuthVerifier --------------------------------------------------
991
992    #[test]
993    fn no_auth_returns_fixed_admin() {
994        let verifier = NoAuthVerifier::new("dev-org");
995        let p = verifier.verify("anything-or-nothing").expect("no-auth");
996        assert_eq!(p.role, Role::Admin);
997        assert_eq!(p.org_id, "dev-org");
998        assert_eq!(verifier.mode(), "none");
999    }
1000
1001    // ---- AuthConfig::from_env — secure by default ------------------------
1002    //
1003    // These mutate process env, so they run serially under a shared lock to
1004    // avoid cross-test interference.
1005
1006    use std::sync::Mutex;
1007    static ENV_LOCK: Mutex<()> = Mutex::new(());
1008
1009    fn clear_auth_env() {
1010        for k in [
1011            "AUTH_MODE",
1012            "AUTH_JWT_HS256_SECRET",
1013            "AUTH_JWT_RS256_PUBLIC_KEY",
1014            "AUTH_JWT_ISSUER",
1015            "AUTH_JWT_AUDIENCE",
1016            "AUTH_DEV_ORG_ID",
1017        ] {
1018            std::env::remove_var(k);
1019        }
1020    }
1021
1022    #[test]
1023    fn from_env_default_disables_admin_without_key() {
1024        let _g = ENV_LOCK.lock().unwrap();
1025        clear_auth_env();
1026        // No AUTH_MODE, no key → the server BOOTS (so /ws serves) with the admin
1027        // API disabled — it does NOT silently fall back to no-auth, and it does
1028        // NOT hard-fail the whole service.
1029        let v = AuthConfig::from_env().expect("default boots with admin disabled");
1030        assert_eq!(v.mode(), "disabled");
1031        // Every admin request is rejected until auth is configured.
1032        assert!(matches!(
1033            v.verify("anything"),
1034            Err(AuthError::InvalidToken(_))
1035        ));
1036        clear_auth_env();
1037    }
1038
1039    #[test]
1040    fn from_env_explicit_jwt_without_key_hard_errors() {
1041        let _g = ENV_LOCK.lock().unwrap();
1042        clear_auth_env();
1043        // EXPLICITLY asking for jwt with no key is a loud startup error (an
1044        // operator who set AUTH_MODE=jwt and forgot the key must be told).
1045        std::env::set_var("AUTH_MODE", "jwt");
1046        match AuthConfig::from_env() {
1047            Err(AuthError::Misconfigured(_)) => {}
1048            Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
1049            Err(other) => panic!("expected Misconfigured, got {other}"),
1050        }
1051        clear_auth_env();
1052    }
1053
1054    #[test]
1055    fn from_env_jwt_with_hs256_secret_builds() {
1056        let _g = ENV_LOCK.lock().unwrap();
1057        clear_auth_env();
1058        std::env::set_var("AUTH_MODE", "jwt");
1059        std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1060        let v = AuthConfig::from_env().expect("builds");
1061        assert_eq!(v.mode(), "jwt");
1062        clear_auth_env();
1063    }
1064
1065    #[test]
1066    fn from_env_none_only_when_explicit() {
1067        let _g = ENV_LOCK.lock().unwrap();
1068        clear_auth_env();
1069        std::env::set_var("AUTH_MODE", "none");
1070        std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
1071        let v = AuthConfig::from_env().expect("none builds");
1072        assert_eq!(v.mode(), "none");
1073        let p = v.verify("").expect("no-auth principal");
1074        assert_eq!(p.role, Role::Admin);
1075        assert_eq!(p.org_id, "explicit-dev-org");
1076        clear_auth_env();
1077    }
1078
1079    #[test]
1080    fn from_env_unknown_mode_errors() {
1081        let _g = ENV_LOCK.lock().unwrap();
1082        clear_auth_env();
1083        std::env::set_var("AUTH_MODE", "banana");
1084        assert!(matches!(
1085            AuthConfig::from_env(),
1086            Err(AuthError::Misconfigured(_))
1087        ));
1088        clear_auth_env();
1089    }
1090
1091    // ---- TrustedIdentityVerifier (AUTH_MODE=trusted) ---------------------
1092    //
1093    // Tokenless proxied-integration mode: the upstream forwards identity as a
1094    // base64url(JSON) blob in the same slot a token would ride. NO signature,
1095    // NO exp; reuses the Claims→Principal mapping. MUST fail closed (error →
1096    // anonymous at the connect path), NEVER admin, on bad input.
1097
1098    /// Encode a claims object as the `base64url(JSON)` blob the trusted upstream
1099    /// would forward (unpadded URL-safe — the canonical form).
1100    fn forward(claims: serde_json::Value) -> String {
1101        use base64::Engine as _;
1102        let json = serde_json::to_vec(&claims).expect("serialize claims");
1103        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
1104    }
1105
1106    #[test]
1107    fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
1108        let verifier = TrustedIdentityVerifier::new();
1109        // No `exp` here on purpose — the upstream owns lifetime; trusted mode
1110        // must NOT require it.
1111        let blob = forward(json!({
1112            "sub": "user-42",
1113            "org": "acme",
1114            "role": "curator",
1115            "name": "Grace Hopper",
1116            "groups": ["github:acme/secret", "eng"],
1117        }));
1118        let p = verifier.verify(&blob).expect("trusted verify");
1119        assert_eq!(p.user_id, "user-42");
1120        assert_eq!(p.org_id, "acme");
1121        assert_eq!(p.role, Role::Curator);
1122        assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
1123        assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1124        assert_eq!(verifier.mode(), "trusted");
1125
1126        // The groups carry into the AccessContext so the SAME ACL enforcement a
1127        // JWT drives applies to a forwarded identity.
1128        let ctx = p.access_context();
1129        let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1130        assert!(
1131            ctx.can_access(&acl),
1132            "forwarded group must drive ACL access"
1133        );
1134    }
1135
1136    #[test]
1137    fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
1138        use base64::Engine as _;
1139        let verifier = TrustedIdentityVerifier::new();
1140        // `org_id` alias + PADDED url-safe base64 must both be accepted.
1141        let json = serde_json::to_vec(&json!({
1142            "sub": "u", "org_id": "org-alias", "role": "admin",
1143        }))
1144        .unwrap();
1145        let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
1146        let p = verifier.verify(&blob).expect("padded + alias");
1147        assert_eq!(p.org_id, "org-alias");
1148        assert_eq!(p.role, Role::Admin);
1149    }
1150
1151    #[test]
1152    fn trusted_verifier_empty_is_unauthenticated_not_admin() {
1153        let verifier = TrustedIdentityVerifier::new();
1154        // Absent/empty forwarded identity ⇒ Unauthenticated error (which the
1155        // connect path maps to anonymous), NOT a fabricated admin principal.
1156        assert_eq!(
1157            verifier.verify("   ").expect_err("empty must error"),
1158            AuthError::Unauthenticated
1159        );
1160    }
1161
1162    #[test]
1163    fn trusted_verifier_malformed_base64_errors_never_admin() {
1164        let verifier = TrustedIdentityVerifier::new();
1165        let err = verifier
1166            .verify("!!!not base64!!!")
1167            .expect_err("malformed base64 must error");
1168        assert!(matches!(err, AuthError::InvalidToken(_)));
1169    }
1170
1171    #[test]
1172    fn trusted_verifier_malformed_json_errors_never_admin() {
1173        use base64::Engine as _;
1174        let verifier = TrustedIdentityVerifier::new();
1175        // Valid base64url but the bytes aren't claims JSON.
1176        let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
1177        let err = verifier.verify(&blob).expect_err("non-json must error");
1178        assert!(matches!(err, AuthError::InvalidToken(_)));
1179    }
1180
1181    #[test]
1182    fn trusted_verifier_missing_role_errors_never_admin() {
1183        // A forwarded identity with NO role must NOT silently become admin — it
1184        // is a MissingRole error (→ anonymous at the connect path).
1185        let verifier = TrustedIdentityVerifier::new();
1186        let blob = forward(json!({ "sub": "u", "org": "o" }));
1187        let err = verifier.verify(&blob).expect_err("no role must error");
1188        assert!(matches!(err, AuthError::MissingRole(_)));
1189    }
1190
1191    #[test]
1192    fn trusted_verifier_missing_org_errors_never_admin() {
1193        let verifier = TrustedIdentityVerifier::new();
1194        let blob = forward(json!({ "sub": "u", "role": "admin" }));
1195        let err = verifier.verify(&blob).expect_err("no org must error");
1196        assert!(matches!(err, AuthError::InvalidToken(_)));
1197    }
1198
1199    #[test]
1200    fn from_env_trusted_only_when_explicit() {
1201        let _g = ENV_LOCK.lock().unwrap();
1202        clear_auth_env();
1203        // trusted is reached ONLY by explicit AUTH_MODE=trusted — no key needed
1204        // (there is nothing to verify), and it never requires AUTH_JWT_* config.
1205        std::env::set_var("AUTH_MODE", "trusted");
1206        let v = AuthConfig::from_env().expect("trusted builds");
1207        assert_eq!(v.mode(), "trusted");
1208        // A forwarded identity is honored...
1209        let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
1210        assert_eq!(
1211            v.verify(&blob).expect("trusted principal").role,
1212            Role::Basic
1213        );
1214        // ...and bad input is an error (→ anonymous at the connect path), never admin.
1215        assert!(v.verify("garbage").is_err());
1216        clear_auth_env();
1217    }
1218
1219    #[test]
1220    fn from_env_unset_does_not_select_trusted() {
1221        let _g = ENV_LOCK.lock().unwrap();
1222        clear_auth_env();
1223        // Secure-by-default unset case is UNCHANGED: no AUTH_MODE ⇒ admin-disabled,
1224        // NOT trusted. trusted is only ever reached by an explicit opt-in.
1225        let v = AuthConfig::from_env().expect("default boots");
1226        assert_eq!(v.mode(), "disabled");
1227        assert_ne!(v.mode(), "trusted");
1228        clear_auth_env();
1229    }
1230
1231    #[test]
1232    fn from_env_smoo_requires_issuer_and_key() {
1233        let _g = ENV_LOCK.lock().unwrap();
1234        clear_auth_env();
1235        std::env::set_var("AUTH_MODE", "smoo");
1236        // No issuer → misconfig.
1237        assert!(matches!(
1238            AuthConfig::from_env(),
1239            Err(AuthError::Misconfigured(_))
1240        ));
1241        std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
1242        // Issuer but no key → misconfig.
1243        assert!(matches!(
1244            AuthConfig::from_env(),
1245            Err(AuthError::Misconfigured(_))
1246        ));
1247        std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1248        let v = AuthConfig::from_env().expect("smoo builds");
1249        assert_eq!(v.mode(), "smoo");
1250        clear_auth_env();
1251    }
1252}