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
5 changes: 5 additions & 0 deletions .changeset/backend-issuer-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

`verifyToken()` and `verifyJwt()` now support an `issuer` option to validate a token's `iss` claim. Pass a string for an exact match, or a list of strings of which one must match. Validation is opt-in: when `issuer` is not provided, the `iss` claim is not checked and existing behavior is unchanged. The option only applies to session-token verification; machine tokens (M2M, OAuth access tokens, API keys) are unaffected.
1 change: 1 addition & 0 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const TokenVerificationErrorReason = {
TokenInvalid: 'token-invalid',
TokenInvalidAlgorithm: 'token-invalid-algorithm',
TokenInvalidAuthorizedParties: 'token-invalid-authorized-parties',
TokenInvalidIssuer: 'token-invalid-issuer',
TokenInvalidSignature: 'token-invalid-signature',
TokenNotActiveYet: 'token-not-active-yet',
TokenIatInTheFuture: 'token-iat-in-the-future',
Expand Down
38 changes: 38 additions & 0 deletions packages/backend/src/jwt/__tests__/assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
assertHeaderAlgorithm,
assertHeaderType,
assertIssuedAtClaim,
assertIssuerClaim,
assertSubClaim,
} from '../assertions';

Expand Down Expand Up @@ -230,6 +231,43 @@ describe('assertAuthorizedPartiesClaim(azp?, authorizedParties?)', () => {
});
});

describe('assertIssuerClaim(iss, issuer?)', () => {
const iss = 'https://clerk.example.com';

it('does not throw if no issuer is provided (opt-in)', () => {
expect(() => assertIssuerClaim(iss)).not.toThrow();
expect(() => assertIssuerClaim(iss, undefined)).not.toThrow();
expect(() => assertIssuerClaim(undefined)).not.toThrow();
expect(() => assertIssuerClaim(iss, '')).not.toThrow();
expect(() => assertIssuerClaim(iss, [])).not.toThrow();
});
Comment on lines +237 to +243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Adjust issuer tests to avoid codifying validation bypass and cover predicate edge-case errors.

The current case at Line 241 (issuer: '') asserts skip behavior even though issuer is supplied. Add/adjust tests so configured-but-empty issuer fails, and include a predicate case with non-string/missing iss to verify controlled failure behavior.

Suggested test updates
   it('does not throw if no issuer is provided (opt-in)', () => {
     expect(() => assertIssuerClaim(iss)).not.toThrow();
     expect(() => assertIssuerClaim(iss, undefined)).not.toThrow();
     expect(() => assertIssuerClaim(undefined)).not.toThrow();
-    expect(() => assertIssuerClaim(iss, '')).not.toThrow();
+    expect(() => assertIssuerClaim(iss, '')).toThrow(`Invalid JWT issuer claim (iss) "https://clerk.example.com".`);
   });
@@
   it('throws if iss is missing or not a string when an issuer string is required', () => {
     expect(() => assertIssuerClaim(undefined, iss)).toThrow(`Invalid JWT issuer claim (iss) undefined.`);
     expect(() => assertIssuerClaim(42, iss)).toThrow(`Invalid JWT issuer claim (iss) 42.`);
   });
+
+  it('throws if iss is missing when issuer resolver is a predicate', () => {
+    expect(() => assertIssuerClaim(undefined, i => i.startsWith('https://clerk.'))).toThrow(
+      `Invalid JWT issuer claim (iss) undefined.`,
+    );
+  });

As per coding guidelines, **/*.{test,spec}.{ts,tsx} should verify proper error handling and edge cases for new functionality.

Also applies to: 254-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/jwt/__tests__/assertions.test.ts` around lines 237 -
242, Update the tests in packages/backend/src/jwt/__tests__/assertions.test.ts
to stop treating an explicitly configured-but-empty issuer as "opt-in": change
the expectation for assertIssuerClaim(iss, '') to expect it to throw (i.e.,
configured-empty should fail), and add a new predicate-edge-case test that calls
assertIssuerClaim with a non-string or missing iss (e.g., an object with
iss:number or undefined) alongside a predicate function to verify it fails in a
controlled way; reference the assertIssuerClaim function and adjust the related
assertions in both the block around the current opt-in test and the second block
noted (lines ~254-267) to cover these failure paths.

Source: Coding guidelines


it('does not throw if iss exactly matches the issuer string', () => {
expect(() => assertIssuerClaim(iss, iss)).not.toThrow();
});

it('does not throw if iss is included in the issuer list', () => {
expect(() => assertIssuerClaim(iss, ['https://clerk.other.com', iss])).not.toThrow();
});

it('throws if iss does not match the issuer string', () => {
expect(() => assertIssuerClaim(iss, 'https://clerk.evil.com')).toThrow(
`Invalid JWT issuer claim (iss) "https://clerk.example.com".`,
);
});

it('throws if iss is not included in the issuer list', () => {
expect(() => assertIssuerClaim(iss, ['https://clerk.evil.com', 'https://clerk.other.com'])).toThrow(
`Invalid JWT issuer claim (iss) "https://clerk.example.com".`,
);
});

it('throws if iss is missing or not a string when an issuer is required', () => {
expect(() => assertIssuerClaim(undefined, iss)).toThrow(`Invalid JWT issuer claim (iss) undefined.`);
expect(() => assertIssuerClaim(42, iss)).toThrow(`Invalid JWT issuer claim (iss) 42.`);
});
});

describe('assertExpirationClaim(exp, clockSkewInMs)', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
32 changes: 30 additions & 2 deletions packages/backend/src/jwt/__tests__/verifyJwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,44 @@ describe('verifyJwt(jwt, options)', () => {
expect(data).toEqual(mockJwtPayload);
});

it('returns the valid JWT payload if valid key & issuer method & azp is given', async () => {
it('returns the valid JWT payload if valid key & issuer list & azp is given', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
issuer: (iss: string) => iss.startsWith('https://clerk'),
issuer: ['https://clerk.other-app.com', mockJwtPayload.iss],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
};
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
expect(data).toEqual(mockJwtPayload);
});

it('returns an error if the issuer string does not match the iss claim', async () => {
const { errors: [error] = [] } = await verifyJwt(mockJwt, {
key: mockJwks.keys[0],
issuer: 'https://clerk.another-app.com',
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
});
expect(error?.reason).toBe('token-invalid-issuer');
expect(error?.message).toContain('Invalid JWT issuer claim (iss)');
});

it('returns an error if no issuer in the list matches the iss claim', async () => {
const { errors: [error] = [] } = await verifyJwt(mockJwt, {
key: mockJwks.keys[0],
issuer: ['https://clerk.another-app.com', 'https://clerk.other-app.com'],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
});
expect(error?.reason).toBe('token-invalid-issuer');
});

it('does not validate the iss claim when no issuer is provided', async () => {
const { data, errors } = await verifyJwt(mockJwt, {
key: mockJwks.keys[0],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
});
expect(errors).toBeUndefined();
expect(data).toEqual(mockJwtPayload);
});

it('returns the valid JWT payload if valid key & issuer & list of azp (with empty string) is given', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
Expand Down
19 changes: 17 additions & 2 deletions packages/backend/src/jwt/assertions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';
import { algs } from './algorithms';

export type IssuerResolver = string | ((iss: string) => boolean);

const isArrayString = (s: unknown): s is string[] => {
return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string');
};
Expand Down Expand Up @@ -96,6 +94,23 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s
}
};

export const assertIssuerClaim = (iss: unknown, issuer?: string | string[]) => {
// No issuer configured, skip validation. Preserves the default behavior, matching how
// the audience and authorized parties claims are only checked when an option is provided.
const issuerList = [issuer].flat().filter(i => !!i);
if (issuerList.length === 0) {
return;
}

if (typeof iss !== 'string' || !issuerList.includes(iss)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalidIssuer,
message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuerList}".`,
});
}
};

export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => {
if (typeof exp !== 'number') {
throw new TokenVerificationError({
Expand Down
10 changes: 8 additions & 2 deletions packages/backend/src/jwt/verifyJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
assertHeaderAlgorithm,
assertHeaderType,
assertIssuedAtClaim,
assertIssuerClaim,
assertSubClaim,
} from './assertions';
import { importKey } from './cryptoKeys';
Expand Down Expand Up @@ -120,6 +121,10 @@ export type VerifyJwtOptions = {
* @default 5000
*/
clockSkewInMs?: number;
/**
* The issuer to verify against the `iss` claim in the token. Accepts a string for an exact match, or a list of strings of which one must match exactly. If omitted, the `iss` claim is not validated.
*/
issuer?: string | string[];
/**
* @internal
*/
Expand All @@ -135,7 +140,7 @@ export async function verifyJwt(
token: string,
options: VerifyJwtOptions,
): Promise<JwtReturnType<JwtPayload, TokenVerificationError>> {
const { audience, authorizedParties, clockSkewInMs, key, headerType } = options;
const { audience, authorizedParties, clockSkewInMs, issuer, key, headerType } = options;
const clockSkew =
typeof clockSkewInMs === 'number' && Number.isFinite(clockSkewInMs) ? clockSkewInMs : DEFAULT_CLOCK_SKEW_IN_MS;

Expand Down Expand Up @@ -183,11 +188,12 @@ export async function verifyJwt(

// Payload verifications (only after signature is confirmed valid)
try {
const { azp, sub, aud, iat, exp, nbf } = payload;
const { azp, sub, aud, iss, iat, exp, nbf } = payload;

assertSubClaim(sub);
assertAudienceClaim(aud, audience);
assertAuthorizedPartiesClaim(azp, authorizedParties);
assertIssuerClaim(iss, issuer);
assertExpirationClaim(exp, clockSkew);
assertActivationClaim(nbf, clockSkew);
assertIssuedAtClaim(iat, clockSkew);
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ async function resolveKeyAndVerifyJwt(
};
}

// Pass only the options declared on JwtMachineVerifyOptions. Callers (e.g. authenticateRequest)
// hand us wider option objects whose session-token claim options (issuer, audience,
// authorizedParties) must not be asserted against machine tokens, which carry different claims.
const { data: payload, errors: verifyErrors } = await verifyJwt(token, {
...options,
key,
clockSkewInMs: options.clockSkewInMs,
...(headerType ? { headerType } : {}),
});

Expand Down
47 changes: 47 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ describe('tokens.verify(token, options)', () => {
expect(data).toEqual(mockJwtPayload);
});

it('rejects the token when the provided issuer option does not match the iss claim', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const { data, errors } = await verifyToken(mockJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
issuer: 'https://clerk.another-app.com',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid-issuer');
});

it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => {
server.use(
http.get(
Expand Down Expand Up @@ -591,6 +613,31 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
});

it('ignores a session-token issuer option when verifying an M2M JWT', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const m2mJwt = await createSignedM2MJwt();

// issuer targets session tokens; machine tokens carry a different iss and must not be
// rejected by it (claim options must not leak through verifyMachineAuthToken).
const result = await verifyMachineAuthToken(m2mJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
issuer: 'https://clerk.inspired.puma-74.lcl.dev',
});

expect(result.tokenType).toBe('m2m_token');
expect(result.errors).toBeUndefined();
expect(result.data).toBeDefined();
});

it('rejects M2M JWT with alg: none', async () => {
server.use(
http.get(
Expand Down
Loading