Tutorial
This tutorial will walk you through the basics of using Nakago to build a simple HTTP service. It will use Axum to provide HTTP routes and will decode the user's JWT token and verify their identity via a separate OAuth2 provider, such as Auth0 or Okta or your own self-hosted service.
Cargo-Generate Template
First install cargo-generate
:
cargo install cargo-generate
Then generate a new project with this template:
cargo generate bkonkle/nakago-simple-template
You'll see a folder structure like this:
simple/
├─ .cargo/ -- Clippy config
├─ .github/ -- Github Actions
├─ config/ -- Config files for different environments
├─ src/
│ ├─ http/ -- Axum HTTP routes
│ │ ├─ mod.rs
│ │ ├─ health.rs -- The HTTP health check handler
│ │ └─ router.rs -- Axum router initialization
│ ├─ lib.rs
│ ├─ config.rs -- Your app's custom Config struct
│ ├─ init.rs -- App initialization
│ └─ main.rs -- Main entry point
├─ Cargo.toml
├─ Makefile.toml
├─ README.md
└─ // ...
This includes a simple app-specific Config
struct with an embedded http
config provided by the nakago-axum
library. You can add your own configuration fields to this struct and they'll be populated by the figment crate using the nakago-figment library.
It includes a barebones init::app()
function that will load your configuration and initialize your dependencies. You can add your own dependencies to this function and they'll be available when you build your Axum routes through a convenient State
struct that contains the injection container.
The main.rs
file uses pico-args to parse a simple command-line argument to specify an alternate config path, which is useful for many deployment scenarios that dynamically map a config file to a certain mount point within a container filesystem.
In the http/
folder, you'll find an Axum handler and a router initialization function. The router maps a simple GET /health
route to a handler that returns a JSON response with a success message.
You now have a simple foundation to build on. Let's add some more functionality!
Setup
Follow the Installation instructions in the README.md
to prepare your new local environment.
Authentication
One of the first things you'll probably want to add to your application is authentication, which establishes the user's identity. This is separate and distinct from authorization, which determines what the user is allowed to do.
The only currently supported method of authentication is through JWT with JWKS keys, though other methods will be added in the future. The nakago-axum
library provides a request extractor for for Axum that uses biscuit with your Figment Config to decode a JWT from the Authorization
header, validate it with a JWKS key from the /.well-known/jwks.json
path on the auth url, and then return the value of the sub
claim from the payload.
Configurable claims and other authentication methods will be added in the future.
Auth Config
In your config.rs
file, add a new property to the app's Config
struct:
use nakago_axum::auth;
// ...
/// Server Config
#[derive(Default, Debug, Serialize, Deserialize, Clone, FromRef)]
pub struct Config {
/// HTTP config
pub http: nakago_axum::Config,
/// HTTP Auth Config
pub auth: auth::Config,
}
This auth Config
is automatically loaded as part of the default config loaders in the nakago-axum
crate, which you'll see below.
Next, add the following values to your config.local.toml.example
file as a hint, so that new developers know they need to reach out to you for real values when they create their own config.local.toml
file:
[auth]
url = "https://simple-dev.oauth-service.com"
audience = "localhost"
[auth.client]
id = "client_id"
secret = "client_secret"
Add the real details to your own config.toml
file, which should be excluded from git via the .gitignore
file. If you don't have real values yet, leave them as the dummy values above. You can still run integration tests without having a real OAuth2 provider running, if you want.
Initialization
You're now ready to head over to your initialization routine. This is where you will provide all of the dependencies and setup your app needs in order to run.
This block that is already in the top-level init.rs
ensures your config is populated from environment variables or the currently chosen config file, along with the auth property you added above:
// These lines should already be in your `init.rs` file - no change needed
nakago_figment::Init::<Config>::default()
.maybe_with_path(config_path)
.init(&i)
.await?;
First, add the default JWKS Validator from nakago_axum
's auth
module using the provide_type
method, which uses the type as the key for the Inject container:
use nakago_axum::auth::{validator, Validator};
// ...
i.provide::<Validator>(validator::Provide::default()).await?;
This will be overridden in your tests to use the unverified variant, but we'll get to that later. Next you should use jwks::Provide
to inject the JWKS config:
use nakago_axum::auth::{jwks, Jwks};
// ...
i.provide::<Jwks>(jwks::Provide::<Config>::default()).await?;
Axum Route
You can now add a quick handler to http/
that allows a user to view their own username when logged in. Create a new file called http/user.rs
:
use axum::Json;
use nakago_axum::auth::Subject;
use serde_derive::{Deserialize, Serialize};
/// A Username Response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsernameResponse {
/// The Status code
code: usize,
/// The username, or "(anonymous)"
username: String,
}
/// Handle Get Username requests
pub async fn get_username(sub: Subject) -> Json<UsernameResponse> {
let username = if let Subject(Some(username)) = sub {
username.clone()
} else {
"(anonymous)".to_string()
};
Json(UsernameResponse {
code: 200,
username,
})
}
The Subject
extension uses Nakago Axum's State proivider to find the Inject container, which it then uses to grab the JWT config and the Validator instance. It decodes the JWT and returns the sub
claim from the payload. If the user is not logged in, the Subject
will contain a None
.
Now add a route that uses the handler to the Init hook at http/router.rs
:
/// This method should already exist in your `http/router.rs` file
pub fn init(i: &Inject) -> Router {
Router::new()
.layer(trace_layer())
.route("/health", get(health::health_check))
.route("/username", get(user::get_username)) // <-- add this line
.with_state(State::new(i.clone()))
}
Running the App
At this point, you can run your app and see the (anonymous)
response at the GET /username
endpoint:
cargo make run
The uses cargo-make, a tool to provide enhanced Makefile-like functionality for Rust projects. You can see the configuration in the Makefile.toml
file.
At first, you'll see a big ugly traceback with the following error message at the top because you don't have a valid autd provider configured:
thread '<unnamed>' panicked at 'Unable to retrieve JWKS: invalid format'
This is okay - you don't have to have a properly configured auth provider to run the integration tests for your app. You can use the "unverified" AuthState
variant during integration testing, and skip the rest of this section.
If you do have a valid OAuth2 provider, then you'll want to create a config.local.toml
file and set the following property in it:
[auth]
url = "https://simple-dev.oauth-service.com"
You can also use the AUTH_URL
environment variable to set this value. Consider using a tool like direnv to manage variables like this in your local development environment with .envrc
files.
Your provider should have a /.well-known/jwks.json
file available at the given auth url, which will avoid the error message above. You should now see output that looks like the following:
2023-09-08T02:14:03.388670Z INFO simple: Started on port: 8000
When you call http://localhost:8000/username
in your browser, you should see the following response:
{
"code": 200,
"username": "(anonymous)"
}
Integration Testing
Now that you have a simple route that requires authentication, you'll want to add some integration tests to ensure that it works as expected. You don't actually need to have an OAuth2 provider running to test this, because the nakago-axum
library provides a mock unverified AuthState
that you can use to simulate a logged-in user.
Test Utils
Nakago Axum's HTTP Utils
class is based on the idea of extending the base test Utils
class you'll find in nakago_axum::test::Utils
with additional functionality, like adding a graphql
property if you're using nakago-async-graphql
or adding convenience methods around your app-specific data.
To start out with, create a tests
folder alongside your src
. This will be used by Cargo as an "integration test" module, and will be excluded from your final binary. It allows you to import the module in your src
as if it were an external package, with access only to the public exports. You don't need to add a lib.rs
, mod.rs
, or main.rs
- each file in the tests
folder will be auto-discovered and treated as a separate entry point with its own module.
For the purposes of your own application, you'll want to create a tests/utils.rs
file that wraps the nakago_axum::test::Utils
so that you can override any dependencies that you need or add convenience methods to build test data easily for your tests. Start out with a newtype like this:
use simple::Config;
pub struct TestUtils(nakago_axum::test::Utils<Config>);
Replace simple
with your actual project name.
To make it easy to access the fields on the inner TestUtils
, you can implement the Deref
trait for your newtype. This isn't generally a good practice for newtypes in Production because it can result in some easy-to-miss implicit conversion behind the scenes, but in testing it's a nice convenience:
use std::ops::Deref;
// ...
impl Deref for TestUtils {
type Target = nakago_axum::test::Utils<Config>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
Now, you can implement an init()
method for your app-specific Utils
wrapper:
use anyhow::Result;
use nakago_axum::auth::{validator, Validator};
use simple::{http::router, init, Config};
// ...
impl TestUtils {
pub async fn init() -> Result<Self> {
let config_path = std::env::var("CONFIG_PATH_SIMPLE")
.unwrap_or_else(|_| "examples/simple/config.test.toml".to_string());
let i = init::app(Some(config_path.clone().into())).await?;
i.replace_with::<Validator>(validator::ProvideUnverified::default())
.await?;
let router = router::init(&i);
let utils = nakago_axum::test::Utils::init(i, "/", router).await?;
Ok(Self(utils))
}
}
Again, replace simple
with your actual project name. The CONFIG_PATH
variable is used so that you can replace that with config.ci.toml
or whatever you need for testing in different environments.
Now, create a test_users_int.rs
to represent your User integration tests, which will currently just test the /username
endpoint.
#![cfg(feature = "integration")]
use utils::TestUtils;
#[tokio::test]
async fn test_get_username_success() -> Result<()> {
let utils = TestUtils::init().await?;
todo!("unimplemented")
}
The #![cfg(feature = "integration")]
at the top of this file means that it will only be included in the build if the integration
feature flag is enabled. This is a good practice to follow for all your integration tests, because it allows you to run your unit tests while skipping integration tests so that you don't need supporting services in a local Docker Compose formation or other external dependencies.
The todo!()
macro allows you to leave this test unfinished for now, but it will throw an error if you try to execute the tests.
HTTP Calls
Next, we can add an HTTP call with a JWT token. First, create the dummy token, which will only work with the auth::subject::ProvideUnverified
Validator provider above for use in testing.
use ulid::Ulid;
#[tokio::test]
async fn test_get_username_success() -> Result<()> {
let utils = TestUtils::init().await?; // <-- this line should already be there
let username = Ulid::new().to_string();
let token = utils.create_jwt(&username).await?;
todo!("unimplemented")
}
Now we can make the HTTP call:
let resp = utils
.http
.get_json("/username", Some(&token))
.send()
.await?;
Pull the response apart into a status and a JSON body:
let status = resp.status();
let json = resp.json::<Value>().await?;
Now you can make assertions based on the response:
assert_eq!(status, 200);
assert_eq!(json["username"], username);
Add an Ok(())
at the end to signal a successful test run, and your final test should look like this:
#![cfg(feature = "integration")]
use anyhow::Result;
#[cfg(test)]
mod utils;
use serde_json::Value;
use ulid::Ulid;
use utils::TestUtils;
#[tokio::test]
async fn test_get_username_success() -> Result<()> {
let utils = TestUtils::init().await?;
let username = Ulid::new().to_string();
let token = utils.create_jwt(&username).await?;
let resp = utils
.http
.get_json("/username", Some(&token))
.send()
.await?;
let status = resp.status();
let json = resp.json::<Value>().await?;
assert_eq!(status, 200);
assert_eq!(json["username"], username);
Ok(())
}
Running the Tests
To run integration tests locally, add the following command to your Makefile.toml
:
[tasks.integration]
env = { "RUN_MODE" = "test", "RUST_LOG" = "info", "RUST_BACKTRACE" = 1 }
command = "cargo"
args = ["nextest", "run", "--features=integration", "--workspace", "${@}"]
This won't work until you add the integration
feature to your Cargo.toml
, however:
[features]
integration = []
Now you can run cargo make integration
, and it will use nextest to run all available integration tests. It also allows you to pass options to nextest
, including filtering down to a specific test or group of tests.
cargo make integration
You should see a message that looks like the following:
Starting 1 test across 4 binaries
PASS [ 0.230s] simple::test_users_int test_get_username_success
------------
Summary [ 0.230s] 1 test run: 1 passed, 0 skipped
If you want to see it fail, you can adjust the expectations at the end of the test in test_users_int.rs
:
assert_eq!(json["username"], "bob");
Instead of the output above, you'll see a gnarly stacktrace with the following at the top:
FAIL [ 0.378s] simple::test_users_int test_get_username_success
--- STDOUT: simple::test_users_int test_get_username_success ---
running 1 test
thread '<unnamed>' panicked at 'assertion failed: `(left == right)`
left: `String("01HA5SF2AB3FV269P5ZEZ46033")`,
right: `"bob"`', tests/test_users_int.rs:32:5
Finished Result
Congratulations! You now have a simple API server with JWT+JWKS authentication in Rust, and you've added integration tests to ensure that it works as expected!
You can see everything together in the examples/simple folder of the nakago
repository.