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//! # Example
8//!
9//! ```ignore
10//! use webx_host::auth_jwt::{JwtAuth, jwt_middleware};
11//! use jsonwebtoken::{DecodingKey, Validation};
12//!
13//! let auth = JwtAuth::new(
14//!     DecodingKey::from_secret(b"my-secret"),
15//!     Validation::default(),
16//! );
17//! let middleware = jwt_middleware(Arc::new(auth));
18//! ```
19
20use jsonwebtoken::{decode, DecodingKey, Validation};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::ops::ControlFlow;
24use std::sync::{Arc, OnceLock};
25use webx_core::auth::{IAuthenticationHandler, IClaims};
26use webx_core::error::Result;
27use webx_core::http::IHttpContext;
28use webx_core::middleware::IMiddleware;
29
30// ---------------------------------------------------------------------------
31// JwtClaims —IClaims implementation backed by JWT payload
32// ---------------------------------------------------------------------------
33
34/// Claims extracted from a JWT token.
35///
36/// By default, looks for `sub`, `roles`, and `permissions` claims.
37/// All other claims are accessible via `claims()` as stringified values.
38pub struct JwtClaims {
39    sub: String,
40    roles: Vec<String>,
41    permissions: Vec<String>,
42    /// Pre-flattened raw claims map for the `claims()` method.
43    raw: HashMap<String, String>,
44}
45
46/// Intermediate deserialization target. After deserialization we convert
47/// into `JwtClaims` which stores pre-computed data for `IClaims`.
48#[derive(Debug, Deserialize, Serialize)]
49struct RawClaims {
50    #[serde(default)]
51    sub: String,
52    #[serde(default)]
53    roles: Vec<String>,
54    #[serde(default)]
55    permissions: Vec<String>,
56    #[serde(flatten)]
57    extra: HashMap<String, serde_json::Value>,
58}
59
60impl JwtClaims {
61    /// Create empty claims with just a subject.
62    pub fn new(subject: impl Into<String>) -> Self {
63        let sub = subject.into();
64        let mut raw = HashMap::new();
65        raw.insert("sub".to_string(), sub.clone());
66        Self {
67            sub,
68            roles: Vec::new(),
69            permissions: Vec::new(),
70            raw,
71        }
72    }
73}
74
75impl From<RawClaims> for JwtClaims {
76    fn from(rc: RawClaims) -> Self {
77        let mut raw = HashMap::new();
78        raw.insert("sub".to_string(), rc.sub.clone());
79        for (k, v) in &rc.extra {
80            match v {
81                serde_json::Value::String(s) => {
82                    raw.insert(k.clone(), s.clone());
83                }
84                other => {
85                    raw.insert(k.clone(), other.to_string());
86                }
87            }
88        }
89        Self {
90            sub: rc.sub,
91            roles: rc.roles,
92            permissions: rc.permissions,
93            raw,
94        }
95    }
96}
97
98impl IClaims for JwtClaims {
99    fn subject(&self) -> &str {
100        &self.sub
101    }
102
103    fn roles(&self) -> &[String] {
104        &self.roles
105    }
106
107    fn permissions(&self) -> &[String] {
108        &self.permissions
109    }
110
111    fn claims(&self) -> &HashMap<String, String> {
112        &self.raw
113    }
114
115    fn clone_box(&self) -> Box<dyn IClaims> {
116        Box::new(Self {
117            sub: self.sub.clone(),
118            roles: self.roles.clone(),
119            permissions: self.permissions.clone(),
120            raw: self.raw.clone(),
121        })
122    }
123}
124
125// ---------------------------------------------------------------------------
126// JwtAuth —IAuthenticationHandler implementation
127// ---------------------------------------------------------------------------
128
129/// JWT-based authentication handler.
130///
131/// Reads the `Authorization: Bearer <token>` header, decodes and validates
132/// the JWT, and returns the resulting claims. Only a **valid** token produces
133/// claims: a missing header, a non-`Bearer` scheme or a token that fails
134/// validation all yield `Ok(None)` — the request simply continues as anonymous.
135pub struct JwtAuth {
136    decoding_key: DecodingKey,
137    validation: Validation,
138}
139
140impl JwtAuth {
141    /// Create a new JWT authenticator.
142    pub fn new(decoding_key: DecodingKey, validation: Validation) -> Self {
143        Self {
144            decoding_key,
145            validation,
146        }
147    }
148}
149
150#[async_trait::async_trait]
151impl IAuthenticationHandler for JwtAuth {
152    async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>> {
153        // Extract token synchronously —avoids holding &dyn IHttpContext
154        // across any async boundary, keeping the future Send.
155        let header = match ctx.request().header("authorization") {
156            Some(h) => h,
157            None => return Ok(None),
158        };
159
160        // RFC 6750: scheme is case-insensitive
161        let token = if header.len() >= 7 && header[..7].eq_ignore_ascii_case("Bearer ") {
162            header[7..].trim().to_string()
163        } else {
164            return Ok(None);
165        };
166
167        if token.is_empty() {
168            return Ok(None);
169        }
170
171        // A rejected token means "not authenticated", not "reject the request":
172        // the request continues as anonymous and the endpoint decides. This is
173        // what keeps a public endpoint (login, health, docs) reachable while a
174        // client still holds an expired token, instead of dead-locking it with a
175        // 401 on every call. Protected endpoints respond 401 further down the
176        // pipeline (`#[authorize]` rules or a required `#[fromclaim]` identity),
177        // through the single unauthorized writer (detail "authentication
178        // required" + `WWW-Authenticate`).
179        let token_data = match decode::<RawClaims>(&token, &self.decoding_key, &self.validation) {
180            Ok(data) => data,
181            Err(e) => {
182                // The decoder's message is for operators, not for the client
183                // (it names InvalidToken/ExpiredSignature/InvalidAlgorithm/...).
184                tracing::debug!(reason = %e, "JWT rejected");
185                return Ok(None);
186            }
187        };
188
189        let claims: JwtClaims = token_data.claims.into();
190        Ok(Some(Box::new(claims)))
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Middleware
196// ---------------------------------------------------------------------------
197
198/// Authentication middleware that uses the given `IAuthenticationHandler`
199/// to extract claims from the request and store them in the context.
200struct AuthMiddleware {
201    handler: Arc<dyn IAuthenticationHandler>,
202}
203
204#[async_trait::async_trait]
205impl IMiddleware for AuthMiddleware {
206    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
207        if let Some(claims) = self.handler.authenticate(ctx).await? {
208            // IHttpContext extends IClaimsExt, so set_claims is available directly.
209            ctx.set_claims(claims);
210        }
211        Ok(ControlFlow::Continue(()))
212    }
213}
214
215/// Create a JWT authentication middleware from an authentication handler.
216///
217/// Reads the Bearer token from `Authorization` header, validates it, and
218/// stores the resulting claims in the HTTP context for downstream use.
219pub fn jwt_middleware(handler: Arc<dyn IAuthenticationHandler>) -> impl IMiddleware {
220    AuthMiddleware { handler }
221}
222
223// ---------------------------------------------------------------------------
224// Global JWT encoding secret (for token creation in handlers)
225// ---------------------------------------------------------------------------
226
227static JWT_ENCODING_SECRET: OnceLock<String> = OnceLock::new();
228
229/// Initialize the global JWT encoding secret from the configured secret.
230/// This is called automatically by `.add_authentication()` on the `HostBuilder`,
231/// but can also be called manually if needed.
232///
233/// Idempotent: subsequent calls are no-ops (the first secret wins).
234pub fn init_jwt_secret(secret: &str) {
235    let _ = JWT_ENCODING_SECRET.set(secret.to_owned());
236}
237
238/// Retrieve the global JWT encoding secret previously set via [`init_jwt_secret`].
239///
240/// # Panics
241/// Panics if [`init_jwt_secret`] has not been called yet.
242pub fn jwt_secret() -> &'static str {
243    JWT_ENCODING_SECRET
244        .get()
245        .expect("init_jwt_secret must be called before jwt_secret")
246}