web

Rust with Actix

Rust with Actix

In this quickstart, you are going to build an application with Rust and Actix and integrate it with FusionAuth. You’ll be building it for ChangeBank, a global leader in converting dollars into coins. It’ll have areas reserved for users who have logged in as well as public facing sections.

The Docker Compose file and source code for a complete application are available at https://github.com/FusionAuth/fusionauth-quickstart-rust-actix-web.

While this article uses Actix, the Rust OAuth2 library can also be used in your preferred framework, such as Rocket or Axum.

Prerequisites

For this quickstart, you’ll need:

  • Docker version 20 or later, which is the quickest way to start FusionAuth. (There are other ways.)
  • Rust and Cargo version 1.7 or later.
  • You may also need to install pkg-config and libssl-dev if they are not already installed on your system.

General Architecture

While this sample application doesn't have login functionality without FusionAuth, a more typical integration will replace an existing login system with FusionAuth.

In that case, the system might look like this before FusionAuth is introduced.

UserApplicationView HomepageClick Login LinkShow Login FormFill Out and Submit Login FormAuthenticates UserDisplay User's Account or OtherInfoUserApplication

Request flow during login before FusionAuth

The login flow will look like this after FusionAuth is introduced.

UserApplicationFusionAuthView HomepageClick Login Link (to FusionAuth)View Login FormShow Login FormFill Out and Submit Login FormAuthenticates UserGo to Redirect URIRequest the Redirect URIIs User Authenticated?User is AuthenticatedDisplay User's Account or OtherInfoUserApplicationFusionAuth

Request flow during login after FusionAuth

In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.

Getting Started

Start with getting FusionAuth up and running and creating a new Actix application.

Clone The Code

First, grab the code from the repository and change to that folder.

git clone https://github.com/FusionAuth/fusionauth-quickstart-rust-actix-web.git
cd fusionauth-quickstart-rust-actix-web
mkdir your-application

All shell commands in this guide can be entered in a terminal in this folder. On Windows, you need to replace forward slashes with backslashes in paths.

All the files you’ll create in this guide already exist in the complete-application subfolder, if you prefer to copy them to your-application.

Run FusionAuth Via Docker

You'll find a Docker Compose file (docker-compose.yml) and an environment variables configuration file (.env) in the root directory of the repo.

Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.

docker compose up -d

Here you are using a bootstrapping feature of FusionAuth called Kickstart. When FusionAuth comes up for the first time, it will look at the kickstart/kickstart.json file and configure FusionAuth to your specified state.

If you ever want to reset the FusionAuth application, you need to delete the volumes created by Docker Compose by executing docker compose down -v, then re-run docker compose up -d.

FusionAuth will be initially configured with these settings:

  • Your client Id is E9FDB985-9173-4E01-9D73-AC2D60D1DC8E.
  • Your client secret is super-secret-secret-that-should-be-regenerated-for-production.
  • Your example username is richard@example.com and the password is password.
  • Your admin username is admin@example.com and the password is password.
  • The base URL of FusionAuth is http://localhost:9011/.

You can log in to the FusionAuth admin UI and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.

If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the View icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the OAuth configuration section.

The .env file contains passwords. In a real application, always add this file to your .gitignore file and never commit secrets to version control.

The Basic Actix Application

While this guide builds a new Actix project, you can use the same method to integrate your existing project with FusionAuth.

If you only want to run the application and not create your own, there is a completed version in the complete-application directory. You can use the following commands to get it up and running.

cd complete-application
cargo run

View the application at http://localhost:9012.

From here on, you’ll work in the your-application directory. Install the dependencies for the web server with the code below.

cd your-application
cargo init
cargo add actix-web@4 
cargo add actix-files@0.6.2 
cargo add actix-session@0.8.0 --features cookie-session 
cargo add dotenv@0.15.0 
cargo add handlebars@4.5 --features dir_source 
cargo add oauth2@4.4.2 
cargo add reqwest@0.11 --features json
cargo add serde@1.0 --features derive

Add the following code to your src/main.rs file.

use actix_web::{get, post, web, App, HttpResponse, HttpServer}; // web server
use actix_files as fs; // static image files
use actix_session::{Session, SessionMiddleware, storage::CookieSessionStore, config::CookieContentSecurity}; // store auth info in browser cookies
use actix_web::cookie::{Key, SameSite};
use handlebars::Handlebars; // html templates
use std::collections::HashMap; // pass data to templates
use dotenv::dotenv; // load .env file
mod auth;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    let handlebars_ref = setup_handlebars().await;
    let key = Key::generate();
    HttpServer::new(move || {
        App::new()
            .wrap(SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
                    .cookie_content_security(CookieContentSecurity::Private)
                    .cookie_same_site(SameSite::Lax)
                    .build())
            .service(account)
            .service(change_get)
            .service(change_post)
            .service(index)
            .service(auth::login)
            .service(auth::logout)
            .service(auth::callback)
            .service(fs::Files::new("/static", "static").show_files_listing())
            .app_data(handlebars_ref.clone())
    })
    .bind(("127.0.0.1", 9012))?
    .run()
    .await
}

async fn setup_handlebars() -> web::Data<Handlebars<'static>> {
    let mut handlebars = Handlebars::new();
    handlebars
        .register_templates_directory(".html", "./templates")
        .unwrap();
    web::Data::new(handlebars)
}

#[get("/")]
async fn index(hb: web::Data<Handlebars<'_>>, session: Session) -> HttpResponse {
    if let Ok(Some(_)) = session.get::<String>("email") {
        return HttpResponse::Found().append_header(("Location", "/account")).finish();
    }
    let body = hb.render("index", &{}).unwrap();
    HttpResponse::Ok().body(body)
}

#[get("/account")]
async fn account(hb: web::Data<Handlebars<'_>>, session: Session) -> HttpResponse {
    if let Ok(None) | Err(_) = session.get::<String>("email") {
        return HttpResponse::Found().append_header(("Location", "/")).finish();
    }
    let mut data = HashMap::new();
    data.insert("email", session.get::<String>("email").unwrap());
    let body = hb.render("account", &data).unwrap();
    HttpResponse::Ok().body(body)
}

#[get("/change")]
async fn change_get(hb: web::Data<Handlebars<'_>>, session: Session) -> HttpResponse {
    if let Ok(None) | Err(_) = session.get::<String>("email") {
        return HttpResponse::Found().append_header(("Location", "/")).finish();
    }
    let mut data = HashMap::<&str, String>::new();
    data.insert("email", session.get::<String>("email").unwrap().unwrap());
    data.insert("isGetRequest", "true".to_string());
    let body = hb.render("change", &data).unwrap();
    HttpResponse::Ok().body(body)
}

#[post("/change")]
async fn change_post(hb: web::Data<Handlebars<'_>>, session: Session, form: web::Form<HashMap<String, String>>) -> HttpResponse {
    if let Ok(None) | Err(_) = session.get::<String>("email") {
        return HttpResponse::Found().append_header(("Location", "/")).finish();
    }
    let mut data = HashMap::<&str, String>::new();
    data.insert("email", session.get::<String>("email").unwrap().unwrap());
    data.insert("isGetRequest", "false".to_string());
    if let Some(amount) = form.get("amount") {
        calculate_change(amount, &mut data);
    }
    else {
        data.insert("isError", "true".to_string());
    }
    let body = hb.render("change", &data).unwrap();
    HttpResponse::Ok().body(body)
}

fn calculate_change(amount: &str, state: &mut HashMap::<&str, String>) -> () {
    let total = match amount.parse::<f64>() {
        Ok(t) => t,
        Err(_) => {
            state.insert("isError", "true".to_string());
            return;
        }
    };
    let rounded_total = (total * 100.0).floor() / 100.0;

    state.insert("isError", (!amount.chars().all(|c| c.is_digit(10) || c == '.')).to_string());
    state.insert("total", format!("{:.2}", rounded_total));

    let nickels = (rounded_total / 0.05).floor().abs();
    state.insert("nickels", format!("{}", nickels));

    let pennies = ((rounded_total - (0.05 * nickels)) / 0.01).round().abs();
    state.insert("pennies", format!("{}", pennies));
}

The main function configures the web server, adds routes to it, and starts it. The function also:

  • Uses dotenv to allow you to call any values from the .env file later in the application.
  • Uses private same-site cookies to store the user’s email when logged in. Actix does not provide an anti-CSRF token as they believe same-site cookies render it unnecessary.
  • Adds routes from the main file to the server, and some authentication routes, which you’ll add in the next section.
  • Enables the Handlebars library to provide HTML templates.

The remainder of this main file is three routes: index, account, and change. They each check if the user’s email is in the HTTP request cookie (meaning the user is logged in), and display an HTML template. The account and change routes send the user’s email to the template.

The change route is more complicated. It has two versions: one for GET and one for POST. The POST version checks the request’s form to extract a dollar amount, then calls calculate_change to convert the dollars to nickels and pennies, and returns them in the state to the Handlebars template.

Authentication

Authentication in Rust is managed by OAuth2.

Create the file .env in the your-application directory and insert the following lines.

FUSIONAUTH_CLIENT_ID="E9FDB985-9173-4E01-9D73-AC2D60D1DC8E"
FUSIONAUTH_CLIENT_SECRET="super-secret-secret-that-should-be-regenerated-for-production"
FUSIONAUTH_URL="http://localhost:9011"
FUSIONAUTH_REDIRECT_URL="http://localhost:9012/callback"

This tells Actix where to find and connect to FusionAuth.

Authentication is handled by src/auth.rs. Create that file now and insert the following code.

use actix_web::{get, web, Error, HttpResponse, Responder};
use actix_session::{Session};
use std::env;
use oauth2::{
    AuthorizationCode,
    AuthUrl,
    ClientId,
    ClientSecret,
    CsrfToken,
    PkceCodeChallenge,
    PkceCodeVerifier,
    RedirectUrl,
    Scope,
    TokenResponse,
    TokenUrl
};
use oauth2::basic::BasicClient;
use oauth2::reqwest::async_http_client;
use reqwest;
use serde::Deserialize;

#[get("/logout")]
async fn logout(session: Session) -> impl Responder {
    let _ = session.remove("email");
    HttpResponse::Found().append_header(("Location", "/")).finish()
}

#[get("/login")]
async fn login(session: Session) -> impl Responder {
    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
    let (auth_url, csrf_token) = get_oauth_client()
        .authorize_url(CsrfToken::new_random)
        .add_scope(Scope::new("openid".to_string()))
        .add_scope(Scope::new("email".to_string()))
        .add_scope(Scope::new("profile".to_string()))
        .set_pkce_challenge(pkce_challenge)
        .url();
    let _ = session.insert("csrf_token", csrf_token);
    let _ = session.insert("pkce_verifier", pkce_verifier);
    HttpResponse::Found().append_header(("Location", auth_url.to_string())).finish()
}

#[get("/callback")]
async fn callback(params: web::Query<AuthCallbackParams>, session: Session) ->  Result<HttpResponse, Error> {
    // confirm pkce match
    let received_state = &params.state;
    if let Ok(saved_state) = session.get::<String>("csrf_token") {
        if saved_state != Some(received_state.clone()) {
            return Ok(HttpResponse::BadRequest().body("PKCE state mismatch"));
        }
    }
    else {
        return Ok(HttpResponse::InternalServerError().body("Session error"));
    }

    // get access token
    let pkce_verifier = session.get::<String>("pkce_verifier").unwrap().unwrap();
    let token_result = match get_oauth_client()
        .exchange_code(AuthorizationCode::new(params.code.clone()))
        .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
        .request_async(async_http_client)
        .await {
            Ok(result) => result,
            Err(e) => {
                println!("{:#?}", e);
                return Ok(HttpResponse::InternalServerError().body("Error during token exchange"));
            }
        };

    // get email
    let client = reqwest::Client::new();
    let user_info_response = match client
        .get(format!("{}/oauth2/userinfo", env::var("FUSIONAUTH_URL").expect("Missing FUSIONAUTH_URL")))
        .bearer_auth(token_result.access_token().secret())
        .send()
        .await {
            Ok(result) => result,
            Err(e) => {
                println!("{:#?}", e);
                return Ok(HttpResponse::InternalServerError().body("Error during get email"));
            }
        };
    if user_info_response.status().is_success() {
        let user_info = match user_info_response.json::<UserInfo>().await {
            Ok(result) => result,
            Err(e) => {
                println!("{:#?}", e);
                return Ok(HttpResponse::InternalServerError().body("Error during get email2"));
            }
        };
        let _ = session.insert("email", user_info.email.clone());
    }
    else {
        println!("{:#?}", user_info_response.error_for_status().unwrap_err());
        return Ok(HttpResponse::InternalServerError().body("Error during get email3"));
    }
    Ok(HttpResponse::Found().append_header(("Location", "/account")).finish())
}

fn get_oauth_client() -> BasicClient {
    BasicClient::new(
        ClientId::new(env::var("FUSIONAUTH_CLIENT_ID").expect("Missing FUSIONAUTH_CLIENT_ID")),
        Some(ClientSecret::new(env::var("FUSIONAUTH_CLIENT_SECRET").expect("Missing FUSIONAUTH_CLIENT_SECRET"))),
        AuthUrl::new(env::var("FUSIONAUTH_URL").expect("Missing FUSIONAUTH_URL") + "/oauth2/authorize").expect("Invalid AuthUrl"),
        Some(TokenUrl::new(env::var("FUSIONAUTH_URL").expect("Missing FUSIONAUTH_URL") + "/oauth2/token").expect("Invalid TokenUrl"))
    )
    .set_redirect_uri(RedirectUrl::new(env::var("FUSIONAUTH_REDIRECT_URL").expect("Missing FUSIONAUTH_REDIRECT_URL")).expect("Invalid RedirectUrl"))
}

#[derive(Deserialize)]
struct AuthCallbackParams {
    state: String,
    code: String,
}

#[derive(Deserialize)]
struct UserInfo {
    email: String,
}

This code has three routes: login, logout, and callback.

  • logout clears the user’s session and returns them to the home page.
  • login uses get_oauth_client to read your variables from the .env file and get a new client that constructs a URL to call FusionAuth, then redirects the user to that URL.
  • callback does most of the work. The user is returned to callback after logging in with FusionAuth. The function checks that the PKCE challenge is correct, retrieves the access token, and makes a final call to FusionAuth to get the user’s email, which it stores in the session. Now the application considers the user logged in.

Actix automatically links the user’s session to their browser by returning a cookie for the site, which is then included in every subsequent request.

Now that callback has set a cookie, you can see how authentication on the other pages is tested. The application:

  • Sends the user to the account page if they already have a login cookie on the home page.
  • Sends the user to the home page if they are not logged in on the account page.

Customization

With authentication done, the last task is to create example pages that a user can browse.

CSS And HTML

Create a static directory within your-application directory.

mkdir static

Copy images from the example app.

cp ../complete-application/static/money.jpg static/money.jpg
cp ../complete-application/static/changebank.svg static/changebank.svg

Create a stylesheet file static/changebank.css and add the following code to it.

h1 {
  color: #096324;
}

h3 {
  color: #096324;
  margin-top: 20px;
  margin-bottom: 40px;
}

a {
  color: #096324;
}

p {
  font-size: 18px;
}

.header-email {
  color: #096324;
  margin-right: 20px;
}

.fine-print {
  font-size: 16px;
}

body {
  font-family: sans-serif;
  padding: 0px;
  margin: 0px;
}

.h-row {
  display: flex;
  align-items: center;
}

#page-container {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
}

#page-header {
  flex: 0;
  display: flex;
  flex-direction: column;
}

#logo-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
}

.menu-bar {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  height: 35px;
  padding: 15px 50px 15px 30px;
  background-color: #096324;
  font-size: 20px;
}

.menu-link {
  font-weight: 600;
  color: #FFFFFF;
  margin-left: 40px;
}

.menu-link {
  font-weight: 600;
  color: #FFFFFF;
  margin-left: 40px;
}

.inactive {
  text-decoration-line: none;
}

.button-lg {
  width: 150px;
  height: 30px;
  background-color: #096324;
  color: #FFFFFF;
  font-size: 16px;
  font-weight: 700;
  border-radius: 10px;
  text-align: center;
  padding-top: 10px;
  text-decoration-line: none;
}

.column-container {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.content-container {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 60px 20px 20px 40px;
}

.balance {
  font-size: 50px;
  font-weight: 800;
}

.change-label {
  font-size: 20px;
  margin-right: 5px;
}

.change-input {
  font-size: 20px;
  height: 40px;
  text-align: end;
  padding-right: 10px;
}

.change-submit {
  font-size: 15px;
  height: 40px;
  margin-left: 15px;
  border-radius: 5px;
}

.change-message {
  font-size: 20px;
  margin-bottom: 15px;
}

.error-message {
  font-size: 20px;
  color: #FF0000;
  margin-bottom: 15px;
}

.app-container {
  flex: 0;
  min-width: 440px;
  display: flex;
  flex-direction: column;
  margin-top: 40px;
  margin-left: 80px;
}

.change-container {
  flex: 1;
}

Next, create a templates directory within your-application. Create three pages inside the templates directory. First create the home page, index.html, and paste the following code into it.

<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>FusionAuth OpenID and PKCE example</title>
  <link rel="stylesheet" href="static/changebank.css">
</head>
<body>
  <div id="page-container">
    <div id="page-header">
      <div id="logo-header">
        <a href="/">
          <img src="static/changebank.svg"  alt="logo"/>
        </a>
        <a class="button-lg" href="login">Login</a>
      </div>

      <div id="menu-bar" class="menu-bar">
        <a class="menu-link">About</a>
        <a class="menu-link">Services</a>
        <a class="menu-link">Products</a>
        <a class="menu-link" style="text-decoration-line: underline;">Home</a>
      </div>
    </div>

    <div style="flex: 1;">
      <div class="column-container">
        <div class="content-container">
          <div style="margin-bottom: 100px;">
            <h1>Welcome to Changebank</h1>
            <p>To get started, <a href="login">log in or create a new account</a>.</p>
          </div>
        </div>
        <div style="flex: 0;">
          <img src="static/money.jpg" style="max-width: 800px;" alt="coins"/>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

The index page contains nothing to note except a link to the login page <a href="/login">.

Next, create an account.html page and paste the following code into it.

<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>FusionAuth OpenID and PKCE example</title>
  <link rel="stylesheet" href="static/changebank.css">
</head>
<body>
  <div id="page-container">
    <div id="page-header">
      <div id="logo-header">
        <a href="/">
          <img src="static/changebank.svg"  alt="logo"/>
        </a>
        <div class="h-row">
          <p class="header-email">{{email}}</p>
          <a class="button-lg" href="/logout" onclick="">Logout</a>
        </div>
      </div>

      <div id="menu-bar" class="menu-bar">
        <a class="menu-link inactive" href="/change">Make Change</a>
        <a class="menu-link" href="/account">Account</a>
      </div>
    </div>

    <div style="flex: 1;">
      <div class="column-container">
        <div class="app-container">
          <h3>Your balance</h3>
          <div class="balance">$0.00</div>
        </div>
      </div>
    </div>
</body>
</html>

The account page displays the user’s email from FusionAuth with <p class="header-email">{{email}}</p>.

The account page is only visible to logged in users. If a session email is not found, the user is redirected to login.

Finally, create a change.html page and paste the following code into it.

<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>FusionAuth OpenID and PKCE example</title>
  <link rel="stylesheet" href="static/changebank.css">
</head>
<body>
  <div id="page-container">
    <div id="page-header">
      <div id="logo-header">
        <a href="/">
          <img src="static/changebank.svg" alt="logo"/>
        </a>
        <div class="h-row">
          <p class="header-email">{{email}}</p>
          <a class="button-lg" href="/logout">Logout</a>
        </div>
      </div>

      <div id="menu-bar" class="menu-bar">
        <a class="menu-link" href="/change">Make Change</a>
        <a class="menu-link inactive" href="/account">Account</a>
      </div>
    </div>

    <div style="flex: 1;">
      <div class="column-container">
        <div class="app-container change-container">
          <h3>We Make Change</h3>


<!-- GET REQUEST ------------------------------------------------>
          {{#if (eq isGetRequest "true")}}
            <div class="change-message">Please enter a dollar amount:</div>
            <form method="post" action="change">
              <div class="h-row">
                <div class="change-label">Amount in USD: $</div>
                <input class="change-input" name="amount" value="" />
                <input class="change-submit" type="submit" value="Make Change" />
              </div>
            </form>
<!-- POST REQUEST ----------------------------------------------->
          {{else}}
          {{#if (eq isError "true")}}
              <div class="error-message">Please enter a dollar amount:</div>
            {{else}}
              <div class="change-message">
                We can make change for {{total}} with {{nickels}} nickels and {{pennies}} pennies!
              </div>
            {{/if}}
            <form method="post" action="change">
              <div class="h-row">
                <div class="change-label">Amount in USD: $</div>
                <input class="change-input" name="amount" value="{{amount}}" />
                <input class="change-submit" type="submit" value="Make Change" />
              </div>
            </form>
          {{/if}}
        </div>
      </div>
    </div>
  </div>
</body>
</html>

The HTML at the bottom of the file displays a blank form when the page first loads (GET) or the result of the calculation when returning (POST).

Run The Application

Run your application.

cargo run

Browse to the app at http://localhost:9012. Log in using richard@example.com and password. The change page allows you to enter a number. If you don’t log in, you won’t be able to access the change or account pages.

Next Steps

This quickstart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.

FusionAuth Customization

FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:

Security

Tenant and Application Management

Rust Authentication

Troubleshooting

  • I get “This site can’t be reached localhost refused to connect” when I click the login button.

Ensure FusionAuth is running in the Docker container. You should be able to log in as the admin user admin@example.com with the password password at http://localhost:9011/admin.

  • It still doesn’t work.

You can always pull down a complete running application and compare what’s different.

git clone https://github.com/FusionAuth/fusionauth-quickstart-rust-actix-web.git
cd fusionauth-quickstart-rust-actix-web
docker compose up
cd complete-application
cargo run

Browse to the app at http://localhost:9012.