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}