Description
This example shows how to use Rocket request guards for authentication with JSON Web Tokens (JWT for short).
The idea is that all requests authenticate first at https://authentication-rocket-app.shuttleapp.rs/login to get a JWT.
Then the JWT is sent with all requests requiring authentication using the HTTP header Authorization: Bearer <token>
.
This example uses the jsonwebtoken
which supports symmetric and asymmetric secret encoding, built-in validations, and most JWT algorithms.
However, this example only makes use of symmetric encoding and validation on the expiration claim.
You can clone the example below by running the following (you’ll need cargo-shuttle
installed):
cargo shuttle init --from shuttle-hq/shuttle-examples \
--subfolder rocket/jwt-authentication
Three Rocket routes are registered in this file:
/public
: a route that can be called without needing any authentication.
/login
: a route for posting a JSON object with a username and password to get a JWT.
/private
: a route that can only be accessed with a valid JWT.
Code
[package]
name = "authentication"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.23"
jsonwebtoken = { version = "8.1.1", default-features = false }
lazy_static = "1.4.0"
rocket = { version = "0.5.0", features = ["json"] }
serde = { version = "1.0.148", features = ["derive"] }
shuttle-rocket = "0.48.0"
shuttle-runtime = "0.48.0"
tokio = "1.26.0"
Your main.rs
should look like this:
use rocket::http::Status;
use rocket::response::status::Custom;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
mod claims;
use claims::Claims;
#[macro_use]
extern crate rocket;
#[derive(Serialize)]
struct PublicResponse {
message: String,
}
#[get("/public")]
fn public() -> Json<PublicResponse> {
Json(PublicResponse {
message: "This endpoint is open to anyone".to_string(),
})
}
#[derive(Serialize)]
struct PrivateResponse {
message: String,
user: String,
}
#[get("/private")]
fn private(user: Claims) -> Json<PrivateResponse> {
Json(PrivateResponse {
message: "The `Claims` request guard ensures only valid JWTs can access this endpoint"
.to_string(),
user: user.name,
})
}
#[derive(Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Serialize)]
struct LoginResponse {
token: String,
}
#[post("/login", data = "<login>")]
fn login(login: Json<LoginRequest>) -> Result<Json<LoginResponse>, Custom<String>> {
if login.username != "username" || login.password != "password" {
return Err(Custom(
Status::Unauthorized,
"account was not found".to_string(),
));
}
let claim = Claims::from_name(&login.username);
let response = LoginResponse {
token: claim.into_token()?,
};
Ok(Json(response))
}
#[shuttle_runtime::main]
async fn rocket() -> shuttle_rocket::ShuttleRocket {
let rocket = rocket::build().mount("/", routes![public, private, login]);
Ok(rocket.into())
}
Your claims.rs
should look like this:
use chrono::{Duration, Utc};
use jsonwebtoken::{
decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation,
};
use lazy_static::lazy_static;
use rocket::{
http::Status,
request::{FromRequest, Outcome},
response::status::Custom,
};
use serde::{Deserialize, Serialize};
const BEARER: &str = "Bearer ";
const AUTHORIZATION: &str = "Authorization";
const SECRET: &str = "secret";
lazy_static! {
static ref TOKEN_EXPIRATION: Duration = Duration::minutes(5);
}
#[derive(Debug, PartialEq)]
pub(crate) enum AuthenticationError {
Missing,
Decoding(String),
Expired,
}
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct Claims {
pub(crate) name: String,
exp: usize,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Claims {
type Error = AuthenticationError;
async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
match request.headers().get_one(AUTHORIZATION) {
None => Outcome::Error((Status::Forbidden, AuthenticationError::Missing)),
Some(value) => match Claims::from_authorization(value) {
Err(e) => Outcome::Error((Status::Forbidden, e)),
Ok(claims) => Outcome::Success(claims),
},
}
}
}
impl Claims {
pub(crate) fn from_name(name: &str) -> Self {
Self {
name: name.to_string(),
exp: 0,
}
}
fn from_authorization(value: &str) -> Result<Self, AuthenticationError> {
let token = value.strip_prefix(BEARER).map(str::trim);
if token.is_none() {
return Err(AuthenticationError::Missing);
}
let token = token.unwrap();
let token = decode::<Claims>(
token,
&DecodingKey::from_secret(SECRET.as_ref()),
&Validation::default(),
)
.map_err(|e| match e.kind() {
ErrorKind::ExpiredSignature => AuthenticationError::Expired,
_ => AuthenticationError::Decoding(e.to_string()),
})?;
Ok(token.claims)
}
pub(crate) fn into_token(mut self) -> Result<String, Custom<String>> {
let expiration = Utc::now()
.checked_add_signed(*TOKEN_EXPIRATION)
.expect("failed to create an expiration time")
.timestamp();
self.exp = expiration as usize;
let token = encode(
&Header::default(),
&self,
&EncodingKey::from_secret(SECRET.as_ref()),
)
.map_err(|e| Custom(Status::BadRequest, e.to_string()))?;
Ok(token)
}
}
#[cfg(test)]
mod tests {
use crate::claims::AuthenticationError;
use super::Claims;
#[test]
fn missing_bearer() {
let claim_err = Claims::from_authorization("no-Bearer-prefix").unwrap_err();
assert_eq!(claim_err, AuthenticationError::Missing);
}
#[test]
fn to_token_and_back() {
let claim = Claims::from_name("test runner");
let token = claim.into_token().unwrap();
let token = format!("Bearer {token}");
let claim = Claims::from_authorization(&token).unwrap();
assert_eq!(claim.name, "test runner");
}
}
Usage
Once you’ve cloned this example, launch it locally by using cargo shuttle run
. Once you’ve verified that it’s up, you’ll now be able to go to http://localhost:8000
and start trying the example out!
First, we should be able to access the public endpoint without any authentication using:
$ curl https://<host>/public
But trying to access the private endpoint will fail with a 403 forbidden:
$ curl https://<host>/private
So let’s get a JWT from the login route first:
$ curl --request POST --data '{"username": "username", "password": "password"}' https://<host>/login
Accessing the private endpoint with the token will now succeed:
$ curl --header "Authorization: Bearer <token>" https://<host>/private
The token is set to expire in 5 minutes, so wait a while and try to access the private endpoint again. Once the token has expired, a user will need to get a new token from login.
Since tokens usually have a longer than 5 minutes expiration time, we can create a /refresh
endpoint that takes an active token and returns a new token with a refreshed expiration time.