This guide shows you the steps needed to integrate1 a PingOne authentication experience into a traditional web app (includes a server component as opposed to something that runs only on the client like a SPA). We'll start with a basic Express server with a simple UI.
| NodeJS | v18.7.1+ required | Modern Browser | Such as Chrome | PingOne Account | Free trial |
Note
See Quick Start in PingOne for Developers for more information.
- Create a test environment and user (with a password) if you haven't already.
- Create an app connection in the test environment using the
OIDC Web Apptemplate - On the configuration tab, add the Redirect URI:
http://localhost:3000/callback - Ensure you enable the OIDC Web App connection using the toggle button!
Note
The configuration values can be found on the Overview or the Configuration tab of your PingOne Application Connection
- Duplicate the
.env.EXAMPLEtemplate file and rename the copy.envat the top directory of the repo. - Fill in the empty values with the corresponding ones from the PingOne App Connection.
You can click me to take a closer look at the template file .env.EXAMPLE
Or click me to see the template here
# Auth base url is dependent upon region
# e.g.,
# NA - https://auth.pingone.com
# CA - https:/auth.pingone.ca
# EU - https:/auth.pingone.eu
# APAC - https:/auth.pingone.asia
PINGONE_AUTH_BASE_URL=https://auth.pingone.com
# PingOne Environment ID
PINGONE_ENVIRONMENT_ID=
# PingOne App Connection Client ID and Secret
PINGONE_CLIENT_ID=
PINGONE_CLIENT_SECRET=
# The base path where this app is running
APP_BASE_URL=http://localhostRun npm install or yarn install from the top of the repo. You only need to install once.
Note
If you want to skip the step-by-step guide and jump straight to the final integration, go to step 3
The walk-through demonstrates the steps to add authentication to a basic web app. Each step can be run as its own isolated app and includes only what's been built up to that point. That way, it's easier to understand what each step does and how it builds on previous ones.
Note
For a deeper dive into what's going on, open your browser's developer tools. The network tab is especially useful to see how the app interacts with PingOne and what's shown to the end user.
Here are some helpful options* to enable before using the app:
-
record network log
-
preserve logs
-
preserve log upon navigation
*These are from Chrome. The naming might be slightly different if a different browser is used.
Important
Stop the server (ctrl+c) between runs. If you see the following error, it likely means there's a version of this app running somewhere (or something is already using port 3000):
Error: listen EADDRINUSE: address already in use :::3000
Running step0 spins up a simple express web app using Express's Hello World example
Also, use this step to rule out any issues in your environment.
*Stop any other versions of this app (ctrl+C) from the terminal where you started the previous app
npm run step0from the root of the repo.- Open an incognito/private browser window
- Navigate to
http://localhost:3000. - You should see "Hello World".
This step's source code can be found in step0/index.js
Or click me to view the code here
/**
* Express Server Config and Init
*/
const express = require("express");
const app = express();
const port = 3000;
/**
* Displays "Hello World!" when opening "http://localhost:3000" (trailing "/" not required in most cases) in a browser.
*/
app.get("/", (req, res) => {
res.send("Hello World!");
});
/**
* Terminal output message when the app starts.
*/
app.listen(port, () => {
console.log(
`The PingOne sample Express app has started listening on ${appBaseURL}:${port}`
);
console.log("Step 0 - Creating a working Express web app.");
});This step creates constants for the values you added in .env along with the OAuth 2.0/OIDC values that will be needed in later steps.
*Stop any other versions of this app (ctrl+C) from the terminal where you started the previous app
-
npm run step1from the root of the repo. -
Refresh your browser or navigate to
http://localhost:3000. -
You should see
Hello Step1! Environment ID: <env-id> Client ID: <client-id>. If you're seeing "undefined", check that you've correctly created the .env file.If you're not seeing ID values (they might be blank or show
undefined), re-check that you've run through Creating the Environment File.
This step's source code can be found in step1/index.js
Or click me to view the code here
Here, the PingOne App Connection config values stored in the .env file are read and assigned to constants
// PingOne specific
// Auth base url
const authBaseURL = process.env.PINGONE_AUTH_BASE_URL;
// Environment ID (where the app and user are located)
const envID = process.env.PINGONE_ENVIRONMENT_ID;
// Client ID
const clientID = process.env.PINGONE_CLIENT_ID;
// Client Secret
const clientSecret = process.env.PINGONE_CLIENT_SECRET;
// Base url of this app
const appBaseURL = process.env.APP_BASE_URL;// App's base origin (default is http://localhost:3000)
const appBaseOrigin = appBaseURL + ":" + port;
// Authorization endpoint
const authorizeEndpoint = "/as/authorize";
// Token endpoint
const tokenEndpoint = "/as/token";
// redirect_uri (e.g., http://localhost:3000/callback)
const callbackPath = "/callback";
const redirectURI = appBaseOrigin + callbackPath;
// Scopes specify what kind of access the client is requesting from the user.
// openid - signals an OIDC request and is available by default
const scopes = "openid";
// The Authorization Code flow is a generally a good place to start
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23
const grantType = "authorization_code";
const responseType = "code";- Instead of only displaying text from the root path, we'll modify it to construct our authorization request as a URL and send it as a clickable "Login" link.
- Once a user navigates their browser to the root path and clicks the login link, they'll be redirected to PingOne to authenticate and authorize any access she wishes to give the client.
Important
You will see a Cannot get /callback error message after clicking Login and authenticating (or authentication might be skipped if a live session is found).
This error is expected! We've not yet set up the redirect path, /callback. The next step will show you how to do that.
*Stop any other versions of this app (ctrl+C) from the terminal where you started the previous app
npm run step2from the root of the repo.- Refresh your browser or navigate to
http://localhost:3000. - Click the
Loginlink - Login with your test user
- You should see the
Cannot get /callbackerror message
This step's source code can be found in step2/index.js
Or click me to view the code here
/**
* Navigating to http://localhost:3000 displays a "Login" link.
* Clicking the link will redirect the user to PingOne with the
* authorization request parameters. This is a simplified way to make the
* authorization request. It could instead be tied to a nicer looking button.
* The user authenticates with PingOne and then is returned to the app via the redirect_uri.
* In this app, the redirect_uri is configured as a different path, but it could be the same.
*/
app.get("/", (req, res) => {
// Authorization server's authorize endpoint's url path
// e.g.,
// "z2345678-0000-456c-a657-3a21fc9ece7e/as/authorize"
const authzPath = envID + authorizeEndpoint;
// authorize request starting with the url origin and path.
const authzReq = new URL(authzPath, authBaseURL);
// Add query parameters to define the authorize request
authzReq.searchParams.append("redirect_uri", redirectURI);
authzReq.searchParams.append("client_id", clientID);
authzReq.searchParams.append("scope", scopes);
authzReq.searchParams.append("response_type", responseType);
// Send a link to the browser to render with the text "Login".
// When the link is clicked the user is redirected to the authorization
// server, PingOne, at the authorize endpoint. The query parameters are read
// by PingOne and combine to make the authorization request.
res.status(200).send("<a href=" + authzReq.toString() + ">Login</a>");
});This step adds in a new /callback path for the redirect_uri and extracts the authorization code from the query parameters of the url.2
-
After the user authenticates, PingOne uses the
redirect_urito redirect the browser (and user) and sends along the authorization code as the value of thecodeparameter:http://localhost:3000/callback?code=<uuid>- For security, the
redirect_urimust be configured on the Application Connection before performing authentication. PingOne will return an error if theredirect_uriprovided in the authorization request is not configured on the Connection.
- For security, the
-
The code is exchanged for tokens with a Token Request at the
/tokenendpoint to get . . . tokens!- *Both an access token and id token are returned (because
openidwas included as a scope) which represent the authorization and authentication, respectively.
- *Both an access token and id token are returned (because
*Stop any other running versions of this app (ctrl+C) from the terminal where you started the previous app
npm run step3from the root of the repo.- Refresh your browser or navigate to
http://localhost:3000. - Click the
Loginlink - Login with your test user (skipped if a session is found; close and open a new incognito window to see the login page instead)
- You should expect to now see some JSON in your browser with an
access_tokenkey and value.
*If this time you didn't have to login, PingOne found a live session! However, you can modify this behavior.
This step's source code can be found in step3/index.js
Or click me to view the code here
/**
* Callback url - "http://localhost:3000/callback"
*
* The path for the redirect_uri. When the user is redirected from PingOne, the
* authorization code is extracted from the query parameters, then the token
* request is constructed and submitted for access and id tokens.
*/
app.get(callbackPath, async (req, res) => {
// Try to parse the authorization code from the query parameters of the url.
const authzCode = req.query?.code;
// Send error if the authorization code was not found.
if (!authzCode) {
const errorMsg =
"Expected authorization code in query parameters.\n" + req.url;
console.error(errorMsg);
res.status(404).send("<a href='/'>Return home</a>");
}
/**
* Set headers for token request.
*/
const headers = new Headers();
// Content type
headers.append("Content-Type", "application/x-www-form-urlencoded");
// Authorization header
// Calculated as the result of base64 encoding the string:
// (clientID + ":" + clientSecret) and appended to "Basic ". e.g., "Basic
// 0123456lNzQtZT3Mi00ZmM0WI4ZWQtY2Q5NTMwTE0123456=="
const authzHeader =
"Basic " + Buffer.from(clientID + ":" + clientSecret).toString("base64");
headers.append("Authorization", authzHeader);
// Use URLSearchParams because we're using
// "application/x-www-form-urlencoded".
const urlBodyParams = new URLSearchParams();
// The grant type used for the OAuth 2.0/OIDC Authorization Code flow.
urlBodyParams.append("grant_type", grantType);
// Include the authorization code that was extracted from the url.
urlBodyParams.append("code", authzCode);
// The redirect_uri is the same as what was sent in the authorize request.
urlBodyParams.append("redirect_uri", redirectURI);
// Options to supply the fetch function.
const requestOptions = {
method: "POST",
headers: headers,
body: urlBodyParams,
};
// PingOne token endpoint
const tokenURL = authBaseURL + "/" + envID + tokenEndpoint;
// Make the exchange for tokens by calling the /token endpoint and sending the
// authorization code.
try {
// Send the token request and get the response body in JSON format.
const response = await fetch(tokenURL, requestOptions);
if (response.ok) {
const result = await response.json();
// For demo purposes, this forwards the json response from the token
// endpoint.
res.status(200).json(result);
} else {
res.status(response.status).send(response.json());
}
} catch (error) {
// Handle error
// For demo purposes, log the error to the server console and send the
// error as a response.
console.log(error);
res.status(500).send(error);
}
});You've just walked through the steps to authenticate a user with PingOne! The returned tokens serve as your proof.
- If PingOne sends a redirect_uri mismatch error, check the PingOne app connection and that you've entered the redirect uri correctly.
- If PingOne sends a resource could not be found error, check the auth base url and that the App Connection has been turned on (flip the toggle on the app conenction)
- If you're having trouble authenticating with a user, make sure that user's identity exists in the same environment as the App Connection.
- If you have problems just running
npm run <step + #>, deletenode_modulesandpackage-lock.jsonand run thenpm installagain. Then try starting the app again. - If you see the following error in the terminal after clicking the Login link in Step 3
UnhandledPromiseRejectionWarning: ReferenceError: Headers is not definedMake sure your version of Node meets the min. version described in the prerequisites.
There are several different next steps you might take depending on your use case. Verifying the token(s), sending it in a request to PingOne, using token introspection, submitting a request to the resource server, and more.
But, first, you don't want to leave the authorization code in the url. It's done here so you can see how the flow works and what to expect when you integrate into your own app.
You'll want to either:
- remove it from the query parameters in the url before loading the UI
- use the
response_mode=form_postin the authorization request so the code is sent in the body of aPOSTrequest3
The authorization code can be used to get an access token in (additional protections exist like PKCE4, which you should use) if someone happens to get a look at that url or it can block you from getting an access token if someone else attempts to use it and the authorization server invalidates it as a precautionary measure before you get a chance to use it.
During testing, you can decode the token(s) with a free Ping Identity tool, verify the signature, check if it's expired, and examine the claims contained within each token.
Whoever "bears" (aka holds) the tokens holds the powers that they grant. This particular decoder runs client-side (i.e., exclusively in the browser), but you should still take extra care to make sure you don't give someone the keys to your kingdom!
Footnotes
-
The
response_typeparameter can be used to return tokens in the authorization request without the code exchange ↩ -
RFC 7636 ↩