other ways.)
pkg-config
and libssl-dev
if they are not already installed on your system.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.
Request flow during login before FusionAuth
The login flow will look like this after FusionAuth is introduced.
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.
Start with getting FusionAuth up and running and creating a new Actix application.
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
.
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:
E9FDB985-9173-4E01-9D73-AC2D60D1DC8E
.super-secret-secret-that-should-be-regenerated-for-production
.richard@example.com
and the password is password
.admin@example.com
and the password is password
.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.
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:
dotenv
to allow you to call any values from the .env
file later in the application.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 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 = ¶ms.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:
With authentication done, the last task is to create example pages that a user can browse.
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 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.
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 gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:
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.
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.