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

Next article ยป

Tags:
rust microservice dog-shelter