Building authentication for a web app from scratch is not a trivial task. A good implementation may require support for complex authentication methods, account management functionality, and secure storage of user credentials. If the app calls a REST API for backend services, a secure mechanism for passing the identity of the user in requests to the API is also required. OpenID Connect Implicit Flow solves these challenges by delegating authentication to a third party and using tokens to encode and transport user identity. In this post, I will explain how it works and demonstrate how to implement it in a React app.
How it WorksIn OpenID Connect Implicit Flow, 1) the app requests that the user authenticate by redirecting the user to a trusted third party to authenticate called the Auth Provider. 2) The user can authenticate by any method supported by the Auth Provider. 3) If successful, the Auth Provider redirects the user back to the app with a signed id_token encoding a set of claims about the user's identity such as the user's email. 4) The app includes this id_token in requests to the REST API for authentication. 5) When the REST API receives the request, it verifies the id_token's signature using the Auth Provider's public key and looks at the id_token's claims to determine the user associated with the request.
The location at the Auth Provider where the Webapp sends the auth request (step 1) is called the authorization_endpoint. Each auth request includes the client_id, which the Auth Provider uses to determine the source of the auth request. The location at the app where the Auth Provider sends the id_token (step 3) is called the redirect_uri. The authorization_endpoint can be found in the Auth Provider's Discovery Document. During registration with the Auth Provider, the app owner sets the redirect_uri and the app is assigned its client_id.
ImplementationIn the React app, the oidc module contains functions for sending the auth request (sendAuthReq) and handling the id_token from the auth response (handleAuthResp). It uses the authorization_endpoint, client_id, redirect_uri defined in oidc config module. The TokenProvider component manages the token state and provides it to its child components, which are rendered by the RouterProvider according to the routes defined in its assigned router. The index component contains a login button which triggers authentication by calling sendAuthReq from its click handler. The callback component, which is mapped to the redirect_uri, calls handleAuthResp to get the id_token and setToken to update the token state. The Claims component gets the token and displays its claims about the identity of the authenticated user.
OIDC module
export const config = {
authorization_endpoint: 'REPLACE_WITH_AUTHORIZATION_ENDPOINT',
client_id: 'REPLACE_WITH_CLIENT_ID',
redirect_uri: 'REPLACE_WITH_REDIRECT_URI',
};
import { decodeJwt } from "jose";
import { nanoid } from "nanoid";
import { config } from "./oidc.config";
export function sendAuthReq() {
const {pathname} = new URL(window.location.href);
localStorage.setItem('location', pathname);
const nonce = nanoid();
localStorage.setItem('nonce', nonce);
const {authorization_endpoint, client_id, redirect_uri} = config;
const params = new URLSearchParams({
client_id,
response_type: 'token id_token',
scope: 'openid profile email',
redirect_uri,
nonce
}).toString();
const {href} = new URL(`${authorization_endpoint}?${params}`);
window.location.href = href;
}
export function handleAuthResp() {
const {hash} = new URL(window.location.href);
if (!hash) {
throw new Error('No fragment');
}
const fragment = new URLSearchParams(hash.substring(1));
const id_token = fragment.get('id_token');
if (!id_token) {
throw new Error('No id_token');
}
const {nonce: received} = decodeJwt(id_token);
const sent = localStorage.getItem('nonce');
if (sent !== received) {
throw new Error('Nonce mismatch');
}
return id_token;
}
Since sendAuthReq can be called from anywhere in the app, it saves the current location to localStorage for the Callback component to retrieve and navigate to after authentication. To protect against replay attacks, a nonce is included in the request, which the auth provider returns in the response as a claim in the id_token. The nonce sent is saved to localStorage for handleAuthResp to retrieve and verify that it matches the nonce received. Since the Auth Provider may support multiple authentication flows, the response_type is set to 'token id_token' to specify that this is a request for Implicit Flow. To request claims about the user's profile and email, the scope is set to 'openid profile email'. In handleAuthResp, the id_token is retrieved from the hash property of the redirect_uri.
TokenProvider
import { decodeJwt } from "jose";
import { createContext, useContext, useEffect, useState } from "react";
import { sendAuthReq } from "./oidc";
export const TokenContext = createContext<null|string>(null);
export const SetTokenContext = createContext((token: null|string) => {});
export default function TokenProvider({children}: any) {
const [token, setToken] = useState<null|string>(null);
// restore token from localStorage
useEffect(() => {
const stored = localStorage.getItem('token');
if (stored) {
const {exp} = decodeJwt(stored);
if (exp && exp*1000 > Date.now()) {
setToken(stored);
}
}
}, []);
// sync token with localStorage
useEffect(() => {
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
}, [token]);
// schedule auth when token expires
useEffect(() => {
if (token) {
const {exp} = decodeJwt(token);
if (exp) {
const expiresIn = exp*1000-Date.now();
const id = setTimeout(sendAuthReq, expiresIn);
return () => clearTimeout(id);
}
}
}, [token]);
return (
<TokenContext.Provider value={token}>
<SetTokenContext.Provider value={setToken}>
{children}
</SetTokenContext.Provider>
</TokenContext.Provider>
);
}
export function useToken() {
return useContext(TokenContext);
}
export function useSetToken() {
return useContext(SetTokenContext);
}
The token state and its setter are provided to TokenProvider's children using Context. The useToken and useSetToken custom Hooks are created to encapsulate the use of Context and simplify access to this state. To prevent the user from having to re-authenticate after reloading the app, the first two Effects sync the token state with localStorage. The third Effect schedules re-authentication when the token expires.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import TokenProvider from './auth/TokenProvider';
import './index.css';
import { router } from './router';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<TokenProvider>
<RouterProvider router={router} />
</TokenProvider>
</React.StrictMode>
);
The TokenProvider wraps the RouterProvider to make its Context available to the components rendered by the RouterProvider.
Routing
import { createBrowserRouter } from "react-router-dom";
import App from "./App";
import Callback, { loader as callbackLoader } from "./auth/Callback";
import Claims from "./Claims";
import Root from "./Root";
export const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
index: true,
element: <App />
},
{
path: 'callback',
element: <Callback />,
loader: callbackLoader
},
{
path: 'claims',
element: <Claims />
}
]
}
]);
The Callback component handles the auth response so its path must match the redirect_uri.
import { Outlet } from "react-router-dom";
import Nav from "./Nav";
export default function Root() {
return (
<>
<Nav />
<Outlet />
</>
);
}
import { NavLink } from "react-router-dom";
import styles from './Nav.module.css';
export default function Nav() {
return (
<ul className={styles.nav}>
<li>
<NavLink
to={'/'}
className={({isActive}) => isActive ? styles.active : ''}
>
App
</NavLink>
</li>
<li>
<NavLink
to={'/claims'}
className={({isActive}) => isActive ? styles.active : ''}
>
Claims
</NavLink>
</li>
</ul>
);
}
Login and Logout
import './App.css';
import { sendAuthReq } from './auth/oidc';
import { useSetToken, useToken } from './auth/TokenProvider';
export default function App() {
const token = useToken();
const setToken = useSetToken();
function login() {
sendAuthReq();
}
function logout() {
setToken(null);
}
return (
<div>
{token ? (
<button onClick={logout}>Logout</button>
) : (
<button onClick={login}>Login</button>
)}
</div>
);
}
The user is logged in if the token is not null. To login, call sendAuthReq from the login button's click handler. To logout, set the token to null from the logout button's click handler.
Callback component
import { useEffect } from "react";
import { useLoaderData, useNavigate } from "react-router-dom";
import { handleAuthResp } from "./oidc";
import { useSetToken } from "./TokenProvider";
export default function Callback() {
const {id_token}: any = useLoaderData();
const setToken = useSetToken();
const navigate = useNavigate();
useEffect(() => {
setToken(id_token);
navigate(localStorage.getItem('location') || '/');
}, [id_token]);
return null;
}
export function loader() {
const id_token = handleAuthResp();
return {id_token};
}
Because handleAuthResp contains side effects (window.location, localStorage), it has to be called from the Callback's loader instead of its render function. In the Effect, the token state is set to the id_token from the auth response and the app navigates to the location before authentication.
Claims component
import { decodeJwt } from "jose";
import { useToken } from "./auth/TokenProvider";
export default function Claims() {
const token = useToken();
let claims: any = {};
if (token) {
claims = decodeJwt(token);
}
return (
<>
{token ? (
<>
<table border={1}>
<thead>
<tr>
<th>Claim</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{Object.entries(claims).map(([name, value]: any) => (
<tr key={name}>
<td>{name}</td>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p>No token</p>
)}
</>
);
}
The useToken hook is used to get the token. The decodeJwt function from the jose module is used to get the token's claims.
Demo!
No comments:
Post a Comment