Published: Mar 13, 2024 by
OAuth2 Authentication with React
I’m unfortunately going to have to add my voice to some assorted tidbits I’ve found regarding OAuth2 authentication in React apps. While there’s been some good efforts made, the reality is that it’s a bit complicated (at least for React beginners like myself) and has a fair number of moving parts. Let’s start, then, by identifying all the moving parts and trying to code each of them in turn.
First, we have the React SPA itself. This is going to be relevant, because it’s the top level of our React app, and some of what we do is going to be done at that level. Next, we have the BrowserRouter, which will mount our callback component for us. Which leads us to the callback component itself. Finally, we have a Login component that displays a login/logout button, as well as the currently logged in user’s email, if logged in.
The React SPA
const App: React.FC = () => {
return (
<ProvideAuth>
<BrowserRouter>
<div>
<div className="row"><Login /></div>
<Routes>
<Route element={<LoginCallback />} path="/callback" />
<Route element={<Home />} path="/" />
</Routes>
</div>
</BrowserRouter>
</ProvideAuth>
);
};
The application doesn’t need much explanation. There’s a home component (which will simply display a greeting for now), a callback component which is responsible for obtaining access tokens given authorization codes (OAuth2 Authorization Code Flow), and a Login component that displays the login/logout button and logged-in username.
Before I go too much further, though, I want to jump in here with a word about
the ProvideAuth
tag seen above. That’s part of the custom hooks we use to
interact with the login server, so let’s look at that before we go anywhere
else:
import React, {createContext, useContext, useState} from "react";
// we are providing a React context of the following type:
interface AuthContextType {
isAuthenticated: boolean,
idToken: string | null
accessToken: string | null,
refreshToken: string | null,
name: string | null,
email: string | null,
login: () => void,
logout: () => void,
processLoginResponse: (response: Response) => void,
processUserInfoResponse: (response: Response) => void
}
// The default React context has no values and no functionality
const defaultAuthContext : AuthContextType = {
isAuthenticated: false,
idToken: null,
accessToken: null,
refreshToken: null,
name: null,
email: null,
login: () => {},
logout: ()=> {},
processLoginResponse: (response: Response) => {},
processUserInfoResponse: (response: Response) => {}
}
const AuthContext = createContext<AuthContextType>(defaultAuthContext);
// provides the `ProvideAuth` tag for our React app. Any child components will
// have access to the authentication context
function ProvideAuth({children}) {
const auth = useProvideAuth();
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
)
}
// the hook that child components will use to access the authentication context
function useAuth() {
return useContext(AuthContext);
}
// the state-backed implementation of the authentication context
function useProvideAuth() {
const [isAuthenticated, setAuthenticated] = useState(false);
const [idToken, setIdToken] = useState("");
const [accessToken, setAccessToken] = useState("");
const [refreshToken, setRefreshToken] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
// calls the auth endpoint of the realm and redirects back to the home URL
const login = () => {
const baseUrl = process.env.REACT_APP_BASE_URL;
const clientId = process.env.REACT_APP_CLIENT_ID;
const realmUrl = process.env.REACT_APP_REALM_URL;
let q = [
`redirect_uri=` + encodeURIComponent(`${baseUrl}/callback`),
`scope=openid profile email`,
`response_type=code`,
`client_id=` + encodeURIComponent(`${clientId}`)
].join('&')
window.location.replace(`${realmUrl}/protocol/openid-connect/auth?${q}`)
}
// calls the logout endpoint of the realm which will redirect back to the
// home URL
const logout = async () => {
const realmUrl = process.env.REACT_APP_REALM_URL;
await fetch(`${realmUrl}/protocol/openid-connect/logout`)
// clear client-side login state
setAuthenticated(false);
setEmail("");
setName("");
setAccessToken("");
setRefreshToken("");
setIdToken("");
}
// process the response from the token endpoint using the authorization code
const processLoginResponse = async (response: Response) => {
let json = await response.json();
setIdToken(json["id_token"]);
setAccessToken(json["access_token"]);
setRefreshToken(json["refresh_token"]);
setAuthenticated(true);
}
// process the userinfo response to get name and email address
const processUserInfoResponse = async (response: Response) => {
let json = await response.json();
setName(json["name"]);
setEmail(json["email"]);
}
// return a compatible context object
return {
isAuthenticated,
idToken,
accessToken,
refreshToken,
name,
email,
login,
logout,
processLoginResponse,
processUserInfoResponse
}
}
export {useAuth, ProvideAuth}
There’s a lot of individual methods there, but I added some comments, and none
of the methods are particularly complicated. But it’s not necessary to entirely
understand the code to know how to use it effectively. There are two key points.
First, you’ll need to call useAuth()
from your React Component. This provides
the authentication context, which itself provides the following methods:
login
: redirects the user to the login page for the realm and handles the authorization code callbacklogout
: signs the user out of the authentication server and redirects back to the home pageprocessLoginResponse
: used by the login callback to retrieve an access token from the authorization codeprocessUserInfoResponse
: used by the login callback to retrieve the user’s name and email address Second, you need to wrap all React components that need the authentication context in theProvideAuth
tag, as shown in the initial example above. Only components whose ancestors includeProvideAuth
can calluseAuth()
.
The LoginCallback
The LoginCallback
is responsible for receiving the authorization code in the
form of a callback URL. Once the code is received, it will be exchanged for id,
access, and refresh tokens. Those values will be stored in the authentication
context for later use, and then the userinfo endpoint will be queried to
complete the authentication context values.
const LoginCallback : React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const auth = useAuth();
const [code, setCode] = useState("");
const [lastCode, setLastCode] = useState("");
const getToken = useCallback(async (code: string) => {
let q = `code=` + encodeURIComponent(code) +
`&redirect_uri=` + encodeURIComponent(`${process.env.REACT_APP_BASE_URL}/callback`) +
`&client_id=` + encodeURIComponent(`${process.env.REACT_APP_CLIENT_ID}`) +
`&client_secret=` + encodeURIComponent(`${process.env.REACT_APP_CLIENT_SECRET}`) +
`&grant_type=authorization_code`;
return await fetch(`${process.env.REACT_APP_REALM_URL}/protocol/openid-connect/token`, {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: q
});
}, []);
// this effect will update the code if it is new. The comparison to lastCode ensures that this
// callback will only fetch one token per code, regardless of how many times this callback is rendered.
useEffect(() => {
let search = location.search;
const theCode = (search.match(/code=([^&]+)/) || [])[1];
if (theCode !== lastCode)
setCode(theCode);
}, [location.search, lastCode]);
// this effect will fetch the token every time the code is updated
useEffect(() =>
{
if (code) {
getToken(code)
.then(res => auth.processLoginResponse(res))
.then(_ => navigate("/"))
.catch(error => console.log(error))
setLastCode(code);
}
}, [code, auth, getToken, navigate])
return <div></div>;
}
export default LoginCallback;
I’ll only note that there are two separate effects required to accomplish the
login. In the course of my testing, I discovered that it was not possible to do
the lastCode
comparison within the same effect as the token fetch. Experienced
React users will understand that this is due to the asynchronous nature of
useState()
. It was very frustrating to discover that I could not simply call
setLastCode
and then immediately use lastCode
’s new value. So, I update the
code if it’s new, and then fetch a new token for every new code.
The Login Component
The Login
component will display a button for logging in or out, as well as
the currently logged in user’s email address. This is illustrative, as it’s
easily modified to suit your tastes. The most notable part of this component is
how it will retrieve userinfo every time the access token changes, and update
the component display accordingly. This demonstrates how to achieve
inter-component communication using React hooks: one component will update a
value within the context (setAccessToken
, called from processLoginResponse
),
and another will use that value as a dependency for its own effects.
const Login: React.FC = () => {
const auth = useAuth();
const getUserInfo = useCallback(async () => {
return await fetch(`${process.env.REACT_APP_REALM_URL}/protocol/openid-connect/userinfo`, {
headers: { "Authorization": `Bearer ${auth.accessToken}` }
});
}, [auth.accessToken]);
// this effect will populate the user details from the userinfo endpoint
useEffect(() => {
if (auth.accessToken) {
getUserInfo()
.then(res => auth.processUserInfoResponse(res))
.catch(error => console.log(error));
}
}, [auth.accessToken, auth, getUserInfo]);
if (auth.isAuthenticated)
return (
<div className="row">
Logged in as {auth.email}
<Button onClick={auth.logout}>Logout</Button>
</div>
);
else {
return <Button onClick={auth.login}>Login</Button>;
}
}
export default Login;
Conclusion
The above code is the verbatim implementation of an OAuth2 login component for React. It demonstrates the use of React hooks and contexts to share state between components and communicate changes between them. I suppose I should also mention a couple of failed approaches. My first attempt used a Material Dialog component to display the login within a modal iframe. However, this fails because the LoginCallback component is then rendered within the iframe, and the application loaded a second time. Another approach suggested similar, but running the HTML through PurifyDOM first. This doesn’t work for the Keycloak server at all.
I hope you find this code useful. I am unfortunately not able to take on the maintenance burden of releasing and maintaining this code as a resuable package. If you’re reading this and you want to, please feel free to do so. This code is provided as-is without warranty or expectation of updates.
Credits
I’d like to add a special thank you to Param Singh
for his article on the subject, as well as Tasos Kakour (Github: @tasoskakour) for
his useOAuth2
package on Github. Both of these authors were significant contributors to this article.