React OAuth2 Authentication with Cloudentity
In this tutorial we’re going to run through creating a react app that authenticates with a OAuth2 authorization server, in this case we’ll use Cloudentity. Cloudentity is an authentication and authorization provider that specialises in hyper-scalability. We’ll implement login using the Resource Owner Password Grant, and demonstrate authenticating API calls.
Create React App
First, let’s start by starting a new Create React App called cloudentity-demo
.
npx create-react-app cloudentity-demo
Once it completes, switch to the project folder and run it.
cd cloudentity-demo
npm start
If everything worked correctly, your browser should open up to http://localhost:3000 where a development webserver is running.
Add Home and Login pages
We’ll start by creating a couple of pages for our site, a home page and a login page. First let’s install some dependencies, we’ll be adding React Router for page routing and Mui for styling.
npm install react-router-dom @mui/material @emotion/react @emotion/styled @mui/lab
Now create a home page in src/pages/Home.js
.
import { Link } from "react-router-dom"
import { Button, Container, Stack, Typography } from "@mui/material"
export const HomePage = () => {
return (
<Container>
<Stack spacing={2}>
<Typography variant="h2">Cloudentity Client Demo</Typography>
<Button component={Link} variant="contained" to="/login">
Login
</Button>
</Stack>
</Container>
)
}
And a login page in src/pages/Login.js
import { Button, Container, Stack, TextField, Typography } from "@mui/material"
export const LoginPage = () => {
return (
<Container>
<form>
<Stack spacing={2}>
<Typography variant="h2">Login</Typography>
<TextField
fullWidth
id="username"
label="username"
name="username"
type="text"
variant="standard"
/>
<TextField
fullWidth
id="password"
label="password"
name="password"
type="password"
variant="standard"
/>
<Button color="primary" fullWidth type="submit" variant="contained">
Login
</Button>
</Stack>
</form>
</Container>
)
}
And replace the contents of src/App.js
with our page routing.
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { HomePage } from "./pages/Home"
import { LoginPage } from "./pages/Login"
const App = () => {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
</Routes>
</BrowserRouter>
)
}
export default App
Now run the application and ensure the site loads correctly.
npm start
Try visting the home page at http://localhost:3000/ and the login page http://localhost:3000/login.
Implementing login
We’ll use Mulesoft’s client-oauth2 JS client. Install it with
npm install client-oauth2
And we’ll update the login page to authenticate using the resource owner password grant. Note that this grant is deprecated and not recommended. Prefer the authorization code flow with PKCE when you can.
Update src/pages/Login.js
to create a new ClientOAuth2, add a form submit handler, and fetch a new access token when the form is submitted. You’ll note that we need to set a number of properties for the OAuth client, specifically client id, client secret, token uri and authorization uri. We’ll go through how to discover these values in Cloudentity shortly, but for now just set them to an empty string.
import ClientOAuth2 from "client-oauth2"
import { Button, Container, Stack, TextField, Typography } from "@mui/material"
const auth = new ClientOAuth2({
clientId: "",
clientSecret: "",
accessTokenUri: "",
authorizationUri: "",
scopes: ["email", "offline_access", "openid"],
})
export const LoginPage = () => {
const handleSubmit = async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
const username = formData.get("username")
const password = formData.get("password")
const token = await auth.owner.getToken(username, password)
console.log(token)
}
return (
<Container>
<form onSubmit={handleSubmit}>
<Stack spacing={2}>
...
Now try running the project with npm start
. You should be presented with an error like the following
Compiled with problems:
ERROR in ./node_modules/client-oauth2/src/client-oauth2.js 3:18-40
Module not found: Error: Can't resolve 'querystring' in '/Users/paul/Projects/sandbox/cloudentity-demo/node_modules/client-oauth2/src'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
Yikes, what’s going on here? It seems like client-oauth2 relies on the querystring
function which doesn’t exist in our webpack bundle, because create react app is using webpack 5 which no longer includes some node.js polyfills. To fix this issue we’ll follow the guidance from this tutorial, using react-app-rewired
to allow us to modify webpack.config.
Webpack polyfill
First, add react-app-rewired and the querystring-es3 polyfill.
npm install react-app-rewired querystring-es3
Now update our package.json
, repalcing react-scripts
with react-app-rewired
, e.g.
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
And finally add a file in the project root called config-overrides.js
, and add the following webpack overrides.
module.exports = function (config) {
return {
...config,
resolve: {
...config.resolve,
fallback: {
...config.resolve.fallback,
querystring: require.resolve("querystring-es3"),
},
},
}
}
Now try running npm start
again and check that the page renders correctly. You should be able to navigate to the login page, try entering any details and click Login. Check the dev tools and you should see that it tried to make an HTTP POST to http://localhost:3000/undefined, which means everything is working (we just haven’t configured the OAuth settings yet).
Configuring Cloudentity
In order to set the OAuth client configuration correctly, we’re going to need a Client setup in Cloudentity. If you have already, sign up for a free Cloudentity tenant here: https://authz.cloudentity.io/register.
Once you have a tenant set up and a demo workspace, from the Admin Portal go to Applications > Clients and click + Create Client.
Select Application Type: Single Page and give your client a name.
Once you’ve created your client, click on the OAuth tab and change the Grant Types to “Password” and “Refresh token”.
Scroll down the page and change Token Endpoint Authentication Method to “Client Secret Basic”. Selecting this option means we need to send the Client Id and Client Secret using a basic Authorization header. This is the behaviour that client-oauth2
expects. Save your changes.
Select the Scopes tab, expand the Profile service and ensure that “Offline access” is enabled. The client is now configured correctly.
Configuring the OAuth client
Go back to the Overview tab in Cloudentity and copy the values from the right hand pane into a new file in our project root called .env.local
, like this.
REACT_APP_CLIENT_ID=<CLIENT ID>
REACT_APP_CLIENT_SECRET=<CLIENT SECRET>
REACT_APP_TOKEN_URI=<TOKEN URL>
REACT_APP_AUTHORIZATION_URI=<AUTHORIZATION URL>
Create React App will automatically load our .env.local
file and make available to us any environment variables that are prefixed with REACT_APP_
. This allows us to configure different values for different environments.
Finally, we can use these values in our src/pages/Login.js
page.
const auth = new ClientOAuth2({
clientId: process.env.REACT_APP_CLIENT_ID,
clientSecret: process.env.REACT_APP_CLIENT_SECRET,
accessTokenUri: process.env.REACT_APP_TOKEN_URI,
authorizationUri: process.env.REACT_APP_AUTHORIZATION_URI,
scopes: ["email", "offline_access", "openid"],
})
Run npm start
and try logging in. If it succeeds, you should see the token printed out in the dev tools console.
Making an authenticated call
Next we want to make a call to a protected endpoint to prove that we’re signed in. For this demo we’ll call the /userinfo
endpoint.
We want to make this call from the home page, but we’ll need a central place to store the fetched token. For this purpose we’ll create a React Context hook and move all the authentication logic here. Create a new file src/hooks/use-auth.js
and move our auth client creation, and login call here.
import ClientOAuth2 from "client-oauth2"
import { createContext, useContext, useEffect, useState } from "react"
const AuthContext = createContext()
export const useAuth = () => useContext(AuthContext)
export const AuthProvider = ({ children }) => {
const value = useProvideAuth()
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
const useProvideAuth = () => {
const [auth, setAuth] = useState(null)
const [token, setToken] = useState(null)
useEffect(() => {
const auth = new ClientOAuth2({
clientId: process.env.REACT_APP_CLIENT_ID,
clientSecret: process.env.REACT_APP_CLIENT_SECRET,
accessTokenUri: process.env.REACT_APP_TOKEN_URI,
authorizationUri: process.env.REACT_APP_AUTHORIZATION_URI,
scopes: ["email", "offline_access", "openid"],
})
setAuth(auth)
}, [])
const login = async (username, password) => {
const newToken = await auth.owner.getToken(username, password)
setToken(newToken)
}
return { login, token }
}
And update src/pages/Login.js
to use our hook, and navigate back to the home page after logging in.
import { useNavigate } from "react-router-dom"
import { Button, Container, Stack, TextField, Typography } from "@mui/material"
import { useAuth } from "../hooks/use-auth"
export const LoginPage = () => {
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
const username = formData.get("username")
const password = formData.get("password")
await login(username, password)
navigate("/")
}
...
We’ll also need to wrap our components in src/App.js
in an AuthProvider
.
...
import { AuthProvider } from "./hooks/use-auth"
const App = () => {
return (
<AuthProvider>
<BrowserRouter>
...
</BrowserRouter>
</AuthProvider>
)
}
...
Now in our src/pages/Home.js
page we can add a button to call the userinfo
endpoint when we’re logged in.
import { Link } from "react-router-dom"
import { Button, Container, Stack, Typography } from "@mui/material"
import { useState } from "react"
import { useAuth } from "../hooks/use-auth"
export const HomePage = () => {
const { token } = useAuth()
const [userInfo, setUserInfo] = useState(null)
const handleClick = async () => {
const resp = await fetch(
"https://<tenantname>.us.authz.cloudentity.io/<tenantname>/<workspace>/userinfo",
token.sign({})
)
if (!resp.ok) {
throw new Error(`HTTP error! Status: ${resp.status}`)
}
const userInfo = await resp.json()
setUserInfo(userInfo)
}
return (
<Container>
<Stack spacing={2}>
<Typography variant="h2">Cloudentity Client Demo</Typography>
{token ? (
<>
<Typography>
Welcome {userInfo ? userInfo.email : "anonymous"}!
</Typography>
<Button variant="contained" onClick={handleClick}>
Get user info
</Button>
</>
) : (
<Button component={Link} variant="contained" to="/login">
Login
</Button>
)}
</Stack>
</Container>
)
}
Updating the API endpoint with your Cloudentity tenant details.
Now if you visit the home page, it should prompt you to login.
After logging in, you’ll be redirected back to the homepage, where you can Get User Info.
And after fetching user info, the user’s email address should be displayed.
To logout, just refresh the page!
A more complete authentication implementation would persist the access token in localStorage, and restore it when the page loads. Logging out involves deleting the persisted token, and calling the revoke token Cloudentity endpoint.
A complete demo is available on GitHub here: https://github.com/phdesign/cloudentity-demo