Skip to main content

axum_security/jwt/
mod.rs

1//! JWT (JSON Web Token) authentication middleware and extractor.
2//!
3//! This module provides [`JwtContext`], a Tower [`Layer`](tower::Layer) that
4//! decodes JWTs from the `Authorization` header (or a cookie) and inserts
5//! the decoded claims into request extensions. Extract them in handlers
6//! with [`Jwt<T>`].
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use axum::{Router, routing::get};
12//! use axum_security::jwt::{Jwt, JwtContext};
13//! use serde::{Serialize, Deserialize};
14//!
15//! #[derive(Serialize, Deserialize, Clone)]
16//! struct Claims { sub: String, exp: u64 }
17//!
18//! let jwt_ctx = JwtContext::builder()
19//!     .jwt_secret("my-secret")
20//!     .build::<Claims>();
21//!
22//! let app = Router::new()
23//!     .route("/", get(|Jwt(claims): Jwt<Claims>| async move {
24//!         format!("Hello, {}!", claims.sub)
25//!     }))
26//!     .layer(jwt_ctx);
27//! ```
28
29mod builder;
30mod service;
31mod session;
32
33use std::{borrow::Cow, convert::Infallible, marker::PhantomData, sync::Arc};
34
35use axum::extract::{FromRef, FromRequestParts};
36pub use builder::{JwtBuilderError, JwtContextBuilder};
37#[cfg(feature = "cookie")]
38use cookie_monster::{Cookie, CookieBuilder};
39use http::{HeaderMap, HeaderName, request::Parts};
40use jsonwebtoken::{TokenData, decode, encode};
41use serde::{Serialize, de::DeserializeOwned};
42pub use session::Jwt;
43
44pub use jsonwebtoken::{
45    DecodingKey, EncodingKey, Header, Validation,
46    errors::{Error as JwtError, ErrorKind as JwtErrorKind},
47    get_current_timestamp,
48};
49
50/// JWT authentication context. Implements Tower [`Layer`](tower::Layer) (decodes
51/// tokens from requests) and [`FromRequestParts`] (via [`FromRef`]) for use in handlers.
52///
53/// Construct with [`JwtContext::builder`].
54pub struct JwtContext<T>(Arc<JwtContextInner<T>>);
55
56struct JwtContextInner<T> {
57    encoding_key: EncodingKey,
58    decoding_key: DecodingKey,
59    jwt_header: Header,
60    validation: Validation,
61    data: PhantomData<T>,
62    extract: ExtractFrom,
63}
64
65pub(crate) enum ExtractFrom {
66    #[cfg(feature = "cookie")]
67    Cookie(Box<CookieBuilder>),
68    Header {
69        header: HeaderName,
70        prefix: Cow<'static, str>,
71    },
72}
73impl JwtContext<()> {
74    /// Create a [`JwtContextBuilder`] to configure keys, extraction method, and validation.
75    pub fn builder() -> JwtContextBuilder {
76        JwtContextBuilder::new()
77    }
78}
79
80impl<T: Serialize> JwtContext<T> {
81    /// Encode claims into a signed JWT string.
82    pub fn encode_token(&self, data: &T) -> jsonwebtoken::errors::Result<String> {
83        encode(&self.0.jwt_header, data, &self.0.encoding_key)
84    }
85
86    #[cfg(feature = "cookie")]
87    /// Encode claims into a signed JWT and wrap it in a `Set-Cookie` cookie.
88    /// Only works when the context is configured to extract from a cookie.
89    pub fn encode_token_to_cookie(&self, data: &T) -> jsonwebtoken::errors::Result<Cookie> {
90        let token = encode(&self.0.jwt_header, data, &self.0.encoding_key)?;
91        match &self.0.extract {
92            ExtractFrom::Cookie(cookie_builder) => Ok(cookie_builder.clone().value(token).build()),
93            ExtractFrom::Header { .. } => Err(jsonwebtoken::errors::ErrorKind::InvalidToken.into()),
94        }
95    }
96
97    #[cfg(feature = "cookie")]
98    /// Return an expired cookie that clears the JWT cookie on the client.
99    pub fn logout_cookie(&self) -> Cookie {
100        match &self.0.extract {
101            ExtractFrom::Cookie(cookie_builder) => {
102                use cookie_monster::Expires;
103
104                cookie_builder
105                    .clone()
106                    .expires(Expires::remove())
107                    .max_age_secs(0)
108                    .value("")
109                    .build()
110            }
111            ExtractFrom::Header { .. } => panic!("no cookie config set"),
112        }
113    }
114}
115
116impl<T: DeserializeOwned> JwtContext<T> {
117    /// Decode and validate a JWT string, returning the header and claims.
118    pub fn decode(&self, jwt: impl AsRef<[u8]>) -> Result<TokenData<T>, JwtError> {
119        decode(jwt.as_ref(), &self.0.decoding_key, &self.0.validation)
120    }
121
122    pub(crate) fn decode_from_headers(&self, headers: &HeaderMap) -> Option<T> {
123        let result = match &self.0.extract {
124            #[cfg(feature = "cookie")]
125            ExtractFrom::Cookie(cookie) => {
126                let jar = cookie_monster::CookieJar::from_headers(headers);
127                let cookie = jar.get(cookie.get_name())?;
128
129                self.decode(cookie.value())
130            }
131            ExtractFrom::Header { header, prefix } => {
132                let authorization_header = headers.get(header)?.to_str().ok()?;
133
134                let jwt = jwt_from_header_value(authorization_header, prefix)?;
135                self.decode(jwt)
136            }
137        };
138
139        result.ok().map(|t| t.claims)
140    }
141}
142
143fn jwt_from_header_value<'a>(header: &'a str, prefix: &str) -> Option<&'a str> {
144    let prefix_len = prefix.len();
145
146    if header.len() < prefix_len {
147        return None;
148    }
149
150    if !header[..prefix_len].eq_ignore_ascii_case(prefix) {
151        return None;
152    }
153
154    Some(&header[prefix_len..])
155}
156
157impl<S, U> FromRequestParts<S> for JwtContext<U>
158where
159    JwtContext<U>: FromRef<S>,
160    S: Send + Sync,
161{
162    type Rejection = Infallible;
163
164    async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
165        Ok(Self::from_ref(state))
166    }
167}
168
169impl<T> Clone for JwtContext<T> {
170    fn clone(&self) -> Self {
171        JwtContext(self.0.clone())
172    }
173}
174
175#[cfg(all(test, feature = "cookie"))]
176mod jwt_test {
177    use std::error::Error;
178
179    use http::StatusCode;
180    use serde::Deserialize;
181
182    #[tokio::test]
183    async fn test_cookie() -> Result<(), Box<dyn Error>> {
184        use axum::{Router, body::Body, routing::get};
185        use http::Request;
186        use serde::Serialize;
187        use tower::ServiceExt;
188
189        use crate::{
190            jwt::{Jwt, JwtContext},
191            utils::utc_now_secs,
192        };
193
194        #[derive(Serialize, Deserialize, Clone)]
195        struct AT {
196            exp: u64,
197        }
198
199        let jwt_context = JwtContext::builder()
200            .extract_cookie("session")
201            .jwt_secret("test-secret")
202            .build::<AT>();
203
204        let valid_access_token = AT {
205            exp: utc_now_secs() + 10000,
206        };
207
208        let invalid_access_token = AT {
209            exp: utc_now_secs() - 10000,
210        };
211
212        let valid_cookie = jwt_context.encode_token_to_cookie(&valid_access_token)?;
213        let invalid_cookie = jwt_context.encode_token_to_cookie(&invalid_access_token)?;
214
215        let router = Router::<()>::new()
216            .route("/", get(move |_: Jwt<AT>| async { StatusCode::CREATED }))
217            .layer(jwt_context);
218
219        let mut header = format!("{}=", valid_cookie.name());
220        header.push_str(valid_cookie.value());
221
222        let request = Request::get("/")
223            .header("cookie", header)
224            .body(Body::empty())?;
225
226        let resp = router.clone().oneshot(request).await.unwrap();
227        assert!(resp.status() == StatusCode::CREATED);
228
229        let mut header = format!("{}=", invalid_cookie.name());
230        header.push_str(invalid_cookie.value());
231
232        let request = Request::get("/")
233            .header("cookie", header)
234            .body(Body::empty())?;
235
236        let resp = router.clone().oneshot(request).await.unwrap();
237        assert!(resp.status() == StatusCode::UNAUTHORIZED);
238        Ok(())
239    }
240}