diff --git a/_source/_assets/img/blog/dpop-oauth-node/dpopflow.jpg b/_source/_assets/img/blog/dpop-oauth-node/dpopflow.jpg new file mode 100644 index 000000000..2448d1f3e Binary files /dev/null and b/_source/_assets/img/blog/dpop-oauth-node/dpopflow.jpg differ diff --git a/_source/_assets/img/blog/dpop-oauth-node/social.jpg b/_source/_assets/img/blog/dpop-oauth-node/social.jpg new file mode 100644 index 000000000..db0a328e4 Binary files /dev/null and b/_source/_assets/img/blog/dpop-oauth-node/social.jpg differ diff --git a/_source/_posts/2024-10-23-dpop-oauth-node.md b/_source/_posts/2024-10-23-dpop-oauth-node.md new file mode 100644 index 000000000..5e9d32f55 --- /dev/null +++ b/_source/_posts/2024-10-23-dpop-oauth-node.md @@ -0,0 +1,439 @@ +--- +layout: blog_post +title: "How to Build Secure Okta Node.js Integrations with DPoP" +author: ram-gandhi +by: internal-contributor +communities: [security, javascript] +description: "Learn how to securely call Okta APIs using a private key JWT in a Node.js service app, then migrate the app to use possession-proof tokens with DPoP." +tags: [oauth, node, authorization, dpop] +image: blog/dpop-oauth-node/social.jpg +type: conversion +github: https://github.com/oktadev/okta-node-dpop-example +--- + +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](https://datatracker.ietf.org/doc/html/rfc9449). You can read more about DPoP in this post: + +{% excerpt /blog/2024/09/05/dpop-oauth %} + +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**{: .hide } +* Table of Contents +{:toc} + +## Create a service app with OAuth 2.0 client credentials without DPoP + +**Prerequisites** + +You'll need the following tools: + * [Node.js](https://nodejs.org/en) v18 or greater + * IDE (I used [VS Code](https://code.visualstudio.com/)) + * 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](https://developer.okta.com/signup/) if you don't already have one. + +Open [your Okta dashboard](https://developer.okta.com/login/) 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: + 1. Change the **Client authentication** to `Public Key / Private Key` + 2. 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. + 3. 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: + +```bash +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: + +```javascript +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: +```javascript +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: + +```json +"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: + +```console +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: + +{% img blog/dpop-oauth-node/dpopflow.jpg alt:"Sequence diagram that displays the back and forth between the client, authorization server, and resource server for Demonstrating Proof-of-Possession" width:"800" %}{: .center-image } + +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](https://mkjwk.org/) + * 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: + +```javascript +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](https://datatracker.ietf.org/doc/html/rfc9449#section-4.2). + +```javascript +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. + +```javascript +// 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](https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid). + * 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: + +```javascript +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: + +```javascript +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 + +```javascript +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](https://github.com/oktadev/okta-node-dpop-example). + +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: + +* [Okta Management API reference](https://developer.okta.com/docs/reference/) +* [OAuth 2.0 and OpenID Connect overview](https://developer.okta.com/docs/concepts/oauth-openid/) +* [Implement oAuth for Okta](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) +* [Configure OAuth 2.0 Demonstrating Proof-of-Possession](https://developer.okta.com/docs/guides/dpop/-/main/) + +Remember to follow us on [Twitter](https://twitter.com/oktadev) and subscribe to our [YouTube channel](https://www.youtube.com/c/OktaDev/) 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!