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//! ```ignore
21//! // login: issue and remember the jti (or give it to the client)
22//! let issued = token_issuer.issue(TokenClaims::new(&user.id)).await?;
23//! // logout: revoke it; the next request with that token is 401
24//! session_store.revoke(&issued.jti).await?;
25//! ```
26//!
27//! # Example
28//!
29//! ```ignore
30//! use webx_host::auth_jwt::{JwtAuth, jwt_middleware};
31//! use jsonwebtoken::{DecodingKey, Validation};
32//!
33//! let auth = JwtAuth::new(
34//!     DecodingKey::from_secret(b"my-secret"),
35//!     Validation::default(),
36//! );
37//! let middleware = jwt_middleware(Arc::new(auth));
38//! ```
39
40use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43use std::ops::ControlFlow;
44use std::sync::{Arc, OnceLock};
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46use webx_core::auth::{IAuthenticationHandler, IClaims};
47use webx_core::error::{Error, Result};
48use webx_core::http::IHttpContext;
49use webx_core::middleware::IMiddleware;
50use webx_core::security::{ISessionStore, ITokenIssuer, IssuedToken, TokenClaims};
51
52// ---------------------------------------------------------------------------
53// JwtClaims —IClaims implementation backed by JWT payload
54// ---------------------------------------------------------------------------
55
56/// Claims extracted from a JWT token.
57///
58/// By default, looks for `sub`, `roles`, and `permissions` claims.
59/// All other claims are accessible via `claims()` as stringified values.
60pub struct JwtClaims {
61    sub: String,
62    roles: Vec<String>,
63    permissions: Vec<String>,
64    /// Pre-flattened raw claims map for the `claims()` method.
65    raw: HashMap<String, String>,
66}
67
68/// Intermediate deserialization target. After deserialization we convert
69/// into `JwtClaims` which stores pre-computed data for `IClaims`.
70#[derive(Debug, Deserialize, Serialize)]
71struct RawClaims {
72    #[serde(default)]
73    sub: String,
74    #[serde(default)]
75    roles: Vec<String>,
76    #[serde(default)]
77    permissions: Vec<String>,
78    #[serde(flatten)]
79    extra: HashMap<String, serde_json::Value>,
80}
81
82impl JwtClaims {
83    /// Create empty claims with just a subject.
84    pub fn new(subject: impl Into<String>) -> Self {
85        let sub = subject.into();
86        let mut raw = HashMap::new();
87        raw.insert("sub".to_string(), sub.clone());
88        Self {
89            sub,
90            roles: Vec::new(),
91            permissions: Vec::new(),
92            raw,
93        }
94    }
95}
96
97impl From<RawClaims> for JwtClaims {
98    fn from(rc: RawClaims) -> Self {
99        let mut raw = HashMap::new();
100        raw.insert("sub".to_string(), rc.sub.clone());
101        for (k, v) in &rc.extra {
102            match v {
103                serde_json::Value::String(s) => {
104                    raw.insert(k.clone(), s.clone());
105                }
106                other => {
107                    raw.insert(k.clone(), other.to_string());
108                }
109            }
110        }
111        Self {
112            sub: rc.sub,
113            roles: rc.roles,
114            permissions: rc.permissions,
115            raw,
116        }
117    }
118}
119
120impl IClaims for JwtClaims {
121    fn subject(&self) -> &str {
122        &self.sub
123    }
124
125    fn roles(&self) -> &[String] {
126        &self.roles
127    }
128
129    fn permissions(&self) -> &[String] {
130        &self.permissions
131    }
132
133    fn claims(&self) -> &HashMap<String, String> {
134        &self.raw
135    }
136
137    fn clone_box(&self) -> Box<dyn IClaims> {
138        Box::new(Self {
139            sub: self.sub.clone(),
140            roles: self.roles.clone(),
141            permissions: self.permissions.clone(),
142            raw: self.raw.clone(),
143        })
144    }
145}
146
147// ---------------------------------------------------------------------------
148// JwtAuth —IAuthenticationHandler implementation
149// ---------------------------------------------------------------------------
150
151/// JWT-based authentication handler.
152///
153/// Reads the `Authorization: Bearer <token>` header, decodes and validates
154/// the JWT, and returns the resulting claims. Only a **valid** token produces
155/// claims: a missing header, a non-`Bearer` scheme or a token that fails
156/// validation all yield `Ok(None)` — the request simply continues as anonymous.
157pub struct JwtAuth {
158    decoding_key: DecodingKey,
159    validation: Validation,
160}
161
162impl JwtAuth {
163    /// Create a new JWT authenticator.
164    pub fn new(decoding_key: DecodingKey, validation: Validation) -> Self {
165        Self {
166            decoding_key,
167            validation,
168        }
169    }
170}
171
172#[async_trait::async_trait]
173impl IAuthenticationHandler for JwtAuth {
174    async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>> {
175        // Extract token synchronously —avoids holding &dyn IHttpContext
176        // across any async boundary, keeping the future Send.
177        let header = match ctx.request().header("authorization") {
178            Some(h) => h,
179            None => return Ok(None),
180        };
181
182        // RFC 6750: scheme is case-insensitive
183        let token = if header.len() >= 7 && header[..7].eq_ignore_ascii_case("Bearer ") {
184            header[7..].trim().to_string()
185        } else {
186            return Ok(None);
187        };
188
189        if token.is_empty() {
190            return Ok(None);
191        }
192
193        // A rejected token means "not authenticated", not "reject the request":
194        // the request continues as anonymous and the endpoint decides. This is
195        // what keeps a public endpoint (login, health, docs) reachable while a
196        // client still holds an expired token, instead of dead-locking it with a
197        // 401 on every call. Protected endpoints respond 401 further down the
198        // pipeline (`#[authorize]` rules or a required `#[fromclaim]` identity),
199        // through the single unauthorized writer (detail "authentication
200        // required" + `WWW-Authenticate`).
201        let token_data = match decode::<RawClaims>(&token, &self.decoding_key, &self.validation) {
202            Ok(data) => data,
203            Err(e) => {
204                // The decoder's message is for operators, not for the client
205                // (it names InvalidToken/ExpiredSignature/InvalidAlgorithm/...).
206                tracing::debug!(reason = %e, "JWT rejected");
207                return Ok(None);
208            }
209        };
210
211        let claims: JwtClaims = token_data.claims.into();
212        Ok(Some(Box::new(claims)))
213    }
214}
215
216// ---------------------------------------------------------------------------
217// JwtTokenIssuer —ITokenIssuer implementation (default token minting)
218// ---------------------------------------------------------------------------
219
220/// Default token lifetime used by [`JwtTokenIssuer::from_secret`] when the
221/// caller does not pass one explicitly.
222///
223/// The host does **not** use this constant: `add_authentication()` builds the
224/// issuer from the `Jwt.ExpirationMinutes` appsettings key (default 60 minutes).
225pub const DEFAULT_TOKEN_LIFETIME: Duration = Duration::from_secs(3600);
226
227fn unix_now() -> u64 {
228    SystemTime::now()
229        .duration_since(UNIX_EPOCH)
230        .map(|elapsed| elapsed.as_secs())
231        .unwrap_or_default()
232}
233
234/// JWT token issuer — the default [`ITokenIssuer`].
235///
236/// **HS256 only.** The token is signed with `HS256` and verified by this
237/// crate's `JwtAuth`, whose `Validation::default()` also expects `HS256`, so
238/// issuer and verifier agree out of the box. Asymmetric algorithms (RSA/EC)
239/// are not supported by this type: using one requires *both* a custom
240/// [`ITokenIssuer`] and a custom [`IAuthenticationHandler`] (the host's
241/// `jwt_middleware` always verifies with `Validation::default()`).
242///
243/// Mints a token carrying `sub`, `roles`, `permissions`, the caller's extra
244/// claims, and the framework-managed `jti` / `iat` / `exp`. The `jti` is what
245/// [`ISessionStore::revoke`] later invalidates, so a login endpoint only needs:
246///
247/// ```ignore
248/// let issued = issuer.issue(TokenClaims::new(&user.id)).await?;
249/// sessions.revoke(&issued.jti).await?; // logout
250/// ```
251///
252/// `HostBuilder::add_authentication()` registers one from the configured
253/// `Jwt.Secret` / `Jwt.ExpirationMinutes`; register a custom [`ITokenIssuer`] in
254/// DI to replace it.
255pub struct JwtTokenIssuer {
256    encoding_key: EncodingKey,
257    default_lifetime: Duration,
258    issuer: Option<String>,
259    audience: Option<String>,
260}
261
262impl JwtTokenIssuer {
263    /// Build an HS256 issuer from a shared secret — the same secret
264    /// `JwtAuth` verifies with.
265    ///
266    /// `default_lifetime` applies when [`TokenClaims::expires_in`] is `None`;
267    /// `DEFAULT_TOKEN_LIFETIME` (1 hour) is the framework default.
268    pub fn from_secret(secret: impl AsRef<[u8]>, default_lifetime: Duration) -> Self {
269        Self {
270            encoding_key: EncodingKey::from_secret(secret.as_ref()),
271            default_lifetime,
272            issuer: None,
273            audience: None,
274        }
275    }
276
277    /// Set the `iss` claim.
278    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
279        self.issuer = Some(issuer.into());
280        self
281    }
282
283    /// Set the `aud` claim.
284    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
285        self.audience = Some(audience.into());
286        self
287    }
288}
289
290#[async_trait::async_trait]
291impl ITokenIssuer for JwtTokenIssuer {
292    async fn issue(&self, claims: TokenClaims) -> Result<IssuedToken> {
293        let subject = claims.subject.trim();
294        if subject.is_empty() {
295            return Err(Error::Validation(
296                "token subject must not be empty".to_string(),
297            ));
298        }
299
300        let lifetime = claims.expires_in.unwrap_or(self.default_lifetime);
301        let issued_at = unix_now();
302        let expires_at_unix_secs = issued_at.saturating_add(lifetime.as_secs());
303        let jti = uuid::Uuid::new_v4().to_string();
304
305        // Extra claims first: reserved claims must win, otherwise a caller could
306        // override `sub`/`exp`/`jti` through `TokenClaims::extra`.
307        let mut payload = serde_json::Map::new();
308        for (key, value) in &claims.extra {
309            payload.insert(key.clone(), serde_json::Value::String(value.clone()));
310        }
311        payload.insert("sub".into(), serde_json::json!(subject));
312        payload.insert("roles".into(), serde_json::json!(claims.roles));
313        payload.insert("permissions".into(), serde_json::json!(claims.permissions));
314        payload.insert("jti".into(), serde_json::json!(jti));
315        payload.insert("iat".into(), serde_json::json!(issued_at));
316        payload.insert("exp".into(), serde_json::json!(expires_at_unix_secs));
317        if let Some(issuer) = &self.issuer {
318            payload.insert("iss".into(), serde_json::json!(issuer));
319        }
320        if let Some(audience) = &self.audience {
321            payload.insert("aud".into(), serde_json::json!(audience));
322        }
323
324        // `Header::default()` is `HS256` — the only algorithm this issuer
325        // promises, and the one `JwtAuth` verifies.
326        let token = encode(
327            &Header::default(),
328            &serde_json::Value::Object(payload),
329            &self.encoding_key,
330        )
331        .map_err(|error| Error::Internal(format!("JWT signing failed: {error}")))?;
332
333        Ok(IssuedToken {
334            token,
335            jti,
336            expires_at_unix_secs,
337        })
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Middleware
343// ---------------------------------------------------------------------------
344
345/// Authentication middleware: verify the token, then reject revoked tokens.
346struct AuthMiddleware {
347    handler: Arc<dyn IAuthenticationHandler>,
348    /// `None` = signature verification only (1.1 behaviour).
349    session_store: Option<Arc<dyn ISessionStore>>,
350}
351
352#[async_trait::async_trait]
353impl IMiddleware for AuthMiddleware {
354    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
355        if let Some(claims) = self.handler.authenticate(ctx).await? {
356            if let Some(store) = &self.session_store {
357                // A token without `jti` cannot be matched against a revocation
358                // record; it keeps its `exp`-based lifetime (no re-reject).
359                if let Some(jti) = claims.jti() {
360                    if store.is_revoked(jti).await? {
361                        // Revoked credentials are a credentials problem, not an
362                        // anonymous request: fail here so the host writes its
363                        // single 401 (problem+json + `WWW-Authenticate`).
364                        return Err(Error::Unauthorized("token has been revoked".to_string()));
365                    }
366                }
367            }
368            // IHttpContext extends IClaimsExt, so set_claims is available directly.
369            ctx.set_claims(claims);
370        }
371        Ok(ControlFlow::Continue(()))
372    }
373}
374
375/// Create a JWT authentication middleware from an authentication handler.
376///
377/// Reads the Bearer token from `Authorization` header, validates it, and
378/// stores the resulting claims in the HTTP context for downstream use.
379pub fn jwt_middleware(handler: Arc<dyn IAuthenticationHandler>) -> impl IMiddleware {
380    AuthMiddleware {
381        handler,
382        session_store: None,
383    }
384}
385
386/// Create a JWT authentication middleware that also rejects revoked tokens.
387///
388/// After the signature check, the token's `jti` is looked up in `session_store`;
389/// a revoked token is answered with the framework's 401 (problem+json +
390/// `WWW-Authenticate`) before the endpoint runs. Tokens without a `jti` pass
391/// through unchanged.
392///
393/// **Advanced entry point** — only for a hand-assembled middleware pipeline.
394/// A regular host enables revocation with
395/// [`HostBuilder::use_session_store`](crate::server::HostBuilder::use_session_store),
396/// which registers the store and wires this middleware itself.
397pub fn jwt_middleware_with_revocation(
398    handler: Arc<dyn IAuthenticationHandler>,
399    session_store: Arc<dyn ISessionStore>,
400) -> impl IMiddleware {
401    AuthMiddleware {
402        handler,
403        session_store: Some(session_store),
404    }
405}
406
407// ---------------------------------------------------------------------------
408// Global JWT encoding secret (for token creation in handlers)
409// ---------------------------------------------------------------------------
410
411static JWT_ENCODING_SECRET: OnceLock<String> = OnceLock::new();
412
413/// Initialize the global JWT encoding secret from the configured secret.
414/// This is called automatically by `.add_authentication()` on the `HostBuilder`,
415/// but can also be called manually if needed.
416///
417/// Idempotent: subsequent calls are no-ops (the first secret wins).
418pub fn init_jwt_secret(secret: &str) {
419    let _ = JWT_ENCODING_SECRET.set(secret.to_owned());
420}
421
422/// Retrieve the global JWT encoding secret previously set via [`init_jwt_secret`].
423///
424/// # Panics
425/// Panics if [`init_jwt_secret`] has not been called yet.
426pub fn jwt_secret() -> &'static str {
427    JWT_ENCODING_SECRET
428        .get()
429        .expect("init_jwt_secret must be called before jwt_secret")
430}