How to Build Secure Okta Node.js Integrations with DPoP
Integrating with Okta management API endpoints might be a good idea if you are trying to read or manage Okta resources programmatically. This blog demonstrates how to securely set up a node application to interact with Okta management API endpoints using a service app.
Okta API management endpoints can be accessed using an access token issued by the Okta org authorization server with the appropriate scopes needed to make an API call. This can be either through authorization code flow for the user as principal or client credentials flow for a service as principal.
For this blog, we will examine the OAuth 2.0 client credentials flow. Okta requires the private_key_jwt
token endpoint authentication type for this flow. Access tokens generated by the Okta org authorization server expire in one hour. Any client can call Okta API endpoints with the token during this hour.
How do you make OAuth 2.0 access tokens more secure?
Increase security by constraining the token to the sender. By constraining the token sender, the resource server knows every request originates from the original client that initially requested the token. OAuth 2.0 Demonstrating Proof of Possession (DPoP) is a way to achieve this, as explained in this rfc. You can read more about DPoP in this post:
Protect your OAuth 2.0 access token with sender constraints. Learn about possession proof tokens using DPoP.
To demonstrate this, we will first set up a node application with a service app without requiring DPoP. Then, we’ll add the DPoP constraint and make the necessary changes in our app to implement it.
Table of Contents
- How do you make OAuth 2.0 access tokens more secure?
- Create a service app with OAuth 2.0 client credentials without DPoP
- Add OAuth 2.0 and OpenID Connect (OIDC) to your Node.js service application
- Create an OAuth 2.0-compliant Node.js service app
- Secure access tokens by adding DPoP to the Node.js service
- Experiment with DPoP and API scopes for Okta API and custom resource server calls
- Learn more about Okta Management API, DPoP, and OAuth 2.0
Create a service app with OAuth 2.0 client credentials without DPoP
Prerequisites
You’ll need the following tools:
- Node.js v18 or greater
- IDE (I used VS Code)
- Terminal window (I used the integrated terminal in VS Code)
Add OAuth 2.0 and OpenID Connect (OIDC) to your Node.js service application
Before you begin, you’ll need a free Okta developer edition account. Sign up for a free Workforce Identity Cloud Developer Edition account if you don’t already have one.
Open your Okta dashboard in a browser. Navigate to Applications > Applications. Select API Services and press Next. Name your application and press Save.
- In the General tab, note the Client ID value and your Okta domain. You can find the Okta domain by expanding the settings menu in the toolbar. You need these values for your application configuration.
- Press edit in the Client Credentials section and follow these steps:
- Change the Client authentication to
Public Key / Private Key
- In the PUBLIC KEYS section, press the Add key button. Click Generate new key to have Okta generate a new key. Save the private key (in PEM format) in a file called
cc_private_key.pem
for later use. - Press Save
In General Settings section, press edit and make the following changes:
- Disable Proof of possession > Require Demonstrating Proof of Possession (DPoP) header in token requests
- Press Save
Navigate to the Okta API Scopes tab and grant the okta.users.read
scope.
In the Admin roles tab, press Edit assignments. Find the Read-only Administrator
in the Role selection menu, and press the Save Changes button.
Those are all of the changes required in Okta until you re-enable DPoP.
Configure OAuth 2.0 in the Node.js service
Create a project directory for local development named okta-node-dpop
. Open the project directory in your IDE. Create a file called .env
file to the project root directory and add the following configuration settings:
OKTA_ORG_URL=https://{yourOktaDomain}
OKTA_CLIENT_ID={yourClientID}
OKTA_SCOPES=okta.users.read
OKTA_CC_PRIVATE_KEY_FILE=./assets/cc_private_key.pem
Save the private key file from the earlier step as assets/cc_private_key.pem
in the root directory.
Create an OAuth 2.0-compliant Node.js service app
Open a terminal window in the project directory and run npm init
to create the scaffolding. Press Enter to accept all defaults.
Install dependencies for the project by running:
npm i dotenv@16.4.5 jsonwebtoken@9.0.2
Create an oktaService.js
file in the project root. We’ll add the basic foundation of authenticating and calling Okta endpoints in this file. This file contains three key functions:
oktaService.authenticate(..)
method gets an access token by:- Generating a private key JWT required for authenticating and signs it using a keypair registered in the Okta application
- Generating the token request to Okta org authorization server
- Retrieving and stores the access token for future calls Note - This token is valid for one hour by default at the time of writing this article
oktaService.managementApiCall(..)
method makes the Okta management API calls and adds the necessary headers and tokens to enable the requestoktaHelper
contains utility methods to store okta configuration, access token, generating private key JWT, generating token request
Add the following code to the oktaService.js
file:
const fs = require("fs");
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
require("dotenv").config(); // Loads variables in .env file into the environment
const oktaHelper = {
oktaDomain: process.env.OKTA_ORG_URL || "", // Okta domain URL
oktaClientId: process.env.OKTA_CLIENT_ID || "", // Client ID of API service app
oktaScopes: process.env.OKTA_SCOPES || "", // Scopes requested - Okta management API scopes
ccPrivateKeyFile: process.env.OKTA_CC_PRIVATE_KEY_FILE || "", // Private Key for signing Private key JWT
ccPrivateKey: null,
accessToken: "",
getTokenEndpoint: function () {
return `${this.oktaDomain}/oauth2/v1/token`;
}, // Token endpoint
getNewJti: function () {
return crypto.randomBytes(32).toString("hex");
}, // Helper method to generate new identifier
generateCcToken: function () {
// Helper method to generate private key jwt
let privateKey =
this.ccPrivateKey || fs.readFileSync(this.ccPrivateKeyFile);
let signingOptions = {
algorithm: "RS256",
expiresIn: "5m",
audience: this.getTokenEndpoint(),
issuer: this.oktaClientId,
subject: this.oktaClientId,
};
return jwt.sign({ jti: this.getNewJti() }, privateKey, signingOptions);
},
tokenRequest: function (ccToken) {
// generate token request using client_credentials grant type
return fetch(this.getTokenEndpoint(), {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: this.oktaScopes,
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: ccToken,
}),
});
},
};
const oktaService = {
authenticate: async function () {
// Use to authenticate and generate access token
if (!oktaHelper.accessToken) {
console.log("Valid access token not found. Retrieving new token...\n");
let ccToken = oktaHelper.generateCcToken();
console.log(`Using Private Key JWT: ${ccToken}\n`);
console.log(`Making token call to ${oktaHelper.getTokenEndpoint()}`);
let tokenResp = await oktaHelper.tokenRequest(ccToken);
let respBody = await tokenResp.json();
oktaHelper.accessToken = respBody["access_token"];
console.log(
`Successfully retrieved access token: ${oktaHelper.accessToken}\n`
);
}
return oktaHelper.accessToken;
},
managementApiCall: function (relativeUri, httpMethod, headers, body) {
// Construct Okta management API calls
let uri = `${oktaHelper.oktaDomain}${relativeUri}`;
let reqHeaders = {
Accept: "application/json",
Authorization: `Bearer ${oktaHelper.accessToken}`,
...headers,
};
return fetch(uri, {
method: httpMethod,
headers: reqHeaders,
body,
});
},
};
module.exports = oktaService;
Add a new file named app.js
in the project root folder. This is the entry point for running our Node.js service application. In this file, we’ll do the following:
- Import
oktaService
- Create an async wrapper to execute asynchronous code
- Authenticate to Okta by calling
oktaService.authenticate()
- Validate the previous step by listing users using a
GET
call to Okta’s/api/v1/users
endpoint
Paste the following code into the app.js
file:
const oktaService = require('./oktaService.js');
(async () => {
await oktaService.authenticate();
let usersResp = await oktaService.managementApiCall('/api/v1/users', 'GET');
if(usersResp.status == 200) {
let respBody = await usersResp.json();
console.log(`Users List: ${JSON.stringify(respBody)}\n`);
} else {
console.log('API error', usersResp);
}
})();
Next, update this as the entry point. In the package.json
file, update the scripts
property with the following:
"scripts": {
"start": "node app.js"
}
This gives us an easy way to run the app. Run the app using npm start
. You should see a list of console logs:
Valid access token not found. Retrieving new token...
Using Private Key JWT: eyJh........
Making token call to https://........../oauth2/v1/token
Successfully retrieved access token: eyJ..................
Users List: [.........]
If you receive any errors, this is a good time to troubleshoot and resolve issues before adding DPoP.
Secure access tokens by adding DPoP to the Node.js service
Why isn’t OAuth 2.0 client credential flow enough?
Our setup used the client_credentials
grant type to authenticate and get an access token. If someone gets hold of the private_key_jwt, they cannot replay it beyond expiration (I reduced it to 5 minutes to shorten this window). However, if someone gets ahold of the access token, they can use it for up to 1 hour, which is the default expiration time of an access token.
Constraining the token sender is one way to make the access token more secure. How can you do that? By adding the Demonstrating Proof of Possession (DPoP) OAuth extension method to the access token interaction. The technique adds a sender-generated token for each call it makes. Doing so prevents replay attacks even before tokens expire since each call needs a fresh DPoP token. Here is the detailed flow:
You’ll enable DPoP in Okta application settings to experiment with sender-constrained tokens. Open the Okta Admin Console in your browser and navigate to Application > Application to see the list of Okta applications in your Okta account. Open the service application to edit it.
In your service app’s General Settings section, change Proof of possession > Require Demonstrating Proof of Possession (DPoP) header in token requests to true
. Then click Save.
You need a new public/private key pair to sign the DPoP proof JWT. If you know how to generate one, feel free to skip this step. I used the following steps to generate it:
- Go to JWK generator
- Select the following and then click Generate.
- Key Use: Signature
- Algorithm: RS256
- Key ID: SHA-256
- Show X.509: Yes
- Copy the Public Key (JSON format) and save it to
assets/dpop_public_key.json
- Copy the Private Key (X.509 PEM format) (Do not click Copy to Clipboard. This will copy as a single line, which will not work with the following steps. Instead, copy the value manually and save it) and save it to
assets/dpop_private_key.pem
Now that you have a new keypair for DPoP, you’ll add the variables to the project. In the .env
file, add the new file paths:
....
OKTA_SCOPES=okta.users.read
OKTA_CC_PRIVATE_KEY_FILE=./assets/cc_private_key.pem
OKTA_DPOP_PRIVATE_KEY_FILE=./assets/dpop_private_key.pem
OKTA_DPOP_PUBLIC_KEY_FILE=./assets/dpop_public_key.json
Add the DPoP-related code to oktaService.js
. Add the key files to config. We can use this while adding DPoP to our methods:
const oktaHelper = {
.......
ccPrivateKeyFile: process.env.OKTA_CC_PRIVATE_KEY_FILE || '', // Private Key for signing Private key JWT
ccPrivateKey: null,
// Add this code ======================
dpopPrivateKeyFile: process.env.OKTA_DPOP_PRIVATE_KEY_FILE || '', // Private key for signing DPoP proof JWT
dpopPublicKeyFile: process.env.OKTA_DPOP_PUBLIC_KEY_FILE || '', // Public key for signing DPoP proof JWT
dpopPrivateKey: null,
dpopPublicKey: null,
// Add above code ======================
accessToken: '',
.....
}
Add a helper method to generate a DPoP value. This helper method adds an access token to the DPoP proof JWT header. It’ll construct the JWT based on the format defined in spec.
const oktaHelper = {
.....
// Add this as the last attribute of oktaHelper object
generateDpopToken: function(htm, htu, additionalClaims) {
let privateKey = this.dpopPrivateKey || fs.readFileSync(this.dpopPrivateKeyFile);
let publicKey = this.dpopPublicKey || fs.readFileSync(this.dpopPublicKeyFile)
let signingOptions = {
algorithm: 'RS256',
expiresIn: '5m',
header: {
typ: 'dpop+jwt',
alg: 'RS256',
jwk: JSON.parse(publicKey)
}
};
let payload = {
...additionalClaims,
htu,
htm,
jti: this.getNewJti()
};
return jwt.sign(payload, privateKey, signingOptions);
}
};
Next, add the DPoP proof token to the tokenRequest
method. This method gets the newly generated DPoP proof token and adds it to the token request as a header.
// Add dpopToken as a new parameter
tokenRequest: function(ccToken, dpopToken) { // generate token request using client_credentials grant type
return fetch(this.getTokenEndpoint(), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
// New Code - Start
DPoP: dpopToken
// New Code - End
},
...
});
},
Add the following steps to the authenticate
method to add DPoP.
- Generate a new DPoP proof for
POST
method and token endpoint - Make token call with both
private_key_jwt
andDPoP
jwt - Okta adds an extra security measure by adding a
nonce
to token requests requiring DPoP. This will respond to token requests that don’t include a nonce with theuse_dpop_nonce
error. Read more about the nonce in the spec. - After this step, we’ll generate a new DPoP proof JWT including nonce value in payload
- Make the token call again with this new JWT
Once we follow these steps, we’ll have a new access token to use in our API call. Let’s implement the steps. Update the authenticate
method to the following:
authenticate: async function() { // Use to authenticate and generate access token
if(!oktaHelper.accessToken) {
console.log('Valid access token not found. Retrieving new token...\n');
let ccToken = oktaHelper.generateCcToken();
console.log(`Using Private Key JWT: ${ccToken}\n`);
// New Code - Start
let dpopToken = oktaHelper.generateDpopToken('POST', oktaHelper.getTokenEndpoint());
console.log(`Using DPoP proof: ${dpopToken}\n`);
// New Code - End
console.log(`Making token call to ${oktaHelper.getTokenEndpoint()}`);
// Update following line by adding dpopToken parameter
let tokenResp = await oktaHelper.tokenRequest(ccToken, dpopToken);
let respBody = await tokenResp.json();
// New Code - Start
if(tokenResp.status != 400 || (respBody && respBody.error != 'use_dpop_nonce')) {
console.log('Authentication Failed');
console.log(respBody);
return null;
}
let dpopNonce = tokenResp.headers.get('dpop-nonce');
console.log(`Token call failed with nonce error \n`);
dpopToken = oktaHelper.generateDpopToken('POST', oktaHelper.getTokenEndpoint(), {nonce: dpopNonce});
ccToken = oktaHelper.generateCcToken();
console.log(`Retrying token call to ${oktaHelper.getTokenEndpoint()} with DPoP nonce ${dpopNonce}`);
tokenResp = await oktaHelper.tokenRequest(ccToken, dpopToken);
respBody = await tokenResp.json();
// New Code - End
oktaHelper.accessToken = respBody['access_token'];
console.log(`Successfully retrieved access token: ${oktaHelper.accessToken}\n`);
}
return oktaHelper.accessToken;
}
Before proceeding, make sure to enable DPoP in your Okta service application. Now, test the steps by running npm start
in the terminal. OOPS! You would have received an access token, but a call to the user’s API failed with a 400 status. We didn’t include the DPoP proof in this API call. With DPoP enabled, we must include a new DPoP proof for every call. This prevents malicious actors from reusing stolen access tokens.
Let’s add some code to include DPoP proof during every API call.
In the oktaService.js
file, add a helper method to generate the hash of the access token or ath
value. You’ll use this value later to bind access tokens with DPoP proofs:
const oktaHelper = {
.....,
// Add as the last attribute of oktaHelper object
generateAth: function(token) {
return crypto.createHash('sha256').update(token).digest('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/\=/g, '');
}
};
A valid DPoP proof JWT includes the access token hash (ath
) value. To make this change, update managementApiCall
method
managementApiCall: function (relativeUri, httpMethod, headers, body) { // Construct Okta management API calls
let uri = `${oktaHelper.oktaDomain}${relativeUri}`;
// New Code - Start
let ath = oktaHelper.generateAth(oktaHelper.accessToken);
let dpopToken = oktaHelper.generateDpopToken(httpMethod, uri, {ath});
// New Code - End
// Update reqHeaders object
let reqHeaders = {
'Accept': 'application/json',
'Authorization': `DPoP ${oktaHelper.accessToken}`,
'DPoP': dpopToken,
...headers
};
return fetch(uri, {
method: httpMethod,
headers: reqHeaders,
body
});
}
Run npm start
. Voila! You see a list of users!
We successfully authenticated to Okta with a service app demonstrating DPoP and are using this access token and DPoP proof to access Okta Admin Management API endpoints.
Experiment with DPoP and API scopes for Okta API and custom resource server calls
You can download the completed project from the GitHub repository.
Try modifying the project using different Okta API scopes and experimenting with other endpoints. Ensure you give permissions to your service app by assigning appropriate Admin roles. To improve security, you can implement similar protection to your custom resource server endpoints using a custom authorization server and custom set of scopes.
Learn more about Okta Management API, DPoP, and OAuth 2.0
In this post, you accessed Okta management API using a node app and were able to make it more secure by adding DPoP support. I hope you enjoyed it! If you want to learn more about the ways you can incorporate authentication and authorization security in your apps, you might want to check out these resources:
- Elevate Access Token Security by Demonstrating Proof-of-Possession
- Okta Management API reference
- OAuth 2.0 and OpenID Connect overview
- Implement OAuth for Okta
- Configure OAuth 2.0 Demonstrating Proof-of-Possession
Remember to follow us on Twitter and subscribe to our YouTube channel for more exciting content. We also want to hear from you about topics you want to see and questions you may have. Leave us a comment below!
Okta Developer Blog Comment Policy
We welcome relevant and respectful comments. Off-topic comments may be removed.