Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
);
});

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a regression test for the iss-missing case (a JWT with no iss claim) to ensure validateJwt rejects it. The updated implementation currently has an explicit if (issuer) guard, so without a test it’s easy for issuer validation to accidentally become optional.

Suggested change
it('should reject when "iss" claim is missing from the token', async () => {
const parts = validToken.split('.');
const [header, payload, signature] = parts;
const decodedPayload = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
);
delete decodedPayload.iss;
const modifiedPayload = Buffer.from(
JSON.stringify(decodedPayload),
).toString('base64url');
const tokenWithoutIss = [header, modifiedPayload, signature].join('.');
await expect(sdk.validateJwt(tokenWithoutIss)).rejects.toThrow();
});

Copilot uses AI. Check for mistakes.
it('should reject with a proper error message when token expired', async () => {
await expect(sdk.validateJwt(expiredToken)).rejects.toThrow(
'"exp" claim timestamp check failed',
Expand Down
44 changes: 36 additions & 8 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Comment on lines +203 to +206
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the AIH issuer check looks for '/apps/agentic/', but the implementation only checks for the 'agentic' path segment (and does not verify the preceding 'apps' segment). Either tighten the detection to match the documented pattern or update the comment so it accurately reflects what the code is doing.

Suggested change
const agenticIndex = parts.indexOf('agentic');
if (agenticIndex !== -1 && agenticIndex < parts.length - 1) {
// AIH format: project ID is right after 'agentic'
extractedProjectId = parts[agenticIndex + 1];
const appsIndex = parts.indexOf('apps');
if (
appsIndex !== -1 &&
appsIndex + 2 < parts.length &&
parts[appsIndex + 1] === 'agentic'
) {
// AIH format: project ID is right after '/apps/agentic/'
extractedProjectId = parts[appsIndex + 2];

Copilot uses AI. Check for mistakes.
} 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',
);
}
Comment on lines +193 to +222
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issuer validation is skipped when token.iss is missing/falsy because all checks are inside if (issuer). This is a behavior change from the previous logic (which rejected missing iss) and would allow JWTs without an iss claim to pass validateJwt as long as the signature verifies. Consider treating missing/empty iss as a validation failure (e.g., throw JWTClaimValidationFailed for iss) so issuer validation cannot be bypassed.

Suggested change
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',
);
}
if (!issuer) {
// Issuer is required for proper project validation
throw new errors.JWTClaimValidationFailed(
'missing "iss" claim',
'iss',
'required',
);
}
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',
);

Copilot uses AI. Check for mistakes.
}
}

Expand Down
Loading