Skip to content

Commit 5701edf

Browse files
talaharoniclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e7d0b2b commit 5701edf

File tree

3 files changed

+93
-29
lines changed

3 files changed

+93
-29
lines changed

lib/index.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { getCookieValue } from './helpers';
1313

1414
let validToken: string;
1515
let validTokenIssuerURL: string;
16+
let validTokenAIHIssuer: string;
1617
let invalidTokenIssuer: string;
18+
let invalidTokenAIHIssuer: string;
1719
let expiredToken: string;
1820
let publicKeys: JWK;
1921
// Audience-specific tokens
@@ -70,12 +72,24 @@ describe('sdk', () => {
7072
.setIssuer('https://descope.com/bla/project-id')
7173
.setExpirationTime(1981398111)
7274
.sign(privateKey);
75+
validTokenAIHIssuer = await new SignJWT({})
76+
.setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' })
77+
.setIssuedAt()
78+
.setIssuer('https://api.descope.com/v1/apps/agentic/project-id/resource-id-123')
79+
.setExpirationTime(1981398111)
80+
.sign(privateKey);
7381
invalidTokenIssuer = await new SignJWT({})
7482
.setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' })
7583
.setIssuedAt()
7684
.setIssuer('https://descope.com/bla/bla')
7785
.setExpirationTime(1981398111)
7886
.sign(privateKey);
87+
invalidTokenAIHIssuer = await new SignJWT({})
88+
.setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' })
89+
.setIssuedAt()
90+
.setIssuer('https://api.descope.com/v1/apps/agentic/wrong-project/resource-id')
91+
.setExpirationTime(1981398111)
92+
.sign(privateKey);
7993
expiredToken = await new SignJWT({})
8094
.setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' })
8195
.setIssuedAt(1181398100)
@@ -134,12 +148,28 @@ describe('sdk', () => {
134148
});
135149
});
136150

151+
it('should return the token payload when issuer is AIH format and valid', async () => {
152+
const resp = await sdk.validateJwt(validTokenAIHIssuer);
153+
expect(resp).toMatchObject({
154+
token: {
155+
exp: 1981398111,
156+
iss: 'project-id',
157+
},
158+
});
159+
});
160+
137161
it('should reject with a proper error message when token issuer invalid', async () => {
138162
await expect(sdk.validateJwt(invalidTokenIssuer)).rejects.toThrow(
139163
'unexpected "iss" claim value',
140164
);
141165
});
142166

167+
it('should reject with a proper error message when AIH token issuer invalid', async () => {
168+
await expect(sdk.validateJwt(invalidTokenAIHIssuer)).rejects.toThrow(
169+
'unexpected "iss" claim value',
170+
);
171+
});
172+
143173
it('should reject with a proper error message when token expired', async () => {
144174
await expect(sdk.validateJwt(expiredToken)).rejects.toThrow(
145175
'"exp" claim timestamp check failed',

lib/index.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,42 @@ const nodeSdk = ({
184184
const token = res.payload;
185185

186186
if (token) {
187-
token.iss = token.iss?.split('/').pop(); // support both url and project id as issuer
188-
if (token.iss !== projectId) {
189-
// We must do the verification here, since issuer can be either project ID or URL
190-
throw new errors.JWTClaimValidationFailed(
191-
'unexpected "iss" claim value',
192-
'iss',
193-
'check_failed',
194-
);
187+
// Extract project ID from issuer claim
188+
// Supports:
189+
// 1. Direct project ID: "project-id"
190+
// 2. Standard URL format: "https://api.descope.com/v1/{projectId}"
191+
// 3. AIH format: "https://api.descope.com/v1/apps/agentic/{projectId}/{resourceId}"
192+
const issuer = token.iss;
193+
if (issuer) {
194+
const parts = issuer.split('/');
195+
let extractedProjectId: string;
196+
197+
if (parts.length === 1) {
198+
// Case 1: Direct project ID
199+
extractedProjectId = issuer;
200+
} else {
201+
// Cases 2 and 3: URL format
202+
// Check if this is an AIH issuer (contains '/apps/agentic/')
203+
const agenticIndex = parts.indexOf('agentic');
204+
if (agenticIndex !== -1 && agenticIndex < parts.length - 1) {
205+
// AIH format: project ID is right after 'agentic'
206+
extractedProjectId = parts[agenticIndex + 1];
207+
} else {
208+
// Standard URL format: project ID is the last segment
209+
extractedProjectId = parts[parts.length - 1];
210+
}
211+
}
212+
213+
token.iss = extractedProjectId;
214+
215+
if (token.iss !== projectId) {
216+
// We must do the verification here, since issuer can be either project ID or URL
217+
throw new errors.JWTClaimValidationFailed(
218+
'unexpected "iss" claim value',
219+
'iss',
220+
'check_failed',
221+
);
222+
}
195223
}
196224
}
197225

0 commit comments

Comments
 (0)