Skip to main content

rws_server/auth/
mod.rs

1//! Built-in authentication middleware (`auth` Cargo feature).
2//!
3//! Enable with `features = ["auth"]` in your `Cargo.toml`. Adds `hmac` and
4//! `sha2` (RustCrypto) as dependencies.
5//!
6//! # HTTP Basic Auth
7//!
8//! [`BasicAuthLayer`] validates `Authorization: Basic <base64>` credentials
9//! against a caller-supplied closure. Issues a `WWW-Authenticate` challenge
10//! when the header is absent.
11//!
12//! # JWT (HS256)
13//!
14//! [`JwtLayer`] verifies `Authorization: Bearer <token>` JWTs signed with
15//! HMAC-SHA256. Tokens with a past `exp` claim are rejected. Use
16//! [`verify_jwt`] directly in a handler if you also need the decoded
17//! [`Claims`].
18//!
19//! # JWT (RS256 / ES256), `auth-asymmetric` feature
20//!
21//! [`JwtLayer::rs256`] and [`JwtLayer::es256`] verify JWTs signed with a
22//! separate auth server's RSA or P-256 private key — the caller presents a
23//! token, `JwtLayer` only needs the corresponding *public* key (PEM,
24//! SubjectPublicKeyInfo format) to verify it. No JWKS endpoint or the full
25//! `sso` feature is required; use [`crate::sso::JwksCache`] instead if you
26//! need multi-key rotation via a live JWKS URL.
27//!
28//! # Example
29//!
30//! ```rust,no_run
31//! # extern crate rws_server as rust_web_server;
32//! use rust_web_server::app::App;
33//! use rust_web_server::auth::{BasicAuthLayer, JwtLayer};
34//! use rust_web_server::core::New;
35//!
36//! // Basic Auth
37//! let app = App::new()
38//!     .wrap(BasicAuthLayer::new(|user, pass| user == "admin" && pass == "secret"));
39//!
40//! // JWT (HS256)
41//! let app = App::new()
42//!     .wrap(JwtLayer::new(b"my-signing-secret"));
43//! ```
44//!
45//! ```rust,no_run
46//! # extern crate rws_server as rust_web_server;
47//! # #[cfg(feature = "auth-asymmetric")] {
48//! use rust_web_server::app::App;
49//! use rust_web_server::auth::JwtLayer;
50//! use rust_web_server::core::New;
51//!
52//! // JWT (RS256) — public_key_pem is a SubjectPublicKeyInfo PEM string,
53//! // e.g. produced by `openssl rsa -in key.pem -pubout`.
54//! let public_key_pem = "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n";
55//! let app = App::new().wrap(JwtLayer::rs256(public_key_pem).unwrap());
56//! # }
57//! ```
58
59pub mod forward;
60
61#[cfg(test)]
62mod tests;
63
64use std::collections::HashMap;
65use std::time::{SystemTime, UNIX_EPOCH};
66
67use hmac::{Hmac, Mac};
68use sha2::{Digest, Sha256};
69
70use crate::application::Application;
71use crate::error::{AppError, IntoResponse};
72use crate::header::Header;
73use crate::middleware::Middleware;
74use crate::request::Request;
75use crate::response::Response;
76use crate::server::ConnectionInfo;
77
78type HmacSha256 = Hmac<Sha256>;
79
80// ── Base64 helpers ────────────────────────────────────────────────────────────
81
82// Decodes standard base64 (+/) and base64url (-_) — accepts either alphabet.
83// Padding characters ('=') are stripped before decoding.
84fn base64_decode(input: &str) -> Option<Vec<u8>> {
85    crate::core::base64::Decoder::EITHER.decode(input).ok()
86}
87
88// URL-safe base64 encoding without padding — used for JWT signature computation.
89fn base64url_encode(input: &[u8]) -> String {
90    const C: &[u8; 64] =
91        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
92    let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
93    for chunk in input.chunks(3) {
94        let b0 = chunk[0] as usize;
95        let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
96        let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
97        out.push(C[b0 >> 2] as char);
98        out.push(C[((b0 & 3) << 4) | (b1 >> 4)] as char);
99        if chunk.len() > 1 { out.push(C[((b1 & 0xf) << 2) | (b2 >> 6)] as char); }
100        if chunk.len() > 2 { out.push(C[b2 & 0x3f] as char); }
101    }
102    out
103}
104
105// Standard base64 encoding with padding — used to build Basic Auth headers.
106// pub(crate): reused by proxy_config's tests to build Authorization headers.
107#[doc(hidden)]
108pub fn base64_encode(input: &[u8]) -> String {
109    const C: &[u8; 64] =
110        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
111    let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
112    for chunk in input.chunks(3) {
113        let b0 = chunk[0] as usize;
114        let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
115        let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
116        out.push(C[b0 >> 2] as char);
117        out.push(C[((b0 & 3) << 4) | (b1 >> 4)] as char);
118        out.push(if chunk.len() > 1 { C[((b1 & 0xf) << 2) | (b2 >> 6)] as char } else { '=' });
119        out.push(if chunk.len() > 2 { C[b2 & 0x3f] as char } else { '=' });
120    }
121    out
122}
123
124// ── Mini JSON claim extractor ─────────────────────────────────────────────────
125
126fn extract_string_claim(json: &str, field: &str) -> Option<String> {
127    let key = format!("\"{}\"", field);
128    let start = json.find(key.as_str())?;
129    let rest = json[start + key.len()..].trim_start();
130    let rest = rest.strip_prefix(':')?.trim_start();
131    let rest = rest.strip_prefix('"')?;
132    Some(rest[..rest.find('"')?].to_string())
133}
134
135fn extract_u64_claim(json: &str, field: &str) -> Option<u64> {
136    let key = format!("\"{}\"", field);
137    let start = json.find(key.as_str())?;
138    let rest = json[start + key.len()..].trim_start();
139    let rest = rest.strip_prefix(':')?.trim_start();
140    let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
141    rest[..end].parse().ok()
142}
143
144// ── Claims ────────────────────────────────────────────────────────────────────
145
146/// Decoded JWT payload.
147///
148/// Standard claims (`sub`, `exp`) are pre-extracted. For other claims, parse
149/// [`Claims::raw`] with `serde_json` or the built-in json module.
150pub struct Claims {
151    /// The `sub` (subject) claim, if present.
152    pub sub: Option<String>,
153    /// The `exp` (expiration) claim as Unix seconds, if present.
154    pub exp: Option<u64>,
155    /// Raw UTF-8 JSON payload — inspect for custom claims.
156    pub raw: String,
157}
158
159impl Claims {
160    fn from_json(json: String) -> Self {
161        Claims {
162            sub: extract_string_claim(&json, "sub"),
163            exp: extract_u64_claim(&json, "exp"),
164            raw: json,
165        }
166    }
167
168    /// Return `true` if the token is not yet expired at `now_secs` (Unix
169    /// timestamp). Returns `true` when `exp` is absent (no expiry set).
170    pub fn is_valid_at(&self, now_secs: u64) -> bool {
171        self.exp.map_or(true, |exp| now_secs < exp)
172    }
173}
174
175// ── JWT helpers ───────────────────────────────────────────────────────────────
176
177fn unix_now() -> u64 {
178    SystemTime::now()
179        .duration_since(UNIX_EPOCH)
180        .unwrap_or_default()
181        .as_secs()
182}
183
184/// Extract the raw token string from `Authorization: Bearer <token>`.
185/// Returns `None` if the header is absent or does not start with `Bearer `.
186pub fn extract_bearer_token(request: &Request) -> Option<String> {
187    let h = request.header(Header::_AUTHORIZATION)?;
188    h.value.strip_prefix("Bearer ").map(str::to_string)
189}
190
191/// Build a signed HS256 JWT from a JSON claims object.
192///
193/// Useful for generating test tokens or issuing tokens from a login handler.
194///
195/// ```rust,no_run
196/// # extern crate rws_server as rust_web_server;
197/// use rust_web_server::auth::build_jwt;
198///
199/// let token = build_jwt(r#"{"sub":"42","exp":9999999999}"#, b"secret");
200/// ```
201pub fn build_jwt(claims_json: &str, secret: &[u8]) -> String {
202    let header = base64url_encode(br#"{"alg":"HS256","typ":"JWT"}"#);
203    let payload = base64url_encode(claims_json.as_bytes());
204    let message = format!("{}.{}", header, payload);
205    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key size");
206    mac.update(message.as_bytes());
207    let sig = mac.finalize().into_bytes();
208    format!("{}.{}.{}", header, payload, base64url_encode(&sig))
209}
210
211/// Verify a JWT string against `secret` (HS256 only).
212///
213/// Returns [`Claims`] on success. Returns `None` on any failure: bad format,
214/// unsupported algorithm, signature mismatch, or expired `exp` claim.
215pub fn verify_jwt(token: &str, secret: &[u8]) -> Option<Claims> {
216    let mut parts = token.splitn(3, '.');
217    let header_b64 = parts.next()?;
218    let payload_b64 = parts.next()?;
219    let sig_b64 = parts.next()?;
220
221    if sig_b64.contains('.') {
222        return None; // more than 3 parts
223    }
224
225    // Verify algorithm is HS256
226    let header_bytes = base64_decode(header_b64)?;
227    let header_str = String::from_utf8(header_bytes).ok()?;
228    if !header_str.contains("\"HS256\"") {
229        return None;
230    }
231
232    // Constant-time signature verification
233    let message = format!("{}.{}", header_b64, payload_b64);
234    let expected = base64_decode(sig_b64)?;
235    let mut mac = HmacSha256::new_from_slice(secret).ok()?;
236    mac.update(message.as_bytes());
237    mac.verify_slice(&expected).ok()?;
238
239    // Decode claims
240    let payload_bytes = base64_decode(payload_b64)?;
241    let payload_str = String::from_utf8(payload_bytes).ok()?;
242    let claims = Claims::from_json(payload_str);
243
244    // Reject expired tokens
245    if !claims.is_valid_at(unix_now()) {
246        return None;
247    }
248
249    Some(claims)
250}
251
252/// Splits a JWT into its three base64url segments. Returns `None` if the
253/// token doesn't have exactly three `.`-separated parts.
254#[cfg(feature = "auth-asymmetric")]
255fn split_jwt(token: &str) -> Option<(&str, &str, &str)> {
256    let mut parts = token.splitn(3, '.');
257    let header_b64 = parts.next()?;
258    let payload_b64 = parts.next()?;
259    let sig_b64 = parts.next()?;
260    if sig_b64.contains('.') {
261        return None; // more than 3 parts
262    }
263    Some((header_b64, payload_b64, sig_b64))
264}
265
266/// Shared tail of `verify_jwt_rs256`/`verify_jwt_es256`, run after the
267/// signature has already checked out: confirms the header's `alg` matches
268/// what was actually verified (so an RS256 key can't be used to wave
269/// through a token whose header merely claims HS256), decodes the claims,
270/// and rejects expired tokens.
271#[cfg(feature = "auth-asymmetric")]
272fn finish_asymmetric_verification(header_str: &str, expected_alg: &str, payload_b64: &str) -> Option<Claims> {
273    if !header_str.contains(&format!("\"{}\"", expected_alg)) {
274        return None;
275    }
276    let payload_bytes = base64_decode(payload_b64)?;
277    let payload_str = String::from_utf8(payload_bytes).ok()?;
278    let claims = Claims::from_json(payload_str);
279    if !claims.is_valid_at(unix_now()) {
280        return None;
281    }
282    Some(claims)
283}
284
285/// Verify a JWT signed with RS256 (RSASSA-PKCS1-v1_5 using SHA-256) against
286/// an RSA public key.
287///
288/// Returns [`Claims`] on success. Returns `None` on any failure: bad format,
289/// a header `alg` other than `"RS256"`, signature mismatch, or an expired
290/// `exp` claim.
291#[cfg(feature = "auth-asymmetric")]
292pub fn verify_jwt_rs256(token: &str, public_key: &crate::rsa_key::RsaPublicKey) -> Option<Claims> {
293    let (header_b64, payload_b64, sig_b64) = split_jwt(token)?;
294    let header_str = String::from_utf8(base64_decode(header_b64)?).ok()?;
295
296    let message = format!("{}.{}", header_b64, payload_b64);
297    let signature = base64_decode(sig_b64)?;
298    if !public_key.verify_pkcs1v15_sha256(message.as_bytes(), &signature) {
299        return None;
300    }
301
302    finish_asymmetric_verification(&header_str, "RS256", payload_b64)
303}
304
305/// Verify a JWT signed with ES256 (ECDSA using P-256 and SHA-256) against a
306/// P-256 public key.
307///
308/// Returns [`Claims`] on success. Returns `None` on any failure: bad format,
309/// a header `alg` other than `"ES256"`, signature mismatch, or an expired
310/// `exp` claim.
311///
312/// JWT ES256 signatures are the raw 64-byte `r || s` concatenation, not the
313/// ASN.1 DER encoding OpenSSL produces by default.
314#[cfg(feature = "auth-asymmetric")]
315pub fn verify_jwt_es256(token: &str, public_key: &p256::ecdsa::VerifyingKey) -> Option<Claims> {
316    use p256::ecdsa::signature::Verifier;
317    use p256::ecdsa::Signature as EcSignature;
318
319    let (header_b64, payload_b64, sig_b64) = split_jwt(token)?;
320    let header_str = String::from_utf8(base64_decode(header_b64)?).ok()?;
321
322    let message = format!("{}.{}", header_b64, payload_b64);
323    let signature = base64_decode(sig_b64)?;
324    if signature.len() != 64 {
325        return None;
326    }
327    let sig = EcSignature::from_slice(&signature).ok()?;
328    public_key.verify(message.as_bytes(), &sig).ok()?;
329
330    finish_asymmetric_verification(&header_str, "ES256", payload_b64)
331}
332
333// ── BasicAuthLayer ────────────────────────────────────────────────────────────
334
335/// Middleware that validates HTTP Basic Auth credentials.
336///
337/// Issues `401 Unauthorized` with `WWW-Authenticate: Basic realm="Protected"`
338/// when the header is absent or malformed. Issues `401` (without the
339/// challenge) when credentials are present but the validator returns `false`.
340///
341/// Passwords containing `:` are handled correctly (only the first `:` splits
342/// username from password, per RFC 7617).
343///
344/// # Example
345///
346/// ```rust,no_run
347/// # extern crate rws_server as rust_web_server;
348/// use rust_web_server::app::App;
349/// use rust_web_server::auth::BasicAuthLayer;
350/// use rust_web_server::core::New;
351///
352/// let app = App::new().wrap(BasicAuthLayer::new(|user, pass| {
353///     user == "admin" && pass == "s3cret"
354/// }));
355/// ```
356pub struct BasicAuthLayer<F> {
357    validate: F,
358}
359
360impl<F: Fn(&str, &str) -> bool + Send + Sync + 'static> BasicAuthLayer<F> {
361    /// Create a layer with a `validate(username, password) -> bool` closure.
362    pub fn new(validate: F) -> Self {
363        BasicAuthLayer { validate }
364    }
365}
366
367impl BasicAuthLayer<Box<dyn Fn(&str, &str) -> bool + Send + Sync>> {
368    /// Create a layer that validates credentials against an htpasswd-style
369    /// file, loaded once at construction time (not re-read per request).
370    ///
371    /// Each non-empty, non-comment (`#`-prefixed) line must be
372    /// `username:credential`, where `credential` is one of:
373    /// - a plain-text password (Apache's `htpasswd -p` format), or
374    /// - `{SHA256}` followed by the base64-encoded SHA-256 digest of the
375    ///   password — **this is rws's own scheme, not Apache's**.
376    ///
377    /// # Not supported
378    ///
379    /// Apache's real `{SHA}` scheme (SHA-1), `$apr1$` (iterated MD5), and
380    /// bcrypt (`$2y$`/`$2b$`) are **not** supported — this crate has no
381    /// third-party HTTP/crypto dependencies beyond the audited RustCrypto
382    /// hash crates it already uses, and hand-rolling SHA-1/MD5/bcrypt from
383    /// scratch is not a risk worth taking for a security check. A real
384    /// Apache-generated htpasswd file (which defaults to bcrypt or `$apr1$`
385    /// in modern `htpasswd` versions) will **not** verify against this —
386    /// regenerate it with `htpasswd -p` (plain text) or write your own
387    /// `{SHA256}` entries, or use [`BasicAuthLayer::new`] with your own
388    /// closure (e.g. backed by the `bcrypt` crate) if you need real
389    /// Apache-hash compatibility.
390    ///
391    /// # Errors
392    ///
393    /// Returns `Err` if `path` can't be read.
394    ///
395    /// The file is re-read when it changes (18.20.0): adding or removing a
396    /// user takes effect within a second, without a restart. Several files,
397    /// comma-separated, are read together (one per team, say).
398    pub fn from_htpasswd_file(path: &str) -> Result<Self, String> {
399        let paths: Vec<String> = path.split(',').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect();
400        if paths.is_empty() {
401            return Err("no htpasswd file given".to_string());
402        }
403        let users = HtpasswdUsers::load(&paths)?;
404        let validate: Box<dyn Fn(&str, &str) -> bool + Send + Sync> = Box::new(move |user: &str, pass: &str| users.check(user, pass));
405        Ok(BasicAuthLayer::new(validate))
406    }
407}
408
409/// HS256 against the secret the token's `kid` names, else each secret.
410fn verify_with_any(token: &str, secrets: &[(Option<String>, Vec<u8>)]) -> Option<Claims> {
411    let kid = token.split('.').next().and_then(base64_decode).and_then(|h| String::from_utf8(h).ok()).and_then(|h| crate::mcp::json_rpc::extract_str(&h, "kid"));
412    if let Some(kid) = kid.as_deref() {
413        if let Some((_, secret)) = secrets.iter().find(|(k, _)| k.as_deref() == Some(kid)) {
414            return verify_jwt(token, secret);
415        }
416    }
417    secrets.iter().find_map(|(_, secret)| verify_jwt(token, secret))
418}
419
420/// htpasswd files re-read when one changes (size or mtime, looked at most
421/// once a second), so adding or removing a user needs no restart. A file
422/// that can't be read keeps the users read before.
423struct HtpasswdUsers {
424    paths: Vec<String>,
425    state: std::sync::Mutex<HtpasswdState>,
426}
427
428struct HtpasswdState {
429    users: HashMap<String, String>,
430    stamps: Vec<Option<(u64, SystemTime)>>,
431    checked: std::time::Instant,
432}
433
434impl HtpasswdUsers {
435    fn load(paths: &[String]) -> Result<HtpasswdUsers, String> {
436        let mut users = HashMap::new();
437        let mut stamps = Vec::new();
438        for path in paths {
439            let contents = std::fs::read_to_string(path).map_err(|e| format!("failed to read htpasswd file '{path}': {e}"))?;
440            users.extend(parse_htpasswd(&contents));
441            stamps.push(stamp(path));
442        }
443        Ok(HtpasswdUsers { paths: paths.to_vec(), state: std::sync::Mutex::new(HtpasswdState { users, stamps, checked: std::time::Instant::now() }) })
444    }
445
446    fn check(&self, user: &str, password: &str) -> bool {
447        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
448        if state.checked.elapsed() >= std::time::Duration::from_secs(1) {
449            state.checked = std::time::Instant::now();
450            let now: Vec<_> = self.paths.iter().map(|p| stamp(p)).collect();
451            if now != state.stamps {
452                let mut users = HashMap::new();
453                let mut ok = true;
454                for path in &self.paths {
455                    match std::fs::read_to_string(path) {
456                        Ok(contents) => users.extend(parse_htpasswd(&contents)),
457                        Err(e) => {
458                            eprintln!("basic auth: can't re-read '{path}': {e}; keeping the users read before");
459                            ok = false;
460                            break;
461                        }
462                    }
463                }
464                if ok {
465                    state.users = users;
466                    state.stamps = now;
467                }
468            }
469        }
470        state.users.get(user).is_some_and(|stored| verify_htpasswd_credential(password, stored))
471    }
472}
473
474fn stamp(path: &str) -> Option<(u64, SystemTime)> {
475    std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().unwrap_or(UNIX_EPOCH)))
476}
477
478fn parse_htpasswd(contents: &str) -> HashMap<String, String> {
479    contents
480        .lines()
481        .map(str::trim)
482        .filter(|line| !line.is_empty() && !line.starts_with('#'))
483        .filter_map(|line| line.split_once(':'))
484        .map(|(user, cred)| (user.to_string(), cred.to_string()))
485        .collect()
486}
487
488fn verify_htpasswd_credential(password: &str, stored: &str) -> bool {
489    match stored.strip_prefix("{SHA256}") {
490        Some(expected_b64) => base64_encode(&Sha256::digest(password.as_bytes())) == expected_b64,
491        None => stored == password,
492    }
493}
494
495impl<F: Fn(&str, &str) -> bool + Send + Sync + 'static> Middleware for BasicAuthLayer<F> {
496    /// Headers only: a streamed body passes through untouched.
497    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
498        true
499    }
500
501
502    fn handle(
503        &self,
504        request: &Request,
505        connection: &ConnectionInfo,
506        next: &dyn Application,
507    ) -> Result<Response, String> {
508        let challenge = || {
509            let mut r = AppError::Unauthorized.into_response();
510            r.headers.push(Header {
511                name: "WWW-Authenticate".into(),
512                value: "Basic realm=\"Protected\"".into(),
513            });
514            r
515        };
516
517        let Some(header) = request.header(Header::_AUTHORIZATION) else {
518            return Ok(challenge());
519        };
520        let Some(encoded) = header.value.strip_prefix("Basic ") else {
521            return Ok(challenge());
522        };
523        let Some(decoded) = base64_decode(encoded) else {
524            return Ok(challenge());
525        };
526        let Ok(credentials) = String::from_utf8(decoded) else {
527            return Ok(challenge());
528        };
529        let Some((user, pass)) = credentials.split_once(':') else {
530            return Ok(challenge());
531        };
532
533        if (self.validate)(user, pass) {
534            match crate::authz::Identity::user(user, "basic") {
535                Some(identity) => crate::authz::with_identity(identity, || next.execute(request, connection)),
536                None => next.execute(request, connection),
537            }
538        } else {
539            // A 401 always carries the challenge (RFC 9110 §11.6.1), wrong
540            // credentials included: browsers ask again only when it's there.
541            Ok(challenge())
542        }
543    }
544}
545
546// ── JwtLayer ──────────────────────────────────────────────────────────────────
547
548/// Middleware that verifies `Authorization: Bearer <token>` JWTs.
549///
550/// [`JwtLayer::new`] verifies HMAC-SHA256 (HS256) tokens against a shared
551/// secret. With the `auth-asymmetric` feature, [`JwtLayer::rs256`] and
552/// [`JwtLayer::es256`] verify tokens signed by a separate auth server's
553/// private key, given only its public key.
554///
555/// Rejects tokens with a past `exp` claim. All other validation (format,
556/// algorithm, signature) is performed by [`verify_jwt`] / [`verify_jwt_rs256`]
557/// / [`verify_jwt_es256`].
558///
559/// If a handler also needs the decoded claims, call the matching `verify_*`
560/// function again inside the handler — verification is cheap.
561///
562/// # Example
563///
564/// ```rust,no_run
565/// # extern crate rws_server as rust_web_server;
566/// use rust_web_server::app::App;
567/// use rust_web_server::auth::JwtLayer;
568/// use rust_web_server::core::New;
569///
570/// let app = App::new().wrap(JwtLayer::new(b"my-signing-secret"));
571/// ```
572pub struct JwtLayer {
573    verifier: JwtVerifier,
574    /// The claim naming the caller's [`Identity`](crate::authz::Identity).
575    identity_claim: String,
576}
577
578enum JwtVerifier {
579    /// Every accepted secret, with the `kid` that names it (key rotation).
580    Hs256(Vec<(Option<String>, Vec<u8>)>),
581    #[cfg(feature = "auth-asymmetric")]
582    Rs256(Box<crate::rsa_key::RsaPublicKey>),
583    #[cfg(feature = "auth-asymmetric")]
584    Es256(Box<p256::ecdsa::VerifyingKey>),
585}
586
587impl JwtLayer {
588    /// Create a layer that verifies HS256 JWTs signed with `secret`.
589    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
590        JwtLayer { verifier: JwtVerifier::Hs256(vec![(None, secret.into())]), identity_claim: "sub".to_string() }
591    }
592
593    /// Create a layer that verifies HS256 JWTs signed with any of `secrets`
594    /// (18.20.0): rotate by adding the new secret, keeping the old one until
595    /// the tokens it signed have expired, then removing it.
596    pub fn secrets<I, S>(secrets: I) -> Self
597    where
598        I: IntoIterator<Item = S>,
599        S: Into<Vec<u8>>,
600    {
601        let list = secrets.into_iter().map(|s| (None, s.into())).filter(|(_, s): &(Option<String>, Vec<u8>)| !s.is_empty()).collect();
602        JwtLayer { verifier: JwtVerifier::Hs256(list), identity_claim: "sub".to_string() }
603    }
604
605    /// Also accept HS256 tokens signed with `secret`; a token whose header
606    /// says `"kid": kid` is checked against this secret only.
607    pub fn secret_with_kid(mut self, kid: impl Into<String>, secret: impl Into<Vec<u8>>) -> Self {
608        // The only kind without `auth-asymmetric`.
609        #[allow(irrefutable_let_patterns)]
610        if let JwtVerifier::Hs256(list) = &mut self.verifier {
611            list.push((Some(kid.into()), secret.into()));
612        }
613        self
614    }
615
616    /// Create a layer that verifies RS256 JWTs against an RSA public key.
617    ///
618    /// `public_key_pem` is a `-----BEGIN PUBLIC KEY-----` block
619    /// (SubjectPublicKeyInfo — `openssl rsa -in key.pem -pubout`,
620    /// `openssl req -pubkey`) or a `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1)
621    /// block; 2048–8192-bit keys.
622    ///
623    /// # Errors
624    ///
625    /// Returns `Err` if `public_key_pem` is not a valid PEM-encoded RSA
626    /// public key.
627    #[cfg(feature = "auth-asymmetric")]
628    pub fn rs256(public_key_pem: &str) -> Result<Self, String> {
629        let key = crate::rsa_key::RsaPublicKey::from_public_key_pem(public_key_pem)
630            .map_err(|e| format!("invalid RSA public key: {e}"))?;
631        Ok(JwtLayer { verifier: JwtVerifier::Rs256(Box::new(key)), identity_claim: "sub".to_string() })
632    }
633
634    /// Create a layer that verifies ES256 JWTs against a P-256 public key.
635    ///
636    /// `public_key_pem` must be in SubjectPublicKeyInfo PEM format (the
637    /// `-----BEGIN PUBLIC KEY-----` block produced by, e.g.,
638    /// `openssl ec -in key.pem -pubout`).
639    ///
640    /// # Errors
641    ///
642    /// Returns `Err` if `public_key_pem` is not a valid PEM-encoded P-256
643    /// public key.
644    #[cfg(feature = "auth-asymmetric")]
645    pub fn es256(public_key_pem: &str) -> Result<Self, String> {
646        use p256::pkcs8::DecodePublicKey;
647        let key = p256::ecdsa::VerifyingKey::from_public_key_pem(public_key_pem)
648            .map_err(|e| format!("invalid P-256 public key: {e}"))?;
649        Ok(JwtLayer { verifier: JwtVerifier::Es256(Box::new(key)), identity_claim: "sub".to_string() })
650    }
651}
652
653impl JwtLayer {
654    /// The claim that names the caller's
655    /// [`Identity`](crate::authz::Identity) (default `sub`; e.g. `email`).
656    /// A token without it is still accepted, with no identity.
657    pub fn identity_claim(mut self, claim: impl Into<String>) -> Self {
658        self.identity_claim = claim.into();
659        self
660    }
661}
662
663impl Middleware for JwtLayer {
664    /// Headers only: a streamed body passes through untouched.
665    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
666        true
667    }
668
669
670    fn handle(
671        &self,
672        request: &Request,
673        connection: &ConnectionInfo,
674        next: &dyn Application,
675    ) -> Result<Response, String> {
676        let claims = extract_bearer_token(request).and_then(|t| match &self.verifier {
677            JwtVerifier::Hs256(secrets) => verify_with_any(&t, secrets),
678            #[cfg(feature = "auth-asymmetric")]
679            JwtVerifier::Rs256(key) => verify_jwt_rs256(&t, key),
680            #[cfg(feature = "auth-asymmetric")]
681            JwtVerifier::Es256(key) => verify_jwt_es256(&t, key),
682        });
683        match claims {
684            Some(claims) => match crate::authz::Identity::from_claims(&claims.raw, &self.identity_claim, "jwt") {
685                Some(identity) => crate::authz::with_identity(identity, || next.execute(request, connection)),
686                None => next.execute(request, connection),
687            },
688            None => Ok(AppError::Unauthorized.into_response()),
689        }
690    }
691}