Rocket.rs demo implementing api key protection

Check out the latest Rocket Documentation.

This is the first in a series of tutorials on working with Rocket.

Prerequisites:

If you're more experienced with Rocket, you may wish to jump to the creating an API Key fairing section.

This article will walk you through setting up an API with some basic functionality and add protection using a header-based API key.

Side note: Throughout this tutorial, I use the > character to indicate your terminal's shell prompt. When following along, don't type the > character, or you'll get some weird errors.

Install Rustup and Cargo

Working with Rocket requires Rust Some crates may require the Nightly Rust release channel.

I recommend using the latest stable version of Rust, but anything in Stable or Nightly should work fine. If you want to change the current channel type information can always be found at the Rust Language Book.

Install Rust:

> curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Change release Channel:

# for nightly channel
> rustup default nightly
# for stable channel
> rustup default stable
# update channel indexes
> rustup update

Create an empty project

You will need a place to put your work, so open a terminal to make a new directory for your project somewhere and set it up as a new Rust project using Cargo:

# Cargo creates a directory for the project then you can `cd` into it
> cargo new rocket-api
> cd rocket-api

# Cargo creates a git repository for us
> git status

# Cargo initializes new projects with a Test 'helloworld', let's run that.
> cargo run

Configure Rocket's dependencies

Rocket is a very modular and extensible framework, which allows Rust devs to target different runtime targets and opt in to various features by including a custom selection of modules.

If you're new to Rocket, I do recommend configuring your project in stages since this can make troubleshooting configuration issues much easier.

# Add the following lines to your Cargo.toml file, located in the root of the project directory.
# [dependencies]
rocket = { version = "^0.5.0-rc.2", features = ["json", "secrets"] }
config = {version = "0.13.1", features = ["json5"] }

Go ahead and run a build to compile our dependencies:

> cargo build

Basic setup

Now that you have Rocket installed, configure what's needed to get your server up and running. At bare minimum, Rocket requires a root route/path and the 'main' function block.

#[macro_use]
extern crate rocket;
use rocket::*;

#[get("/")]
async fn index() -> &'static str {
    "Hello, Astronauts!"
}

#[rocket::main]
pub async fn main() {

    build()
        .mount(
            "/",
            routes![
                index,
            ],
        )
        .launch()
        .await
        .unwrap();
}

Start and test the server:

> cargo run
> curl http://127.0.0.1:8000

Observe the output from your Rocket server, as well as the response to your curl.

Setup Configuration file

It is not recommended to hard-code any API keys, so set up your project to derive some settings from a config file.

Create a Settings.toml within [project-root]/config/

api_key = "yourapikey"
port = 8000

Add the following to your main function block to finish configuring the settings file:

    // load toml config file
    let settings = Config::builder()
        .add_source(config::File::with_name("config/Settings"))
        .build()
        .unwrap();

    // deserialize settings file to HashMap
    let settings_map = settings
        .try_deserialize::<HashMap<String, String>>()
        .unwrap();

    // retrieve settings and create custom Rocket config object
    let config = rocket::Config {
        port: settings_map.clone().get("port").unwrap().parse().unwrap(),
        address: std::net::Ipv4Addr::new(0, 0, 0, 0).into(),
        ..rocket::Config::debug_default()
    };

The updated main.rs file will now look like the following:

Notice that I changed 'build' method to 'custom' in order to pass our custom Rocket configuration. I also used the .manage() method to ask Rocket to maintain the state of my settings_map object. This allows use of the object within any of the routes.

#[macro_use]
extern crate rocket;
use std::collections::HashMap;
use config::Config;
use rocket::{custom, tokio};

#[get("/")]
async fn index(
    settings_map: &rocket::State<HashMap<String, String>>, // Rocket is managing settings_map so it may be accessed within our route
) -> &'static str {
    "Hello, Astronauts!"
}

#[rocket::main]
pub async fn main() {

    // load toml config file
    let settings = Config::builder()
        .add_source(config::File::with_name("config/Settings"))
        .build()
        .unwrap();

    // deserialize settings file to HashMap
    let settings_map = settings
        .try_deserialize::<HashMap<String, String>>()
        .unwrap();

    // retrieve settings and create custom Rocket config object
    let config = rocket::Config {
        port: settings_map.clone().get("port").unwrap().parse().unwrap(),
        address: std::net::Ipv4Addr::new(0, 0, 0, 0).into(),
        ..rocket::Config::debug_default()
    };

    custom(&config)
        .manage::<HashMap<String, String>>(settings_map.clone()) // asking Rocket to manage settings_map so that it may be accessed within the routes
        .mount(
            "/",
            routes![
                index,
            ],
        )
        .launch()
        .await
        .unwrap();
}

Add the following to main.rs to configure the "Rocket fairing"

Adding ApiKey<'_> as a required parameter will allow only requests that include a matching header. If a request does not contain a matching header, the server will respond with a 4xx error. After verifying the presence of our header, the fairing will pass the key into our route/path. At this point within the function, confirm the key against api_key in the config file prior to responding to the client.

ApiKey fairing section

This section can be copy-pasted into an existing project and used by merely adding ApiKey<'_> to a route. This will not confirm validity of the API key, though; that must be done within the route function using your own logic or logic based off of that in this example.

See below for a complete working example

    use rocket::form::validate::Len;
    use rocket::http::Status;
    use rocket::outcome::{Outcome};
    use rocket::request::{Request, FromRequest};

    #[derive(PartialEq)]
    pub struct ApiKey<'r>(pub(crate) &'r str);

    #[derive(Debug)]
    pub enum ApiKeyError {
        MissingError,
        InvalidError,
    }

    #[rocket::async_trait]
    impl<'r> FromRequest<'r> for ApiKey<'r> {
        type Error = ApiKeyError;

        async fn from_request(req: &'r Request<'_>) -> Outcome<ApiKey<'r>, (Status, ApiKeyError), ()> {
            /// Returns true if `key` is a valid API key string.
            fn is_valid(key: &str) -> bool {
                key.len() > 0
            }

            match req.headers().get_one("x-api-key") {
                None => Outcome::Failure((Status::BadRequest, ApiKeyError::MissingError)),
                Some(key) if is_valid(key) => Outcome::Success(ApiKey(key)),
                Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::InvalidError)),
            }
        }
    }

Our full working example

This entire section can be copy-pasted into a project to be used freely as a template. A more complicated example which includes a database connection may be found at Air Quality

#[macro_use]
extern crate rocket;
use std::collections::HashMap;
use config::Config;
use rocket::{custom, tokio};
use rocket::form::validate::Len;
use rocket::http::Status;
use rocket::outcome::{Outcome};
use rocket::request::{Request, FromRequest};


#[get("/")]
async fn index(
    settings_map: &rocket::State<HashMap<String, String>>,
    key:ApiKey<'_> // if match on FromRequest is true, which checks for 'x-api-key' header, use this route
) -> &'static str {
    if key.0.to_string() == settings_map.get("api_key").unwrap().to_string() { // compare given key to config file
        "Hello, Astronauts!" // correct key
    } else {
        "Pool is closed" // incorrect key
    }
}


#[rocket::main]
pub async fn main() {

    // load toml config file
    let settings = Config::builder()
        .add_source(config::File::with_name("config/Settings"))
        .build()
        .unwrap();

    // deserialize settings file to HashMap
    let settings_map = settings
        .try_deserialize::<HashMap<String, String>>()
        .unwrap();

    // retrieve settings and create custom Rocket config object
    let config = rocket::Config {
        port: settings_map.clone().get("port").unwrap().parse().unwrap(),
        address: std::net::Ipv4Addr::new(0, 0, 0, 0).into(),
        ..rocket::Config::debug_default()
    };

    custom(&config)
        .manage::<HashMap<String, String>>(settings_map.clone()) // asking Rocket to manage settings_map so that it may be accessed within the routes
        .mount(
            "/",
            routes![
                index,
            ],
        )
        .launch()
        .await
        .unwrap();
}


#[derive(PartialEq)]
pub struct ApiKey<'r>(pub(crate) &'r str);

#[derive(Debug)]
pub enum ApiKeyError {
    MissingError,
    InvalidError,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey<'r> {
    type Error = ApiKeyError;

    async fn from_request(req: &'r Request<'_>) -> Outcome<ApiKey<'r>, (Status, ApiKeyError), ()> {
        /// Returns true if `key` is a valid API key string.
        fn is_valid(key: &str) -> bool {
            key.len() > 0
        }

        match req.headers().get_one("x-api-key") {
            None => Outcome::Failure((Status::BadRequest, ApiKeyError::MissingError)),
            Some(key) if is_valid(key) => Outcome::Success(ApiKey(key)),
            Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::InvalidError)),
        }
    }
}

Testing the API key protected path, using a FromRequest fairing

Requests with an ApiKey<'_> parameter, stored in the corresponding header, should now only match with functions using the FromRequest implementation, also known as a Rocket Fairing. Observe how the endpoint will now only respond if the key passed by the request header and the key stored in Settings.toml match.

> curl -H 'x-api-key:yourapikey' http://127.0.0.1:8000