Skip to content
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,
});

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