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