other ways).
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.
In this section, you’ll get FusionAuth up and running, and configured with the ChangeBank application.
First off, grab the code from the repository and change into that directory.
git clone https://github.com/FusionAuth/fusionauth-quickstart-javascript-express-web.git
cd fusionauth-quickstart-javascript-express-web
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.
In this section, you’ll set up a basic Express application.
Create a new directory to hold your application, and go into it.
mkdir changebank
cd changebank
The @fusionauth/typescript-client
will be used to exchange the OAuth Code for Access Token using PKCE, and also to retrieve the User using JWT.
{
"name": "fusionauth-quickstart-javascript-express-web",
"version": "1.0.0",
"description": "Example of setting up an express application with typescript.",
"scripts": {
"dev": "ts-node-dev --exit-child src/index.ts",
"setup": "ts-node src/setup.ts"
},
"author": "Alex Patterson",
"license": "Apache-2.0",
"dependencies": {
"@fusionauth/typescript-client": "^1.46.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.0.1",
"pkce-challenge": "^3.1.0"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.3",
"@types/node": "^20.4.2",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.1.6"
}
}
Install dependencies using npm.
npm install
Create a new directory to hold your source code, and go into it.
mkdir src
cd src
Make sure once again that you are in the directory change-bank/src
. Create a file called index.ts
and start by setting up your imports and constants. These constants have already been created by running the kickstart when running FusionAuth.
import FusionAuthClient from "@fusionauth/typescript-client";
import express from 'express';
import cookieParser from 'cookie-parser';
import pkceChallenge from 'pkce-challenge';
import { GetPublicKeyOrSecret, verify } from 'jsonwebtoken';
import jwksClient, { RsaSigningKey } from 'jwks-rsa';
import * as path from 'path';
// Add environment variables
import * as dotenv from "dotenv";
dotenv.config();
const app = express();
const port = 8080; // default port to listen
if (!process.env.clientId) {
console.error('Missing clientId from .env');
process.exit();
}
if (!process.env.clientSecret) {
console.error('Missing clientSecret from .env');
process.exit();
}
if (!process.env.fusionAuthURL) {
console.error('Missing clientSecret from .env');
process.exit();
}
const clientId = process.env.clientId;
const clientSecret = process.env.clientSecret;
const fusionAuthURL = process.env.fusionAuthURL;
// Validate the token signature, make sure it wasn't expired
const validateUser = async (userTokenCookie: { access_token: string }) => {
// Make sure the user is authenticated.
if (!userTokenCookie || !userTokenCookie?.access_token) {
return false;
}
try {
let decodedFromJwt;
await verify(userTokenCookie.access_token, await getKey, undefined, (err, decoded) => {
decodedFromJwt = decoded;
});
return decodedFromJwt;
} catch (err) {
console.error(err);
return false;
}
}
const getKey: GetPublicKeyOrSecret = async (header, callback) => {
const jwks = jwksClient({
jwksUri: `${fusionAuthURL}/.well-known/jwks.json`
});
const key = await jwks.getSigningKey(header.kid) as RsaSigningKey;
var signingKey = key?.getPublicKey() || key?.rsaPublicKey;
callback(null, signingKey);
}
//Cookies
const userSession = 'userSession';
const userToken = 'userToken';
const userDetails = 'userDetails'; //Non Http-Only with user info (not trusted)
const client = new FusionAuthClient('noapikeyneeded', fusionAuthURL);
app.use(cookieParser());
/** Decode Form URL Encoded data */
app.use(express.urlencoded());
If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app (http://localhost:9011/admin). 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.
You need a way to serve your static files, luckily Express makes this easy with express.static
. These files will include all of the CSS and images that are required for the UI of the ChangeBank application.
Make sure that you are in the directory change-bank
when running these commands to create new directories and copy the CSS and images required from complete-application
.
mkdir ../static && mkdir ../static/css && mkdir ../static/img && mkdir ../templates &&
cp ../../complete-application/static/css/changebank.css ../static/css/changebank.css &&
cp ../../complete-application/static/img/money.jpg ../static/img/money.jpg &&
cp ../../complete-application/templates/account.html ../templates/account.html &&
cp ../../complete-application/templates/home.html ../templates/home.html &&
cp ../../complete-application/templates/make-change.html ../templates/make-change.html
At the bottom of your index.ts
file include the below code to serve any assets in the static directory.
app.use('/static', express.static(path.join(__dirname, '../static/')));
In order to show off your homepage you need to create a route with the base URL /
. In this route check to see if userDetails
cookie has any user values and correctly respond with either the home.html
or account.html
template. Also if there is no user you can generate the PKCE challenge pair and store them into userSession
cookie (this is an HttpOnly
cookie, inaccessible to JavaScript).
In this file you will use the pkce-challenge
package to generate a Proof Key for Code Exchange (PKCE) challenge pair. Read more about PKCE.
At the bottom of index.ts
file include the below code, so that the homepage can be displayed when a user is not logged in. If a user is logged in this route will redirect to the /account
route.
app.get("/", async (req, res) => {
const userTokenCookie = req.cookies[userToken];
if (await validateUser(userTokenCookie)) {
res.redirect(302, '/account');
} else {
const stateValue = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const pkcePair = await pkceChallenge();
res.cookie(userSession, { stateValue, verifier: pkcePair.code_verifier, challenge: pkcePair.code_challenge }, { httpOnly: true });
res.sendFile(path.join(__dirname, '../templates/home.html'));
}
});
In the ChangeBank application there is a Login
button which will open the /login
route when clicked. This route will redirect them to a login page managed by FusionAuth. Include the below code to the bottom of index.ts
.
app.get('/login', (req, res, next) => {
const userSessionCookie = req.cookies[userSession];
// Cookie was cleared, just send back (hacky way)
if (!userSessionCookie?.stateValue || !userSessionCookie?.challenge) {
res.redirect(302, '/');
}
res.redirect(302, `${fusionAuthURL}/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=http://localhost:${port}/oauth-redirect&state=${userSessionCookie?.stateValue}&code_challenge=${userSessionCookie?.challenge}&code_challenge_method=S256`)
});
client_id
: Your Client Id.response_type
: code
since you are using the Authorization Code grantredirect_uri
: Where your FusionAuth will redirect back to during the OAuth grant. In this example, this is our route oauth-redirect
, which you’ll build next.state
: A random string created in your homepage route and stored in the cookie as stateValue
code_challenge
: Created in the homepage route using pkce-challenge
and stored in a cookie as challenge
, this is one half of the PKCEWhen FusionAuth redirects back to your Express application, it will return to your /oauth-redirect
route. In this response it will have two query parameters that you need to further examine: code
and state
.
You’ll need to do the following:
stateValue
stored in the cookie matches the state
query parameter.code
, clientId
, clientSecret
, Redirect URI, and verifier
from our userSession
cookie. The response will contain the required accessToken
.userToken
cookie.userDetails
cookie./account
route.Add the following code to the bottom of index.ts
.
app.get('/oauth-redirect', async (req, res, next) => {
// Capture query params
const stateFromFusionAuth = `${req.query?.state}`;
const authCode = `${req.query?.code}`;
// Validate cookie state matches FusionAuth's returned state
const userSessionCookie = req.cookies[userSession];
if (stateFromFusionAuth !== userSessionCookie?.stateValue) {
console.log("State doesn't match. uh-oh.");
console.log("Saw: " + stateFromFusionAuth + ", but expected: " + userSessionCookie?.stateValue);
res.redirect(302, '/');
return;
}
try {
// Exchange Auth Code and Verifier for Access Token
const accessToken = (await client.exchangeOAuthCodeForAccessTokenUsingPKCE(authCode,
clientId,
clientSecret,
`http://localhost:${port}/oauth-redirect`,
userSessionCookie.verifier)).response;
if (!accessToken.access_token) {
console.error('Failed to get Access Token')
return;
}
res.cookie(userToken, accessToken, { httpOnly: true })
// Exchange Access Token for User
const userResponse = (await client.retrieveUserUsingJWT(accessToken.access_token)).response;
if (!userResponse?.user) {
console.error('Failed to get User from access token, redirecting home.');
res.redirect(302, '/');
}
res.cookie(userDetails, userResponse.user);
res.redirect(302, '/account');
} catch (err: any) {
console.error(err);
res.status(err?.statusCode || 500).json(JSON.stringify({
error: err
}))
}
});
The /account
page represents what a user sees when they’re logged into their ChangeBank account. A user that isn’t logged in who tries to access this page will just be taken to the home page.
Add the following code to the bottom of index.ts
.
app.get("/account", async (req, res) => {
const userTokenCookie = req.cookies[userToken];
if (!await validateUser(userTokenCookie)) {
res.redirect(302, '/');
} else {
res.sendFile(path.join(__dirname, '../templates/account.html'));
}
});
Two functions are needed to accept both GET
and POST
requests on the /make-change
route. The GET
verifies that the user is logged in, then serves the HTML file required for the page. The POST
takes the amount form value as a body parameter and uses the it to calculate how many quarters, dimes, nickels and pennies to return. If total
is not a valid dollar value or cannot be converted an error
message is returned. The return string message
is received by asynchronously and updates the change-message
or error-message
div accordingly.
Add the following code to the bottom of index.ts
.
app.get("/make-change", async (req, res) => {
const userTokenCookie = req.cookies[userToken];
if (!await validateUser(userTokenCookie)) {
res.redirect(302, '/');
} else {
res.sendFile(path.join(__dirname, '../templates/make-change.html'));
}
});
app.post("/make-change", async (req, res) => {
const userTokenCookie = req.cookies[userToken];
if (!await validateUser(userTokenCookie)) {
res.status(403).json(JSON.stringify({
error: 'Unauthorized'
}))
return;
}
let error;
let message;
var coins = {
quarters: 0.25,
dimes: 0.1,
nickels: 0.05,
pennies: 0.01,
};
try {
message = 'We can make change for';
let remainingAmount = +req.body.amount;
for (const [name, nominal] of Object.entries(coins)) {
let count = Math.floor(remainingAmount / nominal);
remainingAmount =
Math.round((remainingAmount - count * nominal) * 100) / 100;
message = `${message} ${count} ${name}`;
}
`${message}!`;
} catch (ex: any) {
error = `There was a problem converting the amount submitted. ${ex.message}`;
}
res.json(JSON.stringify({
error,
message
}))
});
Click the Logout
button and watch the browser first go to FusionAuth to log out the user, then return to your home page.
The /logout
route in your application will redirect to the FusionAuth server. FusionAuth will then end the FusionAuth SSO session for this user and redirect back to the application’slogoutURL
specified in the kickstarter.json
file. You’ll build that URL handler below.
Add the following code to the bottom of index.ts
.
app.get('/logout', (req, res, next) => {
res.redirect(302, `${fusionAuthURL}/oauth2/logout?client_id=${clientId}`);
});
The application’s logout route is /oauth2/logout
. Within this route we can safely clear all of the cookies related to this user.
Add the following code to the bottom of index.ts
.
app.get('/oauth2/logout', (req, res, next) => {
console.log('Logging out...')
res.clearCookie(userSession);
res.clearCookie(userToken);
res.clearCookie(userDetails);
res.redirect(302, '/')
});
Listen for connections on port http://localhost:8080
and serve the Express application.
Add the below code to the bottom of index.ts
.
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`);
});
Return to the root directory of your application /changebank
by running the below command.
cd ..
Include the necessary environment variables by creating a file called .env
. Include the following variables in this file.
clientId="e9fdb985-9173-4e01-9d73-ac2d60d1dc8e"
clientSecret="super-secret-secret-that-should-be-regenerated-for-production"
fusionAuthURL="http://localhost:9011"
Now that your application is complete you can run it with the below command.
npm run dev
You can now open up an incognito window and visit the Rails app at http://localhost:8080/ . Log in with the user account mentioned above, richard@example.com
.
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: