In this tutorial, we’ll explain how to implement authentication and authorization using JWTs in a Rust web application.

JSON Web Tokens (JWTs) are a standard for securely representing attributes or claims between systems. They can be used in a client-server fashion to enable stateless authorization, whereas cookies are inherently stateful.

However, they are more flexible than that and can also be used in myriad other ways. A prominent use case is secure user state propagation in a microservice architecture. In such a setup, the use case of JWTs can be purely limited to the backend side, with a stateful authorization mechanism toward the frontend. Upon logging in, a session token is mapped onto a JWT, which is then used within the microservice cluster to authorize requests (access control), but also to distribute state about the user (information distribution).

The advantage of this is that other services, or clients, don’t need to refetch information, which is stored within the JWT. For example, a user role, the user email, or whatever you need to access regularly can be encoded inside a JWT. And because JWTs are cryptographically signed, the data stored within them is secure and can’t be manipulated easily.

In this tutorial, we’ll explain how to implement authentication and authorization using JWTs in a Rust web application. We won’t go into very much detail on JWTs themselves; there are great resources on that topic already.

The example we’ll build will focus more on the access control part of JWTs, so we’ll only save the user ID and the user’s role inside the token — everything we need to make sure a user is allowed to access a resource.

As is custom for security-related blog posts, here is a short disclaimer: The code shown in this blog post is not production ready and shouldn’t be copy/pasted. The sole aim of this example is to show off some of the concepts, techniques, and libraries you might want to use when building an authentication/authorization system.

With that out of the way, let’s get started!

Setup

To follow along, you’ll need a recent Rust installation (1.39+) and a tool to send HTTP requests, such as cURL.

First, create a new Rust project.

cargo new rust-jwt-example
cd rust-jwt-example

Next, edit the Cargo.toml file and add the dependencies you’ll need.

[dependencies]
jsonwebtoken = "=7.2"
tokio = { version = "0.2", features = ["macros", "rt-threaded", "sync", "time"] }
warp = "0.2"
serde = {version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
chrono = "0.4"

We’ll build the web application using the lightweight warp library, which uses tokio as its async runtime. We’ll use Serde for JSON handling and Thiserror and Chrono to handle errors and dates, respectively.

To deal with the JSON Web Tokens, we’ll use the aptly named jsonwebtoken crate, which is mature and widely used within the Rust ecosystem.

Web server

We’ll start by creating a simple web server with a couple of endpoints and an in-memory user store. In a real application, we would probably have a database for user storage. But since that’s not important for our example, we’ll simply hardcode them in memory.

type Result<T> = std::result::Result<T, error::Error>;
type WebResult<T> = std::result::Result<T, Rejection>;
type Users = Arc<RwLock<HashMap<String, User>>>;

Here we define two helper types for Result, specifying an internal result type for propagating errors throughout the application and an external result type for sending errors to the caller.

We also define the Users type, which is a shared HashMap. This is our in-memory user store and we can initialize it like this:

mod auth;
mod error;

#[derive(Clone)]
pub struct User {
    pub uid: String,
    pub email: String,
    pub pw: String,
    pub role: String,
}

#[tokio::main]
async fn main() {
    let users = Arc::new(RwLock::new(init_users()));
    ...
}

fn init_users() -> HashMap<String, User> {
    let mut map = HashMap::new();
    map.insert(
        String::from("1"),
        User {
            uid: String::from("1"),
            email: String::from("user@userland.com"),
            pw: String::from("1234"),
            role: String::from("User"),
        },
    );
    map.insert(
        String::from("2"),
        User {
            uid: String::from("2"),
            email: String::from("admin@adminaty.com"),
            pw: String::from("4321"),
            role: String::from("Admin"),
        },
    );
    map
}

We use a HashMap, which enables us to easily search by the user’s ID. The map is wrapped in an RwLock because multiple threads can access the users map at the same time. This is also the reason it’s finally put into an Arc — an atomic, reference counted smart pointer — which enables us to share this map between threads.

Since we’re building an asynchronous web service and we can’t know in advance on which threads our handler futures will run, we need to make everything we pass around thread-safe.

We’ll set the users map with two users: one with role User and one with role Admin. Later on, we’ll create endpoints, which can only be accessed with the Admin role. This way, we can test that our authorization logic works as intended.

Since we’re using warp, we also need to build a filter to pass the users map to endpoints.

fn with_users(users: Users) -> impl Filter<Extract = (Users,), Error = Infallible> + Clone {
    warp::any().map(move || users.clone())
}

With this first bit of setup out of the way, we can define some basic routes and start the web server.

#[tokio::main]
async fn main() {
    let users = Arc::new(RwLock::new(init_users()));

    let login_route = warp::path!("login")
        .and(warp::post())
        .and_then(login_handler);

    let user_route = warp::path!("user")
        .and_then(user_handler);
    let admin_route = warp::path!("admin")
        .and_then(admin_handler);

    let routes = login_route
        .or(user_route)
        .or(admin_route)
        .recover(error::handle_rejection);

    warp::serve(routes).run(([127, 0, 0, 1], 8000)).await;
}

pub async fn login_handler() -> WebResult<impl Reply> {
    Ok("Login")
}

pub async fn user_handler() -> WebResult<impl Reply> {
    Ok("User")
}

pub async fn admin_handler() -> WebResult<impl Reply> {
    Ok("Admin")
}

In the above snippet, we define three handlers:

  • POST /login — log in with e-mail and password
  • GET /user — an endpoint for every user
  • GET /admin — an endpoint only for admins

Don’t worry about .recover(error::handle_rejection) yet; we’ll deal with error handling a bit later on.

Authentication

Let’s build the login functionality so users and admins can authenticate.

The first step is to get the credentials inside the login_handler.

#[derive(Deserialize)]
pub struct LoginRequest {
    pub email: String,
    pub pw: String,
}

#[derive(Serialize)]
pub struct LoginResponse {
    pub token: String,
}

This is the API we define for the login mechanism. A client sends an email and password and receives a JSON Web Token as response, which the client can then use to make authenticated requests by putting this token inside the Authorization: Bearer $token header field.

We define this as a body to the login_handler, like this:

async fn main() {
    ...
    let login_route = warp::path!("login")
        .and(warp::post())
        .and(with_users(users.clone()))
        .and(warp::body::json())
        .and_then(login_handler);
    ...
}

In the login_handler, the signature and implementation change to:

pub async fn login_handler(users: Users, body: LoginRequest) -> WebResult<impl Reply> {
    match users.read() {
        Ok(read_handle) => {
            match read_handle
                .iter()
                .find(|(_uid, user)| user.email == body.email && user.pw == body.pw)
            {
                Some((uid, user)) => {
                    let token = auth::create_jwt(&uid, &Role::from_str(&user.role))
                        .map_err(|e| reject::custom(e))?;
                    Ok(reply::json(&LoginResponse { token }))
                }
                None => Err(reject::custom(WrongCredentialsError)),
            }
        }
        Err(_) => Err(reject()),
    }
}

What’s happening here? First, we access the shared Users map by calling .read(), which gives us a read-lock on the map. This is all we need for now.

Then, we iterate over this read-only version of the users map, trying to find a user with the email and pw as provided in the incoming body.

If we don’t find a user, we return a WrongCredentialsError, telling the user they didn’t use valid credentials. Otherwise, we call auth::create_jwt with the existing user’s user ID and role, which returns a token. This is what we send back to the caller.

Let’s look at the auth module next.

In auth.rs, we first define some useful data types and constants.

const BEARER: &str = "Bearer ";
const JWT_SECRET: &[u8] = b"secret";

#[derive(Clone, PartialEq)]
pub enum Role {
    User,
    Admin,
}

impl Role {
    pub fn from_str(role: &str) -> Role {
        match role {
            "Admin" => Role::Admin,
            _ => Role::User,
        }
    }
}

impl fmt::Display for Role {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Role::User => write!(f, "User"),
            Role::Admin => write!(f, "Admin"),
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
struct Claims {
    sub: String,
    role: String,
    exp: usize,
}

The Role enum is simply a mapping of the Admin and User roles, so we don’t have to muck around with strings, which is way too error-prone for security-critical stuff like this.

We also define helper methods to convert from and to strings from the Role enum, since this role is saved within the JWT.

Another important type is Claims. This is the data we will save inside and expect of our JWTs. The sub depicts the so-called subject, so “who,” in this case. exp is the expiration date of the token. We also put the user role in there as a custom data point.

The two constants are the prefix of the expected Authorization header and the very important JWT_SECRET. This is the key with which we sign our JSON Web Tokens. In a real system, this would be a long, securely stored string that is changed regularly. If this secret were to leak, anyone could decode all JWTs created with this secret. You could also use a different secret for each user, for example, which would enable you to easily invalidate all of a user’s tokens in case of a data breach by simply changing this secret.

Let’s look at the create_jwt function next.

use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};

pub fn create_jwt(uid: &str, role: &Role) -> Result<String> {
    let expiration = Utc::now()
        .checked_add_signed(chrono::Duration::seconds(60))
        .expect("valid timestamp")
        .timestamp();

    let claims = Claims {
        sub: uid.to_owned(),
        role: role.to_string(),
        exp: expiration as usize,
    };
    let header = Header::new(Algorithm::HS512);
    encode(&header, &claims, &EncodingKey::from_secret(JWT_SECRET))
        .map_err(|_| Error::JWTTokenCreationError)
}

First, we calculate an expiration date for this token. In this case, we only set it to 60 seconds in the future. This is nice for testing because we don’t have to wait long for the token to expire.

The expiration set can be defined using different strategies, but since these tokens are security-critical and hold sensible information, they definitely should expire at some point. Some systems rely on a refresh token mechanism, setting short (minutes/hours) expiration times and providing a refresh token to the caller, which can be used to get a new token if the old one is expired.

Next, we create the Claims struct with the user’s ID, the user’s role, and the expiration date. After that comes our first interaction with the jsonwebtoken crate.

If you have dealt with JWTs before, you’ll know they consist of three parts:

  1. Header
  2. Payload
  3. Signature

This is reflected here since we create a new header and encode this header, plus our payload (claims) with the above-mentioned secret. If this fails, we return an error. Otherwise, we return the resulting JWT.

Now users can log in to our service, but we don’t have a mechanism for handling authorization yet. We’ll look at that next.

#rust #jwt #security #programming #developer

How to Implement Authentication and Authorization using JWTs in Rust
4.75 GEEK