๐Ÿ˜Ž React App Leveled Up with SSO Auth Wizardry ๐Ÿช„

๐Ÿ˜Ž React App Leveled Up with SSO Auth Wizardry ๐Ÿช„

ยท

10 min read

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.

Please star โญ Firecamp

Baby Yoda

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

SAML Jackson

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.

  1. Once the app shell is rendered, a useEffect runs which conducts the flow to authClient from useOAuthClient.

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 };
  1. 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 });
  1. signIn and signOut 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!

ย