TL;DR
I'm Nathan and one thing I love is React development.
By the end of this tutorial, you'll have a proper React app that will authenticate users by SAML Single Sign-On.
Before we dive into the implementation details, make sure you have the following in place:
A React app for enabling SAML Single Sign-On.
An Express.js backend for our React App. You can find the code for this backend here.
Knowledge of UI best practices for configuring SAML Single Sign-On and the SSO Connection API (These concepts are essential for integrating SAML SSO into your app).
Since we are using API's I would recommend using the open-source Postman alternative, Firecamp for testing your routes.
Why You Should Care About SSO
SAML SSO enables seamless and secure authentication, reducing the need for users to remember multiple passwords and minimizing the risk of password-related security breaches.
It also allows enterprises to centrally manage user access and permissions, making it easier to control who has access to the app and what they can do within it.
By integrating SAML SSO, your app aligns with industry standards for authentication and access control, providing a trusted and efficient user experience while bolstering the app's security and administrative capabilities.
Now that we've got the prerequisite's covered, we can now begin this journey of enabling SAML Single Sign-On authentication for our users. ๐
I've mentioned SAML...you've probably heard of him? โค๏ธ
GIF
Please star โญ the SAML Jackson repo
Overview of the Integration Steps
Integrating SAML Single Sign-On into your React app involves the following key steps:
Configure SAML Single Sign-On: Set up SAML Single Sign-On for your application.
Authenticate with SAML Single Sign-On: Implement the authentication process with SAML Single Sign-On.
Configure Enterprise SSO on React: Allow your tenants to configure SAML connections for their users.
Now, let's dive into the details of each step.
Authenticate with SAML Single Sign On
Once you've added a SAML connection to your application, you can use it to initiate the SSO authentication flow using SAML Jackson. This section will focus on the SSO authentication process.
Deploy SAML Jackson
To get started, deploy the SAML Jackson service. Follow the deployment documentation to install and configure SAML Jackson.
Set Up SAML Jackson Integration
We'll use the client library @bity/oauth2-auth-code-pkce
to implement the authentication process.
This library is a zero-dependency OAuth 2.0 client that implements the authorization code grant with PKCE for client-side protection. You can install it using npm:
npm i --save @bity/oauth2-auth-code-pkce
Next, configure the OAuth2AuthCodePKCE
client to use the SAML Jackson service for authentication. You can create a custom hook to use the oauthclient
throughout your app.
Let's start by creating this file:
src/hooks/useOAuthClient.ts
import { OAuth2AuthCodePKCE } from '@bity/oauth2-auth-code-pkce';
import { useEffect, useState } from 'react';
const JACKSON_URL = process.env.REACT_APP_JACKSON_URL;
interface OauthClientOptions {
redirectUrl: string;
}
export default function useOAuthClient({
redirectUrl,
}: OauthClientOptions): OAuth2AuthCodePKCE | null {
const [oauthClient, setOauthClient] = useState<OAuth2AuthCodePKCE | null>(
null
);
useEffect(() => {
setOauthClient(
new OAuth2AuthCodePKCE({
authorizationUrl: `${JACKSON_URL}/api/oauth/authorize`,
tokenUrl: `${JACKSON_URL}/api/oauth/token`,
// Setting the clientId dummy here. We pass additional query params for
// tenant and product in the authorize request.
clientId: 'dummy',
redirectUrl,
scopes: [],
onAccessTokenExpiry(refreshAccessToken) {
console.log('Expired! Access token needs to be renewed.');
alert(
'We will try to get a new access token via grant code or refresh token.'
);
return refreshAccessToken();
},
onInvalidGrant(refreshAuthCodeOrRefreshToken) {
console.log(
'Expired! Auth code or refresh token needs to be renewed.'
);
alert('Redirecting to auth server to obtain a new auth grant code.');
//return refreshAuthCodeOrRefreshToken();
},
})
);
}, [redirectUrl]);
return oauthClient;
}
Set Up Global Authentication Primitives
Let me describe how we will use AuthContext
.
To make the authentication process globally accessible throughout our app, we'll create an AuthContext
that stores information about the logged-in user
, as well as signIn
and signOut
methods.
These, along with the setTenant
(method used to select the tenant for the SSO flow) and authStatus
(boolean which helps us to conditionally render content based on whether the authenticated status is fully known or being loaded).
Let's create our next file:
src/lib/AuthProvider.tsx
import React, { useState, useEffect, ReactNode, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import useOAuthClient from '../hooks/useOAuthClient';
import { authenticate, getProfileByJWT } from './backend';
interface ProviderProps {
children: ReactNode;
}
interface AuthContextInterface {
setTenant?: React.Dispatch<React.SetStateAction<string>>;
authStatus: 'UNKNOWN' | 'FETCHING' | 'LOADED';
user: any;
signIn: () => void;
signOut: (callback: VoidFunction) => void;
}
// localstorage key to store from url
const APP_FROM_URL = 'appFromUrl';
export const AuthContext = createContext<AuthContextInterface>(null!);
The next task at hand is creating a custom hook that returns a handle to the AuthContext
.
Make a new file:
src/hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../lib/AuthProvider';
const useAuth = () => {
return useContext(AuthContext);
};
export default useAuth;
AuthProvider
Next, we'll wire up the flow inside the AuthProvider
.
- Once the app shell is rendered, a useEffect runs which conducts the flow to
authClient
fromuseOAuthClient
.
Two scenarios need to be handled here.
We secure an
access_token
from the SSO provider (Jackson), which we retrieve the logged-in user profile by passing in the cookie.The browser gets redirected back to the app, after signing in at IdP. The authorization code in the redirect is exchanged for an access token which is then passed to the app backend to complete the login.
src/lib/AuthProvider.tsx
const AuthProvider = ({ children }: ProviderProps) => {
const [user, setUser] = useState<any>(null);
const [authStatus, setAuthStatus] = useState<AuthContextInterface['authStatus']>('UNKNOWN');
...
const redirectUrl = process.env.REACT_APP_APP_URL + from;
const authClient = useOAuthClient({ redirectUrl });
useEffect(() => {
let didCancel = false;
const loadUser = async () => {
if (!authClient) {
return;
}
setAuthStatus('FETCHING');
if (authClient.isAuthorized()) {
const { data, error } = await getProfileByJWT();
if (!didCancel && !error) {
setUser(data);
setAuthStatus('LOADED');
}
} else {
try {
const hasAuthCode = await authClient?.isReturningFromAuthServer();
if (!hasAuthCode) {
devLogger('no auth code detected...');
} else {
const token = !didCancel
? await authClient?.getAccessToken()
: null;
token && localStorage.removeItem(APP_FROM_URL);
// authentication happens at the backend where the above token is used
// to retrieve user profile
const profile = await authenticate(token?.token?.value);
if (!didCancel && profile) {
setUser(profile);
}
}
} catch (err) {
console.error(err);
} finally {
setAuthStatus('LOADED');
}
}
};
loadUser();
return () => {
didCancel = true;
};
}, [authClient]);
...
const value = {
authStatus,
user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export { AuthContext, AuthProvider };
- When someone tries to access protected/private routes they will be
redirected
to the login page.
Let's explain a little further.
First we save the current location they were trying to access in the history state.
This logic is encapsulated in the RequireAuth
wrapper component. We'll use it to protect routes that require authentication.
src/components/RequireAuth.tsx
const RequireAuth = ({ children }: { children: JSX.Element }) => {
let { user, authStatus } = useAuth();
let location = useLocation();
if (authStatus !== 'LOADED') {
return null;
}
if (!user) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience
// than dropping them off on the home page.
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;
Next we will use the from
state in the redirectUrl
to construct the oAuthClient
inside AuthProvider
.
src/lib/AuthProvider.tsx
let location = useLocation();
let from =
location.state?.from?.pathname ||
localStorage.getItem(APP_FROM_URL) ||
'/profile';
const redirectUrl = process.env.REACT_APP_APP_URL + from;
const authClient = useOAuthClient({ redirectUrl });
signIn
andsignOut
methods can be implemented as follows:
src/lib/AuthProvider.tsx
const signIn = async () => {
// store the 'from' url before redirecting ... we need this to correctly initialize
// the oauthClient after getting redirected back from SSO Provider.
localStorage.setItem(APP_FROM_URL, from);
// Initiate the login flow
await authClient?.fetchAuthorizationCode({
tenant,
product: 'saml-demo.boxyhq.com',
});
};
const signOut = async (callback: VoidFunction) => {
authClient?.reset();
setUser(null);
callback();
};
const value = {
signIn,
signOut,
};
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
Authentication Request
Let's add a page to begin the authenticate flow.
This page initiates (by calling signIn
from the AuthContext
) the SAML SSO flow by redirecting the users to their configured Identity Provider (via Jackson).
The user will be redirected to the IdP when clicking the "Continue with SAML SSO" button.
src/pages/Login.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuth from '../hooks/useAuth';
const Login = () => {
let location = useLocation();
let from = location.state?.from?.pathname || '/profile';
const { signIn, setTenant, authStatus, user } = useAuth();
if (authStatus !== 'LOADED') {
return null;
}
if (authStatus === 'LOADED' && user) {
return <Navigate to={from} replace />;
}
return (
<div className="mx-auto h-screen max-w-7xl">
<div className="flex h-full flex-col justify-center space-y-5">
<h2 className="text-center text-3xl">Log in to App</h2>
<div className="mx-auto w-full max-w-md px-3 md:px-0">
<div className="rounded border border-gray-200 bg-white py-5 px-5">
<form className="space-y-3" method="POST" onSubmit={signIn}>
<label htmlFor="tenant" className="block text-sm">
Tenant ID
</label>
<input
type="text"
name="tenant"
placeholder="boxyhq"
defaultValue="boxyhq.com"
className="block w-full appearance-none rounded border border-gray-300 text-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500"
required
onChange={(e) =>
typeof setTenant === 'function' && setTenant(e.target.value)
}
/>
<button
type="submit"
className="w-full rounded border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white focus:outline-none"
>
Continue with SAML SSO
</button>
</form>
</div>
</div>
</div>
</div>
);
};
export default Login;
Fetch User Profile
Once the accessToken
has been fetched, the React app can use it to retrieve the user profile from the Identity Provider.
Typically you would use your backend service (Eg: Express.js) to call the SAML Jackson API to fetch the user profile using the accessToken
.
Let's take a look at the express.js routes that return the user profile either on login or by parsing the JWT from the client-side cookie.
app.get('/api/authenticate', async function (req, res, next) {
const accessToken = req.query.access_token;
if (!accessToken) {
throw new Error('Access token not found.');
}
const response = await fetch(
`${jacksonUrl}/api/oauth/userinfo?access_token=${accessToken}`,
{
method: 'GET',
}
);
const profile = await response.json();
// Once the user has been retrieved from the Identity Provider,
// you may determine if the user exists in your application and authenticate the user.
// If the user does not exist in your application, you will typically create a new record in your database to represent the user.
const token = jsonwebtoken.sign(
{
id: profile.id,
email: profile.email,
firstName: profile.firstName,
lastName: profile.lastName,
},
jwtSecret
);
res.cookie('sso-token', token, { httpOnly: true });
res.json(profile);
});
app.get('/api/profile', async function (req, res, next) {
const token = req.cookies['sso-token'];
if (!token) {
return res
.status(401)
.json({ data: null, error: { message: 'Missing JWT' } });
}
// You may fetch the user profile from your database using the user id.
const payload = jsonwebtoken.verify(token, jwtSecret);
return res.json({ data: payload, error: null });
});
You can check the terminal and see the returned profile in JSON.
{
"id":"<id from the Identity Provider>",
"email": "jackson@coolstartup.com",
"firstName": "SAML",
"lastName": "Jackson",
"requested": {
"tenant": "<tenant>",
"product": "<product>",
"client_id": "<client_id>",
"state": "<state>"
},
"raw": {
...
}
}
In the React app, we call the getProfileByJWT
if an access_token is already in possession or we call the authenticate
when returning back from SSO provider with the authorization code.
src/lib/backend.ts
const apiUrl = process.env.REACT_APP_API_URL;
export const authenticate = async (token: string | undefined) => {
if (!token) {
throw new Error('Access token not found.');
}
const response = await fetch(
`${apiUrl}/api/authenticate?access_token=${token}`,
{
method: 'GET',
credentials: 'include',
}
);
if (response.ok) {
return await response.json();
}
return null;
};
export const getProfileByJWT = async () => {
const response = await fetch(`${apiUrl}/api/profile`, {
method: 'GET',
credentials: 'include',
});
return await response.json();
};
Success
That's it, your React app is ready to handle Single Sign-On authentication. ๐
Key Takeaways
According to statista, React is the second most popular framework used by developers at roughly 40%.
The ability to create and implement SAML Single Sign-On (SSO) in your own deployed app is of paramount importance because it not only enhances security and user convenience but also streamlines access control for both users and administrators.
If you love open-source, please let me know in the comments or reach out to me on Twitter (X)and tell me what you're building!