From 5701edffd5de7511bd38c15b1d50d5790d757f42 Mon Sep 17 00:00:00 2001 From: Tal Aharoni Date: Tue, 24 Feb 2026 11:16:00 +0000 Subject: [PATCH] feat(jwt): support AIH-issued JWT token validation Enhance JWT validation to properly extract project ID from Agentic Identity Hub (AIH) issued tokens. The issuer format for AIH tokens is: https://api.descope.com/v1/apps/agentic/{projectId}/{resourceId} Previously, using .pop() on the split issuer would incorrectly extract the resourceId instead of the projectId, causing validation to fail. This change implements smart project ID extraction that supports: 1. Direct project ID: "project-id" 2. Standard URL format: "https://api.descope.com/v1/{projectId}" 3. AIH format: "https://api.descope.com/v1/apps/agentic/{projectId}/{resourceId}" The fix checks for the presence of 'agentic' in the issuer path and extracts the segment immediately following it as the project ID, ensuring correct validation for AIH-issued tokens. Tests added for both valid and invalid AIH issuer formats. Co-Authored-By: Claude Sonnet 4.5 --- lib/index.test.ts | 30 +++++++++++++++++++++++++++++ lib/index.ts | 44 +++++++++++++++++++++++++++++++++++-------- package-lock.json | 48 ++++++++++++++++++++++++++--------------------- 3 files changed, 93 insertions(+), 29 deletions(-) diff --git a/lib/index.test.ts b/lib/index.test.ts index f59946597..88834d7f2 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -13,7 +13,9 @@ import { getCookieValue } from './helpers'; let validToken: string; let validTokenIssuerURL: string; +let validTokenAIHIssuer: string; let invalidTokenIssuer: string; +let invalidTokenAIHIssuer: string; let expiredToken: string; let publicKeys: JWK; // Audience-specific tokens @@ -70,12 +72,24 @@ describe('sdk', () => { .setIssuer('https://descope.com/bla/project-id') .setExpirationTime(1981398111) .sign(privateKey); + validTokenAIHIssuer = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('https://api.descope.com/v1/apps/agentic/project-id/resource-id-123') + .setExpirationTime(1981398111) + .sign(privateKey); invalidTokenIssuer = await new SignJWT({}) .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) .setIssuedAt() .setIssuer('https://descope.com/bla/bla') .setExpirationTime(1981398111) .sign(privateKey); + invalidTokenAIHIssuer = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('https://api.descope.com/v1/apps/agentic/wrong-project/resource-id') + .setExpirationTime(1981398111) + .sign(privateKey); expiredToken = await new SignJWT({}) .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) .setIssuedAt(1181398100) @@ -134,12 +148,28 @@ describe('sdk', () => { }); }); + it('should return the token payload when issuer is AIH format and valid', async () => { + const resp = await sdk.validateJwt(validTokenAIHIssuer); + expect(resp).toMatchObject({ + token: { + exp: 1981398111, + iss: 'project-id', + }, + }); + }); + it('should reject with a proper error message when token issuer invalid', async () => { await expect(sdk.validateJwt(invalidTokenIssuer)).rejects.toThrow( 'unexpected "iss" claim value', ); }); + it('should reject with a proper error message when AIH token issuer invalid', async () => { + await expect(sdk.validateJwt(invalidTokenAIHIssuer)).rejects.toThrow( + 'unexpected "iss" claim value', + ); + }); + it('should reject with a proper error message when token expired', async () => { await expect(sdk.validateJwt(expiredToken)).rejects.toThrow( '"exp" claim timestamp check failed', diff --git a/lib/index.ts b/lib/index.ts index 64993f00c..8ad5c4f27 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -184,14 +184,42 @@ const nodeSdk = ({ const token = res.payload; if (token) { - token.iss = token.iss?.split('/').pop(); // support both url and project id as issuer - if (token.iss !== projectId) { - // We must do the verification here, since issuer can be either project ID or URL - throw new errors.JWTClaimValidationFailed( - 'unexpected "iss" claim value', - 'iss', - 'check_failed', - ); + // Extract project ID from issuer claim + // Supports: + // 1. Direct project ID: "project-id" + // 2. Standard URL format: "https://api.descope.com/v1/{projectId}" + // 3. AIH format: "https://api.descope.com/v1/apps/agentic/{projectId}/{resourceId}" + const issuer = token.iss; + if (issuer) { + const parts = issuer.split('/'); + let extractedProjectId: string; + + if (parts.length === 1) { + // Case 1: Direct project ID + extractedProjectId = issuer; + } else { + // Cases 2 and 3: URL format + // Check if this is an AIH issuer (contains '/apps/agentic/') + const agenticIndex = parts.indexOf('agentic'); + if (agenticIndex !== -1 && agenticIndex < parts.length - 1) { + // AIH format: project ID is right after 'agentic' + extractedProjectId = parts[agenticIndex + 1]; + } else { + // Standard URL format: project ID is the last segment + extractedProjectId = parts[parts.length - 1]; + } + } + + token.iss = extractedProjectId; + + if (token.iss !== projectId) { + // We must do the verification here, since issuer can be either project ID or URL + throw new errors.JWTClaimValidationFailed( + 'unexpected "iss" claim value', + 'iss', + 'check_failed', + ); + } } } diff --git a/package-lock.json b/package-lock.json index a068e658f..5073c0e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^5.25.0", "@typescript-eslint/parser": "^5.27.0", - "eslint": "^8.15.0", + "eslint": "^8.0.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^10.0.0", @@ -105,7 +105,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.10.tgz", "integrity": "sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", @@ -800,7 +799,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -1124,6 +1122,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1141,6 +1140,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1158,6 +1158,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1175,6 +1176,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1192,6 +1194,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1209,6 +1212,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1226,6 +1230,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1243,6 +1248,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1260,6 +1266,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1277,6 +1284,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1294,6 +1302,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1311,6 +1320,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1328,6 +1338,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1345,6 +1356,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1362,6 +1374,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1379,6 +1392,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1396,6 +1410,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1413,6 +1428,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1430,6 +1446,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1447,6 +1464,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1464,6 +1482,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1498,6 +1517,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1515,6 +1535,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1532,6 +1553,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1549,6 +1571,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3454,7 +3477,6 @@ "integrity": "sha512-JBG8dioIs0m2kHOhs9jD6E/tZKD08vmbf2bfqj/rJyNWqJxk/ZcakixjhYtsqdbi+AKVbfPkt3g2RRZiKaizYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "lilconfig": "^3.1.3", @@ -3664,7 +3686,6 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", "dev": true, - "peer": true, "dependencies": { "@types/linkify-it": "*", "@types/mdurl": "*" @@ -3688,7 +3709,6 @@ "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -3732,7 +3752,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.5.tgz", "integrity": "sha512-lftkqRoBvc28VFXEoRgyZuztyVUQ04JvUnATSPtIRFAccbXTWL6DEtXGYMcbg998kXw1NLUJm7rTQ9eUt+q6Ig==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.30.5", "@typescript-eslint/type-utils": "5.30.5", @@ -3781,7 +3800,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.30.5.tgz", "integrity": "sha512-zj251pcPXI8GO9NDKWWmygP6+UjwWmrdf9qMW/L/uQJBM/0XbU2inxe5io/234y/RCvwpKEYjZ6c1YrXERkK4Q==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.30.5", "@typescript-eslint/types": "5.30.5", @@ -3961,7 +3979,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4652,7 +4669,6 @@ "url": "https://tidelift.com/funding/github/npm/browserslist" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001370", "electron-to-chromium": "^1.4.202", @@ -5825,7 +5841,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.19.0.tgz", "integrity": "sha512-SXOPj3x9VKvPe81TjjUJCYlV4oJjQw68Uek+AM0X4p+33dj2HY5bpTZOgnQHcG2eAm1mtCU9uNMnJi7exU/kYw==", "dev": true, - "peer": true, "dependencies": { "@eslint/eslintrc": "^1.3.0", "@humanwhocodes/config-array": "^0.9.2", @@ -6022,7 +6037,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.4", "array.prototype.flat": "^1.2.5", @@ -7787,7 +7801,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.4.2.tgz", "integrity": "sha512-+5hLd260vNIHu+7ZgMIooSpKl7Jp5pHKb51e73AJU3owd5dEo/RfVwHbA/na3C/eozrt3hJOLGf96c7EWwIAzg==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.4.2", "@jest/types": "^29.4.2", @@ -10274,7 +10287,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", "dev": true, - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "~2.1.0", @@ -11086,7 +11098,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -11744,7 +11755,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -12864,7 +12874,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12976,7 +12985,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.2.tgz", "integrity": "sha512-LYdGnoGddf1D6v8REPtIH+5iq/gTDuZqv2/UJUU7tKjuEU8xVZorBM+buCGNjj+pGEud+sOoM4CX3/YzINpENA==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13051,8 +13059,7 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "peer": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -13127,7 +13134,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"