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