Production-ready microservice in Rust: 9. JWT authorization middleware
Posted 2023-11-14 16:32:00 by sanyi ‐ 7 min read
JWT bearer token validation and a simple error handling method
This post is about the POST /v1/dogs
endpoint. The client can POST a JSON data structure
to this endpoint, something like this:
{
"name": "Fido",
"description": "...",
"date_of_birth": "2022-01-01",
"chip_number": "1234",
"gender": "male",
"is_sterilized": true,
"breed": "mixed",
"size": "medium",
"weight": 25,
"hair": "brown"
}
and the endpoint will create a new Dog record in the database. The endpoint requires JWT bearer token authentication. Token validation is implemented in an axum middleware. The middleware code is based on this example: GitHub, Axum JWT auth
I will start with the core functionality of the endpoint and add the authentication middleware
later. We already have a model struct in the entity::dog
module. We have to create an additional
DogCreateRequest
struct - this struct will receive all the data from the POST request:
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveIntoActiveModel)]
pub struct DogCreateRequest {
pub name: String,
pub description: String,
pub date_of_birth: NaiveDate,
pub date_of_vaccination: Option<NaiveDate>,
pub chip_number: String,
pub gender: String,
pub is_sterilized: bool,
pub breed: String,
pub size: String,
pub weight: Option<i32>,
pub hair: String,
}
The DeriveIntoActiveModel
macro ensures that we can convert the request into an ActiveModel
like this:
let dog_active_model = dog_request.into_active_model();
To handle the POST /v1/dogs
endpoint we will create a new handler function:
use crate::api::response::dogs::DogCreateResponse;
use crate::api::response::error::AppError;
use crate::api::response::TokenClaims;
use crate::state::ApplicationState;
use axum::extract::State;
use axum::{Extension, Json};
use entity::dog::DogCreateRequest;
use sea_orm::{ActiveModelTrait, IntoActiveModel, TryIntoModel};
use std::sync::Arc;
pub async fn create(
Extension(_claims): Extension<TokenClaims>,
State(state): State<Arc<ApplicationState>>,
Json(payload): Json<DogCreateRequest>,
) -> Result<Json<DogCreateResponse>, AppError> {
let dog_active_model = payload.into_active_model();
let dog_model = dog_active_model.save(state.db_conn.load().as_ref()).await?;
let dog = dog_model.try_into_model()?;
let response = DogCreateResponse {
status: "success".to_string(),
data: Some(dog),
};
Ok(Json(response))
}
The TokenClaims
will be provided by our authentication middleware later. The JSON received
in the POST body will be converted into a DogCreateRequest
into the payload
variable.
The handler is quite simple for now, there is no data validation, we simply convert
the payload DogCreateRequest
into an ActiveModel
and save this dog_active_model
into the database. After a successful database operation the save method returns a
new ActiveModel
instance, we store this in dog_model
and finally convert it
into an entity::dog::Model
instance.
In the response
module we have to create a new dogs
submodule and a response
struct for this endpoint:
use entity::dog::Model;
use serde::Serialize;
#[derive(Serialize)]
pub struct DogCreateResponse {
pub status: String,
pub data: Option<Model>,
}
The response will contain a status: "success"
value and the newly created entity model instance
in the data
field. The entity is serializable, so axum can convert it into JSON.
Now a few words about the error handling. As you can see the handler method returns an AppError
instance when something goes wrong. Errors can occur in the save
method or in the try_into_model
method. Both of these are simply handled by the question mark at the end of the line.
But how will be these errors converted into an AppError
? This magic happens in the implementations
related to AppError
:
pub struct AppError(anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
status: "error".to_string(),
message: self.0.to_string(),
}),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
The AppError
struct is a tuple containing an anyhow::Error
instance. We implement the From
trait so rust can automatically convert any anyhow compatible error
(marked by the Into<anyhow::Error>
declaration) into an AppError
instance too.
I placed the AppError
struct and the related implementations into the api::response:error
module.
The IntoResponse
trait implementation is the other part of the magic: this allows axum to
convert the AppError
struct into an HTTP response. The response's status code will be
500 internal server error and the JSON response body will contain a status: "error"
value
and an error message.
Now we can jump to the routing configuration in v1.rs
.
use super::handlers;
use crate::api::handlers::jwt::auth;
use crate::state::ApplicationState;
use axum::routing::{get, post};
use axum::{middleware, Router};
use std::sync::Arc;
pub fn configure(state: Arc<ApplicationState>) -> Router {
Router::new()
.route(
"/hello",
get(handlers::hello::hello).with_state(state.clone()),
)
.route(
"/login",
post(handlers::login::login).with_state(state.clone()),
)
.route(
"/dogs",
post(handlers::dogs::create)
.with_state(state.clone())
.route_layer(middleware::from_fn_with_state(state, auth)),
)
}
I added the /dogs
route, routed it to the handlers::dogs::create
method, passed
the application state to the handler method via the with_state
call and finally
added the authentication middleware via the route_layer
call.
Now we can take a look at the api::handlers::jwt
module:
use std::sync::Arc;
use axum::{
extract::State,
http::{header, Request, StatusCode},
middleware::Next,
response::IntoResponse,
Json,
};
use crate::api::response::error::ErrorResponse;
use crate::api::response::TokenClaims;
use crate::state::ApplicationState;
use jsonwebtoken::{decode, DecodingKey, Validation};
pub async fn auth<B>(
State(state): State<Arc<ApplicationState>>,
mut req: Request<B>,
next: Next<B>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|auth_header| auth_header.to_str().ok())
.and_then(|auth_value| {
auth_value
.strip_prefix("Bearer ")
.map(|stripped| stripped.to_owned())
});
let token = token.ok_or_else(|| {
let json_error = ErrorResponse {
status: "error".to_string(),
message: "Missing bearer token".to_string(),
};
(StatusCode::UNAUTHORIZED, Json(json_error))
})?;
let secret = &state.settings.load().token_secret;
let claims = decode::<TokenClaims>(
&token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| {
let json_error = ErrorResponse {
status: "error".to_string(),
message: "Invalid bearer token".to_string(),
};
(StatusCode::UNAUTHORIZED, Json(json_error))
})?
.claims;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
The auth
function is the middleware implementation itself. It receives an application state,
an axum request and a next
parameter. The next
parameter provides the middleware chaining
functionality, we call its run
method after we are finished with our job.
First we read out the Authorization
header from the request and strip the Bearer
prefix
from it. The rest of the header value is the JWT token. We return an unauthorized error if
the token is missing. Next we load the JWT encryption secret from the application state and
try to decode the JWT token. The default Validation
configuration ensures that the encryption
algorithm is HS256 and the token is not expired. If the validation failed we return an
unauthorized error. Finally the middleware passes the TokenClaims
to the handler function
as an extension. This extension will be the first parameter of the handler function:
Extension(_claims): Extension<TokenClaims>,
. We simply ignore the claims in this case,
but the handler could implement more complex authorization rules based on the sub
value of
the token (and that contains the username).
Now we can test the functionality (cargo build, ./target/debug/shelter_main serve). Do not forget to set the configuration for the postgresql database url:
$ export SHELTER__DATABASE__URL="postgresql://postgres:[email protected]/shelter"
First you have to call the login endpoint (see the previous post), that will return a JWT token to be used in the next call:
$ curl -v -XPOST -d '{"name": "Fido", "description": "...", "date_of_birth": "2022-01-01", "chip_number": "1234", "gender": "male", "is_sterilized": true, "breed": "mixed", "size": "medium", "weight": 25, "hair": "brown"}' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <JWT TOKEN>' \
http://127.0.0.1:8080/v1/dogs
You can find the sample code on GitHub