In the sixth post of our actix-web learning application, we implemented a basic email-password login process with a placeholder for a token. In this post, we will implement a comprehensive JSON Web Token (JWT)-based authentication system. We will utilise the jsonwebtoken crate, which we have previously studied.

πŸ¦€ Index of the Complete Series.

100-feature-image.png
Rust: actix-web JSON Web Token authentication.

πŸš€ Please note, complete code for this post can be downloaded from GitHub with:

git clone -b v0.10.0 https://github.com/behai-nguyen/rust_web_01.git

The actix-web learning application mentioned above has been discussed in the following nine previous posts:

  1. Rust web application: MySQL server, sqlx, actix-web and tera.
  2. Rust: learning actix-web middleware 01.
  3. Rust: retrofit integration tests to an existing actix-web application.
  4. Rust: adding actix-session and actix-identity to an existing actix-web application.
  5. Rust: actix-web endpoints which accept both application/x-www-form-urlencoded and application/json content types.
  6. Rust: simple actix-web email-password login and request authentication using middleware.
  7. Rust: actix-web get SSL/HTTPS for localhost.
  8. Rust: actix-web CORS, Cookies and AJAX calls.
  9. Rust: actix-web global extractor error handlers.

The code we’re developing in this post is a continuation of the code from the ninth post above. πŸš€ To get the code of this ninth post, please use the following command:

git clone -b v0.9.0 https://github.com/behai-nguyen/rust_web_01.git

– Note the tag v0.9.0.

Table of contents

Previous Studies on JSON Web Token (JWT)

As mentioned earlier, we conducted studies on the jsonwebtoken crate, as detailed in the post titled Rust: JSON Web Token – some investigative studies on crate jsonwebtoken. The JWT implementation in this post is based on the specifications discussed in the second example of the aforementioned post, particularly focusing on this specification:

πŸš€ 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!

We will provide further details on this specification later in the post. Additionally, before studying the jsonwebtoken crate, we conducted research on the jwt-simple crate, as discussed in the post titled Rust: JSON Web Token – some investigative studies on crate jwt-simple. It would be beneficial to review this post as well, as it covers background information on JWT.

Proposed JWT Implementations: Problems and Solutions

Proposed JWT Implementations

Let’s revisit the specifications outlined in the previous section:

πŸš€ 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!

This concept involves extending the expiry time of a valid token every time a request is made. This functionality was demonstrated in the original discussion, specifically in the second example section mentioned earlier.

πŸ¦€ Since the expiry time is updated, we generate a new access token. Here’s what we do with the new token:

  1. Replace the current actix-identity::Identity login with the new access token.
  2. Always send the new access token to clients via both the response header and the response cookie authorization, as in the login process.

We generate a new access token based on logic, but it doesn’t necessarily mean the previous ones have expired.”

Problems with the Proposed Implementations

The proposed implementations outlined above present some practical challenges, which we will discuss next.

However, for the sake of learning in this project, we will proceed with the proposed implementations despite the identified issues.

Problems when Used as an API-Server or Service

In an API-like server or a service, users are required to include a valid access token in the request authorization header. Therefore, if a new token is generated, users should have access to this latest token.

What happens if users simply ignore the new tokens and continue using a previous one that has not yet expired? In such a scenario, request authentication would still be successful, and the requests would potentially succeed until the old token expires. However, a more serious concern arises if we implement blacklisting. In that case, we would need to blacklist all previous tokens. This would necessitate writing the current access token to a blacklist table for every request, which is impractical.

Problems when Used as an Application Server

When used as an application server, we simply replace the current actix-identity::Identity login with the new access token. If we implement blacklisting, we only need to blacklist the last token

πŸš€ This process makes sense, as we cannot expire a session while a user is still actively using it.

However, we still encounter similar problems as described in the previous section for API-like servers or services. Since clients always have access to the authorization response header and cookie, they can use this token with different client tools to send requests, effectively treating the application as an API-like server or a service.

Proposed Solutions

The above problems would disappear, and the actual implementations would be simpler if we adjust the logic slightly:

  1. Only send the access token to clients once if the content type of the login request is application/json.
  2. Then users of an API-like server or a service will only have one access token until it expires. They will need to log in again to obtain a new token.
  3. Still replace the current actix-identity::Identity login with the new access token. The application server continues to function as usual. However, since users no longer have access to the token, we only need to manage the one stored in the actix-identity::Identity login.

But as mentioned at the start of this section, we will ignore the problems and, therefore, the solutions for this revision of the code.

The β€œBearer” Token Scheme

We adhere to the β€œBearer” token scheme as specified in RFC 6750, section 2.1. Authorization Request Header Field:

    For example:
        GET /resource HTTP/1.1
        Host: server.example.com
        Authorization: Bearer mF_9.B5f-4.1JqM

That is, the access token used during request authentication is in the format:

Bearer. + the proper JSON Web Token

For example:

Bearer.eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImNoaXJzdGlhbi5rb2JsaWNrLjEwMDA0QGdtYWlsLmNvbSIsImlhdCI6MTcwODU1OTcwNywiZXhwIjoxNzA4NTYxNTA3LCJsYXN0X2FjdGl2ZSI6MTcwODU1OTcwN30.CN-whQ0rWW8IuLPVTF7qprk4-GgtK1JSJqp3C8X-ytE

❢ The access token included in the request authorization header must adhere to the β€œBearer” token format.

❷ Similarly, the access token set for the actix-identity::Identity login is also a β€œBearer” token.

πŸ¦€ However, the access token sent to clients via the response header and the response cookie authorization is always a pure JSON Web Token.

Project Layout

Below is the complete project layout.

– Please note, those marked with β˜… are updated, and those marked with β˜† are new.

.
β”œβ”€β”€ .env β˜…
β”œβ”€β”€ Cargo.toml β˜…
β”œβ”€β”€ cert
β”‚ β”œβ”€β”€ cert-pass.pem
β”‚ β”œβ”€β”€ key-pass-decrypted.pem
β”‚ └── key-pass.pem
β”œβ”€β”€ migrations
β”‚ β”œβ”€β”€ mysql
β”‚ β”‚ └── migrations
β”‚ β”‚     β”œβ”€β”€ 20231128234321_emp_email_pwd.down.sql
β”‚ β”‚     └── 20231128234321_emp_email_pwd.up.sql
β”‚ └── postgres
β”‚     └── migrations
β”‚         β”œβ”€β”€ 20231130023147_emp_email_pwd.down.sql
β”‚         └── 20231130023147_emp_email_pwd.up.sql
β”œβ”€β”€ README.md β˜…
β”œβ”€β”€ src
β”‚ β”œβ”€β”€ auth_handlers.rs β˜…
β”‚ β”œβ”€β”€ auth_middleware.rs β˜…
β”‚ β”œβ”€β”€ bh_libs
β”‚ β”‚ β”œβ”€β”€ api_status.rs
β”‚ β”‚ └── australian_date.rs
β”‚ β”œβ”€β”€ bh_libs.rs
β”‚ β”œβ”€β”€ config.rs β˜…
β”‚ β”œβ”€β”€ database.rs
β”‚ β”œβ”€β”€ handlers.rs
β”‚ β”œβ”€β”€ helper
β”‚ β”‚ β”œβ”€β”€ app_utils.rs β˜…
β”‚ β”‚ β”œβ”€β”€ constants.rs β˜…
β”‚ β”‚ β”œβ”€β”€ endpoint.rs β˜…
β”‚ β”‚ β”œβ”€β”€ jwt_utils.rs β˜†
β”‚ β”‚ └── messages.rs β˜…
β”‚ β”œβ”€β”€ helper.rs β˜…
β”‚ β”œβ”€β”€ lib.rs β˜…
β”‚ β”œβ”€β”€ main.rs
β”‚ β”œβ”€β”€ middleware.rs
β”‚ └── models.rs β˜…
β”œβ”€β”€ templates
β”‚ β”œβ”€β”€ auth
β”‚ β”‚ β”œβ”€β”€ home.html
β”‚ β”‚ └── login.html
β”‚ └── employees.html
└── tests
    β”œβ”€β”€ common.rs β˜…
    β”œβ”€β”€ test_auth_handlers.rs β˜…
    β”œβ”€β”€ test_handlers.rs β˜…
    └── test_jsonwebtoken.rs β˜†

The Token Utility jwt_utils.rs and Test test_jsonwebtoken.rs Modules

The Token Utility src/helper/jwt_utils.rs Module

In the module src/helper/jwt_utils.rs, we implement all the JWT management code, which includes the core essential code that somewhat repeats the code already mentioned in the second example:

  • struct JWTPayload -- represents the JWT payload, where the email field uniquely identifies the logged-in user.
  • JWTPayload implementation -- implements some of the required functions and methods:
    • A function to create a new instance.
    • Methods to update the expiry field (exp) and the last_active field using seconds, minutes, and hours.
    • Four getter methods which return the values of the iat, email, exp, and last_active fields.

Additionally, there are two main functions:

  1. pub fn make_token -- creates a new JWT from an email. The parameter secs_valid_for indicates how many seconds the token is valid for, and the parameter secret_key is used by the jsonwebtoken crate to encode the token. It creates an instance of struct JWTPayload, and then creates a token using this instance.
  2. pub fn decode_token -- decodes a given token. If the token is valid and successfully decoded, it returns the token's struct JWTPayload. Otherwise, it returns an ApiStatus which describes the error.

Other functions are β€œconvenient” functions or wrapper functions:

  1. pub fn make_token_from_payload -- creates a JWT from an instance of struct struct JWTPayload. It is a "convenient" function. We decode the current token, update the extracted payload, then call this function to create an updated token.
  2. pub fn make_bearer_token -- a wrapper function that creates a β€œBearer” token from a given token.
  3. pub fn decode_bearer_token -- a wrapper function that decodes a β€œBearer” token.

Please note also the unit test section in this module. There are sufficient tests to cover all functions and methods.

The documentation in the source code should be sufficient to aid in the reading of the code.

The Test tests/test_jsonwebtoken.rs Module

We implement some integration tests for JWT management code. These tests are self-explanatory.

The Updated Login Process

In the current login process, at step 4, we note:

...
    // TO_DO: Work in progress -- future implementations will formalise access token.
    let access_token = &selected_login.email;

    // https://docs.rs/actix-identity/latest/actix_identity/
    // Attach a verified user identity to the active session
    Identity::login(&request.extensions(), String::from(access_token)).unwrap();
...	

This part of the login process handler pub async fn login(request: HttpRequest, app_state: web::Data<super::AppState>, body: Bytes) -> Either<impl Responder, HttpResponse> is updated to:

...
    let access_token = make_token(&selected_login.email, 
        app_state.cfg.jwt_secret_key.as_ref(), app_state.cfg.jwt_mins_valid_for * 60);

    // https://docs.rs/actix-identity/latest/actix_identity/
    // Attach a verified user identity to the active session
    Identity::login(&request.extensions(), String::from( make_bearer_token(&access_token) )).unwrap();
...	

Please note the call to make_bearer_token, which adheres to The β€œBearer” Token Scheme.

This update would take care of the application server case. In the case of an API-like server or a service, users are required to include a valid access token in the request authorization header, as mentioned, so we don’t need to do anything.

The next task is to update the request authentication process. This update occurs in the src/auth_middleware.rs and the src/lib.rs modules.

The Updated Request Authentication Process

The updated request request authentication involves changes to both the src/auth_middleware.rs and src/lib.rs modules.

This section, How the Request Authentication Process Works, describes the current process.

Code Updated in the src/auth_middleware.rs Module

Please recall that the src/auth_middleware.rs module serves as the request authentication middleware. We will make some substantial updates within this module.

Although the code has sufficient documentation, we will discuss the updates in the following sections.

β“΅ The module documentation has been updated to describe how the request authentication process works with JWT. Please refer to the documentation section How This Middleware Works for more details.

β“Ά New struct TokenStatus:

struct TokenStatus {
    is_logged_in: bool,
    payload: Option<JWTPayload>,
    api_status: Option<ApiStatus>
}

The struct TokenStatus represents the status of the access token for the current request:

β“· The function fn verify_valid_access_token(request: &ServiceRequest) -> TokenStatus has been completely rewritten, although its purpose remains the same. It checks if the token is present and, if so, decodes it.

The return value of this function is struct TokenStatus, whose fields are set based on the rules discussed previously.

β“Έ The new helper function fn update_and_set_updated_token(request: &ServiceRequest, token_status: TokenStatus) is called when there is a token and the token is successfully decoded.

It uses the JWTPayload instance in the token_status parameter to create the updated access token. Then, it:

  1. Replaces the current actix-identity::Identity login with the new updated token, as discussed earlier.
  2. Attaches the updated token to dev::ServiceRequest's dev::Extensions by calling fn extensions_mut(&self) -> RefMut<'_, Extensions>.

    The next adhoc middleware, discussed in the next section, consumes this extension.

β“Ή The new closure, let unauthorised_token = |req: ServiceRequest, api_status: ApiStatus| -> Self::Future, calls the Unauthorized() method on HttpResponse to return a JSON serialisation of ApiStatus.

Note the calls to remove the server-side per-request cookies redirect-message and original-content-type.

β“Ί Update the fn call(&self, request: ServiceRequest) -> Self::Future function. All groundwork has been completed. The updates to this method are fairly straightforward:

  1. Update the call to fn verify_valid_access_token(request: &ServiceRequest) -> TokenStatus; the return value is now struct TokenStatus.
  2. If the token is in error, call the closure unauthorised_token() to return the error response. The request is then completed.
  3. If the request is from an authenticated session, meaning we have a token, and the token has been decoded successfully, we make an additional call to the new helper function fn update_and_set_updated_token(request: &ServiceRequest, token_status: TokenStatus), which has been described in the previous section.

The core logic of this method remains unchanged.

Code Updated in the src/lib.rs Module

As mentioned previously, if a valid token is present, an updated token is generated from the current token’s payload every time a request occurs. This updated access token is then sent to the client via both the response header and the response cookie authorization.

This section describes how the updated token is attached to the request extension so that the next adhoc middleware can pick it up and send it to the clients.

This is the updated src/lib.rs next adhoc middleware. Its functionality is straightforward. It queries the current dev::ServiceRequest’s dev::Extensions for a String, which represents the updated token. If found, it sets the ServiceResponse authorization header and cookie with this updated token.

Afterward, it forwards the response. Since it is currently the last middleware in the call stack, the response will be sent directly to the client, completing the request.

JWT and Logout

Due to the issues outlined in this section and this section, we were unable to effectively implement the logout functionality in the application. This will remain unresolved until we implement the proposed solutions and integrate blacklisting.

– For the time being, we will retain the current logout process unchanged.

Once blacklisting is implemented, the request authentication process will need to validate the access token against the blacklist table. If the token is found in the blacklist, it will be considered invalid.

Updating Integration Tests

There is a new integration test module as already discussed in section The Test tests/test_jsonwebtoken.rs Module. There is no new integration test added to existing modules.

Some common test code has been updated as a result of implementing JSON Web Token.

β“΅ There are several updates in module tests/common.rs:

  1. Function pub fn mock_access_token(&self, secs_valid_for: u64) -> String now returns a correctly formatted β€œBearer” token. Please note the new parameter secs_valid_for.
  2. New function pub fn jwt_secret_key() -> String
  3. New function pub fn assert_token_email(token: &str, email: &str). It decodes the parameter token, which is expected to always succeed, then tests that the token JWTPayload's email value equal to parameter email.
  4. Rewrote pub fn assert_access_token_in_header(response: &reqwest::Response, email: &str) and pub fn assert_access_token_in_cookie(response: &reqwest::Response, email: &str).
  5. Updated pub async fn assert_json_successful_login(response: reqwest::Response, email: &str).

β“Ά Some minor changes in both the tests/test_handlers.rs and the tests/test_auth_handlers.rs modules:

  1. Call the function pub fn mock_access_token(&self, secs_valid_for: u64) -> String with the new parameter secs_valid_for.
  2. Other updates as a result of the updates in the tests/common.rs module.

Concluding Remarks

It has been an interesting process for me as I delved into the world of actix-web adhoc middleware. While the code may seem simple at first glance, I encountered some problems along the way and sought assistance to overcome them.

I anticipated the problems, as described in this section and this section, before diving into the actual coding process. Despite the hurdles, I proceeded with the implementation because I wanted to learn how to set a custom header for all routes before their final response is sent to clients – that’s the essence of adhoc middleware.

In a future post, I plan to implement the proposed solutions and explore the concept of blacklisting.

I hope you find this post informative and helpful. Thank you for reading. And stay safe, as always.

✿✿✿

Feature image source:

πŸ¦€ Index of the Complete Series.