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/reject-uncategorized-session-jwts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

JWTs without a `cat` header are no longer accepted as session tokens: `authenticateRequest()` returns signed out (`token-type-mismatch`) and `verifyToken()` returns `token-invalid`. This includes OIDC `id_token`s issued by Clerk. Tests that sign their own session tokens must add `cat: 'cl_B7d4PD111AAA'` to the header.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This could be breaking some people's tests

2 changes: 1 addition & 1 deletion integration/testUtils/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'l
return {
token: jwt.sign(claims, rsa.private, {
algorithm: 'RS256',
header: { kid: ins_id },
header: { kid: ins_id, cat: 'cl_B7d4PD111AAA' },
}),
claims,
};
Expand Down
12 changes: 7 additions & 5 deletions packages/backend/src/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { JWT_CATEGORY_SESSION_TOKEN } from '../tokens/jwtCategories';
import { base64url } from '../util/rfc4648';

// signed with signingJwks
export const mockJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.ByJ10T3PqSo2BEHr384QSitFK2_dyIwt1AGILFwwCecVN_BHOMF44zjBBTeW1118dkdpTMYM7pe43NEmvjQqXbyESv_AjB11B-2jwt54zPoKJyYRHxSNli4vbXXixFvXMIcYEHf-vpnoiUVjtjcHt5A72fJZI-Cs-C_Xschqti5aYm2CzFbNBlNdUaychFolZ7VlggKy9wGBTaIJDZbZX-hBWOSXotZittAEgH_W6yUtc3Qq5UlRgCGSL-ESmu-YTXFXQpq5oRg8JPWTvY1prixi8PAMZ8HWJl2VpEcEd8zysgAcBzIET_ktiRMzs2DjioOZmO10_TTkgpd7ARImXg';

// signed with signingJwks, same as mockJwt but with lower iat and exp values
export const mockExpiredJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODIwMCwiaWF0IjoxNjY2NjQ3MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.jLImjg2vGwOJDkK9gtIeJnEJWVOgoCMeC46OFtfzJ1d8OT0KVvwRppC60QIMfHKoOwLTLYlq8SccrkARwlJ_jOvMAYMGZT-R4qHoEfGmet1cSTC67zaafq5gpf9759x1kNMyckry_PJNSx-9hTFbBMWhY7XVLVlrauppqHXOQr1-BC7u-0InzKjCQTCJj-81Yt8xRKweLbO689oYSRAFYK5LNH8BYoLZFWuWLO-6nxUJu0_XAq9xpZPqZOqj3LxFS4hHVGGmTqnPgR8vBetLXxSLAOBsEyIkeQkOBA03YA6enTNIppmy0XTLgAYmUO_JWOGjjjDQoEojuXtuLRdQHQ';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODIwMCwiaWF0IjoxNjY2NjQ3MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.DvtvOiys4VMrVaGCIKsJwN636zYlarVxqoK6LdzM4D-FE31uMYEslj59GMJS2_MdudSdzFs3Dr-CrPAF1kd65BYKl9S_F-U8xRAdEzbkaYOEBWHWRqeip2rb62o3Kz9jPNfVozZDW2SGxAKcBO4UWcoe_lUNpOzteYsx5SXA2K0zUVlzqQulhqUazAZgdb-yP6Is_rq9owG9iwLt36ac3s9ln6a2GhNt-fPNLQLwTKFRxGBsl1PMbSHME6sKtoaaGvXrQbaP5_GAVQ2xp6gXHlnG3KqR4lZ9OaQDZGpZOBzKzXhFHn8ZA4--_P6dTHta9NbtFt8y9tPOh5jhUer-GA';

export const mockInvalidSignatureJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLnRhbXBlcmVkLWRvbWFpbi5kZXYiLCJleHAiOjE2NjY2NDgzMTAsImlhdCI6MTY2NjY0ODI1MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5pbnNwaXJlZC5wdW1hLTc0LmxjbC5kZXYiLCJuYmYiOjE2NjY2NDgyNDAsInNpZCI6InNlc3NfMkdiREI0ZW5OZENhNXZTMXpwQzNYemc5dEs5Iiwic3ViIjoidXNlcl8yR0lwWE9FcFZ5Snc1MXJrWm45S21uYzZTeHIifQ.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLnRhbXBlcmVkLWRvbWFpbi5kZXYiLCJleHAiOjE2NjY2NDgzMTAsImlhdCI6MTY2NjY0ODI1MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5pbnNwaXJlZC5wdW1hLTc0LmxjbC5kZXYiLCJuYmYiOjE2NjY2NDgyNDAsInNpZCI6InNlc3NfMkdiREI0ZW5OZENhNXZTMXpwQzNYemc5dEs5Iiwic3ViIjoidXNlcl8yR0lwWE9FcFZ5Snc1MXJrWm45S21uYzZTeHIifQ.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';

export const mockMalformedJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2NjY2NDgyNTB9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJpYXQiOjE2NjY2NDgyNTB9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';

const mockJwtSignature =
'n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';
Expand All @@ -21,6 +22,7 @@ export const mockJwtHeader = {
alg: 'RS256',
kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD',
typ: 'JWT',
cat: JWT_CATEGORY_SESSION_TOKEN,
};

export const mockJwtPayload = {
Expand Down Expand Up @@ -170,7 +172,7 @@ export const publicJwks = {

// this jwt has be signed with the keys above. The payload is mockJwtPayload and the header is mockJwtHeader
export const signedJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.ByJ10T3PqSo2BEHr384QSitFK2_dyIwt1AGILFwwCecVN_BHOMF44zjBBTeW1118dkdpTMYM7pe43NEmvjQqXbyESv_AjB11B-2jwt54zPoKJyYRHxSNli4vbXXixFvXMIcYEHf-vpnoiUVjtjcHt5A72fJZI-Cs-C_Xschqti5aYm2CzFbNBlNdUaychFolZ7VlggKy9wGBTaIJDZbZX-hBWOSXotZittAEgH_W6yUtc3Qq5UlRgCGSL-ESmu-YTXFXQpq5oRg8JPWTvY1prixi8PAMZ8HWJl2VpEcEd8zysgAcBzIET_ktiRMzs2DjioOZmO10_TTkgpd7ARImXg';

export const pkTest = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
export const pkLive = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('../verify.js', async importOriginal => {
vi.mock('../../jwt/verifyJwt.js', () => ({
decodeJwt: vi.fn().mockReturnValue({
data: {
header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid' },
header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid', cat: 'cl_B7d4PD111AAA' },
payload: {
sub: 'user_123',
__raw: 'raw-token',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('tokens.verifyHandshakeToken(token, options)', () => {
it.each([
['the session-token category, which is what the handshake minter stamps', JWT_CATEGORY_SESSION_TOKEN],
['the ignore category, used by instances that opt out of category tagging', JWT_CATEGORY_IGNORE],
['no category, for tokens minted before the category rollout', undefined],
['no category, which the edge session-minter handshake signer does not stamp', undefined],
])('verifies a handshake token with %s', async (_label, cat) => {
await expect(verify(await createHandshakeJwt(cat))).resolves.toMatchObject({ handshake: directives });
});
Expand Down
43 changes: 40 additions & 3 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { signJwt } from '../../jwt/signJwt';
import { server } from '../../mock-server';
import type { AuthReason } from '../authStatus';
import { AuthErrorReason, AuthStatus } from '../authStatus';
import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories';
import { JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_M2M_TOKEN, JWT_CATEGORY_SESSION_TOKEN } from '../jwtCategories';
import { OrganizationMatcher } from '../organizationMatcher';
import { authenticateRequest, RefreshTokenErrorReason } from '../request';
import { type MachineTokenType, TokenType } from '../tokenTypes';
Expand Down Expand Up @@ -1234,7 +1234,7 @@ describe('tokens.authenticateRequest(options)', () => {
const { sub: _, ...payloadWithoutSub } = mockJwtPayload;
const { data: malformedJwt } = await signJwt(payloadWithoutSub, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_SESSION_TOKEN },
});

const requestState = await authenticateRequest(
Expand Down Expand Up @@ -1285,7 +1285,7 @@ describe('tokens.authenticateRequest(options)', () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_M2M_TOKEN },
});

const requestState = await authenticateRequest(
Expand Down Expand Up @@ -1346,6 +1346,43 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

describe.each([
['headerToken', templateInHeader, {}],
[
'cookieToken',
(jwt: string) =>
mockRequestWithCookies(
{},
{ __clerk_db_jwt: 'deadbeef', __client_uat: `${mockJwtPayload.iat - 10}`, __session: jwt },
),
{},
],
['headerToken, acceptsToken: any', templateInHeader, { acceptsToken: 'any' as const }],
[
'headerToken, acceptsToken: [session_token, m2m_token]',
templateInHeader,
{ acceptsToken: ['session_token', 'm2m_token'] as const },
],
])('%s: OIDC id_token presented as a session token (AISEC-21)', (_label, buildRequest, options) => {
test('returns signed out', async () => {
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
const { azp: _azp, ...payloadWithoutAzp } = mockJwtPayload;
const { data: idToken } = await signJwt(
{ ...payloadWithoutAzp, aud: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc' },
signingJwks,
{ algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' } },
);

const requestState = await authenticateRequest(buildRequest(idToken!), mockOptions(options));

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
7 changes: 4 additions & 3 deletions packages/backend/src/tokens/__tests__/request_azp.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from 'vitest';

import { decodeJwt } from '../../jwt/verifyJwt';
import { JWT_CATEGORY_SESSION_TOKEN } from '../jwtCategories';
import { authenticateRequest } from '../request';
import { verifyToken } from '../verify';

Expand Down Expand Up @@ -31,7 +32,7 @@ describe('authenticateRequest with cookie token', () => {
});

vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any,
errors: undefined,
});

Expand Down Expand Up @@ -79,7 +80,7 @@ describe('authenticateRequest with cookie token', () => {
});

vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any,
errors: undefined,
});

Expand Down Expand Up @@ -121,7 +122,7 @@ describe('authenticateRequest with header token', () => {
});

vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any,
errors: undefined,
});

Expand Down
63 changes: 56 additions & 7 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
} from '../../fixtures/machine';
import { signJwt } from '../../jwt/signJwt';
import { server, validateHeaders } from '../../mock-server';
import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories';
import {
JWT_CATEGORY_IGNORE,
JWT_CATEGORY_JWT_TEMPLATE,
JWT_CATEGORY_M2M_TOKEN,
JWT_CATEGORY_SESSION_TOKEN,
} from '../jwtCategories';
import { verifyMachineAuthToken, verifyToken } from '../verify';

async function createSignedOAuthJwt(
Expand All @@ -35,11 +40,11 @@ async function createSignedOAuthJwt(

async function createSignedM2MJwt(
payload: Record<string, unknown> = mockM2MJwtPayload,
cat: string | undefined = JWT_CATEGORY_M2M_TOKEN,
cat: string | null = JWT_CATEGORY_M2M_TOKEN,
) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) },
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== null ? { cat } : {}) },
});
return data!;
}
Expand Down Expand Up @@ -146,6 +151,50 @@ describe('tokens.verify(token, options)', () => {
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});

it('rejects an OIDC id_token, which carries no cat header, before key resolution (AISEC-21)', async () => {
const { azp: _azp, ...payloadWithoutAzp } = mockJwtPayload;
const token = await createSignedM2MJwt({ ...payloadWithoutAzp, aud: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc' }, null);

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});

it('still verifies a JWT-template token for direct callers', async () => {
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
const { sid: _sid, ...payloadWithoutSid } = mockJwtPayload;
const token = await createSignedM2MJwt(payloadWithoutSid, JWT_CATEGORY_JWT_TEMPLATE);

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(errors).toBeUndefined();
expect(data).toEqual(payloadWithoutSid);
});

it('verifies a session JWT carrying the ignore category', async () => {
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
const token = await createSignedM2MJwt(mockJwtPayload, JWT_CATEGORY_IGNORE);

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(errors).toBeUndefined();
expect(data).toEqual(mockJwtPayload);
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down Expand Up @@ -877,7 +926,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
);

const m2mJwt = createJwt({
header: { typ: 'JWT', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
header: { typ: 'JWT', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_M2M_TOKEN },
payload: mockM2MJwtPayload,
});

Expand Down Expand Up @@ -926,7 +975,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
),
);

const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, undefined);
const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, null);

const result = await verifyMachineAuthToken(m2mJwt, {
apiUrl: 'https://api.clerk.test',
Expand All @@ -938,8 +987,8 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
});

describe.each([
['session-token', 'cl_B7d4PD111AAA'],
['jwt-template', 'cl_B7d4PD222AAA'],
['session-token', JWT_CATEGORY_SESSION_TOKEN],
['jwt-template', JWT_CATEGORY_JWT_TEMPLATE],
['unknown', 'cl_some_future_unknown_cat'],
])('rejects M2M JWT masquerading with a non-M2M cat', (label, cat) => {
it(`rejects cat=${label} without attempting signature verification`, async () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/tokens/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro
// Handshake tokens are minted with the session-token category, so any other class signed by
// the same instance key is not one. Without this a JWT-template token passes, and a template
// can carry an author-controlled top-level `handshake[]` claim that resolveHandshake emits
// verbatim as Set-Cookie (AISEC-85).
if (isNonSessionJwtCategory(header.cat)) {
// verbatim as Set-Cookie (AISEC-85). An absent category is still tolerated here, unlike the
// session paths, because the edge session-minter signs handshake tokens without one.
if (header.cat !== undefined && isNonSessionJwtCategory(header.cat)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
Expand Down
Loading
Loading