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}