This guide will walk you through the process of implementing RS256 JWT authentication with Rust, using asymmetric keys (private and public keys) to ensure strong security. The API will be hosted on a high-performance Actix Web server and will store data in a PostgreSQL database.

While RS256 is widely used and scalable, it has some security limitations when used in a stateless environment without server sessions. For example, it can be challenging to invalidate a stolen or compromised token before it expires, and revoking access can be difficult without server-side sessions.

Additionally, if a JWT is intercepted, attackers can use it to access protected resources until it expires, even if the user logs out.

Rust and Actix Web App - JWT Access...
Rust and Actix Web App - JWT Access and Refresh Tokens

To mitigate these security risks, we will introduce Redis as a server-side session storage solution. This will allow us to securely store JWT metadata, making it possible to easily invalidate a compromised token or revoke a user’s access when necessary. As a result, the likelihood of unauthorized access to protected resources will be reduced.

By the end of this guide, you’ll be equipped with the knowledge needed to create a secure authentication system that utilizes the RS256 algorithm with Rust, allowing you to safeguard your API from unauthorized access.

More practice:

Rust and Actix Web - JWT Access and Refresh Tokens

Run the Rust Actix Web Project Locally

To run the RS256 Rust backend server on your machine, follow these steps:

  1. Download or clone the Rust JWT RS256 project from https://github.com/wpcodevo/rust-jwt-rs256 and open the source code in a code editor.
  2. Launch the PostgreSQL, Redis, and pgAdmin servers by running the command docker-compose up -d in the terminal of the root directory.
  3. If you haven’t already, install the SQLx CLI by running cargo install sqlx-cli. Push the migration script to the PostgreSQL database by running sqlx migrate run.
  4. Run the command cargo build to install the required crates and build the project.
  5. Start the Actix-Web HTTP server by running the command cargo r.
  6. To test the RS256 JWT authentication, import the Rust_JWT_RS256.postman_collection.json file into Postman or Thunder Client VS Code extension. This allows you to test each endpoint and confirm that they work correctly.

    Alternatively, if you prefer to use a front-end app built with Yew.rs to interact with the API, follow the instructions in the section below to set it up.

Run the Rust API with a Frontend App

Here’s how you can run the RS256 Rust frontend app on your machine:

  1. Before you begin, make sure that you have the Trunk tool installed on your computer. You’ll also need to install the WebAssembly target, which you can do by visiting https://yew.rs/docs/getting-started/introduction.
  2. Download or clone the RS256 Rust Yew.rs project from https://github.com/wpcodevo/rust-yew-rs256-web-app and open the source code in your preferred code editor.
  3. Open the integrated terminal and run the command trunk build in your IDE or text editor. This will install the necessary crates and create asset-build pipelines for the assets specified in the index.html target.
  4. Once the installation of the necessary crates and asset build pipelines is complete, start the Yew web server by running the command trunk serve --port 3000. This will create a local web server on your machine that you can access through your web browser at localhost:3000.
  5. Finally, interact with the app to register, log in, and log out users, and request your account details.

Setup the Rust Project

After completing this tutorial, your project structure will resemble the screenshot below, excluding the Makefile and Rust_JWT_RS256.postman_collection.json files.

Project Structure of the Rust JWT RS256 Backend API

First things first, choose a convenient location where you would like to store the project’s source code and open a terminal window in that directory. Then, execute the following commands, which will create a folder called rust-jwt-rs256, change into the newly-created directory, and initialize the Rust project with Cargo.


mkdir rust-jwt-rs256
cd rust-jwt-rs256
cargo init

After initializing the Rust project, open it in your preferred IDE or text editor and execute the commands listed below to install the necessary crates and packages.


cargo add actix-web
cargo add actix-cors
cargo add serde_json
cargo add serde --features derive
cargo add chrono --features serde
cargo add env_logger
cargo add dotenv
cargo add uuid --features "serde v4"
cargo add sqlx --features "runtime-async-std-native-tls postgres chrono uuid"
cargo add jsonwebtoken
cargo add argon2
cargo add base64
cargo add futures
cargo add rand_core --features "std"
cargo add redis --features "tokio-comp"

Since Rust and its crates are continuously evolving, newer versions of the crates may introduce breaking changes that can cause your application to encounter errors and fail to run. In such cases, you can use the versions specified in the Cargo.toml file below to revert to the earlier versions of the crates.

Cargo.toml


[package]
name = "rust-jwt-rs256"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-cors = "0.6.4"
actix-web = "4.3.0"
argon2 = "0.5.0"
base64 = "0.21.0"
chrono = { version = "0.4.23", features = ["serde"] }
dotenv = "0.15.0"
env_logger = "0.10.0"
futures = "0.3.26"
jsonwebtoken = "8.2.0"
rand_core = { version = "0.6.4", features = ["std"] }
redis = { version = "0.22.3", features = ["tokio-comp"] }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.92"
sqlx = { version = "0.6.2", features = ["runtime-async-std-native-tls", "postgres", "chrono", "uuid"] }
uuid = { version = "1.3.0", features = ["serde", "v4"] }

With the crates installed, let’s create a basic Actix Web server to verify if we did everything correctly. To do this, open the src/main.rs file and replace its content with the following code.

src/main.rs


use actix_web::middleware::Logger;
use actix_web::{get, App, HttpResponse, HttpServer, Responder};

#[get("/api/healthchecker")]
async fn health_checker_handler() -> impl Responder {
    const MESSAGE: &str = "Actix-web and Postgres: JWT RS256 Access and Refresh Tokens";

    HttpResponse::Ok().json(serde_json::json!({"status": "success", "message": MESSAGE}))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    if std::env::var_os("RUST_LOG").is_none() {
        std::env::set_var("RUST_LOG", "actix_web=info");
    }
    env_logger::init();

    println!("🚀 Server started successfully");

    HttpServer::new(move || {
        App::new()
            .service(health_checker_handler)
            .wrap(Logger::default())
    })
    .bind(("127.0.0.1", 8000))?
    .run()
    .await
}

Although the project can be built and run using the standard Cargo CLI, a third-party CLI tool is available that can automatically rebuild and restart the server when changes are made to certain files. To install this tool, run the following command in your terminal: cargo install cargo-watch.

Once installed, use the command below to build the project and start the Actix Web HTTP server. This command will also watch for any changes in the “src” directory and automatically restart the server when a change is detected.


cargo watch -q -c -w src/ -x run

With the server now listening for requests, you can test its functionality by making a GET request to the URL http://localhost:8000/api/healthchecker using your browser or an API testing software. If you receive the expected JSON object from the server, it means that everything is working correctly.

Testing the Health Checker Route of the Rust RS256 Algorithm Access and Refresh Tokens Project

Setup PostgreSQL, Redis and pgAdmin with Docker

If you already have PostgreSQL and Redis servers running on your machine, you can skip this section, but remember to add the credentials to the environment variables file.

Otherwise, you can create a docker-compose.yml file in the project’s root directory and include the following Docker Compose configurations to set up the necessary services.

docker-compose.yml


version: '3'
services:
  postgres:
    image: postgres:latest
    container_name: postgres
    ports:
      - '6500:5432'
    volumes:
      - progresDB:/var/lib/postgresql/data
    env_file:
      - ./.env
  pgAdmin:
    image: dpage/pgadmin4
    container_name: pgAdmin
    env_file:
      - ./.env
    ports:
      - "5050:80"
  redis:
    image: redis:alpine
    container_name: redis
    ports:
      - '6379:6379'
    volumes:
      - redisDB:/data
volumes:
  redisDB:
  progresDB:

To provide the required authentication credentials to the Docker Compose configurations, you need to create a .env file in the project’s root directory and add the following environment variables:

.env


POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=6500
POSTGRES_USER=admin
POSTGRES_PASSWORD=password123
POSTGRES_DB=rust_jwt_rs256

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

DATABASE_URL=postgresql://admin:password123@localhost:6500/rust_jwt_rs256?schema=public

PORT=8000
CLIENT_ORIGIN=http://localhost:3000

REDIS_URL=redis://127.0.0.1:6379/

ACCESS_TOKEN_PRIVATE_KEY=
ACCESS_TOKEN_PUBLIC_KEY=
ACCESS_TOKEN_EXPIRED_IN=15m
ACCESS_TOKEN_MAXAGE=15


REFRESH_TOKEN_PRIVATE_KEY=
REFRESH_TOKEN_PUBLIC_KEY=
REFRESH_TOKEN_EXPIRED_IN=60m
REFRESH_TOKEN_MAXAGE=60

Once you have added the required environment variables to the .env file, you can start the Postgres, Redis, and pgAdmin servers in their respective Docker containers by running the command docker-compose up -d. You can use the command docker ps to see the running containers or check Docker Desktop.

Check Docker Desktop to See the pgAdmin, PostgreSQL, and Redis Containers

Database Migration with SQLX-CLI

Let’s create migration scripts for our PostgreSQL server using the SQLX command-line tool. These migration files will contain SQL queries that will be synced with the database schema.

First, ensure that you have installed the SQLX command-line tool by running the command cargo install sqlx-cli. Once installed, generate reversible migrations with corresponding “up” and “down” scripts by running the following command:


sqlx migrate add -r init

To configure the PostgreSQL database to support UUID generation and create the necessary table and index, open the “up” migration script and insert the following SQL queries.

migrations/20230126163950_init.up.sql


CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE
    "users" (
        id UUID NOT NULL PRIMARY KEY DEFAULT (uuid_generate_v4()),
        name VARCHAR(100) NOT NULL,
        email VARCHAR(255) NOT NULL UNIQUE,
        photo VARCHAR NOT NULL DEFAULT 'default.png',
        verified BOOLEAN NOT NULL DEFAULT FALSE,
        password VARCHAR(100) NOT NULL,
        role VARCHAR(50) NOT NULL DEFAULT 'user',
        created_at TIMESTAMP
        WITH
            TIME ZONE DEFAULT NOW(),
            updated_at TIMESTAMP
        WITH
            TIME ZONE DEFAULT NOW()
    );

CREATE INDEX users_email_idx ON users (email);

These statements will activate the uuid-ossp extension, which allows the use of uuid_generate_v4() function to generate UUIDs for the ‘id‘ columns. Additionally, the code will create a “users” table in the database and add a unique index on the email column to prevent duplicate entries.

In order to make it possible to reverse the changes made by the “up” migration script, open the corresponding “down” script and include the following SQL query. This query will remove the users table from the database.

migrations/20230126163950_init.down.sql


DROP TABLE IF EXISTS "users";

Once you have defined the SQL queries in the migration files, you can apply the “up” migration script to the database by running the command sqlx migrate run. To check if the SQLx-CLI applied the “up” migration script successfully, you can sign into the Postgres server and inspect the table.

To do this, open a new browser tab and navigate to ‘http://localhost:5050/‘ to access the pgAdmin container. Use the credentials provided in the .env file to log in. Then, click on the “Add New Server” button and enter the password from the .env file.

To get the IP address of the Postgres container, run the command docker inspect postgres in the terminal and copy it from the output. Paste it into the “Host name/address” input field and click on the “Save” button to finish setting up the server connection.

Sign into the Postgres container

Once you have added the server to pgAdmin, you can navigate to the rust_jwt_rs256 database to view the SQL tables created by the SQLx-CLI.

Inspect the SQL table and columns created by the SQLX CLI in pgAdmin

Create the SQLX Database Model

To map the “users” table to a Rust struct, we’ll create a new file called model.rs in the src directory and define a struct with fields that correspond to the table’s columns. We’ll also implement the sqlx::FromRow trait, which will enable us to automatically map the columns of the table to the struct’s fields.

Here’s the code to add to the model.rs file:

src/model.rs


use chrono::prelude::*;
use serde::{Deserialize, Serialize};

#[allow(non_snake_case)]
#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)]
pub struct User {
    pub id: uuid::Uuid,
    pub name: String,
    pub email: String,
    pub password: String,
    pub role: String,
    pub photo: String,
    pub verified: bool,
    #[serde(rename = "createdAt")]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(rename = "updatedAt")]
    pub updated_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Deserialize)]
pub struct RegisterUserSchema {
    pub name: String,
    pub email: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginUserSchema {
    pub email: String,
    pub password: String,
}

The RegisterUserSchema and LoginUserSchema structs will come in handy to validate user input during account registration and login. Typically, we would keep these structs in a separate file, but for simplicity’s sake, we’ll include them in the src/model.rs file.

Create the API Response Structs

Next, we’ll create structs to represent the API responses. To do this, create a new file named response.rs and add the following structs:

src/response.rs


use chrono::prelude::*;
use serde::Serialize;

#[allow(non_snake_case)]
#[derive(Debug, Serialize)]
pub struct FilteredUser {
    pub id: String,
    pub name: String,
    pub email: String,
    pub role: String,
    pub photo: String,
    pub verified: bool,
    pub createdAt: DateTime<Utc>,
    pub updatedAt: DateTime<Utc>,
}

#[derive(Serialize, Debug)]
pub struct UserData {
    pub user: FilteredUser,
}

#[derive(Serialize, Debug)]
pub struct UserResponse {
    pub status: String,
    pub data: UserData,
}

The FilteredUser struct will come in handy when filtering the record returned from the database query to remove sensitive information such as the hashed password.

Generate the RSA 256 Private and Public Keys

Let’s proceed with generating the asymmetric keys, consisting of a private and a public key. Please follow the steps below to complete this process:

  • To start, go to the Online RSA Key Generator website and select a key size of either 2048 or 4096 bits. Click on the “Generate New Keys” button, and wait patiently while the keys are generated. This process may take a moment.
  • Once the keys are generated, copy the private key and convert it to base64 format using https://www.base64encode.org/. Then, copy the base64-encoded key and add it to the .env file as the ACCESS_TOKEN_PRIVATE_KEY value.
  • Similarly, copy the corresponding public key of the private key from the “Online RSA Key Generator” website. Convert it to base64 format using https://www.base64encode.org/ and copy the base64-encoded key to the .env file as the ACCESS_TOKEN_PUBLIC_KEY value.
  • To generate the private and public keys for the refresh token, repeat the same process as the access token. Convert the private key to base64 format and add it to the .env file as the value of the REFRESH_TOKEN_PRIVATE_KEY field. Similarly, add the corresponding base64-encoded public key as the REFRESH_TOKEN_PUBLIC_KEY.

We had to convert the private and public keys to base64 formats to prevent any unnecessary warnings in the terminal when Docker Compose retrieves the pgAdmin and Postgres credentials from the .env file.

After successfully following the aforementioned steps, your .env file should now resemble the example below. If you have difficulty generating the private and public keys, you can use the following pre-defined environment variables.

.env


POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=6500
POSTGRES_USER=admin
POSTGRES_PASSWORD=password123
POSTGRES_DB=rust_jwt_rs256

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

DATABASE_URL=postgresql://admin:password123@localhost:6500/rust_jwt_rs256?schema=public

PORT=8000
CLIENT_ORIGIN=http://localhost:3000

REDIS_URL=redis://127.0.0.1:6379/

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS0FJQkFBS0NBZ0VBME05dm5IWExKZFgzbk1hWXM4OHVvd1dRS21NSWRNMXVzbGN1MUhZdW01NWs1RE1yCm9pclBXcjcyQW5uVUhVZDczSmo2b3kzSGtYMmZrU3NGSkpVSitZdlQrL3RSRHpGdHlMWXJrbUxFVnJNbmVjVSsKeis0RHJVYitDdmkwUitXWmorMDRLdU1JdTNjSU5ONjh5ZWtQSjB4VVRQSm04bWNtT1ZGN1NJUVBxRXJKR3NtRgp2dTJZOEZGdmo5VkluK2Z3ZmRBeHJhRTEyem05WlhkWnloL2QvU05wZUgxWkVXYmVnSmhPTUJzWWlLcVhMS3V5Clc5bm5uRld2QUNTbGtoYjFLVlY0UW1TV0FVVnNnMEdTMGo5QlFrVkQ1TEZBVWpndDlzSzVDRWtxRGhpS1pNQXIKVFpWVU12eDcwTHRoZmpRNng0ZXljVEVNeG10dXRqam1kYXRQcmJFWGZleHNqNTRIVHlwSzNwSU1ncW1OVTFjNQpHYVFlcW5CSElNa0o2MTk1cUZ4WE5HejE5c2liQlkzTlpleE5HWmc0bkdGTjdrUW9QR2FWdHMyYXdiVU4xL0JZCnBjN0FpSnh5RFg5SkFweUFSUWgxcmxDVkdXb3daQ05WRkJ4OWNMTjBDeGpyYi9td0sxSkRmMHFmSms3QmpyVHcKTnVzL1k5NUp5TE1JSHNvTlpRYk1uL095N2pmMXVjV3dNUkRnYjhqSDdxa2tCQ2F3OW1md2djZVE0cVBtZzFsMgovMjVmQzh1eGlJdWRZWCtQZjBaSVVkQ09zTDllT2xYYWJGcTA4UG5jUmFuRzBFcHRsNnV6eTVuNi9waHdEK0R0Cmh1RE5ycURoNjVTUy9uU1JEVWRHbGtITms0RlByZGNRK0kraWtBZDM1RnJVb0l3ajRjT0VLa0JyT1Q4Q0F3RUEKQVFLQ0FnQnpCUkN4MnFEZ1lwQldwMzZON1YzL0pwMVcrOTQ0bU1DVk5EanpoM1g4K3E4UWxLOUFVTnlQWEFrZgpMQVNQYkVUcUtzcEZBSDZod2RVWG5kN2pXOFYyMUhNY3BqN3NZNG5adVo4ZXI1RC9RUWhKcDBFR1FGRitMVkRhCnNreDhIaGtNa3RzUnBLVzJ2Y2FqZU4zOVNvZXlXZlZGdlhDL3JkbjhVTW5jRkFLYjdUWUJyMmdnMTdnYkNJQ3YKZGdqZkxGL29yYm52cnBHQUJMb3pIaDh6bTRJb1lrMUN0YWxPVUovWHJnM0RxZWxGdnRJdkpSVEdTNjJ0Qy9XdAoyb0hwaXdQWWxOLzlrbktlbUtOQldlbUtMcFcvNzIrS2xhaWNvWjJRQTRydzZYeGs3MWVzVDc2S3Flc0xldENwCkZjNktPakwybmVUSlBQK1FmTFVyWXdSdlpNSXFKOVBVQjZUR1BIRVpsSmROQml5VlNya2d2S2R1NjllemJZZmgKQkRJeXh2Mnh4Q0pSTFU1VUJXb2I0YWp6RWlQZkhmSkIvUnNrOGdVNGNrc0Z2U0ZhZHpPU1hlNlZEYjNRR3NZNgozdFFlK2xsem5lOFVFWTg1NGg2L0JiRENWbHVEa2UxNTk5Ny8yam9MUnl0U0EySGxXc1N4MW41SFp5ZDZ1a1NpCkd4bXgvNHN6b2NGZ1FYVnhhMTljdVlIZXFSK2haa3FGaC9EYTh5UVNsOWRHYXh4WkF5RWplMzBWdjdIeEcxQ0MKQjM4RjZSUmh5Qm9LSnpRbnRNVlY2YXc2Q2FZMk43YS9hRFBLWjRONU5YY0dDKzZSRHh3b0M5bFNleXRrbkRCago1UWVIZmJMai9mRzhQWUU1NnRSWnNEZGNNVmg4SllDdk1acG1uUW9Qb0lUYU9PenNRUUtDQVFFQTl0bzZFOXhnCmZTa1NJMHpDYUdLNkQzMnBmdFJXbkR5QWJDVXpOYk5rcEJLOHJuSGlXanFJVDQzbVd6empGc0RvNlZwVXpscFQKYVVHWkNHMXc5THpHaWlaNllBd0F4TXBOZERzMFFOemhJNjMzS0tseHd0NGlUaGQ0aG9oYmZqdndGWHkyQ0paWgovUkkyZ1AwUEdvSENURXFMMTgzQklpYnJJR0g4dzY2K0F5cFc2L3cvdENEQ2NReFA1RE45YlNPSmFlQVI0a1NzCjg3REM1bmdNMVhJeVFpSCtvL21zaEpUS3ZhZUVpeTVmM1BaaExJNWZNQlZwN0tWTUNZY3V2NWZ4Y3pHVHZFM1YKcHcxamJmSzRDdG9xemFmK3hrdUk5ZWNjakp4TU5KRGc0QW5CNEpxWm11Y2dQWGJPdEpRR2VHaHZqZlBqTVZHZworTHhzSUFWZE8vRjFtUUtDQVFFQTJJeFNNK1VZOTFoem5vUURSbzV4WWVGS0dUeDhVZ00rWDdycURzTXp6NUVSCkRWKzh5WlNsY29NVjNlcGVSdjFHYlRodEUvTlZ4c1k2SW5yUkVJNHB2WFJqYkxqZDZPVkJYWENsYVl1YWsyV20KV2QxTVo4dDZRMUtVWXBFS0piZVRMN09SUmtibnIzTHhmWGJ2WTRPV1BaQjZyNktoaXljbTFubUNJU0hiMFh5Mwp1WHY1VVZEYVZWdklnS0RkNGhrRGZSWmEzNEZZUDYvcUFzMzkyWkJnclpvbVk0SkFMN2F0RnpmWVVZMUtlamV3CmpJWCtpQmRkdkd0cXQ0ZzYwQkgzQUxCZjJFb0Q4bkluaHRuUWtSd0d5QnRFN1pRVGdCYzRJbm5mR2tMZTRpWDkKQlZaSFgxb0VHWUp3RkVUNk1zUHFwcU8yWDhPT21YRDFFVFhUTUVjOGx3S0NBUUFmMWQwUG1xaEcrL2orM0hObQpDdlY3OGZUZUNueHhBY3grSmY0SXV1NEx5dTdTZ0pWMGxYL200cUlHdWo5L083bk4vbnhaY0lTNVdtQm1HZGNyCmVQMFI3QXgwUHBnS3lSeGNGUmFVRnVoaU5abGVnUnZPeWQ4YXV5UXNGWUhYTWR1d3FiakFPc080UTVVTDVaY0IKRUNNQ3U4cDFObS9sKzZidk1qUHErS3BBdGtFbmhneWhLbWhwTS9GSnVPcEFIUWtud21JTUVGZE54a29jZHZjUQp2LzJEVWVjSk5yWHRFMU5pU2l4cDFyMCtQZmdpU3VvenhVODMyY21Jb1FxQ1l4SWNqUlJFZ0xWQktoVGNwU1RmCklXdkx3aEsxZUNCZHRrU1VUY1AyTTRrTTI3VkpSaWJ4TjBXTko3bFl5STVkRVByeUQ3WUpNa0hVVWxpUGVLR2gKalc1aEFvSUJBQWdWQktSbk1vMVl3Y2Z5eVdTQ3dIeVV1ZjFESXFpMDhra0VZdVAyS1NMZ0dURFVsK2sySVE2cgpFYy9jaFhSRTA3SVQzdzVWa0tnQWtmN2pjcFlabURrMzlOWUQrRlJPNmllZ29xdlR5QXNrU2hja2lVdCticXZBCmswVXlnSnh6dzR5T09TZlVVYVZjdHVLbDQ3MWxGZUJxV2duZ0dnTmxqSytJalhETElMY3EzbmlQeGZoZytpVWgKYmRSUExMalpraVhEQmRVOXNKdC81MDMvZmkvMmtZVXBNYkdaRk9neSt6YllvTHc2ZDhNai9QVGhzMlJFNnZ5egpUYUpYOVVuNndhdEc2ZXphcGxjUUo2V0N6NlA2MWMzMkpwWnZabUxyZXU3ZWVaTXpWN285RExwOFErR3RMR1gvClZrdUxYNE14aUxwN2RiMFJRV3M4cWdqZ1oyZHY0VFVDZ2dFQkFMRjRiNnhRNjJJaCtMaTdsVk9lSWQ5VFVub08KUU1LUVNRN0xlWjJ4TmhCYWRPUEt0ZmJ5U0dGMGZieXZiVWk2czAyVnJpWC93S1V6T2o1WEFUbUZYQVdzYnU1dwo2M1JVR09ua2Z6cjIwWDZJWTVzOS9kdnJWZXFLNkpLdlQyZ0F0dWMwNXNCZzJPaG5CdHh2c0JDekhYVy9YRWJsCktWamVIMUxQTnZMaFNSc3BvT2FFVUhlaHpNN2c1V3FGSXhSQmRlb2J1SWNxQ1J2WjRFZGl6b05ybzVRZXFub3oKMTlyU0VVcTNBMEdIdE5Pb0xuV2Q3ZkZta2NOMEw5S3R0MTdsK2wxV0c3Y2kxVTVuSXBlOXBxZThlUUU2YmNYaApkNnlkdWd3UUpXbUxKSlpMQUs3eFpZdzd1ODhoa3ppZ2pSR2ltWHZ4VTJCMTU5OW5OT2NrNWQ0YXJTRT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0=
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUEwTTl2bkhYTEpkWDNuTWFZczg4dQpvd1dRS21NSWRNMXVzbGN1MUhZdW01NWs1RE1yb2lyUFdyNzJBbm5VSFVkNzNKajZveTNIa1gyZmtTc0ZKSlVKCitZdlQrL3RSRHpGdHlMWXJrbUxFVnJNbmVjVSt6KzREclViK0N2aTBSK1daaiswNEt1TUl1M2NJTk42OHlla1AKSjB4VVRQSm04bWNtT1ZGN1NJUVBxRXJKR3NtRnZ1Mlk4RkZ2ajlWSW4rZndmZEF4cmFFMTJ6bTlaWGRaeWgvZAovU05wZUgxWkVXYmVnSmhPTUJzWWlLcVhMS3V5Vzlubm5GV3ZBQ1Nsa2hiMUtWVjRRbVNXQVVWc2cwR1MwajlCClFrVkQ1TEZBVWpndDlzSzVDRWtxRGhpS1pNQXJUWlZVTXZ4NzBMdGhmalE2eDRleWNURU14bXR1dGpqbWRhdFAKcmJFWGZleHNqNTRIVHlwSzNwSU1ncW1OVTFjNUdhUWVxbkJISU1rSjYxOTVxRnhYTkd6MTlzaWJCWTNOWmV4TgpHWmc0bkdGTjdrUW9QR2FWdHMyYXdiVU4xL0JZcGM3QWlKeHlEWDlKQXB5QVJRaDFybENWR1dvd1pDTlZGQng5CmNMTjBDeGpyYi9td0sxSkRmMHFmSms3QmpyVHdOdXMvWTk1SnlMTUlIc29OWlFiTW4vT3k3amYxdWNXd01SRGcKYjhqSDdxa2tCQ2F3OW1md2djZVE0cVBtZzFsMi8yNWZDOHV4aUl1ZFlYK1BmMFpJVWRDT3NMOWVPbFhhYkZxMAo4UG5jUmFuRzBFcHRsNnV6eTVuNi9waHdEK0R0aHVETnJxRGg2NVNTL25TUkRVZEdsa0hOazRGUHJkY1ErSStpCmtBZDM1RnJVb0l3ajRjT0VLa0JyT1Q4Q0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
ACCESS_TOKEN_EXPIRED_IN=15m
ACCESS_TOKEN_MAXAGE=15


REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS0FJQkFBS0NBZ0VBbGEyend2WGFoL3pmdXdWN3lQY0FHTG0rY2ZXaVExUm00QWtFZW1wUjZCNmxTMThrCjhsN2Vub0puTGNLY3pLRXhsY2lQKy9uTk5oQlZuNTc2QXh3RERHdmpoRFdqTHIrZzdiUUhYVmozZVNnaG5HUVIKNlhjMTdJNGRzZXd4NVZIYlNXbitWbEt4UkZ4a0xVQVUyUWFVOTNrTFh3UlVjeFhJMngrNFFZeUkra0JGbXdndgozRjB3QXRqS1hFTmhrcmxiMjNsUTNST2l5enRhQWJwcmVYWFVWbGhwM0tGTjZHb052VFRjbEk0MitoYjlZcndDCnN2SkEwdGVxZzdqSWlOTE1XdW41QVg3VlgxOWI0VEt2TzAzc29BMWM5aDVzRHZ2anRvTmdUV25PUFNFanFqNEEKaGhnK09ZWHF0YmROWk5Ld1F3bVhCMGlzVDdwdTEyZ1g3YWU5OWNaeVhPM0dGTkpkTmcvOURCZDBuNkdQL0ZzcQpCZ2lmdkJqNTVUR3J4VDRmNGJUanYyN0xsNzc4Y1h0U2R1aThwY0dMb0E5R2RlZmViTGJCenN0TzZTMzd2ZkR2CjZNQ1oxdHlINW5aWVAzaE5rZlJ1M1pYbUlUSDVwM01zcUpwcVowUGw5ZDVlWXFidG1LMnpIVUVUT1A3bjJpSVQKR1B0RHVWeTQ3SE9QYmVISDVteTVhYzIwQUNHeUlNeXFCSUo3SXZSVDNBUm9QV1V5T2lZKzZkNXJCZjk5ZkpCdAo0NkJZOFVxZDVrcUFULzFjYjArN0NJTm5zRSszV29xYzVQOW94UW1XbnYvRjRURnQ4RUx4em13WGtmQTVhN3JoCmtKZUZqL3JjMXdCOW5sc3J4MHBsWmxwMW80cFlzQVdGQ1hrNzZCSmZiZmtsUGduTUxwbDcxc2VDekIwQ0F3RUEKQVFLQ0FnQitTT1lJTWVKRkpnZW1CWVJoRkhVU1ozVFZOWWZJQXVnaFVicGpobHpBMlVwaEEwOXE1cnd4UkpqRgpOUk9TV3RZNUo5VERwZ21MK2RBa01yK0I3QnB1V29ERlJYUCt0MU9SK25qVU80SGd5UWxDcC9PczVSV3NGbVBiCmdBckJEb1ZUdFlnUFVRbWJRZENMbFN1QnlGbmJTbGRidlkxNjVBQnBVS1BuT2lrLzZ3WlBQV01VSzlPY00walkKKzBqUndHNU9DRmMvajVla25OamQ2R2xST3ljQ0N1cVdhY29QczVzUDdnL0ZqdyszaGJvWG1jVTFNY3VibUxhWApHRXFwbGlFdys0Tkp6YmM5Rm5tdzBWQ2pXcVd3akZYSW1mWlYxaFJVSXhnWGVKTzNZOFJ4bUlwY21RdTNBTlA4CnFVRTFOY1hkYWJQeFExR09teDkxd3ErZHBnOVFpYnN0cnMxZ00wcnRVSmtTRGhjQ2Z4S1ZVekYzSFNZejZpbk4Kc1ZETjRFRGJjTXdHY3huMWh4ZkZPS0haL1hCcmNWLzA5TllPRTdzRGN2TSt5SzF0SEJvRXVscmh0SmJ5YUUyNwpNR3VCaGovbTF0OGhUN25TemdFRnhackFLWHByTzVMdzJPSWo5UmxoenMzME9GbjVDeU04a1ZNS1MxODRkcmdYCnJ3OEV5KzFxUzE0MVdUQWp0ZE9mRW85Q3kyT1U3SGM0N2pPZjdrSS9vTmdWaGQ2QXNUc01RNFRYai93Ymh1ajIKM1l0THkyeElVVVNyRlNZRU9mNFdHTlZjeHVGakk0SnR2Rk9JRTN4MXE4blJ3NkZabDBITkd5QU9SL2tNWlcydwpKZjZWNyszL05yOXJIeHFRRVBpRWEydGx1UUpxbjV0WU9NcEx3SzNhaWh4ektsUmJpUUtDQVFFQXllcUQwUFVQCkdyeWk1WTRoWG1OcGtaaDE4dldsZ2F3eEllS2YrTGd4eEswTE9ZNDk2NW9kUDFVNTFFRUdZSGxFcVVnWW9TSDkKOFY4dFROd1JwWDF6ZTE0LzdYM2NCWHZsTEEwWDRBdm4vNXFKYm9QRUhncFIvSTZyc1pxYnVHRUFhT3N5ME9abAp0aVJNRHU4VWtkTUZBL3lYYVgycWYrRG42YktIWFRtcXVrc3dhejNmNFI0RUUvNjBheTFJeU9BeERDNlJJSnFaCnhSRXJJcWttZHFzR3k5bmEvQy9RYVlBcDJNRDRjWDRkbmVhU2h6Z2hSMWgrWmROV3h4cWhqV3JwMWRkNmMrWk4KekxQcjY0Q2Fmb3c1OWdwLzJld0xOaEFSc1dyclF6OFhLeHFmUVRnN0ZLQm8zangwNWp0WUlaS0lKV0xLZGdhUwp1UklHSUQ3eGt0cFpmd0tDQVFFQXZjVTdqNGRaTGlCVi84TmwvWkwwTXRDa1pBR2ZzWGw4dU9WQU1IZW1QM3JwCjRkbDFaNnYvb05NRjZJQXVjeWlmelR4dlJLNjg1c3dJTlVmOEFKRGRwZXBDZTFjdG1INEpHZUdMWmhic1lFc3cKOEFydTRSM1BNT0dxS0Y0OEZsbktVQTZhY050TEZmZDJvNnlvY0Y0MCtaeXh3QTdhdVllR0ljNTNEZ2FoYUd4Mwo5bitVOXVLSjYzL0FLNGV3dlhsWmhYNUhzRTdmN0sxb0pxdkRMSXRGS01CSVhwaTJVOUpUME1XRm1jK2tkMTVKCjZONTZvVEppWXplNjg0VzRGbGJqSFd4OFlTQU1ad0xpZGFZOEQzVUEvV1l3czNZWDNncG5oa3gxWUJSL3hMaUoKYW1GM3c0MUpOa2Q4UHNmVXRsSmg3UHBPWEQ3aGZSdzRoNmFGMnlYUVl3S0NBUUF1MzZCR0svMmJxVnJ2aTNVMwpva0JwcWtrSFkvdE9CUmxLMG45c2orWU4wRllnd0dLamhSMXhER25tV2tvT3IxZy9MQnQ3bkphRktDRXVESkNVCktIRmNuRjZlMVc3MFh2U3VxME4xb1kzMENuNEpCOUhKWDMvMDczSHdRd0lQWllWZzFlandFZXhld2tKZDNTYWIKUzYrSVkyVUsrajlRZkhlYUN2WGRzSHR2ei9DbmxLK2FaUXR4VU5tMVg4ZmJ5aC9Zd2g2eXdQRWRqSVRGQVJ1Swp4TjFKQ1lRS3MxYmdodjR2OFd3N2ZKbUhoSFZUcXJZZkIrNGYyVlgxMXJyV1I1R05NUDZlVlVLT1dONVZ4MzhXCkRadVBBSlQ1bEJCdU5vREUvUnNzZTBMM29MQ0R4WGdCcTlOc2RBQjNTaU9GZDZ6ZmNQV3JQSTluSTBZRXlsZnUKVFg0bEFvSUJBRzdhWnVkNXpldDI4aVdjYzlpRFhtak1uaXJaRS9ydEY2RStNWmZlWE52YUpnTkxMeHpuU1VVZAozK2FuOGZwTk1jUUcySXlMY2tkenlodXR1QlJ3aXpsZk5ZU3RNVEpSOVdrTDZvMHhPTlVyTnlRUmp1Y3JyWnRGClIwdWJlSWdwM1ZlVW9EenFyTnJoR29tVDB6VUlvdk5veUNDRHpOcnh3clcrMEtiOTBvMllSeDlUK2FXYVFheXkKaklRaEdHb21GOWcySXhSbmpzREhydjVmK1h2c3d2S0NHQVJDT3NlT0ptM2U1Q01zTzB1TFphdEZRdWNrOG5vNAoxTmxxTkZYQVhaMFRnVGlQS3crRmpObml5RlRUS1VmY3lQZ2NOT2I4dHVxcGdTc2w3bGp3M3p5b1FQaVhjTHZuCldEbW9LNlp4UzBqT0VyWXArVGhISXZLQ29OQ2FMemNDZ2dFQkFJeFdyL3MvSjdIZmovc3hUU2xWamFBNHVKdEsKMTNXaW9kMnB3bFFxai9HanZra3VSYTVZQ0g5YlVNTmFqYXpTVmhYOUtkcURoSVJSTHBaTUNSMXlSUWtGb0NSSgpGbTJJbGJCaFlybWptdzEzRzNkL0xhb1RVWFlIMkZ0YnFEQlUyYmFsMEJTNlZOQkcxZUh4dVRtSys1Sk4waldUCkUzQmhtYnl5SnZjcFZ6RUJ1eXcwY1ROeGJncTcxRWRXYlpILzVRcitJSzk4VDh3NzM5N084dFY5cEZuV2MrcVcKejhWQVNiUEc1aEVJTStUbUlzRDFhV0lrWHRsemdxbTRSaFBob3diTmVMRkVkZ1YvVnc3cjBpRUlDbXFOYjVpTgoraTlEZUlZd25WY0FKZk5hV0dwaStRekd2MGl5Szk1TDI4TncrOUtSSzJVK0VnTWZnUzQ5c1lOMW44cz0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0=
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFsYTJ6d3ZYYWgvemZ1d1Y3eVBjQQpHTG0rY2ZXaVExUm00QWtFZW1wUjZCNmxTMThrOGw3ZW5vSm5MY0tjektFeGxjaVArL25OTmhCVm41NzZBeHdECkRHdmpoRFdqTHIrZzdiUUhYVmozZVNnaG5HUVI2WGMxN0k0ZHNld3g1VkhiU1duK1ZsS3hSRnhrTFVBVTJRYVUKOTNrTFh3UlVjeFhJMngrNFFZeUkra0JGbXdndjNGMHdBdGpLWEVOaGtybGIyM2xRM1JPaXl6dGFBYnByZVhYVQpWbGhwM0tGTjZHb052VFRjbEk0MitoYjlZcndDc3ZKQTB0ZXFnN2pJaU5MTVd1bjVBWDdWWDE5YjRUS3ZPMDNzCm9BMWM5aDVzRHZ2anRvTmdUV25PUFNFanFqNEFoaGcrT1lYcXRiZE5aTkt3UXdtWEIwaXNUN3B1MTJnWDdhZTkKOWNaeVhPM0dGTkpkTmcvOURCZDBuNkdQL0ZzcUJnaWZ2Qmo1NVRHcnhUNGY0YlRqdjI3TGw3NzhjWHRTZHVpOApwY0dMb0E5R2RlZmViTGJCenN0TzZTMzd2ZkR2Nk1DWjF0eUg1blpZUDNoTmtmUnUzWlhtSVRINXAzTXNxSnBxClowUGw5ZDVlWXFidG1LMnpIVUVUT1A3bjJpSVRHUHREdVZ5NDdIT1BiZUhINW15NWFjMjBBQ0d5SU15cUJJSjcKSXZSVDNBUm9QV1V5T2lZKzZkNXJCZjk5ZkpCdDQ2Qlk4VXFkNWtxQVQvMWNiMCs3Q0lObnNFKzNXb3FjNVA5bwp4UW1XbnYvRjRURnQ4RUx4em13WGtmQTVhN3Joa0plRmovcmMxd0I5bmxzcngwcGxabHAxbzRwWXNBV0ZDWGs3CjZCSmZiZmtsUGduTUxwbDcxc2VDekIwQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
REFRESH_TOKEN_EXPIRED_IN=60m
REFRESH_TOKEN_MAXAGE=60

Load the Environment Variables into the App

Here, you’ll create a helper function that will load the environment variables from the .env file into the Rust runtime, making them available in the application. To do this, create a config.rs file in the ‘src‘ directory and add the following code:

src/config.rs


fn get_env_var(var_name: &str) -> String {
    std::env::var(var_name).unwrap_or_else(|_| panic!("{} must be set", var_name))
}

#[derive(Debug, Clone)]
pub struct Config {
    pub database_url: String,
    pub redis_url: String,
    pub client_origin: String,

    pub access_token_private_key: String,
    pub access_token_public_key: String,
    pub access_token_expires_in: String,
    pub access_token_max_age: i64,

    pub refresh_token_private_key: String,
    pub refresh_token_public_key: String,
    pub refresh_token_expires_in: String,
    pub refresh_token_max_age: i64,
}

impl Config {
    pub fn init() -> Config {
        let database_url = get_env_var("DATABASE_URL");
        let redis_url = get_env_var("REDIS_URL");
        let client_origin = get_env_var("CLIENT_ORIGIN");

        let access_token_private_key = get_env_var("ACCESS_TOKEN_PRIVATE_KEY");
        let access_token_public_key = get_env_var("ACCESS_TOKEN_PUBLIC_KEY");
        let access_token_expires_in = get_env_var("ACCESS_TOKEN_EXPIRED_IN");
        let access_token_max_age = get_env_var("ACCESS_TOKEN_MAXAGE");

        let refresh_token_private_key = get_env_var("REFRESH_TOKEN_PRIVATE_KEY");
        let refresh_token_public_key = get_env_var("REFRESH_TOKEN_PUBLIC_KEY");
        let refresh_token_expires_in = get_env_var("REFRESH_TOKEN_EXPIRED_IN");
        let refresh_token_max_age = get_env_var("REFRESH_TOKEN_MAXAGE");

        Config {
            database_url,
            redis_url,
            client_origin,
            access_token_private_key,
            access_token_public_key,
            refresh_token_private_key,
            refresh_token_public_key,
            access_token_expires_in,
            refresh_token_expires_in,
            access_token_max_age: access_token_max_age.parse::<i64>().unwrap(),
            refresh_token_max_age: refresh_token_max_age.parse::<i64>().unwrap(),
        }
    }
}

Create Utility Functions to Sign and Verify the JWTs

Having obtained the private and public keys, the next step is to create utility functions for signing and verifying JSON Web Tokens. The private keys will be used to sign the access and refresh tokens, while the public keys will ensure their authenticity.

To get started, create a new file called token.rs inside the “src” directory and add the following code.

src/token.rs


use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenDetails {
    pub token: Option<String>,
    pub token_uuid: uuid::Uuid,
    pub user_id: uuid::Uuid,
    pub expires_in: Option<i64>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
    pub sub: String,
    pub token_uuid: String,
    pub exp: i64,
    pub iat: i64,
    pub nbf: i64,
}

Sign the JWT with the Private Key

This helper function uses the private keys to sign JSON Web Tokens using the RS256 algorithm. It first converts the base64-encoded private key back to its original string format and then uses it to sign the JWT.

The function then returns the TokenDetails struct containing the metadata of the generated token. We will store the token metadata in the Redis database so that we can invalidate the token when necessary.

src/token.rs


pub fn generate_jwt_token(
    user_id: uuid::Uuid,
    ttl: i64,
    private_key: String,
) -> Result<TokenDetails, jsonwebtoken::errors::Error> {
    let bytes_private_key = general_purpose::STANDARD.decode(private_key).unwrap();
    let decoded_private_key = String::from_utf8(bytes_private_key).unwrap();

    let now = chrono::Utc::now();
    let mut token_details = TokenDetails {
        user_id,
        token_uuid: Uuid::new_v4(),
        expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()),
        token: None,
    };

    let claims = TokenClaims {
        sub: token_details.user_id.to_string(),
        token_uuid: token_details.token_uuid.to_string(),
        exp: token_details.expires_in.unwrap(),
        iat: now.timestamp(),
        nbf: now.timestamp(),
    };

    let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
    let token = jsonwebtoken::encode(
        &header,
        &claims,
        &jsonwebtoken::EncodingKey::from_rsa_pem(decoded_private_key.as_bytes())?,
    )?;
    token_details.token = Some(token);
    Ok(token_details)
}

Verify the JWT with the Public Key

This utility function allows for JWT authentication using the public keys. It first decodes the base64-encoded public key to its original string format and then verifies the JWT using the key to extract the corresponding payload. The extracted payload is then used to construct the TokenDetails struct, which is returned by the function.

src/token.rs


pub fn verify_jwt_token(
    public_key: String,
    token: &str,
) -> Result<TokenDetails, jsonwebtoken::errors::Error> {
    let bytes_public_key = general_purpose::STANDARD.decode(public_key).unwrap();
    let decoded_public_key = String::from_utf8(bytes_public_key).unwrap();

    let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);

    let decoded = jsonwebtoken::decode::<TokenClaims>(
        token,
        &jsonwebtoken::DecodingKey::from_rsa_pem(decoded_public_key.as_bytes())?,
        &validation,
    )?;

    let user_id = Uuid::parse_str(decoded.claims.sub.as_str()).unwrap();
    let token_uuid = Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap();

    Ok(TokenDetails {
        token: None,
        token_uuid,
        user_id,
        expires_in: None,
    })
}

Create a JWT Authentication Middleware

Let’s implement an Actix Web JWT middleware guard to ensure that only authorized users with valid JWTs, either in the Authorization header or Cookies object, can access protected routes.

To achieve this, we’ll create a new file named jwt_auth.rs in the “src” directory and add the following code:

src/jwt_auth.rs


use core::fmt;
use std::future::{ready, Ready};

use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized};
use actix_web::{dev::Payload, Error as ActixWebError};
use actix_web::{http, web, FromRequest, HttpRequest};
use futures::executor::block_on;
use redis::Commands;
use serde::{Deserialize, Serialize};

use crate::model::User;
use crate::token;
use crate::AppState;

#[derive(Debug, Serialize)]
struct ErrorResponse {
    status: String,
    message: String,
}

impl fmt::Display for ErrorResponse {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", serde_json::to_string(&self).unwrap())
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct JwtMiddleware {
    pub user: User,
    pub access_token_uuid: uuid::Uuid,
}

impl FromRequest for JwtMiddleware {
    type Error = ActixWebError;
    type Future = Ready<Result<Self, Self::Error>>;
    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
        let data = req.app_data::<web::Data<AppState>>().unwrap();

        let access_token = req
            .cookie("access_token")
            .map(|c| c.value().to_string())
            .or_else(|| {
                req.headers()
                    .get(http::header::AUTHORIZATION)
                    .map(|h| h.to_str().unwrap().split_at(7).1.to_string())
            });

        if access_token.is_none() {
            let json_error = ErrorResponse {
                status: "fail".to_string(),
                message: "You are not logged in, please provide token".to_string(),
            };
            return ready(Err(ErrorUnauthorized(json_error)));
        }

        let access_token_details = match token::verify_jwt_token(
            data.env.access_token_public_key.to_owned(),
            &access_token.unwrap(),
        ) {
            Ok(token_details) => token_details,
            Err(e) => {
                let json_error = ErrorResponse {
                    status: "fail".to_string(),
                    message: format!("{:?}", e),
                };
                return ready(Err(ErrorUnauthorized(json_error)));
            }
        };

        let access_token_uuid =
            uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();

        let user_id_redis_result = async move {
            let mut redis_client = match data.redis_client.get_connection() {
                Ok(redis_client) => redis_client,
                Err(e) => {
                    return Err(ErrorInternalServerError(ErrorResponse {
                        status: "fail".to_string(),
                        message: format!("Could not connect to Redis: {}", e),
                    }));
                }
            };

            let redis_result = redis_client.get::<_, String>(access_token_uuid.clone().to_string());

            match redis_result {
                Ok(value) => Ok(value),
                Err(_) => Err(ErrorUnauthorized(ErrorResponse {
                    status: "fail".to_string(),
                    message: "Token is invalid or session has expired".to_string(),
                })),
            }
        };

        let user_exists_result = async move {
            let user_id = user_id_redis_result.await?;
            let user_id_uuid = uuid::Uuid::parse_str(user_id.as_str()).unwrap();

            let query_result =
                sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id_uuid)
                    .fetch_optional(&data.db)
                    .await;

            match query_result {
                Ok(Some(user)) => Ok(user),
                Ok(None) => {
                    let json_error = ErrorResponse {
                        status: "fail".to_string(),
                        message: "the user belonging to this token no logger exists".to_string(),
                    };
                    Err(ErrorUnauthorized(json_error))
                }
                Err(_) => {
                    let json_error = ErrorResponse {
                        status: "error".to_string(),
                        message: "Faled to check user existence".to_string(),
                    };
                    Err(ErrorInternalServerError(json_error))
                }
            }
        };

        match block_on(user_exists_result) {
            Ok(user) => ready(Ok(JwtMiddleware {
                access_token_uuid,
                user,
            })),
            Err(error) => ready(Err(error)),
        }
    }
}

When a user attempts to access a protected route, the JWT middleware will first search for the token in the Authorization header. If it is not found there, it will check the Cookies object for the access_token key. If the token cannot be found in either location, the middleware will send a 401 Unauthorized response with the message “You are not logged in, please provide token” to the client.

However, if the token is present, the middleware will call the token::verify_jwt_token() function to verify its authenticity. If the token is valid, the function will return the token’s metadata, which will be used in the next step to query the Redis database to check whether the user associated with the token has a valid session.

If the token metadata is found in the Redis database, it indicates that the user’s session is still active.

If the user has an active session, the middleware will use the user’s ID returned from the Redis query to check if the user associated with the token exists in the PostgreSQL database. If the user is found, the middleware will return the corresponding record obtained from the query.

Create the Actix Web Route Handlers

Now that we’ve covered quite a bit of code, we can proceed to create Actix-Web routes for various user actions such as registration, sign-in, log-out, refreshing access tokens, and retrieving authenticated user information.

To get started, we need to create a handler.rs file within the ‘src‘ directory and include the following dependencies and modules.

src/handler.rs


use crate::{
    jwt_auth,
    model::{LoginUserSchema, RegisterUserSchema, User},
    response::FilteredUser,
    token, AppState,
};
use actix_web::{
    cookie::{time::Duration as ActixWebDuration, Cookie},
    get, post, web, HttpRequest, HttpResponse, Responder,
};
use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

use redis::AsyncCommands;
use sqlx::Row;
use uuid::Uuid;

To ensure that sensitive information such as hashed passwords is not exposed, let’s create a filter_user_record function that utilizes the FilteredUser struct to filter the Postgres database records.

src/handler.rs


fn filter_user_record(user: &User) -> FilteredUser {
    FilteredUser {
        id: user.id.to_string(),
        email: user.email.to_owned(),
        name: user.name.to_owned(),
        photo: user.photo.to_owned(),
        role: user.role.to_owned(),
        verified: user.verified,
        createdAt: user.created_at.unwrap(),
        updatedAt: user.updated_at.unwrap(),
    }
}

Register User Route Function

Here, you’ll implement a route handler for the #[post("/auth/register")] macro that will handle user registration requests. When a registration request is received by the Actix-Web application, the handler will first check if a user with the submitted email address already exists in the database by performing a query.

If the query returns a user with the same email address, the handler will send a 409 Conflict response to the client, indicating that a user with that email already exists. However, if no user with the email is found, the password will be securely hashed using Argon2, which was chosen over Bcrypt due to its faster hashing speed.

src/handler.rs


#[post("/auth/register")]
async fn register_user_handler(
    body: web::Json<RegisterUserSchema>,
    data: web::Data<AppState>,
) -> impl Responder {
    let exists: bool = sqlx::query("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
        .bind(body.email.to_owned())
        .fetch_one(&data.db)
        .await
        .unwrap()
        .get(0);

    if exists {
        return HttpResponse::Conflict().json(
            serde_json::json!({"status": "fail","message": "User with that email already exists"}),
        );
    }

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .expect("Error while hashing password")
        .to_string();
    let query_result = sqlx::query_as!(
        User,
        "INSERT INTO users (name,email,password) VALUES ($1, $2, $3) RETURNING *",
        body.name.to_string(),
        body.email.to_string().to_lowercase(),
        hashed_password
    )
    .fetch_one(&data.db)
    .await;

    match query_result {
        Ok(user) => {
            let user_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "user": filter_user_record(&user)
            })});

            return HttpResponse::Ok().json(user_response);
        }
        Err(e) => {
            return HttpResponse::InternalServerError()
                .json(serde_json::json!({"status": "error","message": format_args!("{:?}", e)}));
        }
    }
}

After the password is securely hashed using Argon2, the newly registered user’s details will be added to the database. To ensure the user’s privacy and security, the resulting database record will be filtered using the filter_user_record function.

This will remove sensitive details, such as the hashed password, leaving only the non-sensitive information that will be included in the JSON response.

Login User Route Function

Let’s implement a route handler for the #[post("/auth/login")] macro that will handle user sign-in requests.

The handler will start by querying the database using the sqlx::query_as! macro to check if a user with the provided email address exists.

If a match is found, the Argon2::default().verify_password() method will be used to compare the plain-text password in the request body against the hashed password.

src/handler.rs


#[post("/auth/login")]
async fn login_user_handler(
    body: web::Json<LoginUserSchema>,
    data: web::Data<AppState>,
) -> impl Responder {
    let query_result = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", body.email)
        .fetch_optional(&data.db)
        .await
        .unwrap();

    let user = match query_result {
        Some(user) => user,
        None => {
            return HttpResponse::BadRequest().json(
                serde_json::json!({"status": "fail", "message": "Invalid email or password"}),
            );
        }
    };

    let is_valid = PasswordHash::new(&user.password)
        .and_then(|parsed_hash| {
            Argon2::default().verify_password(body.password.as_bytes(), &parsed_hash)
        })
        .map_or(false, |_| true);

    if !is_valid {
        return HttpResponse::BadRequest()
            .json(serde_json::json!({"status": "fail", "message": "Invalid email or password"}));
    }

    let access_token_details = match token::generate_jwt_token(
        user.id,
        data.env.access_token_max_age,
        data.env.access_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let refresh_token_details = match token::generate_jwt_token(
        user.id,
        data.env.refresh_token_max_age,
        data.env.refresh_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let mut redis_client = match data.redis_client.get_async_connection().await {
        Ok(redis_client) => redis_client,
        Err(e) => {
            return HttpResponse::InternalServerError()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let access_result: redis::RedisResult<()> = redis_client
        .set_ex(
            access_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.access_token_max_age * 60) as usize,
        )
        .await;

    if let Err(e) = access_result {
        return HttpResponse::UnprocessableEntity()
            .json(serde_json::json!({"status": "error", "message": format_args!("{}", e)}));
    }

    let refresh_result: redis::RedisResult<()> = redis_client
        .set_ex(
            refresh_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.refresh_token_max_age * 60) as usize,
        )
        .await;

    if let Err(e) = refresh_result {
        return HttpResponse::UnprocessableEntity()
            .json(serde_json::json!({"status": "error", "message": format_args!("{}", e)}));
    }

    let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(true)
        .finish();
    let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(
            data.env.refresh_token_max_age * 60,
            0,
        ))
        .http_only(true)
        .finish();
    let logged_in_cookie = Cookie::build("logged_in", "true")
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(false)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(refresh_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success", "access_token": access_token_details.token.unwrap()}))
}

If the passwords match, the token::generate_jwt_token() helper function will be called to sign both the access and refresh tokens with their corresponding private keys. The metadata of each token will be securely stored in the Redis database using the redis_client.set_ex() method.

Finally, the tokens will be included in the response as HTTP-only cookies, and a copy of the access token will be sent in the JSON object. This will enable the user to include the access token as a Bearer token in the Authorization header of future requests that require authentication.

Refresh JWT Route Function

Let’s create a route handler for the #[get("/auth/refresh")] macro that will manage the renewal of access tokens that have expired.

Upon invocation, the function will first retrieve the refresh token from the Cookies object and then validate its authenticity using the token::verify_jwt_token() method. If the validation is successful, the token’s metadata will be returned and assigned to the refresh_token_details variable.

The function will then check if the user has an active session in the Redis database using the redis_client.get() method and the token_uuid present in the token’s metadata as the key.

If the user has an active session, a query will be made to the Postgres database to verify that the associated user still exists.

src/handler.rs


#[get("/auth/refresh")]
async fn refresh_access_token_handler(
    req: HttpRequest,
    data: web::Data<AppState>,
) -> impl Responder {
    let message = "could not refresh access token";

    let refresh_token = match req.cookie("refresh_token") {
        Some(c) => c.value().to_string(),
        None => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let refresh_token_details =
        match token::verify_jwt_token(data.env.refresh_token_public_key.to_owned(), &refresh_token)
        {
            Ok(token_details) => token_details,
            Err(e) => {
                return HttpResponse::Forbidden().json(
                    serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}),
                );
            }
        };

    let result = data.redis_client.get_async_connection().await;
    let mut redis_client = match result {
        Ok(redis_client) => redis_client,
        Err(e) => {
            return HttpResponse::Forbidden().json(
                serde_json::json!({"status": "fail", "message": format!("Could not connect to Redis: {}", e)}),
            );
        }
    };
    let redis_result: redis::RedisResult<String> = redis_client
        .get(refresh_token_details.token_uuid.to_string())
        .await;

    let user_id = match redis_result {
        Ok(value) => value,
        Err(_) => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let user_id_uuid = Uuid::parse_str(&user_id).unwrap();
    let query_result = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id_uuid)
        .fetch_optional(&data.db)
        .await
        .unwrap();

    if query_result.is_none() {
        return HttpResponse::Forbidden()
            .json(serde_json::json!({"status": "fail", "message": "the user belonging to this token no logger exists"}));
    }

    let user = query_result.unwrap();

    let access_token_details = match token::generate_jwt_token(
        user.id,
        data.env.access_token_max_age,
        data.env.access_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}));
        }
    };

    let redis_result: redis::RedisResult<()> = redis_client
        .set_ex(
            access_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.access_token_max_age * 60) as usize,
        )
        .await;

    if redis_result.is_err() {
        return HttpResponse::UnprocessableEntity().json(
            serde_json::json!({"status": "error", "message": format_args!("{:?}", redis_result.unwrap_err())}),
        );
    }

    let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(true)
        .finish();

    let logged_in_cookie = Cookie::build("logged_in", "true")
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(false)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success", "access_token": access_token_details.token.unwrap()}))
}

If all the verification steps are completed successfully, a new access token will be generated, and its metadata will be stored in the Redis database. The token will then be included in the response as an HTTP-only cookie and in the JSON object.

Logout User Route Function

Let’s create a route handler for the #[get("/auth/logout")] macro that will handle user logout requests.

When this handler is invoked, it first retrieves the refresh token from the Cookies object and verifies it to obtain its metadata. The metadata associated with both the access and refresh tokens is then removed from the Redis database using the redis_client.del() function.

This additional security measure ensures that any existing session associated with the tokens is invalidated, thereby preventing any unauthorized access even if the tokens are compromised.

src/handler.rs


#[get("/auth/logout")]
async fn logout_handler(
    req: HttpRequest,
    auth_guard: jwt_auth::JwtMiddleware,
    data: web::Data<AppState>,
) -> impl Responder {
    let message = "Token is invalid or session has expired";

    let refresh_token = match req.cookie("refresh_token") {
        Some(c) => c.value().to_string(),
        None => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let refresh_token_details =
        match token::verify_jwt_token(data.env.refresh_token_public_key.to_owned(), &refresh_token)
        {
            Ok(token_details) => token_details,
            Err(e) => {
                return HttpResponse::Forbidden().json(
                    serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}),
                );
            }
        };

    let mut redis_client = data.redis_client.get_async_connection().await.unwrap();
    let redis_result: redis::RedisResult<usize> = redis_client
        .del(&[
            refresh_token_details.token_uuid.to_string(),
            auth_guard.access_token_uuid.to_string(),
        ])
        .await;

    if redis_result.is_err() {
        return HttpResponse::UnprocessableEntity().json(
            serde_json::json!({"status": "error", "message": format_args!("{:?}", redis_result.unwrap_err())}),
        );
    }

    let access_cookie = Cookie::build("access_token", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();
    let refresh_cookie = Cookie::build("refresh_token", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();
    let logged_in_cookie = Cookie::build("logged_in", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(refresh_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success"}))
}

Finally, the function will send expired cookies to the user’s API client or browser, effectively deleting the existing cookies. As a result, the user will be prompted to log in once again in order to access protected routes.

Get Authenticated User Route Function

To retrieve the account information of the authenticated user, we’ll create a route function for the #[get("/users/me")] macro. This function will be protected by the Actix Web JWT middleware guard, ensuring that only users with valid JWT tokens can access it.

When the route function is called, it will obtain the user record from the JWT middleware guard and pass it to the filter_user_record function to remove any sensitive information. The resulting non-sensitive details will then be included in the JSON response.

src/handler.rs


#[get("/users/me")]
async fn get_me_handler(jwt_guard: jwt_auth::JwtMiddleware) -> impl Responder {
    let json_response = serde_json::json!({
        "status":  "success",
        "data": serde_json::json!({
            "user": filter_user_record(&jwt_guard.user)
        })
    });

    HttpResponse::Ok().json(json_response)
}

Merge the Route Handlers

We can consolidate the route handlers by utilizing the web::ServiceConfig struct and the web::scope function provided by Actix-Web. This allows us to group them together under the same path prefix. To do this, simply add the following code at the bottom of the handler.rs file.

src/handler.rs


pub fn config(conf: &mut web::ServiceConfig) {
    let scope = web::scope("/api")
        .service(health_checker_handler)
        .service(register_user_handler)
        .service(login_user_handler)
        .service(refresh_access_token_handler)
        .service(logout_handler)
        .service(get_me_handler);

    conf.service(scope);
}

The Complete Route Handlers

src/handler.rs


use crate::{
    jwt_auth,
    model::{LoginUserSchema, RegisterUserSchema, User},
    response::FilteredUser,
    token, AppState,
};
use actix_web::{
    cookie::{time::Duration as ActixWebDuration, Cookie},
    get, post, web, HttpRequest, HttpResponse, Responder,
};
use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

use redis::AsyncCommands;
use sqlx::Row;
use uuid::Uuid;

#[get("/healthchecker")]
async fn health_checker_handler() -> impl Responder {
    const MESSAGE: &str = "Actix-web and Postgres: JWT RS256 Access and Refresh Tokens";

    HttpResponse::Ok().json(serde_json::json!({"status": "success", "message": MESSAGE}))
}

#[post("/auth/register")]
async fn register_user_handler(
    body: web::Json<RegisterUserSchema>,
    data: web::Data<AppState>,
) -> impl Responder {
    let exists: bool = sqlx::query("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
        .bind(body.email.to_owned())
        .fetch_one(&data.db)
        .await
        .unwrap()
        .get(0);

    if exists {
        return HttpResponse::Conflict().json(
            serde_json::json!({"status": "fail","message": "User with that email already exists"}),
        );
    }

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .expect("Error while hashing password")
        .to_string();
    let query_result = sqlx::query_as!(
        User,
        "INSERT INTO users (name,email,password) VALUES ($1, $2, $3) RETURNING *",
        body.name.to_string(),
        body.email.to_string().to_lowercase(),
        hashed_password
    )
    .fetch_one(&data.db)
    .await;

    match query_result {
        Ok(user) => {
            let user_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "user": filter_user_record(&user)
            })});

            return HttpResponse::Ok().json(user_response);
        }
        Err(e) => {
            return HttpResponse::InternalServerError()
                .json(serde_json::json!({"status": "error","message": format_args!("{:?}", e)}));
        }
    }
}
#[post("/auth/login")]
async fn login_user_handler(
    body: web::Json<LoginUserSchema>,
    data: web::Data<AppState>,
) -> impl Responder {
    let query_result = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", body.email)
        .fetch_optional(&data.db)
        .await
        .unwrap();

    let user = match query_result {
        Some(user) => user,
        None => {
            return HttpResponse::BadRequest().json(
                serde_json::json!({"status": "fail", "message": "Invalid email or password"}),
            );
        }
    };

    let is_valid = PasswordHash::new(&user.password)
        .and_then(|parsed_hash| {
            Argon2::default().verify_password(body.password.as_bytes(), &parsed_hash)
        })
        .map_or(false, |_| true);

    if !is_valid {
        return HttpResponse::BadRequest()
            .json(serde_json::json!({"status": "fail", "message": "Invalid email or password"}));
    }

    let access_token_details = match token::generate_jwt_token(
        user.id,
        data.env.access_token_max_age,
        data.env.access_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let refresh_token_details = match token::generate_jwt_token(
        user.id,
        data.env.refresh_token_max_age,
        data.env.refresh_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let mut redis_client = match data.redis_client.get_async_connection().await {
        Ok(redis_client) => redis_client,
        Err(e) => {
            return HttpResponse::InternalServerError()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{}", e)}));
        }
    };

    let access_result: redis::RedisResult<()> = redis_client
        .set_ex(
            access_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.access_token_max_age * 60) as usize,
        )
        .await;

    if let Err(e) = access_result {
        return HttpResponse::UnprocessableEntity()
            .json(serde_json::json!({"status": "error", "message": format_args!("{}", e)}));
    }

    let refresh_result: redis::RedisResult<()> = redis_client
        .set_ex(
            refresh_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.refresh_token_max_age * 60) as usize,
        )
        .await;

    if let Err(e) = refresh_result {
        return HttpResponse::UnprocessableEntity()
            .json(serde_json::json!({"status": "error", "message": format_args!("{}", e)}));
    }

    let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(true)
        .finish();
    let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(
            data.env.refresh_token_max_age * 60,
            0,
        ))
        .http_only(true)
        .finish();
    let logged_in_cookie = Cookie::build("logged_in", "true")
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(false)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(refresh_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success", "access_token": access_token_details.token.unwrap()}))
}

#[get("/auth/refresh")]
async fn refresh_access_token_handler(
    req: HttpRequest,
    data: web::Data<AppState>,
) -> impl Responder {
    let message = "could not refresh access token";

    let refresh_token = match req.cookie("refresh_token") {
        Some(c) => c.value().to_string(),
        None => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let refresh_token_details =
        match token::verify_jwt_token(data.env.refresh_token_public_key.to_owned(), &refresh_token)
        {
            Ok(token_details) => token_details,
            Err(e) => {
                return HttpResponse::Forbidden().json(
                    serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}),
                );
            }
        };

    let result = data.redis_client.get_async_connection().await;
    let mut redis_client = match result {
        Ok(redis_client) => redis_client,
        Err(e) => {
            return HttpResponse::Forbidden().json(
                serde_json::json!({"status": "fail", "message": format!("Could not connect to Redis: {}", e)}),
            );
        }
    };
    let redis_result: redis::RedisResult<String> = redis_client
        .get(refresh_token_details.token_uuid.to_string())
        .await;

    let user_id = match redis_result {
        Ok(value) => value,
        Err(_) => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let user_id_uuid = Uuid::parse_str(&user_id).unwrap();
    let query_result = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id_uuid)
        .fetch_optional(&data.db)
        .await
        .unwrap();

    if query_result.is_none() {
        return HttpResponse::Forbidden()
            .json(serde_json::json!({"status": "fail", "message": "the user belonging to this token no logger exists"}));
    }

    let user = query_result.unwrap();

    let access_token_details = match token::generate_jwt_token(
        user.id,
        data.env.access_token_max_age,
        data.env.access_token_private_key.to_owned(),
    ) {
        Ok(token_details) => token_details,
        Err(e) => {
            return HttpResponse::BadGateway()
                .json(serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}));
        }
    };

    let redis_result: redis::RedisResult<()> = redis_client
        .set_ex(
            access_token_details.token_uuid.to_string(),
            user.id.to_string(),
            (data.env.access_token_max_age * 60) as usize,
        )
        .await;

    if redis_result.is_err() {
        return HttpResponse::UnprocessableEntity().json(
            serde_json::json!({"status": "error", "message": format_args!("{:?}", redis_result.unwrap_err())}),
        );
    }

    let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap())
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(true)
        .finish();

    let logged_in_cookie = Cookie::build("logged_in", "true")
        .path("/")
        .max_age(ActixWebDuration::new(data.env.access_token_max_age * 60, 0))
        .http_only(false)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success", "access_token": access_token_details.token.unwrap()}))
}

#[get("/auth/logout")]
async fn logout_handler(
    req: HttpRequest,
    auth_guard: jwt_auth::JwtMiddleware,
    data: web::Data<AppState>,
) -> impl Responder {
    let message = "Token is invalid or session has expired";

    let refresh_token = match req.cookie("refresh_token") {
        Some(c) => c.value().to_string(),
        None => {
            return HttpResponse::Forbidden()
                .json(serde_json::json!({"status": "fail", "message": message}));
        }
    };

    let refresh_token_details =
        match token::verify_jwt_token(data.env.refresh_token_public_key.to_owned(), &refresh_token)
        {
            Ok(token_details) => token_details,
            Err(e) => {
                return HttpResponse::Forbidden().json(
                    serde_json::json!({"status": "fail", "message": format_args!("{:?}", e)}),
                );
            }
        };

    let mut redis_client = data.redis_client.get_async_connection().await.unwrap();
    let redis_result: redis::RedisResult<usize> = redis_client
        .del(&[
            refresh_token_details.token_uuid.to_string(),
            auth_guard.access_token_uuid.to_string(),
        ])
        .await;

    if redis_result.is_err() {
        return HttpResponse::UnprocessableEntity().json(
            serde_json::json!({"status": "error", "message": format_args!("{:?}", redis_result.unwrap_err())}),
        );
    }

    let access_cookie = Cookie::build("access_token", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();
    let refresh_cookie = Cookie::build("refresh_token", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();
    let logged_in_cookie = Cookie::build("logged_in", "")
        .path("/")
        .max_age(ActixWebDuration::new(-1, 0))
        .http_only(true)
        .finish();

    HttpResponse::Ok()
        .cookie(access_cookie)
        .cookie(refresh_cookie)
        .cookie(logged_in_cookie)
        .json(serde_json::json!({"status": "success"}))
}

#[get("/users/me")]
async fn get_me_handler(jwt_guard: jwt_auth::JwtMiddleware) -> impl Responder {
    let json_response = serde_json::json!({
        "status":  "success",
        "data": serde_json::json!({
            "user": filter_user_record(&jwt_guard.user)
        })
    });

    HttpResponse::Ok().json(json_response)
}

fn filter_user_record(user: &User) -> FilteredUser {
    FilteredUser {
        id: user.id.to_string(),
        email: user.email.to_owned(),
        name: user.name.to_owned(),
        photo: user.photo.to_owned(),
        role: user.role.to_owned(),
        verified: user.verified,
        createdAt: user.created_at.unwrap(),
        updatedAt: user.updated_at.unwrap(),
    }
}

pub fn config(conf: &mut web::ServiceConfig) {
    let scope = web::scope("/api")
        .service(health_checker_handler)
        .service(register_user_handler)
        .service(login_user_handler)
        .service(refresh_access_token_handler)
        .service(logout_handler)
        .service(get_me_handler);

    conf.service(scope);
}

Connect to the Database and Register the Routes

With the route handlers now created, it’s time to establish connections to the Redis and PostgreSQL servers, configure CORS, and register the handlers. To accomplish this, navigate to the src/main.rs file and replace its contents with the code provided below.

src/main.rs


mod config;
mod handler;
mod jwt_auth;
mod model;
mod response;
mod token;

use actix_cors::Cors;
use actix_web::middleware::Logger;
use actix_web::{http::header, web, App, HttpServer};
use config::Config;
use dotenv::dotenv;
use redis::Client;
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};

pub struct AppState {
    db: Pool<Postgres>,
    env: Config,
    redis_client: Client,
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    if std::env::var_os("RUST_LOG").is_none() {
        std::env::set_var("RUST_LOG", "actix_web=info");
    }
    dotenv().ok();
    env_logger::init();

    let config = Config::init();

    let pool = match PgPoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await
    {
        Ok(pool) => {
            println!("✅Connection to the database is successful!");
            pool
        }
        Err(err) => {
            println!("🔥 Failed to connect to the database: {}", err);
            std::process::exit(1);
        }
    };

    let redis_client = match Client::open(config.redis_url.to_owned()) {
        Ok(client) => {
            println!("✅Connection to the redis is successful!");
            client
        }
        Err(e) => {
            println!("🔥 Error connecting to Redis: {}", e);
            std::process::exit(1);
        }
    };

    println!("🚀 Server started successfully");

    HttpServer::new(move || {
        let cors = Cors::default()
            .allowed_origin(&config.client_origin)
            .allowed_methods(vec!["GET", "POST"])
            .allowed_headers(vec![
                header::CONTENT_TYPE,
                header::AUTHORIZATION,
                header::ACCEPT,
            ])
            .supports_credentials();
        App::new()
            .app_data(web::Data::new(AppState {
                db: pool.clone(),
                env: config.clone(),
                redis_client: redis_client.clone(),
            }))
            .configure(handler::config)
            .wrap(cors)
            .wrap(Logger::default())
    })
    .bind(("127.0.0.1", 8000))?
    .run()
    .await
}

Finally, you can build the project and launch the Actix-web server again by running the command cargo watch -q -c -w src/ -x run.

Test the RSA 256 Rust Project

Now, you can clone the project from https://github.com/wpcodevo/rust-jwt-rs256 and import the Rust_JWT_RS256.postman_collection.json file into Postman to access the API collection. This will allow you to make requests to the API and test the RS256 JWT authentication flow.

Alternatively, if you prefer to use a frontend application, follow the instructions outlined in the “Run the Rust API with a Frontend App” section to quickly set up a Yew.rs application to interact with the Rust API.

User Registration

On the account registration page, enter the necessary credentials in the form and click on the “Sign Up” button to submit the form data to the Rust API.

Rust RS256 JWT Authentication - Register New User

If the request is successful, the Yew app will redirect you to the account login page.

User Login

On the login page, enter your email and password, which you used to register the account, and click on the “Login” button to send the form data to the Rust API.

Rust RS256 JWT Authentication - Login User

The API will then authenticate the credentials and return cookies to the browser upon successful authentication.

View the Cookies Sent by the Rust API in the Browser Dev Tools

Once the request is successful, you will be redirected to the profile page, where you can view your account details.

Request Account Details

After you’ve been redirected to the profile page, the Yew app will initiate a GET request to retrieve your account information from the Rust API. In order for the request to be successful, the browser must include the authentication cookies that were previously obtained during the login process. This is because the profile page is restricted to only authenticated users.

If the request is successful, your account details will be displayed on the page. However, if the request fails for any reason, the app will redirect you back to the login page.

Rust RS256 JWT Authentication - Access Profile Detials

To enable the automatic refresh of the access token, we need to simulate its expiration. To do this, open your browser’s dev tools, go to the “Application” tab, find the Cookies section and locate the access token for http://localhost:3000/. Delete it, then refresh the browser.

Within milliseconds, the Rust API will send a new access token. This will only work if you’re on the profile page and have a valid refresh token cookie.

To log out, click the “Logout” link in the navigation menu. If the logout request is successful, all the cookies obtained during the login and refresh process will be deleted and you’ll be redirected to the login page.

Conclusion

That brings us to the end of this tutorial, where we covered the implementation of JWT authentication in a Rust API using the RS256 algorithm. I hope you found this guide informative and helpful in understanding the authentication process. If you have any questions or feedback, feel free to leave a comment below.

Thank you for reading!