See you at Oktane in Las Vegas on October 15-17, 2024. Read more about the activities planned with you mind here.

How to Build Secure Okta Node.js Integrations with DPoP

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:

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

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.

  1. 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.
  2. Press edit in the Client Credentials section and follow these steps:
  3. Change the Client authentication to Public Key / Private Key
  4. 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.
  5. 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 request
  • oktaHelper 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:

Sequence diagram that displays the back and forth between the client, authorization server, and resource server for Demonstrating Proof-of-Possession

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 and DPoP 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 the use_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:

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!

Ram Gandhi is a Developer Support Solutions Architect at Okta, a full-stack developer, DevOps expert, and a problem solver who is excited by learning new things. He has over 13 years of experience developing software applications across various industries and securing them using industry best practices. He loves to work in cross-platform development and Kubernetes.

Okta Developer Blog Comment Policy

We welcome relevant and respectful comments. Off-topic comments may be removed.