Skip to main content

churust_auth/
lib.rs

1//! Authentication plugins for the [Churust](churust_core) web framework.
2//!
3//! This crate provides ready-made authentication schemes — [`Bearer`] tokens,
4//! HTTP [`Basic`] credentials, and [`Jwt`] bearer tokens — plus the
5//! [`Principal<P>`] extractor used to require and read the authenticated user
6//! inside a handler.
7//!
8//! # Authentication vs. authorization
9//!
10//! An auth plugin only *authenticates*: it inspects the incoming `Authorization`
11//! header, verifies the credentials, and — on success — inserts a *principal*
12//! value (your own user/claims type) into the call's extensions. It never
13//! rejects a request on its own. If the credentials are missing or invalid, the
14//! plugin simply leaves no principal behind.
15//!
16//! *Authorization* — actually requiring a logged-in user — is type-driven and
17//! explicit: a handler asks for a [`Principal<P>`] argument. When a principal of
18//! type `P` is present the handler runs; when it is absent the extractor returns
19//! `401 Unauthorized` with a `WWW-Authenticate: Bearer` challenge before the
20//! handler body is ever entered. Routes that do not ask for a `Principal` stay
21//! public.
22//!
23//! All schemes are constructed through the [`Auth`] namespace and installed with
24//! [`AppBuilder::install`](churust_core::AppBuilder::install). The principal type
25//! `P` you choose is what later handlers extract; only one principal type can be
26//! resolved per call (the most recently inserted value of a given type wins).
27//!
28//! # Comparing credentials
29//!
30//! [`Bearer`] and [`Basic`] do not check the credential themselves — they decode
31//! the header and hand you the value, and *your* closure decides. That makes the
32//! comparison yours to get right, and `==` on a `String` is the wrong tool: it
33//! returns as soon as it meets a differing byte, so how long it took reveals how
34//! much of a guess was correct. Enough requests recovers the secret a byte at a
35//! time.
36//!
37//! Use [`churust_core::secure_compare`], which compares in constant time:
38//!
39//! ```
40//! use churust_core::secure_compare;
41//! # struct User;
42//! # let verify = |token: String| async move {
43//! secure_compare(&token, "the-expected-token").then_some(User)
44//! # };
45//! ```
46//!
47//! Every example below does this, so copying one does not copy a timing leak.
48//! A username is not a secret — it identifies rather than authenticates, and is
49//! often visible anyway — so comparing it with `==` is fine; the password or
50//! token beside it is what needs the constant-time path.
51//!
52//! [`Jwt`] needs none of this: it verifies an HMAC signature through
53//! `jsonwebtoken`, which already compares in constant time.
54//!
55//! # Example
56//!
57//! Protect a route with bearer-token auth. The `/me` handler only runs when a
58//! valid token resolved to a `User` principal; otherwise the [`Principal`]
59//! extractor short-circuits with `401`.
60//!
61//! ```
62//! use churust_core::{secure_compare, Churust, TestClient};
63//! use churust_auth::{Auth, Principal};
64//!
65//! #[derive(Clone)]
66//! struct User {
67//!     name: String,
68//! }
69//!
70//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
71//! let app = Churust::server()
72//!     .install(Auth::bearer(|token: String| async move {
73//!         // `secure_compare`, not `==`: see "Comparing credentials" below.
74//!         secure_compare(&token, "s3cret").then(|| User { name: "ana".into() })
75//!     }))
76//!     .routing(|r| {
77//!         r.get("/me", |Principal(u): Principal<User>| async move {
78//!             format!("hello {}", u.name)
79//!         });
80//!     })
81//!     .build();
82//!
83//! // A valid token reaches the handler.
84//! let ok = TestClient::new(app.clone())
85//!     .get("/me")
86//!     .header("authorization", "Bearer s3cret")
87//!     .send()
88//!     .await;
89//! assert_eq!(ok.status().as_u16(), 200);
90//! assert_eq!(ok.text(), "hello ana");
91//!
92//! // No credentials => 401 with a challenge, handler never runs.
93//! let denied = TestClient::new(app).get("/me").send().await;
94//! assert_eq!(denied.status().as_u16(), 401);
95//! assert_eq!(denied.header("www-authenticate"), Some("Bearer"));
96//! # });
97//! ```
98#![deny(missing_docs)]
99
100use async_trait::async_trait;
101use churust_core::{
102    AppBuilder, Call, Error, FromCallParts, Middleware, Next, Phase, Plugin, Response, Result,
103};
104use http::header::WWW_AUTHENTICATE;
105use http::{HeaderValue, StatusCode};
106use std::future::Future;
107use std::marker::PhantomData;
108use std::pin::Pin;
109use std::sync::Arc;
110
111type BoxFut<T> = Pin<Box<dyn Future<Output = T> + Send>>;
112
113/// Handler argument that requires and yields the authenticated principal of
114/// type `P`.
115///
116/// `Principal<P>` is the bridge between *authenticating* (a scheme plugin
117/// inserting a principal into the call) and *authorizing* (a handler demanding
118/// one). Add it as a handler parameter to make that route require auth: the
119/// extractor looks up a value of type `P` previously stored on the call by an
120/// auth plugin such as [`Auth::bearer`], [`Auth::basic`], or [`Auth::jwt_hs256`].
121///
122/// The inner value is exposed as the public tuple field, so you typically
123/// destructure it directly in the parameter list, e.g.
124/// `|Principal(user): Principal<User>|`.
125///
126/// # Type parameter
127///
128/// * `P` — the principal type you chose when installing the auth scheme. It must
129///   be `Clone + Send + Sync + 'static`. This is the same type the plugin's
130///   `verify` closure returns (or, for [`Jwt`], the decoded claims type).
131///
132/// # Errors
133///
134/// If no principal of type `P` was inserted for this call (no/invalid/expired
135/// credentials, or a mismatched principal type) the extractor returns
136/// `401 Unauthorized` with a `WWW-Authenticate: Bearer` response header, and the
137/// handler body never runs.
138///
139/// # Examples
140///
141/// ```
142/// use churust_core::{secure_compare, Churust, TestClient};
143/// use churust_auth::{Auth, Principal};
144///
145/// #[derive(Clone)]
146/// struct User {
147///     id: u64,
148/// }
149///
150/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
151/// let app = Churust::server()
152///     .install(Auth::bearer(|tok: String| async move {
153///         secure_compare(&tok, "ok").then(|| User { id: 7 })
154///     }))
155///     .routing(|r| {
156///         r.get("/id", |Principal(u): Principal<User>| async move {
157///             u.id.to_string()
158///         });
159///     })
160///     .build();
161///
162/// let res = TestClient::new(app)
163///     .get("/id")
164///     .header("authorization", "Bearer ok")
165///     .send()
166///     .await;
167/// assert_eq!(res.status().as_u16(), 200);
168/// assert_eq!(res.text(), "7");
169/// # });
170/// ```
171#[derive(Debug, Clone)]
172pub struct Principal<P>(
173    /// The authenticated principal value inserted by the auth scheme.
174    pub P,
175);
176
177#[async_trait]
178impl<P> FromCallParts for Principal<P>
179where
180    P: Clone + Send + Sync + 'static,
181{
182    async fn from_call_parts(call: &mut Call) -> Result<Self> {
183        match call.get::<P>() {
184            Some(p) => Ok(Principal(p)),
185            None => {
186                let offered = call.get::<Challenges>().unwrap_or_default().0;
187                let mut err = Error::new(StatusCode::UNAUTHORIZED, "authentication required");
188                if offered.is_empty() {
189                    err = err.with_response_header(WWW_AUTHENTICATE, default_challenge());
190                } else {
191                    for challenge in offered {
192                        err = err.with_response_header(WWW_AUTHENTICATE, challenge);
193                    }
194                }
195                Err(err)
196            }
197        }
198    }
199}
200
201/// The `WWW-Authenticate` challenges the schemes on this call would accept.
202///
203/// The [`Principal<P>`] extractor refuses with `401`, and a `401` has to say
204/// *how* to authenticate — RFC 9110 §11.6.1 requires the header, and a browser
205/// only opens its own credential dialog for a scheme it recognises. The extractor
206/// is generic over the principal type and cannot know which scheme guards the
207/// route, so each plugin records its own challenge here as the request passes
208/// through and the extractor emits what it finds.
209///
210/// A `Vec` because more than one scheme can be installed, and a `401` may
211/// legitimately offer several. Values are in installation order.
212#[derive(Clone, Debug, Default)]
213struct Challenges(Vec<HeaderValue>);
214
215impl Challenges {
216    /// Record `challenge` for this call, keeping installation order and not
217    /// repeating one that is already there.
218    fn offer(call: &mut Call, challenge: HeaderValue) {
219        let mut existing = call.get::<Challenges>().unwrap_or_default();
220        if !existing.0.contains(&challenge) {
221            existing.0.push(challenge);
222            call.insert(existing);
223        }
224    }
225}
226
227/// The challenge used when nothing recorded one.
228///
229/// A route can ask for a `Principal` with no auth plugin installed at all, and a
230/// bare `401` with no header would be the one answer that tells the client
231/// nothing. `Bearer` is the safe default: it is what this crate emitted for every
232/// scheme before challenges were tracked, and unlike `Basic` it does not make a
233/// browser pop up a login dialog for a route that may not want one.
234fn default_challenge() -> HeaderValue {
235    HeaderValue::from_static("Bearer")
236}
237
238// ---------- Bearer ----------
239
240/// Bearer-token authentication plugin produced by [`Auth::bearer`].
241///
242/// On every request this plugin reads the `Authorization: Bearer <token>`
243/// header (the scheme word is matched case-insensitively), trims the token, and
244/// calls your `verify` closure. If the closure returns `Some(principal)`, that
245/// principal is inserted into the call so a later [`Principal<P>`] extractor can
246/// pick it up. A missing header, a non-bearer scheme, or a `None` result simply
247/// leaves the call unauthenticated — it is never rejected here; rejection is the
248/// job of [`Principal<P>`].
249///
250/// You rarely name this type directly; construct it with [`Auth::bearer`] and
251/// hand it to [`AppBuilder::install`](churust_core::AppBuilder::install).
252///
253/// # Type parameters
254///
255/// * `P` — the principal type the closure resolves to.
256/// * `F` — the verifier closure type (inferred from the argument).
257///
258/// # Examples
259///
260/// ```
261/// use churust_core::{secure_compare, Churust, TestClient};
262/// use churust_auth::{Auth, Principal};
263///
264/// #[derive(Clone)]
265/// struct User;
266///
267/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
268/// let app = Churust::server()
269///     .install(Auth::bearer(|t: String| async move {
270///         secure_compare(&t, "ok").then_some(User)
271///     }))
272///     .routing(|r| {
273///         r.get("/", |_p: Principal<User>| async { "in" });
274///     })
275///     .build();
276///
277/// let res = TestClient::new(app)
278///     .get("/")
279///     .header("authorization", "Bearer ok")
280///     .send()
281///     .await;
282/// assert_eq!(res.status().as_u16(), 200);
283/// # });
284/// ```
285pub struct Bearer<P, F> {
286    verify: Arc<F>,
287    _p: PhantomData<fn() -> P>,
288}
289
290/// Constructor namespace for the authentication scheme plugins.
291///
292/// `Auth` is a zero-sized type that groups the factory functions for every
293/// scheme this crate offers. You never instantiate it; call its associated
294/// functions and pass the result to
295/// [`AppBuilder::install`](churust_core::AppBuilder::install):
296///
297/// * [`Auth::bearer`] — opaque `Authorization: Bearer` tokens verified by a closure.
298/// * [`Auth::basic`] — HTTP Basic `username:password` credentials verified by a closure.
299/// * [`Auth::jwt_hs256`] — HS256-signed JWTs decoded into a claims principal.
300///
301/// # Examples
302///
303/// ```
304/// use churust_core::Churust;
305/// use churust_auth::Auth;
306///
307/// #[derive(Clone)]
308/// struct User;
309///
310/// // Each factory yields a plugin ready to `.install(..)`.
311/// let _app = Churust::server()
312///     .install(Auth::bearer(|_t: String| async { Some(User) }))
313///     .build();
314/// ```
315pub struct Auth;
316
317impl Auth {
318    /// Builds a [`Bearer`] plugin that authenticates `Authorization: Bearer`
319    /// tokens.
320    ///
321    /// `verify` is invoked with the raw token string (without the `Bearer `
322    /// prefix, trimmed) and returns a future resolving to `Some(principal)` when
323    /// the token is valid or `None` when it is not. The returned principal `P`
324    /// is what handlers later read via [`Principal<P>`].
325    ///
326    /// # Parameters
327    ///
328    /// * `verify` — async closure `Fn(String) -> impl Future<Output = Option<P>>`,
329    ///   run once per request that carries a bearer token.
330    ///
331    /// # Examples
332    ///
333    /// ```
334    /// use churust_core::{secure_compare, Churust, TestClient};
335    /// use churust_auth::{Auth, Principal};
336    ///
337    /// #[derive(Clone)]
338    /// struct User { name: String }
339    ///
340    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
341    /// let app = Churust::server()
342    ///     .install(Auth::bearer(|token: String| async move {
343    ///         secure_compare(&token, "letmein").then(|| User { name: "ada".into() })
344    ///     }))
345    ///     .routing(|r| {
346    ///         r.get("/me", |Principal(u): Principal<User>| async move { u.name });
347    ///     })
348    ///     .build();
349    ///
350    /// let res = TestClient::new(app)
351    ///     .get("/me")
352    ///     .header("authorization", "Bearer letmein")
353    ///     .send()
354    ///     .await;
355    /// assert_eq!(res.text(), "ada");
356    /// # });
357    /// ```
358    pub fn bearer<P, F, Fut>(verify: F) -> Bearer<P, F>
359    where
360        F: Fn(String) -> Fut + Send + Sync + 'static,
361        Fut: Future<Output = Option<P>> + Send + 'static,
362        P: Clone + Send + Sync + 'static,
363    {
364        Bearer {
365            verify: Arc::new(verify),
366            _p: PhantomData,
367        }
368    }
369
370    /// Builds a [`Basic`] plugin that authenticates HTTP Basic credentials.
371    ///
372    /// On each request the plugin decodes the `Authorization: Basic <base64>`
373    /// header into a `username` and `password` and calls `verify`. A return of
374    /// `Some(principal)` authenticates the call; `None`, a malformed header, or a
375    /// missing header leaves it unauthenticated. The decoded principal `P` is
376    /// read by handlers via [`Principal<P>`].
377    ///
378    /// # Parameters
379    ///
380    /// * `verify` — async closure `Fn(String, String) -> impl Future<Output = Option<P>>`
381    ///   receiving `(username, password)`.
382    ///
383    /// # Gotcha
384    ///
385    /// HTTP Basic transmits the password in reversibly-encoded (base64) form, so
386    /// only use it over TLS.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use churust_core::{secure_compare, Churust, TestClient};
392    /// use churust_auth::{Auth, Principal};
393    ///
394    /// #[derive(Clone)]
395    /// struct User { name: String }
396    ///
397    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
398    /// let app = Churust::server()
399    ///     .install(Auth::basic(|u: String, p: String| async move {
400    ///         // `==` on the username, constant-time on the password.
401    ///         (u == "admin" && secure_compare(&p, "pw")).then(|| User { name: u })
402    ///     }))
403    ///     .routing(|r| {
404    ///         r.get("/me", |Principal(u): Principal<User>| async move { u.name });
405    ///     })
406    ///     .build();
407    ///
408    /// // base64("admin:pw") == "YWRtaW46cHc="
409    /// let res = TestClient::new(app)
410    ///     .get("/me")
411    ///     .header("authorization", "Basic YWRtaW46cHc=")
412    ///     .send()
413    ///     .await;
414    /// assert_eq!(res.text(), "admin");
415    /// # });
416    /// ```
417    pub fn basic<P, F, Fut>(verify: F) -> Basic<P, F>
418    where
419        F: Fn(String, String) -> Fut + Send + Sync + 'static,
420        Fut: Future<Output = Option<P>> + Send + 'static,
421        P: Clone + Send + Sync + 'static,
422    {
423        Basic {
424            verify: Arc::new(verify),
425            realm: "restricted".to_string(),
426            _p: PhantomData,
427        }
428    }
429}
430
431impl<P, F, Fut> Plugin for Bearer<P, F>
432where
433    F: Fn(String) -> Fut + Send + Sync + 'static,
434    Fut: Future<Output = Option<P>> + Send + 'static,
435    P: Clone + Send + Sync + 'static,
436{
437    fn install(self: Box<Self>, app: &mut AppBuilder) {
438        let verify = self.verify.clone();
439        app.add_middleware_in(
440            Phase::Plugins,
441            Arc::new(BearerMiddleware::<P> {
442                verify: Arc::new(move |token: String| {
443                    let verify = verify.clone();
444                    Box::pin(async move { verify(token).await }) as BoxFut<Option<P>>
445                }),
446            }),
447        );
448    }
449}
450
451struct BearerMiddleware<P> {
452    #[allow(clippy::type_complexity)]
453    verify: Arc<dyn Fn(String) -> BoxFut<Option<P>> + Send + Sync>,
454}
455
456#[async_trait]
457impl<P> Middleware for BearerMiddleware<P>
458where
459    P: Clone + Send + Sync + 'static,
460{
461    async fn handle(&self, mut call: Call, next: Next) -> Response {
462        Challenges::offer(&mut call, HeaderValue::from_static("Bearer"));
463        if let Some(raw) = call.header("authorization") {
464            if let Some(token) = raw
465                .strip_prefix("Bearer ")
466                .or_else(|| raw.strip_prefix("bearer "))
467            {
468                if let Some(principal) = (self.verify)(token.trim().to_string()).await {
469                    call.insert(principal);
470                }
471            }
472        }
473        next.run(call).await
474    }
475}
476
477// ---------- Basic ----------
478
479/// HTTP Basic authentication plugin produced by [`Auth::basic`].
480///
481/// On every request this plugin reads `Authorization: Basic <base64>` (scheme
482/// matched case-insensitively), base64-decodes it, splits the `username:password`
483/// pair on the first `:`, and calls your `verify` closure. `Some(principal)`
484/// authenticates the call by inserting the principal for a later
485/// [`Principal<P>`] extractor; `None`, a header that is not valid base64, or one
486/// without a `:` leaves the call unauthenticated. As with all schemes in this
487/// crate it never rejects the request itself.
488///
489/// Construct it with [`Auth::basic`] rather than naming this type directly. Note
490/// that Basic credentials are only base64-encoded, not encrypted, so serve such
491/// routes over TLS.
492///
493/// # Type parameters
494///
495/// * `P` — the principal type the closure resolves to.
496/// * `F` — the verifier closure type (inferred from the argument).
497///
498/// # Examples
499///
500/// ```
501/// use churust_core::{secure_compare, Churust, TestClient};
502/// use churust_auth::{Auth, Principal};
503///
504/// #[derive(Clone)]
505/// struct User { name: String }
506///
507/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
508/// let app = Churust::server()
509///     .install(Auth::basic(|u: String, p: String| async move {
510///         secure_compare(&p, "hunter2").then(|| User { name: u })
511///     }))
512///     .routing(|r| {
513///         r.get("/me", |Principal(u): Principal<User>| async move { u.name });
514///     })
515///     .build();
516///
517/// // base64("ada:hunter2") == "YWRhOmh1bnRlcjI="
518/// let res = TestClient::new(app)
519///     .get("/me")
520///     .header("authorization", "Basic YWRhOmh1bnRlcjI=")
521///     .send()
522///     .await;
523/// assert_eq!(res.text(), "ada");
524/// # });
525/// ```
526pub struct Basic<P, F> {
527    verify: Arc<F>,
528    realm: String,
529    _p: PhantomData<fn() -> P>,
530}
531
532impl<P, F> Basic<P, F> {
533    /// Set the protection space named in the `WWW-Authenticate` challenge.
534    ///
535    /// RFC 9110 §11.6.1 requires a `realm` on a Basic challenge. It is what a
536    /// browser shows in its credential dialog and what a client keys stored
537    /// credentials on, so a name the user will recognise is worth setting. The
538    /// default is `"restricted"`.
539    ///
540    /// A `"` in the name is replaced with `'`: the value is a quoted string, and
541    /// a stray quote would end it early and let the rest of the name be read as
542    /// further authentication parameters.
543    pub fn realm(mut self, realm: impl Into<String>) -> Self {
544        self.realm = realm.into().replace('"', "'");
545        self
546    }
547
548    /// The challenge this scheme offers, as a header value.
549    fn challenge(&self) -> HeaderValue {
550        HeaderValue::from_str(&format!("Basic realm=\"{}\"", self.realm))
551            .unwrap_or_else(|_| HeaderValue::from_static("Basic realm=\"restricted\""))
552    }
553}
554
555impl<P, F, Fut> Plugin for Basic<P, F>
556where
557    F: Fn(String, String) -> Fut + Send + Sync + 'static,
558    Fut: Future<Output = Option<P>> + Send + 'static,
559    P: Clone + Send + Sync + 'static,
560{
561    fn install(self: Box<Self>, app: &mut AppBuilder) {
562        let verify = self.verify.clone();
563        let challenge = self.challenge();
564        app.add_middleware_in(
565            Phase::Plugins,
566            Arc::new(BasicMiddleware::<P> {
567                verify: Arc::new(move |u: String, p: String| {
568                    let verify = verify.clone();
569                    Box::pin(async move { verify(u, p).await }) as BoxFut<Option<P>>
570                }),
571                challenge,
572            }),
573        );
574    }
575}
576
577struct BasicMiddleware<P> {
578    #[allow(clippy::type_complexity)]
579    verify: Arc<dyn Fn(String, String) -> BoxFut<Option<P>> + Send + Sync>,
580    /// Rendered once at install time rather than per request.
581    challenge: HeaderValue,
582}
583
584#[async_trait]
585impl<P> Middleware for BasicMiddleware<P>
586where
587    P: Clone + Send + Sync + 'static,
588{
589    async fn handle(&self, mut call: Call, next: Next) -> Response {
590        Challenges::offer(&mut call, self.challenge.clone());
591        if let Some((user, pass)) = call.header("authorization").and_then(decode_basic) {
592            if let Some(principal) = (self.verify)(user, pass).await {
593                call.insert(principal);
594            }
595        }
596        next.run(call).await
597    }
598}
599
600fn decode_basic(header: &str) -> Option<(String, String)> {
601    use base64::Engine;
602    let b64 = header
603        .strip_prefix("Basic ")
604        .or_else(|| header.strip_prefix("basic "))?;
605    let decoded = base64::engine::general_purpose::STANDARD
606        .decode(b64.trim())
607        .ok()?;
608    let text = String::from_utf8(decoded).ok()?;
609    let (user, pass) = text.split_once(':')?;
610    Some((user.to_string(), pass.to_string()))
611}
612
613// ---------- JWT ----------
614
615/// JWT bearer-token authentication plugin produced by [`Auth::jwt_hs256`].
616///
617/// This plugin reads `Authorization: Bearer <jwt>` (scheme matched
618/// case-insensitively), verifies the token's signature and standard claims
619/// against the configured key and validation, and on success inserts the decoded
620/// claims of type `C` as the principal. Handlers then read those claims via
621/// [`Principal<C>`]. A missing token, a bad signature, or a failed validation
622/// (e.g. an expired `exp`) leaves the call unauthenticated rather than erroring.
623///
624/// Unlike [`Bearer`] there is no `verify` closure: trust comes from the
625/// cryptographic signature, and the claims are deserialized directly into `C`.
626/// Construct it with [`Auth::jwt_hs256`].
627///
628/// # Type parameter
629///
630/// * `C` — the claims type, which must be
631///   `serde::de::DeserializeOwned + Clone + Send + Sync + 'static`. By default
632///   `jsonwebtoken` validates `exp`, so include an `exp` field in `C`.
633///
634/// # Examples
635///
636/// ```
637/// use churust_core::{Churust, TestClient};
638/// use churust_auth::{Auth, Principal};
639/// use jsonwebtoken::{encode, EncodingKey, Header};
640/// use serde::{Deserialize, Serialize};
641///
642/// #[derive(Serialize, Deserialize, Clone)]
643/// struct Claims { sub: String, exp: usize }
644///
645/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
646/// let secret = b"shhh";
647/// let token = encode(
648///     &Header::default(),
649///     &Claims { sub: "u1".into(), exp: 9_999_999_999 },
650///     &EncodingKey::from_secret(secret),
651/// ).unwrap();
652///
653/// let app = Churust::server()
654///     .install(Auth::jwt_hs256::<Claims>(secret))
655///     .routing(|r| {
656///         r.get("/who", |Principal(c): Principal<Claims>| async move { c.sub });
657///     })
658///     .build();
659///
660/// let res = TestClient::new(app)
661///     .get("/who")
662///     .header("authorization", &format!("Bearer {token}"))
663///     .send()
664///     .await;
665/// assert_eq!(res.text(), "u1");
666/// # });
667/// ```
668pub struct Jwt<C> {
669    key: jsonwebtoken::DecodingKey,
670    validation: jsonwebtoken::Validation,
671    _c: PhantomData<fn() -> C>,
672}
673
674impl Auth {
675    /// Builds a [`Jwt`] plugin that verifies HS256-signed JWT bearer tokens with
676    /// an HMAC secret.
677    ///
678    /// Uses the default `jsonwebtoken` validation for the `HS256` algorithm
679    /// (which, among other things, requires and checks an `exp` claim). Valid
680    /// tokens are decoded into the claims type `C` and inserted as the principal
681    /// for handlers to read with [`Principal<C>`].
682    ///
683    /// # Parameters
684    ///
685    /// * `secret` — the shared HMAC secret bytes used to sign and verify tokens.
686    ///   The same bytes must be used by whatever issues the tokens.
687    ///
688    /// # Gotcha
689    ///
690    /// Because default validation enforces `exp`, a claims type without an `exp`
691    /// field (or with one in the past) will fail to validate and leave the call
692    /// unauthenticated.
693    ///
694    /// # Examples
695    ///
696    /// ```
697    /// use churust_core::Churust;
698    /// use churust_auth::Auth;
699    /// use serde::{Deserialize, Serialize};
700    ///
701    /// #[derive(Serialize, Deserialize, Clone)]
702    /// struct Claims { sub: String, exp: usize }
703    ///
704    /// let _app = Churust::server()
705    ///     .install(Auth::jwt_hs256::<Claims>(b"shared-secret"))
706    ///     .build();
707    /// ```
708    pub fn jwt_hs256<C>(secret: &[u8]) -> Jwt<C>
709    where
710        C: serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
711    {
712        Jwt {
713            key: jsonwebtoken::DecodingKey::from_secret(secret),
714            validation: jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256),
715            _c: PhantomData,
716        }
717    }
718}
719
720impl<C> Plugin for Jwt<C>
721where
722    C: serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
723{
724    fn install(self: Box<Self>, app: &mut AppBuilder) {
725        app.add_middleware_in(
726            Phase::Plugins,
727            Arc::new(JwtMiddleware::<C> {
728                key: Arc::new(self.key),
729                validation: Arc::new(self.validation),
730                _c: PhantomData,
731            }),
732        );
733    }
734}
735
736struct JwtMiddleware<C> {
737    key: Arc<jsonwebtoken::DecodingKey>,
738    validation: Arc<jsonwebtoken::Validation>,
739    _c: PhantomData<fn() -> C>,
740}
741
742#[async_trait]
743impl<C> Middleware for JwtMiddleware<C>
744where
745    C: serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
746{
747    async fn handle(&self, mut call: Call, next: Next) -> Response {
748        if let Some(raw) = call.header("authorization") {
749            if let Some(token) = raw
750                .strip_prefix("Bearer ")
751                .or_else(|| raw.strip_prefix("bearer "))
752            {
753                if let Ok(data) =
754                    jsonwebtoken::decode::<C>(token.trim(), &self.key, &self.validation)
755                {
756                    call.insert(data.claims);
757                }
758            }
759        }
760        next.run(call).await
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use churust_core::{App, Churust, TestClient};
768    use serde::{Deserialize, Serialize};
769
770    #[derive(Clone, Debug, PartialEq)]
771    struct User {
772        name: String,
773    }
774
775    fn bearer_app() -> App {
776        Churust::server()
777            .install(Auth::bearer(|token: String| async move {
778                if token == "secret" {
779                    Some(User { name: "ana".into() })
780                } else {
781                    None
782                }
783            }))
784            .routing(|r| {
785                r.get("/me", |Principal(u): Principal<User>| async move {
786                    format!("hello {}", u.name)
787                });
788            })
789            .build()
790    }
791
792    #[tokio::test]
793    async fn valid_bearer_reaches_protected_route() {
794        let client = TestClient::new(bearer_app());
795        let res = client
796            .get("/me")
797            .header("authorization", "Bearer secret")
798            .send()
799            .await;
800        assert_eq!(res.status(), StatusCode::OK);
801        assert_eq!(res.text(), "hello ana");
802    }
803
804    #[tokio::test]
805    async fn missing_token_is_401_with_challenge() {
806        let client = TestClient::new(bearer_app());
807        let res = client.get("/me").send().await;
808        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
809        assert_eq!(res.header("www-authenticate"), Some("Bearer"));
810    }
811
812    #[tokio::test]
813    async fn wrong_token_is_401() {
814        let client = TestClient::new(bearer_app());
815        let res = client
816            .get("/me")
817            .header("authorization", "Bearer nope")
818            .send()
819            .await;
820        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
821    }
822
823    #[tokio::test]
824    async fn basic_auth_works() {
825        let app = Churust::server()
826            .install(Auth::basic(|u: String, p: String| async move {
827                if u == "admin" && p == "pw" {
828                    Some(User { name: u })
829                } else {
830                    None
831                }
832            }))
833            .routing(|r| {
834                r.get("/me", |Principal(u): Principal<User>| async move { u.name });
835            })
836            .build();
837        let client = TestClient::new(app);
838        // base64("admin:pw") = YWRtaW46cHc=
839        let res = client
840            .get("/me")
841            .header("authorization", "Basic YWRtaW46cHc=")
842            .send()
843            .await;
844        assert_eq!(res.status(), StatusCode::OK);
845        assert_eq!(res.text(), "admin");
846    }
847
848    #[derive(Serialize, Deserialize, Clone, Debug)]
849    struct Claims {
850        sub: String,
851        exp: usize,
852    }
853
854    #[tokio::test]
855    async fn jwt_decodes_claims() {
856        use jsonwebtoken::{encode, EncodingKey, Header};
857        let secret = b"topsecret";
858        let claims = Claims {
859            sub: "u42".into(),
860            exp: 9_999_999_999,
861        };
862        let token = encode(
863            &Header::default(),
864            &claims,
865            &EncodingKey::from_secret(secret),
866        )
867        .unwrap();
868
869        let app = Churust::server()
870            .install(Auth::jwt_hs256::<Claims>(secret))
871            .routing(|r| {
872                r.get(
873                    "/who",
874                    |Principal(c): Principal<Claims>| async move { c.sub },
875                );
876            })
877            .build();
878        let client = TestClient::new(app);
879        let res = client
880            .get("/who")
881            .header("authorization", &format!("Bearer {token}"))
882            .send()
883            .await;
884        assert_eq!(res.status(), StatusCode::OK);
885        assert_eq!(res.text(), "u42");
886    }
887
888    #[derive(Clone)]
889    struct Guest;
890
891    fn basic_app() -> App {
892        Churust::server()
893            .install(Auth::basic(|_u: String, p: String| async move {
894                churust_core::secure_compare(&p, "pw").then_some(Guest)
895            }))
896            .routing(|r| {
897                r.get("/private", |_p: Principal<Guest>| async { "in" });
898            })
899            .build()
900    }
901
902    #[tokio::test]
903    async fn a_basic_route_challenges_with_basic_and_a_realm() {
904        // A `401` has to say how to authenticate, and a browser only opens its
905        // credential dialog for a scheme it recognises. Every scheme in this crate
906        // used to answer `Bearer`, so a Basic-protected route asked for
907        // credentials a browser had no way to supply.
908        let res = TestClient::new(basic_app()).get("/private").send().await;
909        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
910        assert_eq!(
911            res.header("www-authenticate"),
912            Some("Basic realm=\"restricted\"")
913        );
914    }
915
916    #[tokio::test]
917    async fn a_custom_realm_reaches_the_challenge() {
918        let app = Churust::server()
919            .install(
920                Auth::basic(|_u: String, p: String| async move {
921                    churust_core::secure_compare(&p, "pw").then_some(Guest)
922                })
923                .realm("staging area"),
924            )
925            .routing(|r| {
926                r.get("/private", |_p: Principal<Guest>| async { "in" });
927            })
928            .build();
929        let res = TestClient::new(app).get("/private").send().await;
930        assert_eq!(
931            res.header("www-authenticate"),
932            Some("Basic realm=\"staging area\"")
933        );
934    }
935
936    #[tokio::test]
937    async fn a_quote_in_a_realm_cannot_end_the_quoted_string() {
938        // The value is a quoted string; a stray `"` would close it early and let
939        // the rest be read as further auth parameters.
940        let app = Churust::server()
941            .install(
942                Auth::basic(|_u: String, p: String| async move {
943                    churust_core::secure_compare(&p, "pw").then_some(Guest)
944                })
945                .realm("a\" , error=\"nope"),
946            )
947            .routing(|r| {
948                r.get("/private", |_p: Principal<Guest>| async { "in" });
949            })
950            .build();
951        let res = TestClient::new(app).get("/private").send().await;
952        let got = res.header("www-authenticate").unwrap().to_string();
953        assert!(
954            !got.trim_start_matches("Basic realm=\"").contains('"')
955                || got.ends_with('"') && got.matches('"').count() == 2,
956            "the realm must stay one quoted string, got: {got}"
957        );
958    }
959
960    #[tokio::test]
961    async fn a_bearer_route_still_challenges_with_bearer() {
962        // The guard against making every challenge Basic.
963        let app = Churust::server()
964            .install(Auth::bearer(|t: String| async move {
965                churust_core::secure_compare(&t, "ok").then_some(Guest)
966            }))
967            .routing(|r| {
968                r.get("/private", |_p: Principal<Guest>| async { "in" });
969            })
970            .build();
971        let res = TestClient::new(app).get("/private").send().await;
972        assert_eq!(res.header("www-authenticate"), Some("Bearer"));
973    }
974
975    #[tokio::test]
976    async fn a_route_with_both_schemes_offers_both_challenges() {
977        // Both installed, so a `401` names both. This is what needed
978        // `IntoResponse for Error` to stop collapsing repeated header names.
979        let app = Churust::server()
980            .install(Auth::basic(|_u: String, p: String| async move {
981                churust_core::secure_compare(&p, "pw").then_some(Guest)
982            }))
983            .install(Auth::bearer(|t: String| async move {
984                churust_core::secure_compare(&t, "ok").then_some(Guest)
985            }))
986            .routing(|r| {
987                r.get("/private", |_p: Principal<Guest>| async { "in" });
988            })
989            .build();
990        let res = TestClient::new(app).get("/private").send().await;
991        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
992        let all = res.headers().get_all("www-authenticate").iter().count();
993        assert_eq!(all, 2, "both schemes should be offered, got {all}");
994    }
995
996    #[tokio::test]
997    async fn a_valid_basic_credential_still_reaches_the_handler() {
998        // base64("ada:pw") == "YWRhOnB3"
999        let res = TestClient::new(basic_app())
1000            .get("/private")
1001            .header("authorization", "Basic YWRhOnB3")
1002            .send()
1003            .await;
1004        assert_eq!(res.status(), StatusCode::OK);
1005        assert_eq!(res.text(), "in");
1006    }
1007}