Skip to content
Merged
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-non-session-jwt-categories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject JWT-template tokens where a session or handshake token is expected. `authenticateRequest()` now returns a signed-out state with reason `token-type-mismatch` for such a token in the `Authorization` header or `__session` cookie. Tokens with no category tag, and instances configured to omit it, are unaffected.
3 changes: 2 additions & 1 deletion packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
} from '../errors';
import type { MachineTokenReturnType } from '../jwt/types';
import { verifyJwt } from '../jwt/verifyJwt';
import { JWT_CATEGORY_M2M_TOKEN } from '../tokens/jwtCategories';
import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys';
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys';
import { JWT_CATEGORY_M2M_TOKEN, OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine';
import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine';
import { TokenType } from '../tokens/tokenTypes';

export type JwtMachineVerifyOptions = Pick<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
Expand Down
66 changes: 66 additions & 0 deletions packages/backend/src/tokens/__tests__/handshakeToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';

import { mockJwks, mockRsaJwkKid, signingJwks } from '../../fixtures';
import { signJwt } from '../../jwt/signJwt';
import { server, validateHeaders } from '../../mock-server';
import { verifyHandshakeToken } from '../handshake';
import {
JWT_CATEGORY_IGNORE,
JWT_CATEGORY_JWT_TEMPLATE,
JWT_CATEGORY_M2M_TOKEN,
JWT_CATEGORY_SESSION_TOKEN,
} from '../jwtCategories';

const directives = ['__session=foo; Path=/'];

async function createHandshakeJwt(cat: string | undefined, handshake = directives) {
const { data } = await signJwt({ handshake }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: mockRsaJwkKid, ...(cat !== undefined ? { cat } : {}) },
});
return data!;
}

function verify(token: string) {
return verifyHandshakeToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});
}

describe('tokens.verifyHandshakeToken(token, options)', () => {
beforeEach(() => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => HttpResponse.json(mockJwks)),
),
);
});

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],
])('verifies a handshake token with %s', async (_label, cat) => {
await expect(verify(await createHandshakeJwt(cat))).resolves.toMatchObject({ handshake: directives });
});

// Regression test for AISEC-85. A JWT template is the one customer-authorable producer of a
// token carrying a top-level `handshake[]` claim, and resolveHandshake emits those entries
// verbatim as Set-Cookie.
it('rejects a JWT-template token presented as a handshake token', async () => {
const token = await createHandshakeJwt(JWT_CATEGORY_JWT_TEMPLATE, ['ATTACKER_INJECTED=pwned; Path=/']);

await expect(verify(token)).rejects.toThrowError('Invalid handshake token category.');
});

it.each([
['m2m', JWT_CATEGORY_M2M_TOKEN],
['unknown', 'cl_some_future_unknown_cat'],
])('rejects a handshake token with a %s category', async (_label, cat) => {
await expect(verify(await createHandshakeJwt(cat))).rejects.toThrowError('Invalid handshake token category.');
});
});
32 changes: 32 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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 { OrganizationMatcher } from '../organizationMatcher';
import { authenticateRequest, RefreshTokenErrorReason } from '../request';
import { type MachineTokenType, TokenType } from '../tokenTypes';
Expand Down Expand Up @@ -1306,6 +1307,37 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// Regression tests for SEC-340. A JWT-template token is signed by the same instance key and
// passes verifyToken(), but carries no `sid`, so it outlives revocation of the session that
// minted it and must not authenticate one.
describe.each([
['headerToken', (jwt: string) => mockRequestWithHeaderAuth({ authorization: jwt })],
[
'cookieToken',
(jwt: string) =>
mockRequestWithCookies(
{},
{ __clerk_db_jwt: 'deadbeef', __client_uat: `${mockJwtPayload.iat - 10}`, __session: jwt },
),
],
])('%s: JWT-template token presented as a session token (SEC-340)', (_label, buildRequest) => {
test('returns signed out', async () => {
const { sid: _sid, ...payloadWithoutSid } = mockJwtPayload;
const { data: templateJwt } = await signJwt(payloadWithoutSid, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_JWT_TEMPLATE },
});

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

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
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '../../fixtures/machine';
import { signJwt } from '../../jwt/signJwt';
import { server, validateHeaders } from '../../mock-server';
import { JWT_CATEGORY_M2M_TOKEN } from '../machine';
import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories';
import { verifyMachineAuthToken, verifyToken } from '../verify';

async function createSignedOAuthJwt(
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AuthenticateContext } from './authenticateContext';
import type { SignedInState, SignedOutState } from './authStatus';
import { AuthErrorReason, signedIn, signedOut } from './authStatus';
import { getCookieName, getCookieValue } from './cookie';
import { isNonSessionJwtCategory } from './jwtCategories';
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import type { OrganizationMatcher } from './organizationMatcher';
import { TokenType } from './tokenTypes';
Expand All @@ -28,6 +29,18 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro
assertHeaderType(typ);
assertHeaderAlgorithm(alg);

// 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)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid handshake token category.',
});
}

const { data: signatureValid, errors: signatureErrors } = await hasValidSignature(decoded, key);
if (signatureErrors) {
throw new TokenVerificationError({
Expand Down
25 changes: 25 additions & 0 deletions packages/backend/src/tokens/jwtCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { decodeJwt } from '../jwt/verifyJwt';

// Token-category tags in the protected JOSE header, distinguishing JWT classes signed by the
// same instance key. Kept in sync with clerk_go (pkg/jwt/jwt.go).
export const JWT_CATEGORY_SESSION_TOKEN = 'cl_B7d4PD111AAA';
export const JWT_CATEGORY_JWT_TEMPLATE = 'cl_B7d4PD222AAA';
export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA';
// Instances with `use_ignore_jwt_cat` stamp this on every class, so it carries no class info
// and must not be discriminated on.
export const JWT_CATEGORY_IGNORE = 'cl_I7d4PD111III';

/**
* Whether `cat` marks a JWT as something other than a session token. Handshake tokens are
* minted with the session-token category too. An absent `cat` is accepted for tokens minted
* before the category rollout.
*/
export function isNonSessionJwtCategory(cat?: string): boolean {
return cat !== undefined && cat !== JWT_CATEGORY_SESSION_TOKEN && cat !== JWT_CATEGORY_IGNORE;
}

/** Malformed tokens return `false`; signature verification is left to reject them. */
export function hasNonSessionJwtCategory(token: string): boolean {
const { data, errors } = decodeJwt(token);
return !errors && isNonSessionJwtCategory(data?.header?.cat);
}
5 changes: 0 additions & 5 deletions packages/backend/src/tokens/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ export const M2M_SUBJECT_PREFIX = 'mch_';
export const OAUTH_TOKEN_PREFIX = 'oat_';
export const API_KEY_PREFIX = 'ak_';

// Token-category tag in the protected JOSE header of instance-signed M2M JWTs,
// used to distinguish them from other JWT classes signed by the same instance
// key. Kept in sync with clerk_go (pkg/jwt) and cloudflare-workers.
export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA';

const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PREFIX] as const;

export const JwtFormatRegExp = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/;
Expand Down
21 changes: 14 additions & 7 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { HandshakeService } from './handshake';
import { hasNonSessionJwtCategory } from './jwtCategories';
import { getMachineTokenType, isMachineJwt, isMachineToken, isTokenTypeAccepted } from './machine';
import { OrganizationMatcher } from './organizationMatcher';
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
Expand Down Expand Up @@ -419,11 +420,12 @@ export const authenticateRequest: AuthenticateRequest = (async (
async function authenticateRequestWithTokenInHeader() {
const { tokenInHeader } = authenticateContext;

// Reject machine JWTs (OAuth or M2M) that may appear in headers when expecting session tokens.
// These are valid Clerk-signed JWTs and will pass verify() verification,
// but should not be accepted as session tokens.
// Reject JWTs of another class (machine tokens, JWT templates) presented where a session
// token is expected. They are validly signed by the same instance key and pass
// verifyToken(), but a JWT-template token carries no `sid` and outlives revocation of the
// session that minted it (SEC-340).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(tokenInHeader!)) {
if (isMachineJwt(tokenInHeader!) || hasNonSessionJwtCategory(tokenInHeader!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
Expand Down Expand Up @@ -631,9 +633,14 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
// Machine JWTs and JWT-template tokens pass verifyToken() but must not be accepted as
// session tokens (mirrors header path).
if (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
isMachineJwt(authenticateContext.sessionTokenInCookie!) ||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
hasNonSessionJwtCategory(authenticateContext.sessionTokenInCookie!)
) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import type { VerifyJwtOptions } from '../jwt';
import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types';
import { decodeJwt, verifyJwt } from '../jwt/verifyJwt';
import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt';
import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories';
import type { LoadClerkJWKFromRemoteOptions } from './keys';
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand Down
Loading