Skip to main content

webx_host/
auth_jwt.rs

1//! JWT authentication module for the rust-webx framework.
2//!
3//! Provides `JwtAuth` —an `IAuthenticationHandler` implementation that reads
4//! a Bearer token from the `Authorization` header and validates it using
5//! the `jsonwebtoken` crate.
6//!
7//! # Issuance and revocation (WEBX-2)
8//!
9//! Minting and invalidation are ports, so application code does not need its
10//! own signing or session code:
11//!
12//! * [`JwtTokenIssuer`] implements `ITokenIssuer` and is registered
13//!   automatically by `HostBuilder::add_authentication()` from `Jwt.Secret`.
14//!   A login handler injects `Arc<dyn ITokenIssuer>` and calls
15//!   [`ITokenIssuer::issue`].
16//! * `.use_session_store(store)` registers an `ISessionStore`; the auth
17//!   middleware then rejects a token whose `jti` was revoked. Revocation is
18//!   opt-in: without a store the middleware only verifies signatures.
19//!
20//! # Secret sources (XR-2)
21//!
22//! The signing secret comes from the first non-empty of `APP__Jwt__Secret` /
23//! `JWT_SECRET`, `Jwt.Secret`, and `Jwt.SecretFile`. The last one is read by
24//! [`apply_jwt_secret_file`] during host start-up — a missing, unreadable,
25//! short or (on Unix) group/other-accessible file aborts start-up with a
26//! readable error instead of signing tokens with a broken key.
27//!
28//! ```ignore
29//! // login: issue and remember the jti (or give it to the client)
30//! let issued = token_issuer.issue(TokenClaims::new(&user.id)).await?;
31//! // logout: revoke it; the next request with that token is 401
32//! session_store.revoke(&issued.jti).await?;
33//! ```
34//!
35//! # Example
36//!
37//! ```ignore
38//! use webx_host::auth_jwt::{JwtAuth, jwt_middleware};
39//! use jsonwebtoken::{DecodingKey, Validation};
40//!
41//! let auth = JwtAuth::new(
42//!     DecodingKey::from_secret(b"my-secret"),
43//!     Validation::default(),
44//! );
45//! let middleware = jwt_middleware(Arc::new(auth));
46//! ```
47
48use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use std::ops::ControlFlow;
52use std::path::Path;
53use std::sync::{Arc, OnceLock};
54use std::time::{Duration, SystemTime, UNIX_EPOCH};
55use webx_core::auth::{IAuthenticationHandler, IClaims};
56use webx_core::config::AppOptions;
57use webx_core::error::{Error, Result};
58use webx_core::http::IHttpContext;
59use webx_core::middleware::IMiddleware;
60use webx_core::security::{ISessionStore, ITokenIssuer, IssuedToken, TokenClaims};
61
62// ---------------------------------------------------------------------------
63// JwtClaims —IClaims implementation backed by JWT payload
64// ---------------------------------------------------------------------------
65
66/// Claims extracted from a JWT token.
67///
68/// By default, looks for `sub`, `roles`, and `permissions` claims.
69/// All other claims are accessible via `claims()` as stringified values.
70pub struct JwtClaims {
71    sub: String,
72    roles: Vec<String>,
73    permissions: Vec<String>,
74    /// Pre-flattened raw claims map for the `claims()` method.
75    raw: HashMap<String, String>,
76}
77
78/// Intermediate deserialization target. After deserialization we convert
79/// into `JwtClaims` which stores pre-computed data for `IClaims`.
80#[derive(Debug, Deserialize, Serialize)]
81struct RawClaims {
82    #[serde(default)]
83    sub: String,
84    #[serde(default)]
85    roles: Vec<String>,
86    #[serde(default)]
87    permissions: Vec<String>,
88    #[serde(flatten)]
89    extra: HashMap<String, serde_json::Value>,
90}
91
92impl JwtClaims {
93    /// Create empty claims with just a subject.
94    pub fn new(subject: impl Into<String>) -> Self {
95        let sub = subject.into();
96        let mut raw = HashMap::new();
97        raw.insert("sub".to_string(), sub.clone());
98        Self {
99            sub,
100            roles: Vec::new(),
101            permissions: Vec::new(),
102            raw,
103        }
104    }
105}
106
107impl From<RawClaims> for JwtClaims {
108    fn from(rc: RawClaims) -> Self {
109        let mut raw = HashMap::new();
110        raw.insert("sub".to_string(), rc.sub.clone());
111        for (k, v) in &rc.extra {
112            match v {
113                serde_json::Value::String(s) => {
114                    raw.insert(k.clone(), s.clone());
115                }
116                other => {
117                    raw.insert(k.clone(), other.to_string());
118                }
119            }
120        }
121        Self {
122            sub: rc.sub,
123            roles: rc.roles,
124            permissions: rc.permissions,
125            raw,
126        }
127    }
128}
129
130impl IClaims for JwtClaims {
131    fn subject(&self) -> &str {
132        &self.sub
133    }
134
135    fn roles(&self) -> &[String] {
136        &self.roles
137    }
138
139    fn permissions(&self) -> &[String] {
140        &self.permissions
141    }
142
143    fn claims(&self) -> &HashMap<String, String> {
144        &self.raw
145    }
146
147    fn clone_box(&self) -> Box<dyn IClaims> {
148        Box::new(Self {
149            sub: self.sub.clone(),
150            roles: self.roles.clone(),
151            permissions: self.permissions.clone(),
152            raw: self.raw.clone(),
153        })
154    }
155}
156
157// ---------------------------------------------------------------------------
158// JwtAuth —IAuthenticationHandler implementation
159// ---------------------------------------------------------------------------
160
161/// JWT-based authentication handler.
162///
163/// Reads the `Authorization: Bearer <token>` header, decodes and validates
164/// the JWT, and returns the resulting claims. Only a **valid** token produces
165/// claims: a missing header, a non-`Bearer` scheme or a token that fails
166/// validation all yield `Ok(None)` — the request simply continues as anonymous.
167pub struct JwtAuth {
168    decoding_key: DecodingKey,
169    validation: Validation,
170}
171
172impl JwtAuth {
173    /// Create a new JWT authenticator.
174    pub fn new(decoding_key: DecodingKey, validation: Validation) -> Self {
175        Self {
176            decoding_key,
177            validation,
178        }
179    }
180}
181
182#[async_trait::async_trait]
183impl IAuthenticationHandler for JwtAuth {
184    async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>> {
185        // Extract token synchronously —avoids holding &dyn IHttpContext
186        // across any async boundary, keeping the future Send.
187        let header = match ctx.request().header("authorization") {
188            Some(h) => h,
189            None => return Ok(None),
190        };
191
192        // RFC 6750: scheme is case-insensitive
193        let token = if header.len() >= 7 && header[..7].eq_ignore_ascii_case("Bearer ") {
194            header[7..].trim().to_string()
195        } else {
196            return Ok(None);
197        };
198
199        if token.is_empty() {
200            return Ok(None);
201        }
202
203        // A rejected token means "not authenticated", not "reject the request":
204        // the request continues as anonymous and the endpoint decides. This is
205        // what keeps a public endpoint (login, health, docs) reachable while a
206        // client still holds an expired token, instead of dead-locking it with a
207        // 401 on every call. Protected endpoints respond 401 further down the
208        // pipeline (`#[authorize]` rules or a required `#[fromclaim]` identity),
209        // through the single unauthorized writer (detail "authentication
210        // required" + `WWW-Authenticate`).
211        let token_data = match decode::<RawClaims>(&token, &self.decoding_key, &self.validation) {
212            Ok(data) => data,
213            Err(e) => {
214                // The decoder's message is for operators, not for the client
215                // (it names InvalidToken/ExpiredSignature/InvalidAlgorithm/...).
216                tracing::debug!(reason = %e, "JWT rejected");
217                return Ok(None);
218            }
219        };
220
221        let claims: JwtClaims = token_data.claims.into();
222        Ok(Some(Box::new(claims)))
223    }
224}
225
226// ---------------------------------------------------------------------------
227// JwtTokenIssuer —ITokenIssuer implementation (default token minting)
228// ---------------------------------------------------------------------------
229
230/// Default token lifetime used by [`JwtTokenIssuer::from_secret`] when the
231/// caller does not pass one explicitly.
232///
233/// The host does **not** use this constant: `add_authentication()` builds the
234/// issuer from the `Jwt.ExpirationMinutes` appsettings key (default 60 minutes).
235pub const DEFAULT_TOKEN_LIFETIME: Duration = Duration::from_secs(3600);
236
237fn unix_now() -> u64 {
238    SystemTime::now()
239        .duration_since(UNIX_EPOCH)
240        .map(|elapsed| elapsed.as_secs())
241        .unwrap_or_default()
242}
243
244/// JWT token issuer — the default [`ITokenIssuer`].
245///
246/// **HS256 only.** The token is signed with `HS256` and verified by this
247/// crate's `JwtAuth`, whose `Validation::default()` also expects `HS256`, so
248/// issuer and verifier agree out of the box. Asymmetric algorithms (RSA/EC)
249/// are not supported by this type: using one requires *both* a custom
250/// [`ITokenIssuer`] and a custom [`IAuthenticationHandler`] (the host's
251/// `jwt_middleware` always verifies with `Validation::default()`).
252///
253/// Mints a token carrying `sub`, `roles`, `permissions`, the caller's extra
254/// claims, and the framework-managed `jti` / `iat` / `exp`. The `jti` is what
255/// [`ISessionStore::revoke`] later invalidates, so a login endpoint only needs:
256///
257/// ```ignore
258/// let issued = issuer.issue(TokenClaims::new(&user.id)).await?;
259/// sessions.revoke(&issued.jti).await?; // logout
260/// ```
261///
262/// `HostBuilder::add_authentication()` registers one from the configured
263/// `Jwt.Secret` / `Jwt.ExpirationMinutes`; register a custom [`ITokenIssuer`] in
264/// DI to replace it.
265pub struct JwtTokenIssuer {
266    encoding_key: EncodingKey,
267    default_lifetime: Duration,
268    issuer: Option<String>,
269    audience: Option<String>,
270}
271
272impl JwtTokenIssuer {
273    /// Build an HS256 issuer from a shared secret — the same secret
274    /// `JwtAuth` verifies with.
275    ///
276    /// `default_lifetime` applies when [`TokenClaims::expires_in`] is `None`;
277    /// `DEFAULT_TOKEN_LIFETIME` (1 hour) is the framework default.
278    pub fn from_secret(secret: impl AsRef<[u8]>, default_lifetime: Duration) -> Self {
279        Self {
280            encoding_key: EncodingKey::from_secret(secret.as_ref()),
281            default_lifetime,
282            issuer: None,
283            audience: None,
284        }
285    }
286
287    /// Set the `iss` claim.
288    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
289        self.issuer = Some(issuer.into());
290        self
291    }
292
293    /// Set the `aud` claim.
294    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
295        self.audience = Some(audience.into());
296        self
297    }
298}
299
300#[async_trait::async_trait]
301impl ITokenIssuer for JwtTokenIssuer {
302    async fn issue(&self, claims: TokenClaims) -> Result<IssuedToken> {
303        let subject = claims.subject.trim();
304        if subject.is_empty() {
305            return Err(Error::Validation(
306                "token subject must not be empty".to_string(),
307            ));
308        }
309
310        let lifetime = claims.expires_in.unwrap_or(self.default_lifetime);
311        let issued_at = unix_now();
312        let expires_at_unix_secs = issued_at.saturating_add(lifetime.as_secs());
313        let jti = uuid::Uuid::new_v4().to_string();
314
315        // Extra claims first: reserved claims must win, otherwise a caller could
316        // override `sub`/`exp`/`jti` through `TokenClaims::extra`.
317        let mut payload = serde_json::Map::new();
318        for (key, value) in &claims.extra {
319            payload.insert(key.clone(), serde_json::Value::String(value.clone()));
320        }
321        payload.insert("sub".into(), serde_json::json!(subject));
322        payload.insert("roles".into(), serde_json::json!(claims.roles));
323        payload.insert("permissions".into(), serde_json::json!(claims.permissions));
324        payload.insert("jti".into(), serde_json::json!(jti));
325        payload.insert("iat".into(), serde_json::json!(issued_at));
326        payload.insert("exp".into(), serde_json::json!(expires_at_unix_secs));
327        if let Some(issuer) = &self.issuer {
328            payload.insert("iss".into(), serde_json::json!(issuer));
329        }
330        if let Some(audience) = &self.audience {
331            payload.insert("aud".into(), serde_json::json!(audience));
332        }
333
334        // `Header::default()` is `HS256` — the only algorithm this issuer
335        // promises, and the one `JwtAuth` verifies.
336        let token = encode(
337            &Header::default(),
338            &serde_json::Value::Object(payload),
339            &self.encoding_key,
340        )
341        .map_err(|error| Error::Internal(format!("JWT signing failed: {error}")))?;
342
343        Ok(IssuedToken {
344            token,
345            jti,
346            expires_at_unix_secs,
347        })
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Middleware
353// ---------------------------------------------------------------------------
354
355/// Authentication middleware: verify the token, then reject revoked tokens.
356struct AuthMiddleware {
357    handler: Arc<dyn IAuthenticationHandler>,
358    /// `None` = signature verification only (1.1 behaviour).
359    session_store: Option<Arc<dyn ISessionStore>>,
360}
361
362#[async_trait::async_trait]
363impl IMiddleware for AuthMiddleware {
364    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
365        if let Some(claims) = self.handler.authenticate(ctx).await? {
366            if let Some(store) = &self.session_store {
367                // A token without `jti` cannot be matched against a revocation
368                // record; it keeps its `exp`-based lifetime (no re-reject).
369                if let Some(jti) = claims.jti() {
370                    if store.is_revoked(jti).await? {
371                        // Revoked credentials are a credentials problem, not an
372                        // anonymous request: fail here so the host writes its
373                        // single 401 (problem+json + `WWW-Authenticate`).
374                        return Err(Error::Unauthorized("token has been revoked".to_string()));
375                    }
376                }
377            }
378            // IHttpContext extends IClaimsExt, so set_claims is available directly.
379            ctx.set_claims(claims);
380        }
381        Ok(ControlFlow::Continue(()))
382    }
383}
384
385/// Create a JWT authentication middleware from an authentication handler.
386///
387/// Reads the Bearer token from `Authorization` header, validates it, and
388/// stores the resulting claims in the HTTP context for downstream use.
389pub fn jwt_middleware(handler: Arc<dyn IAuthenticationHandler>) -> impl IMiddleware {
390    AuthMiddleware {
391        handler,
392        session_store: None,
393    }
394}
395
396/// Create a JWT authentication middleware that also rejects revoked tokens.
397///
398/// After the signature check, the token's `jti` is looked up in `session_store`;
399/// a revoked token is answered with the framework's 401 (problem+json +
400/// `WWW-Authenticate`) before the endpoint runs. Tokens without a `jti` pass
401/// through unchanged.
402///
403/// **Advanced entry point** — only for a hand-assembled middleware pipeline.
404/// A regular host enables revocation with
405/// [`HostBuilder::use_session_store`](crate::server::HostBuilder::use_session_store),
406/// which registers the store and wires this middleware itself.
407pub fn jwt_middleware_with_revocation(
408    handler: Arc<dyn IAuthenticationHandler>,
409    session_store: Arc<dyn ISessionStore>,
410) -> impl IMiddleware {
411    AuthMiddleware {
412        handler,
413        session_store: Some(session_store),
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Global JWT encoding secret (for token creation in handlers)
419// ---------------------------------------------------------------------------
420
421static JWT_ENCODING_SECRET: OnceLock<String> = OnceLock::new();
422
423/// Initialize the global JWT encoding secret from the configured secret.
424/// This is called automatically by `.add_authentication()` on the `HostBuilder`,
425/// but can also be called manually if needed.
426///
427/// Idempotent: subsequent calls are no-ops (the first secret wins).
428pub fn init_jwt_secret(secret: &str) {
429    let _ = JWT_ENCODING_SECRET.set(secret.to_owned());
430}
431
432/// Retrieve the global JWT encoding secret previously set via [`init_jwt_secret`].
433///
434/// # Panics
435/// Panics if [`init_jwt_secret`] has not been called yet.
436pub fn jwt_secret() -> &'static str {
437    JWT_ENCODING_SECRET
438        .get()
439        .expect("init_jwt_secret must be called before jwt_secret")
440}
441
442// ---------------------------------------------------------------------------
443// Jwt.SecretFile resolution (XR-2)
444// ---------------------------------------------------------------------------
445
446/// Minimum accepted length, in bytes, of a JWT signing secret read from a file.
447///
448/// HS256 keys shorter than 256 bits are brute-forceable, so a too-short file is
449/// a startup error rather than a warning.
450pub const MIN_JWT_SECRET_LEN: usize = 32;
451
452/// Resolve `Jwt.SecretFile` into `Jwt.Secret`, failing fast on a bad file.
453///
454/// This is the third source in the documented priority chain — the first
455/// non-empty one wins:
456///
457/// 1. `APP__Jwt__Secret` / `JWT_SECRET` — applied over the JSON by
458///    `webx_core::config::load_appsettings`, so they are already in `Secret`;
459/// 2. `Jwt.Secret` — an explicit value in `appsettings.json` or a
460///    `HostBuilder` `useOptions` override;
461/// 3. `Jwt.SecretFile` — the path configured here, read from disk.
462///
463/// The host calls this once while preparing itself (before the default
464/// `ITokenIssuer` and the authentication middleware are built), so every later
465/// reader of `options.jwt.secret` — issuer, verifier, `init_jwt_secret`, the
466/// Production weak-secret guard — sees the resolved value.
467///
468/// # Behaviour
469///
470/// * No-op (`Ok(())`) when a secret is already configured or `SecretFile` is
471///   unset/blank, so existing appsettings are unaffected.
472/// * The file's content is trimmed; its surrounding whitespace (e.g. the
473///   trailing newline `echo` writes) is not part of the key.
474/// * Fails with [`Error::Di`] — a host startup error, never a panic — when the
475///   file is missing/unreadable, empty, shorter than [`MIN_JWT_SECRET_LEN`]
476///   bytes, or (on Unix) accessible by group/other. `Error::Di` is what the
477///   existing Production configuration guards return; the message names the
478///   path and the fix so an operator can act on it.
479/// * **Windows:** the ACL is *not* validated — the POSIX mode model does not
480///   exist there. Restrict the file to the service account (and
481///   `Administrators`) yourself. DPAPI-wrapped secrets are a future item, not
482///   part of this release.
483///
484/// # Example
485///
486/// ```
487/// use webx_core::config::AppOptions;
488/// use webx_host::auth_jwt::apply_jwt_secret_file;
489///
490/// let path = std::env::temp_dir().join(format!(
491///     "webx-jwt-secret-{}-{}.key",
492///     std::process::id(),
493///     std::time::SystemTime::now()
494///         .duration_since(std::time::UNIX_EPOCH)
495///         .unwrap()
496///         .as_nanos()
497/// ));
498/// # #[cfg(unix)]
499/// # {
500/// # use std::os::unix::fs::PermissionsExt;
501/// # std::fs::write(&path, b"test-only-secret-material-32-bytes\n").unwrap();
502/// # std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
503/// # }
504/// # #[cfg(not(unix))]
505/// # std::fs::write(&path, b"test-only-secret-material-32-bytes\n").unwrap();
506///
507/// let mut options = AppOptions::default();
508/// options.jwt.secret_file = Some(path.to_string_lossy().into_owned());
509/// apply_jwt_secret_file(&mut options).unwrap();
510/// assert_eq!(options.jwt.secret, "test-only-secret-material-32-bytes");
511///
512/// # std::fs::remove_file(&path).ok();
513/// ```
514pub fn apply_jwt_secret_file(options: &mut AppOptions) -> Result<()> {
515    if !options.jwt.secret.trim().is_empty() {
516        if options.jwt.secret_file.is_some() {
517            // Deliberate, documented precedence — `Jwt.Secret` wins.
518            tracing::debug!("[Auth] Jwt.Secret is set; ignoring Jwt.SecretFile");
519        }
520        return Ok(());
521    }
522
523    let Some(path) = options
524        .jwt
525        .secret_file
526        .as_deref()
527        .map(str::trim)
528        .filter(|path| !path.is_empty())
529    else {
530        return Ok(());
531    };
532
533    let secret = read_jwt_secret_file(Path::new(path))?;
534    tracing::info!(
535        "[Auth] JWT signing secret loaded from '{}' ({} bytes)",
536        path,
537        secret.len()
538    );
539    options.jwt.secret = secret;
540    Ok(())
541}
542
543fn read_jwt_secret_file(path: &Path) -> Result<String> {
544    verify_secret_file_permissions(path)?;
545
546    let content = std::fs::read_to_string(path).map_err(|error| {
547        Error::Di(format!(
548            "JWT secret file '{}' could not be read: {error}. \
549             Provision a readable file (at least {MIN_JWT_SECRET_LEN} bytes, Unix mode 0600), \
550             or set Jwt.Secret / APP__Jwt__Secret / JWT_SECRET.",
551            path.display()
552        ))
553    })?;
554    let secret = content.trim();
555    if secret.is_empty() {
556        return Err(Error::Di(format!(
557            "JWT secret file '{}' is empty; it must contain at least {MIN_JWT_SECRET_LEN} bytes of secret material.",
558            path.display()
559        )));
560    }
561    if secret.len() < MIN_JWT_SECRET_LEN {
562        return Err(Error::Di(format!(
563            "JWT secret file '{}' contains only {} bytes; at least {MIN_JWT_SECRET_LEN} bytes are required.",
564            path.display(),
565            secret.len()
566        )));
567    }
568    Ok(secret.to_string())
569}
570
571/// Reject a secret file that group/other can read or write (Unix only).
572///
573/// The POSIX analog of "the key file is private": anything but mode `0600`-or-
574/// stricter (e.g. `0400`, `0700` for the owner) is refused. On Windows the ACL
575/// is not inspected — start-up trusts the operator there.
576#[cfg(unix)]
577fn verify_secret_file_permissions(path: &Path) -> Result<()> {
578    use std::os::unix::fs::PermissionsExt;
579
580    let metadata = std::fs::metadata(path).map_err(|error| {
581        Error::Di(format!(
582            "JWT secret file '{}' could not be inspected: {error}. \
583             Provision it (at least {MIN_JWT_SECRET_LEN} bytes, Unix mode 0600), \
584             or set Jwt.Secret / APP__Jwt__Secret / JWT_SECRET.",
585            path.display()
586        ))
587    })?;
588    let mode = metadata.permissions().mode();
589    // 0o077 covers group/other read, write and execute bits.
590    if mode & 0o077 != 0 {
591        return Err(Error::Di(format!(
592            "JWT secret file '{}' has mode {mode:04o}; group/other access must be removed (`chmod 600`).",
593            path.display()
594        )));
595    }
596    Ok(())
597}
598
599/// Windows (and other non-Unix targets): no permission model to inspect.
600#[cfg(not(unix))]
601fn verify_secret_file_permissions(_path: &Path) -> Result<()> {
602    Ok(())
603}