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 theAuthorizer
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 becauseSignWithKey
has a blanket implementation for all types that implementToBase64
which in turn has a blanked implementation for all types that implementSerialize
. 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.