../sidetracked-introducing-jwt-authentication

Sidetracked: Introducing JWT Authentication

Take me straight to the code

Now that we have a very basic application set up, we are going to add JWT based authentication to our app. I know, I know, it seems a little premature - but I really think this will help us flow into user and then todo creation. We will pretend we have externalised the login process to an IdP (Identity Provider) and expect that all requests to protected routes will contain a valid JWT, all we are going to do is validate it.

Authentication Already?

Now, usually when I’m writing an application I have a very loose idea of what I want to do in my mind, and then I just wing it. This involves continuous refactoring, and could probably be more efficient in general but hey, it works. The issue with my usual strategy is that it’s hard to write about what I’m doing when I’m constantly refactoring. So, for this series of tutorial-esque articles I’m trying to keep that to a minimum. This means that I will keep the refactoring either very, very small, or I will dedicate and entire article to it.

Anyway, authentication, right?

Well I’ve decided, rightly or wrongly that todos will be owned by users. In the future I would also like the ability to share todos with other users. The consequence of this decision is that I believe the user should then be in the resource path for todos. Something like /users/{:id}/todos/{:id}. This means that we need to have users before we can have todos. Make sense?

Following on from my reasoning above, I have decided that I want to externalise the login process and identity management to an IdP (Identity Provider). For the sake of this article let’s think of the IdP as the thing that will provide us with JWTs when a valid user logs in, and JWTs are just a (mostly) secure way to identify users and provide trusted information about them.

Initially we won’t fully integrate with a real IdP, we will pretend that the JWTs are generated by an IdP and that we can trust them, in reality we will just manually generate our own. Once our application receives a request it will validate it and use the claims to identify the user. To clarify, we won’t just accept any JWT, we are going to use a shared secret to both sign (using JWT.IO) and validate the JWTs in our application. This will mean we can very easily plug in a real IdP in the future, which I fully intend to do!.

I’m sure I don’t need to say it but I will anyway, DO NOT paste real production secrets into JWT.IO. Also, most IdPs will allow you to use other methods, i.e. JWKs endpoints, to validate JWTs so you don’t need shared secrets. Secondly there are some security concerns with long-lived JWTs, so make sure you are aware of them. The IdP will handle these via short-lived JWTs and refresh tokens.

A Plan

Okay, so I have a little bit of a plan for how we are going to handle users. For now, we aren’t going to worry about passwords and signup etc. What we are going to do is use JWTs that we will generate using JWT.IO. The generated JWTs will use a super secret value that we will also use in our application when validating the JWT. Effectively pretending the JWT was generated by a trusted Identity Provider (IdP). This JWT payload will contain all the information we need to identify the user. Something like:

{
  "sub": "018eef43-1283-70dd-b738-5bc64b3313c5",
  "name": "Jason Asano",
  "iat": 1713411102,
  "exp": 1913411102
}

In summary, if we receive a JWT that we can validate with our super secret value, we will trust the payload and use it to identify the user. This means either fetching the user from a data store or creating a new user if they don’t exist.

Just to lay it out simply, we are going to work through the following:

  • Generate a JWT
  • Validate the JWT
  • Implement a route at /profile
  • Protect /profile with JWT validation middleware
  • Test /profile with and without a JWT

Starting with a Test

Let’s continue our outside-in approach by writing a test for the /profile route. The simplest thing for us to test at this stage is that it returns a 401 Unauthorized if no JWT is provided. Once we have that up and running we will move on to what to do when a JWT is provided!

In the same test file as our health check test, let’s add a new in-line module to contain our /profile tests.

#[cfg(test)]
mod test_profile {
    use super::*;

  // New tests go here
}

We want a 401 Unauthorized if no JWT is provided in the Authorization header i.e. we DON’T receive a header like this: Authorization: Bearer <JWT>. Note this test isn’t actually covering the case where a JWT is provided but is invalid. We will cover that in a later test.

// ./sidetracked/tests/routes.rs

    #[tokio::test]
    async fn it_should_return_401() {
        // Arrange
        let mut app = helpers::new_test_app().await;
        app.expect_failure();

        // Act
        let response = app.get("/profile").await;

        // Assert
        response.assert_status(StatusCode::UNAUTHORIZED);
    }

Now we are going to implement the required code to make this pass in one big step. We won’t be doing the minimal TDD approach here.

A Few Dependencies

The first thing we need to do is add a crate that will help us to validate and parse JWTs. I have some experience with jwt-authorizer so I am going to use that. We are also going to use anyhow to help us handle errors.

cargo add jwt-authorizer anyhow

Less interesting, but also important, we need to add a few more dependencies to help us with our tests. We will get to this code eventually.

cargo add --dev jwt sha2 hmac

Setting Up JWT Validation

From the usage example in the documentation we see that we need to do the following things:

  • Create an Authorizer with suitable configuration for our use-case. Note that the Authorizer is generic over the type of claims it will validate - so we will need to define a struct to represent our claims.
  • Add that authorizer to our routes in such a way that it applies to the routes we want to protect.
  • Use an extractor to get the validated claims in our profile route handler.

Claims

As I mentioned earlier, we need to define a struct to represent the claims in our JWT. We are going to keep it fairly simple for now. At some point we may want to add more claims to implement finer-grained access control etc. This is what our route is eventually going to have access to. It can then use this to identify the user, get their name etc.

// ./sidetracked/src/web/auth.rs

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Claims {
    pub sub: String,
    pub name: String,
    pub iat: i64,
    pub exp: i64,
}

Creating an Authorizer

The Authorizer is what we will use to validate JWTs. It is very central to our application and it will protect most, if not all, of our routes.

The way it is going to work was described in a previous article, but I will reiterate it here. The Authorizer implements IntoLayer to provide a method to produce a Layer (middleware) that we can apply to our routes. This middleware will validate the JWT in the Authorization header and extract the claims. If the JWT is invalid or missing, it will return a 401 Unauthorized response. If the JWT is valid, it will continue to the next middleware in the chain. We will then be able to access the claims in our route handler.

Let’s go ahead and make Authorizer available to the Application struct.

// ./sidetracked/src/web/application.rs

pub struct Application {
    authorizer: Arc<Authorizer<Claims>>,
}

Now that our Application struct has an Authorizer, we need to add a new constructor to the Application struct that takes an Authorizer as a parameter. This will allow us to create an Application with a custom Authorizer. This is particularly useful for testing, allowing us to easily inject our own Authorizer.

// ./sidetracked/src/web/application.rs

    /// Create a new application with the provided authorizer
    pub fn new(authorizer: Authorizer<Claims>) -> Self {
        Self {
            authorizer: Arc::new(authorizer),
        }
    }

For a bit of sugar, and to keep our main.rs cleaner, we will add another specialised constructor. This will give us an Application with an Authorizer that, by default, uses a secret we will define in an environment variable. There is the potential to use something akin to the Builder pattern here, but I think that would be overkill for our needs.

// ./sidetracked/src/web/application.rs

    /// Create a new application with a default authorizer that expects a secret to be set in the
    /// `SIDETRACKED_SECRET` environment variable.
    pub async fn new_with_default_authorizer() -> Result<Self> {
        let secret = std::env::var("SIDETRACKED_SECRET").context("SIDETRACKED_SECRET not set")?;

        let authorizer = AuthorizerBuilder::<Claims>::from_secret(&secret)
            .jwt_source(JwtSource::AuthorizationHeader)
            .build()
            .await?;

        Ok(Self::new(authorizer))
    }

Updating the Router

So, we have defined our Authorizer but it’s not doing anything yet!

Let’s categorise our routes into two types, protected and unprotected. We will then apply the Authorizer middleware to the protected routes. As we defined in our test, the /profile route is going to be protected, and we will leave the health_check route unprotected. Note how we create the protected and unprotected routers and merge them together. On the merged router we make sure to apply the original TraceLayer for logging so that all routes are logged.

// ./sidetracked/src/web/application.rs

        let protected = Router::new()
            // Add a profile route
            .route("/profile", get(profile))
            // Add the authorizer layer
            .layer(ServiceBuilder::new().layer(self.authorizer.clone().into_layer()));

        let unprotected = Router::new()
            // Add a health check route
            .route("/health_check", get(health_check));

        Router::new()
            .merge(protected)
            .merge(unprotected)
            // Add `TraceLayer` to log all incoming requests
            .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))

Our Shiny, New, Protected Application

Finally let’s update main.rs to use our shiny new Application via the new_with_default_authorizer constructor.

// ./sidetracked/src/main.rs

#[tokio::main]
async fn main() {
    init_logging();

    let config = ApplicationConfig::default();

    let app = Application::new_with_default_authorizer()
        .await
        .expect("Failed to build application");

    run(app, config).await;
}

And we are back to the test, let’s run the original 401 Unauthorized test.

     Running tests/routes.rs (target/debug/deps/routes-fc2627eea41f5e01)

running 2 tests
test test_profile::it_should_return_401 ... ok
test test_health_check::it_should_return_200 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Great! Our test is passing. We have proven that if we just make any old request against the protected /profile route, we get a 401 UNAUTHORIZED.

Some More Useful Tests

Denying access to a route is all well and good, but we also need to allow access to the route when a valid JWT is provided, so let’s add a test for that. We are going to check 2 things in this test. Firstly, that the route returns a 200 OK status code. Secondly, that the claims we expect are returned in the response body.

Let’s create a little helper to minimise some of the awkwardness of working with the Authorizer and Claims. Firstly, lets hard code a secret into the tests that the Authorizer can use. We need a secret that we can use that is consistent between the Authorizer and the other helper we will write to sign our claims. This way we can generate a signed JWT with custom Claims that the Authorizer will accept, allowing us to test various scenarios.

// ./sidetracked/tests/helpers/mod.rs

const TEST_SECRET: &str = "7750e0e7ad62179c3a5299f40ec6fb69fffa0b95aff0424955f654012e5cedb5";

Following that we will write a helper to sign claims with the secret we have defined. It may not be obvious, but your IDE should help you here. The .sign_with_key method comes from importing jwt::SignWithKey; which has a bunch of blanked implementations which covers our Claims struct.

For those that are interested, the implementation applies to our Claims struct because SignWithKey has a blanket implementation for all types that implement ToBase64 which in turn has a blanked implementation for all types that implement Serialize. This (in my opinion) is a super cool aspect of the Rust type system.

So, back on track - let’s define that helper.

// ./sidetracked/tests/helpers/mod.rs

#[cfg(test)]
pub async fn new_test_token(claims: Claims) -> String {
    let key: Hmac<Sha256> =
        Hmac::new_from_slice(TEST_SECRET.as_bytes()).expect("Failed to create key");

    claims.sign_with_key(&key).expect("Failed to sign token")
}

The next step is to modify new_test_app to create an Application containing a test Authorizer. This test Authorizer will use the secret we hard-coded earlier.

// ./sidetracked/tests/helpers/mod.rs

#[cfg(test)]
pub async fn new_test_app() -> TestServer {
    let authorizer = AuthorizerBuilder::<Claims>::from_secret(TEST_SECRET)
        .jwt_source(JwtSource::AuthorizationHeader)
        .build()
        .await
        .expect("Failed to build authorizer");

    let app = Application::new(authorizer);

    let config = TestServerConfig::builder()
        // Use an actual HTTP transport on a random port.
        .http_transport()
        // Behave like a browser and save cookies between requests.
        .save_cookies()
        // We are testing a JSON API.
        .default_content_type("application/json")
        // Panic if the response is outside the 2XX range (Unless request marked as expected failure).
        .expect_success_by_default()
        .build();

    TestServer::new_with_config(app.router(), config).unwrap()
}

With all of the above in place, we can now write our test for the /profile route.

// ./sidetracked/tests/routes.rs

    #[tokio::test]
    async fn it_should_return_200() {
        // Arrange
        let mut app = helpers::new_test_app().await;

        // Construct some valid claims
        let test_claims = Claims {
            sub: "018eef43-1283-70dd-b738-5bc64b3313c5".to_string(),
            name: "Jason Asano".to_string(),
            iat: 1713411102,
            // Set the expiration time to a time in the future. One day this may be a problem - but
            // I don't think it is worth worrying about :)
            exp: 1913411102,
        };

        // Sign the claims to create a token
        let test_token = helpers::new_test_token(test_claims.clone()).await;

        // Add the token to the Authorization header
        app.add_header(
            AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {test_token}")).unwrap(),
        );

        // Act
        let response = app.get("/profile").await;

        // Assert
        response.assert_status(StatusCode::OK);
        response.assert_json::<Claims>(&test_claims);
    }

Let’s briefly go over what this test actually does.

First we create a Claims struct containing the appropriate information for the user we are pretending to be. We then use our new_test_token helper to sign these claims and create a JWT. We then make a request to the /profile route with the JWT in the Authorization header. Finally, we check that the response status is 200 OK and that the response body contains the claims we expect.

A Few More Tests

Now, for the sake of completion let’s add a few more tests, and then we will run then to check all is as expected!

The first test we will add is to check that we get a 401 UNAUTHORIZED if we provide an invalid JWT. This is a good test to have as it will help us to ensure that our Authorizer is correctly rejecting invalid JWTs.

// ./sidetracked/tests/routes.rs

    #[tokio::test]
    async fn it_should_return_401_invalid_token() {
        // Arrange
        let mut app = helpers::new_test_app().await;
        app.expect_failure();

        // Add an invalid token to the Authorization header
        app.add_header(
            AUTHORIZATION,
            HeaderValue::from_str("Bearer invalid_token").unwrap(),
        );

        // Act
        let response = app.get("/profile").await;

        // Assert
        response.assert_status(StatusCode::UNAUTHORIZED);
    }

And finally, one test to check that expired JWTs are rejected.

// ./sidetracked/tests/routes.rs

    #[tokio::test]
    async fn it_should_return_401_expired_token() {
        // Arrange
        let mut app = helpers::new_test_app().await;
        app.expect_failure();

        let test_claims = Claims {
            sub: "018eef43-1283-70dd-b738-5bc64b3313c5".to_string(),
            name: "Jason Asano".to_string(),
            iat: 1713411102,
            // Set the expiration time to a time in the past
            exp: 1713411102,
        };

        let test_token = helpers::new_test_token(test_claims.clone()).await;

        app.add_header(
            AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {test_token}")).unwrap(),
        );

        // Act
        let response = app.get("/profile").await;

        // Assert
        response.assert_status(StatusCode::UNAUTHORIZED);
    }

Run them!

     Running tests/routes.rs (target/debug/deps/routes-a299ea667a35339c)

running 5 tests
test test_health_check::it_should_return_200 ... ok
test test_profile::it_should_return_401_invalid_token ... ok
test test_profile::it_should_return_401 ... ok
test test_profile::it_should_return_401_expired_token ... ok
test test_profile::it_should_return_200 ... ok

Interacting with our Application

Whilst the implementation is complete, and we have validated that the application behaves as we expect, it is always good to test it manually. As we now have authentication in place, we need to run our server with a shared secret defined in the SIDETRACKED_SECRET variable. Let’s do that.

SIDETRACKED_SECRET=supersecret cargo run

Now with the same secret we need to use something to generate a valid JWT. As I suggested earlier I am going to use JWT.IO. You will need to craft the payload appropriately to match the Claims we expect, including a valid expiration time. Make sure you utilise the same shared secret as you have in your environment variable. Of course, if you have another method you would like to use, then go ahead and do that - also shoot me an email and let me know!

Now, with your valid JWT, you can make a request to the /profile route. You should get a 200 OK response with the claims you expect.

For me, that looked something like this:

❯ curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikphc29uIEFzYW5vIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI5MTYyMzkwMjJ9.IO1SIjRQpjFrI3WQUzBb-PEuLzbKo1B9N3G2b6nD-gA" localhost:3000/profile
HTTP/1.1 200 OK
content-type: application/json
content-length: 75
date: Thu, 16 May 2024 13:44:48 GMT

{"sub":"1234567890","name":"Jason Asano","iat":1516239022,"exp":2916239022}

Magic!

In the next article we will look at doing something with these authenticated users.

Tags: sidetracked rust