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/three-things-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': major
---

Add validation to require `azp` claim in cookie-based session tokens. Tokens from cookies that are missing the `azp` (authorized party) claim will now return a signed-out state with reason `token-missing-azp`.
1 change: 1 addition & 0 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const TokenVerificationErrorReason = {
TokenInvalid: 'token-invalid',
TokenInvalidAlgorithm: 'token-invalid-algorithm',
TokenInvalidAuthorizedParties: 'token-invalid-authorized-parties',
TokenMissingAzp: 'token-missing-azp',
TokenInvalidSignature: 'token-invalid-signature',
TokenNotActiveYet: 'token-not-active-yet',
TokenIatInTheFuture: 'token-iat-in-the-future',
Expand Down
132 changes: 132 additions & 0 deletions packages/backend/src/tokens/__tests__/request_azp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, test, vi } from 'vitest';

import { TokenVerificationErrorReason } from '../../errors';
import { decodeJwt } from '../../jwt/verifyJwt';
import { authenticateRequest } from '../request';
import { verifyToken } from '../verify';

vi.mock('../verify', () => ({
verifyToken: vi.fn(),
verifyMachineAuthToken: vi.fn(),
}));

vi.mock('../../jwt/verifyJwt', () => ({
decodeJwt: vi.fn(),
}));

describe('authenticateRequest with cookie token', () => {
test('throws TokenMissingAzp when azp claim is missing', async () => {
const payload = {
sub: 'user_123',
sid: 'sess_123',
iat: 1234567891,
exp: 1234567991,
// azp is missing
};

// Mock verifyToken to return a payload without azp
vi.mocked(verifyToken).mockResolvedValue({
data: payload as any,
errors: undefined,
});

// Mock decodeJwt to return the same payload
vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
errors: undefined,
});

Comment on lines +19 to +38
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Blocker: secret-looking test keys (Gitleaks) + as any casts may fail CI

  • Line 46 / Line 87 / Line 125: the pk_live_... literal is being detected as an API key; this is likely to break security scanning (and shouldn’t be in tests).
  • Line 29 / Line 35 / Line 70 / Line 76 / Line 108 / Line 114: as any can trip no-explicit-any (common in TS repos) and fail lint.
Proposed fix
 import { describe, expect, test, vi } from 'vitest';

 import { TokenVerificationErrorReason } from '../../errors';
 import { decodeJwt } from '../../jwt/verifyJwt';
 import { authenticateRequest } from '../request';
 import { verifyToken } from '../verify';

+type VerifyTokenResult = Awaited<ReturnType<typeof verifyToken>>;
+type DecodeJwtResult = ReturnType<typeof decodeJwt>;
+
+const testKeys = {
+  // Avoid secret-scanner false-positives while still resembling expected formats.
+  publishableKey: 'pk_test_' + 'local_testing_key',
+  secretKey: 'sk_test_' + 'local_testing_key',
+} as const;
+
 vi.mock('../verify', () => ({
   verifyToken: vi.fn(),
   verifyMachineAuthToken: vi.fn(),
 }));

 vi.mock('../../jwt/verifyJwt', () => ({
   decodeJwt: vi.fn(),
 }));

 describe('authenticateRequest with cookie token', () => {
   test('throws TokenMissingAzp when azp claim is missing', async () => {
     const payload = {
       sub: 'user_123',
       sid: 'sess_123',
       iat: 1234567891,
       exp: 1234567991,
       // azp is missing
     };

     // Mock verifyToken to return a payload without azp
     vi.mocked(verifyToken).mockResolvedValue({
-      data: payload as any,
+      data: payload as unknown as VerifyTokenResult['data'],
       errors: undefined,
     });

     // Mock decodeJwt to return the same payload
     vi.mocked(decodeJwt).mockReturnValue({
-      data: { payload } as any,
+      data: { payload } as unknown as DecodeJwtResult extends { data: infer D } ? D : never,
       errors: undefined,
     });

     const request = new Request('http://localhost:3000', {
       headers: {
         cookie: '__session=mock_token; __client_uat=1234567890',
       },
     });

-    const options = {
-      publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
-      secretKey: 'sk_live_deadbeef',
-    };
+    const options = testKeys;

     const result = await authenticateRequest(request, options);

     expect(result.status).toBe('signed-out');
     expect(result.reason).toBe(TokenVerificationErrorReason.TokenMissingAzp);
     expect(result.message).toBe(
       'Session tokens from cookies must have an azp claim. (reason=token-missing-azp, token-carrier=cookie)',
     );
   });

   test('succeeds when azp claim is present', async () => {
     const payload = {
       sub: 'user_123',
       sid: 'sess_123',
       iat: 1234567891,
       exp: 1234567991,
       azp: 'http://localhost:3000',
     };

     // Mock verifyToken to return a payload with azp
     vi.mocked(verifyToken).mockResolvedValue({
-      data: payload as any,
+      data: payload as unknown as VerifyTokenResult['data'],
       errors: undefined,
     });

     // Mock decodeJwt to return the same payload
     vi.mocked(decodeJwt).mockReturnValue({
-      data: { payload } as any,
+      data: { payload } as unknown as DecodeJwtResult extends { data: infer D } ? D : never,
       errors: undefined,
     });

     const request = new Request('http://localhost:3000', {
       headers: {
         cookie: '__session=mock_token; __client_uat=1234567890',
       },
     });

-    const options = {
-      publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
-      secretKey: 'sk_live_deadbeef',
-    };
+    const options = testKeys;

     const result = await authenticateRequest(request, options);
     expect(result.isSignedIn).toBe(true);
   });
 });

 describe('authenticateRequest with header token', () => {
   test('succeeds when azp claim is missing', async () => {
     const payload = {
       sub: 'user_123',
       sid: 'sess_123',
       iat: 1234567891,
       exp: 1234567991,
       // azp is missing
     };

     // Mock verifyToken to return a payload without azp
     vi.mocked(verifyToken).mockResolvedValue({
-      data: payload as any,
+      data: payload as unknown as VerifyTokenResult['data'],
       errors: undefined,
     });

     // Mock decodeJwt to return the same payload
     vi.mocked(decodeJwt).mockReturnValue({
-      data: { payload } as any,
+      data: { payload } as unknown as DecodeJwtResult extends { data: infer D } ? D : never,
       errors: undefined,
     });

     const request = new Request('http://localhost:3000', {
       headers: {
         authorization: 'Bearer mock_token',
       },
     });

-    const options = {
-      publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
-      secretKey: 'sk_live_deadbeef',
-    };
+    const options = testKeys;

     const result = await authenticateRequest(request, options);
     expect(result.isSignedIn).toBe(true);
   });
 });

Also applies to: 45-49, 60-79, 86-90, 98-117, 124-128

🤖 Prompt for AI Agents
In @packages/backend/src/tokens/__tests__/request_azp.test.ts around lines 19 -
38, Replace secret-looking literals and remove unsafe `as any` casts in the
tests: use clearly non-secret placeholder strings (e.g., "pk_test_placeholder"
or generated test token) instead of any "pk_live_..." literal, and replace `as
any` by properly typing the test data and mock returns — declare the payload
with a specific type or Partial (e.g., `const payload:
Partial<YourJwtPayloadType> = {...}`) and cast mock results with `as unknown as
ExpectedReturnType` or use generics on `vi.mocked` to satisfy the compiler;
update the `vi.mocked(verifyToken).mockResolvedValue(...)` and
`vi.mocked(decodeJwt).mockReturnValue(...)` calls to return correctly typed
objects rather than using `as any`, and ensure all token literals are
non-sensitive placeholders.

const request = new Request('http://localhost:3000', {
headers: {
cookie: '__session=mock_token; __client_uat=1234567890',
},
});

const options = {
publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
secretKey: 'sk_live_deadbeef',
};

const result = await authenticateRequest(request, options);

expect(result.status).toBe('signed-out');
expect(result.reason).toBe(TokenVerificationErrorReason.TokenMissingAzp);
expect(result.message).toBe(
'Session tokens from cookies must have an azp claim. (reason=token-missing-azp, token-carrier=cookie)',
);
});

test('succeeds when azp claim is present', async () => {
const payload = {
sub: 'user_123',
sid: 'sess_123',
iat: 1234567891,
exp: 1234567991,
azp: 'http://localhost:3000',
};

// Mock verifyToken to return a payload with azp
vi.mocked(verifyToken).mockResolvedValue({
data: payload as any,
errors: undefined,
});

// Mock decodeJwt to return the same payload
vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
errors: undefined,
});

const request = new Request('http://localhost:3000', {
headers: {
cookie: '__session=mock_token; __client_uat=1234567890',
},
});

const options = {
publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
secretKey: 'sk_live_deadbeef',
};

const result = await authenticateRequest(request, options);
expect(result.isSignedIn).toBe(true);
});
});

describe('authenticateRequest with header token', () => {
test('succeeds when azp claim is missing', async () => {
const payload = {
sub: 'user_123',
sid: 'sess_123',
iat: 1234567891,
exp: 1234567991,
// azp is missing
};

// Mock verifyToken to return a payload without azp
vi.mocked(verifyToken).mockResolvedValue({
data: payload as any,
errors: undefined,
});

// Mock decodeJwt to return the same payload
vi.mocked(decodeJwt).mockReturnValue({
data: { payload } as any,
errors: undefined,
});

const request = new Request('http://localhost:3000', {
headers: {
authorization: 'Bearer mock_token',
},
});

const options = {
publishableKey: 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA',
secretKey: 'sk_live_deadbeef',
};

const result = await authenticateRequest(request, options);
expect(result.isSignedIn).toBe(true);
});
});
7 changes: 7 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,13 @@ export const authenticateRequest: AuthenticateRequest = (async (
throw errors[0];
}

if (!data.azp) {
throw new TokenVerificationError({
reason: TokenVerificationErrorReason.TokenMissingAzp,
message: 'Session tokens from cookies must have an azp claim.',
});
}

const signedInRequestState = signedIn({
tokenType: TokenType.SessionToken,
authenticateContext,
Expand Down
Loading