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}