Rust: JSON Web Token -- some investigative studies on crate jsonwebtoken.
Be Hai Nguyen
Posted on November 20, 2023
Regarding crate jsonwebtoken, the primary question is still how to check if a token is still valid, i.e. not already expired? We're discussing this question in this post.
Rust: JSON Web Token -- some investigative studies on crate jsonwebtoken. |
We've previously done some studies on crate jwt-simple. And we've reviewed some background information on JSON Web Token (JWT).
In this post, we're studying crate jsonwebtoken. This crate has more than twelve (12) million downloads, and also seems to be actively maintained.
Crate jsonwebtoken also appears to be simpler to use than jwt-simple. It doesn't seem to assume much default, I feel having much more control over the usage of this crate.
-- For me, personally, I feel that it's closer to a session expiration management approach which I've coded before.
Base on the documentation, it employs both secret string
and key pair to calculate token's Signature
. We're only using secret string
in this post.
🚀 As per the previous post on jwt-simple, tokens generated by the examples in this post also suffer Invalid Signature
when pasted into https://jwt.io/. We can safely ignore this report.
-- Again, the primary objective is to find out how to manage token expiration.
Cargo.toml
is common for all examples. Its dependencies
section is as follow:
...
[dependencies]
time = { version = "0.3.22", default-features = false, features = ["formatting", "macros", "parsing"] }
serde = { version = "1.0.188", features = ["derive"] }
jsonwebtoken = "9.1"
Please note: the example code has been tested on both Windows 10 and Ubuntu 22.10.
❶ In this first example, we'll just combine examples from the following two (2) sections Function jsonwebtoken::encode and Function jsonwebtoken::decode from the official documentation.
Content of src/main.rs:
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, Algorithm, Header, EncodingKey};
use jsonwebtoken::{decode, DecodingKey, Validation};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
company: String
}
fn main() {
let my_claims = Claims {
sub: "b@b.com".to_owned(),
company: "ACME".to_owned()
};
// my_claims is a struct that implements Serialize
// This will create a JWT using HS256 as algorithm
let token = encode(&Header::default(), &my_claims, &EncodingKey::from_secret("secret".as_ref())).unwrap();
// Claims is a struct that implements Deserialize
let token_message = decode::<Claims>(&token, &DecodingKey::from_secret("secret".as_ref()), &Validation::new(Algorithm::HS256));
println!("token_message: {:#?}", token_message);
}
It's simple, create a token using a default Header
and a custom Payload
. The Signature
is calculated using a secret string
.
But it results in an error:
token_message: Err(
Error(
MissingRequiredClaim(
"exp",
),
),
)
Adding required field exp
to struct Claims
fixes the above error:
...
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
company: String,
exp: u64,
}
...
let my_claims = Claims {
sub: "b@b.com".to_owned(),
company: "ACME".to_owned(),
exp: 10000000000, // Just some random number.
};
...
And the output is:
token_message: Ok(
TokenData {
header: Header {
typ: Some(
"JWT",
),
alg: HS256,
cty: None,
jku: None,
jwk: None,
kid: None,
x5u: None,
x5c: None,
x5t: None,
x5t_s256: None,
},
claims: Claims {
sub: "b@b.com",
company: "ACME",
exp: 10000000000,
},
},
)
🦀 Also, in the decode token call, take note of the last parameter &Validation::new(Algorithm::HS256)
. This's its documentation page Struct jsonwebtoken::Validation. We're interested in the field validate_exp: bool, which by default has been set to validate if a token has been expired: i.e., if it has, the decode token call would return an appropriate Enum jsonwebtoken::errors::ErrorKind value, which we're using in the next example.
The GitHub repo's examples directory has a few more examples. Except for auth0.rs
which results in a run time error, every others work.
❷ In this second example, and also the final one, we are going to simulate server-client token exchanging and expiration checking. The example is a bit long, but it's only a sequence of steps, no tricky logic.
We'll implement the standard iat
field, the value of this field gets set once, and stays fixed during the entire life of a token. It's more or less for historical purposes. For expiration management, the standard exp
field is used; every time the server receives a token from a client, if the token is still valid, the value of this field gets updated to a new expiry time.
Initially, when the token first created by the server, iat
gets set to “seconds since epoch”, which we've discussed in Rust: seconds since epoch -- “1970-01-01 00:00:00 UTC”. And exp
is iat
plus (+) the duration in which the token is valid for, which's the value of the constant SECONDS_VALID_FOR
.
In a similar manner, on subsequent updates, exp
gets set to the current “seconds since epoch” plus (+) the duration expressed as seconds by constant SECONDS_VALID_FOR
.
🚀 It should be obvious that: this implementation implies SECONDS_VALID_FOR
is the duration the token stays valid since last active. It does not mean that after this duration, the token becomes invalid or expired. So long as the client keeps sending requests while the token is valid, it will never expire!
Let's see the example, then we'll briefly discuss it.
In the code, please pay attention to the instance validation
of Struct jsonwebtoken::Validation, which we've mentioned previously. We set leeway field to 0
to make it simpler for the purpose of studying token expiration behaviour.
Content of src/main.rs:
use std;
use serde::{Deserialize, Serialize};
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{encode, get_current_timestamp, Algorithm, Header, EncodingKey};
use jsonwebtoken::{decode, DecodingKey, Validation};
// A custom payload.
#[derive(Serialize, Deserialize, Debug)]
pub struct PayLoad {
iat: u64, // This standard field value stays fixed.
exp: u64, // Required.
email: String,
}
//
// When adjust, ensure that: ( SECONDS_TO_SLEEP * 2 ) > SECONDS_VALID_FOR.
//
static SECONDS_VALID_FOR: u64 = 5;
static SECONDS_TO_SLEEP: u64 = 4;
static SECRET_KEY: &str = "007: The Spy Who Loved Me";
fn seconds_since_epoch() -> u64 {
get_current_timestamp()
}
fn expiry(secs_since_epoch: u64, secs_valid_for: u64) -> u64 {
secs_since_epoch + secs_valid_for
}
fn main() {
//
// Server prepares a brand new token to be sent to a client.
//
let iat = seconds_since_epoch();
let exp = expiry(iat, SECONDS_VALID_FOR);
let pay_load = PayLoad{
iat,
exp,
email: String::from("behai_nguyen@hotmail.com"),
};
println!("original iat: {}", pay_load.iat);
println!("original exp: {}\n", pay_load.exp);
// Panic when in error, in proper code, it should return an error.
let token1 = match encode(&Header::default(),
&pay_load,
&EncodingKey::from_secret(SECRET_KEY.as_ref())) {
Ok(x) => x,
Err(e) => {
println!("1. Failed to encode token. Error: {}", e.to_string());
panic!("1. Program terminated!");
}
};
//
// Default global validation.
//
let mut validation = Validation::new(Algorithm::HS256);
//
// For the shake of simplicity, 0 would make leeway not having any effect
// on expiration calculations.
//
validation.leeway = 0;
//
// Simulate token has not been expired.
//
// After SECONDS_TO_SLEEP seconds the client submits the token
// to the server to request some resource.
//
// After SECONDS_TO_SLEEP seconds...
let sleep_time = std::time::Duration::from_secs(SECONDS_TO_SLEEP);
std::thread::sleep(sleep_time);
let mut ret_claims: PayLoad;
// Server decodes the token.
// Panic when in error, in proper code, it should return an error.
match decode::<PayLoad>(&token1,
&DecodingKey::from_secret(SECRET_KEY.as_ref()), &validation) {
Ok(x) => ret_claims = x.claims,
Err(err) => match *err.kind() {
ErrorKind::InvalidToken => panic!("1. Decode: token is invalid"),
ErrorKind::ExpiredSignature => panic!("1. Decode: token has expired"),
_ => panic!("1. Decode: some other errors"),
},
};
println!("ret_claims.iat: {}", ret_claims.iat);
println!("ret_claims.exp before updated: {}\n", ret_claims.exp);
// Get the current time.
let secs_since_epoch = seconds_since_epoch();
println!("1. current time: {}\n", secs_since_epoch);
// If the token has not been expired, server the request resource.
// At this step, the code as it is, the token has not been expired.
println!("1. Token is still valid!\n");
//
// The token has been verified valid.
// Update the payload's exp value, then prepare an updated token and
// send back to the client.
//
// Expiry should be moved back by SECONDS_VALID_FOR seconds from current time.
ret_claims.exp = expiry(secs_since_epoch, SECONDS_VALID_FOR);
println!("ret_claims.exp after updated: {}\n", ret_claims.exp);
//
// Updated token sent back to the client.
//
let token2 = encode(&Header::default(),
&ret_claims, &EncodingKey::from_secret(SECRET_KEY.as_ref())).unwrap();
//
// Simulate token has been expired.
//
// After ( SECONDS_TO_SLEEP * 2 ) seconds the client submits the token
// to the server to request some resource.
//
// After SECONDS_TO_SLEEP seconds...
let sleep_time = std::time::Duration::from_secs(SECONDS_TO_SLEEP * 2);
std::thread::sleep(sleep_time);
let ret_claims2: PayLoad;
// Server decodes the token.
// Panic when in error, in proper code, it should return an error.
match decode::<PayLoad>(&token2,
&DecodingKey::from_secret(SECRET_KEY.as_ref()), &validation) {
Ok(x) => ret_claims2 = x.claims,
Err(err) => match *err.kind() {
ErrorKind::InvalidToken => panic!("2. Decode: token is invalid"),
ErrorKind::ExpiredSignature => panic!("2. Decode: token has expired"),
_ => panic!("2. Decode: some other errors"),
},
};
println!("ret_claims2.iat: {}", ret_claims2.iat);
println!("ret_claims2.exp: {}\n", ret_claims2.exp);
// Get the current time.
let secs_since_epoch = seconds_since_epoch();
println!("2. current time: {}\n", secs_since_epoch);
// If the token has not been expired, server the request resource.
// At this step, the code as it is, the token has been expired: this message
// never gets printed out.
println!("2. Token is still valid!");
}
The example has the following sequential steps. ⓵ The server creates a token1
, (and of course sends it the client.) ⓶ Waits for SECONDS_TO_SLEEP
seconds before receiving the token back from the client. ⓷ The server should decode the token successfully, it shouldn't be expired. ⓸ Then the server updates the value of the exp
field of the decoded token. ⓹ From the updated claim, it prepares a new token2
and sends this to the client. ⓺ Waits for (SECONDS_TO_SLEEP * 2)
seconds before receiving the token back from the client. ⓻ Decoding this token2
should result in error ExpiredSignature, which cause panic!("2. Decode: token has expired")
to come into effect.
Please also note that, along each step, we also print out some information for visual inspection.
The example code unaltered as is, its output's as follows:
original iat: 1700390661
original exp: 1700390666
ret_claims.iat: 1700390661
ret_claims.exp before updated: 1700390666
1. current time: 1700390665
1. Token is still valid!
ret_claims.exp after updated: 1700390670
thread 'main' panicked at '2. Decode: token has expired', src/main.rs:145:48
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The number should make sense based on the values of SECONDS_VALID_FOR
and SECONDS_TO_SLEEP
.
We can try the following:
⓵ Setting SECONDS_TO_SLEEP
to 6
, recompile and run, the first panic!("1. Decode: token has expired")
should come into effect.
⓶ Setting SECONDS_TO_SLEEP
to 2
, recompile and run, both tokens stay valid during the entire execution of the program.
That's how expiration validation works. The next step is to use JWT in a web application to authenticate requests. I aim to do a full stack post in the near future.
I hope you find the information in this post helpful. Thank you for reading and stay safe as always.
✿✿✿
Feature image source:
Posted on November 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.