Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ac71cb0
feat(backend): Add support to JWTs in oauth token type
wobsoriano Nov 25, 2025
3c62ccf
Merge branch 'main' into rob/user-4076-oat
wobsoriano Nov 25, 2025
d914b61
chore: add changeset
wobsoriano Nov 25, 2025
e86c3ab
update changeset
wobsoriano Nov 25, 2025
bc57e96
remove duplicate jwt helper
wobsoriano Nov 25, 2025
1b6de49
Apply suggestion from @coderabbitai[bot]
wobsoriano Nov 25, 2025
bf6d6e2
fix issue with jti
wobsoriano Nov 25, 2025
8aad12b
Update packages/backend/src/fixtures/index.ts
wobsoriano Nov 25, 2025
41b1c73
chore: clean up condition
wobsoriano Nov 25, 2025
fed6afb
fix tests
wobsoriano Nov 25, 2025
f8645f3
Update packages/backend/src/tokens/__tests__/verify.test.ts
wobsoriano Nov 25, 2025
2eccc33
chore: Move jwt oauth verification logic to its own function
wobsoriano Nov 25, 2025
4550c8d
Update packages/backend/src/tokens/machine.ts
wobsoriano Nov 25, 2025
d0d47ba
chore: skip mocking verifyJwt
wobsoriano Nov 25, 2025
ac9c94d
chore: add test for oauth header type assertions
wobsoriano Nov 25, 2025
b54b14a
Merge branch 'main' into rob/user-4076-oat
wobsoriano Nov 25, 2025
78e9603
restore createJwt helper
wobsoriano Nov 26, 2025
9ceae69
Apply suggestion from @coderabbitai[bot]
wobsoriano Nov 26, 2025
41bdf1d
Merge branch 'main' into rob/user-4076-oat
wobsoriano Nov 26, 2025
0ddb1ab
dedupe
wobsoriano Nov 26, 2025
150bb33
Merge branch 'main' into rob/user-4076-oat
wobsoriano Nov 26, 2025
c2eb4a5
chore: format
wobsoriano Nov 26, 2025
c427316
chore: add machine helper unit tests
wobsoriano Nov 26, 2025
4c11786
Merge branch 'main' into rob/user-4076-oat
wobsoriano Nov 27, 2025
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/swift-sheep-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/backend": minor
---

Added support for JWTs in oauth token type
32 changes: 32 additions & 0 deletions packages/backend/src/api/resources/IdPOAuthAccessToken.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { JwtPayload } from '@clerk/types';

import type { IdPOAuthAccessTokenJSON } from './JSON';

type OAuthJwtPayload = JwtPayload & {
jti?: string;
client_id?: string;
scope?: string;
scp?: string[];
};

export class IdPOAuthAccessToken {
constructor(
readonly id: string,
Expand Down Expand Up @@ -30,4 +39,27 @@ export class IdPOAuthAccessToken {
data.updated_at,
);
}

/**
* Creates an IdPOAuthAccessToken from a JWT payload.
* Maps standard JWT claims and OAuth-specific fields to token properties.
*/
static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): IdPOAuthAccessToken {
const oauthPayload = payload as OAuthJwtPayload;

// Map JWT claims to IdPOAuthAccessToken fields
return new IdPOAuthAccessToken(
oauthPayload.jti ?? '',
oauthPayload.client_id ?? '',
'oauth_token',
payload.sub,
oauthPayload.scp ?? oauthPayload.scope?.split(' ') ?? [],
false,
null,
payload.exp * 1000 <= Date.now() - clockSkewInMs,
payload.exp,
payload.iat,
payload.iat,
);
}
}
19 changes: 16 additions & 3 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const MachineTokenVerificationErrorCode = {
TokenInvalid: 'token-invalid',
InvalidSecretKey: 'secret-key-invalid',
UnexpectedError: 'unexpected-error',
TokenVerificationFailed: 'token-verification-failed',
} as const;

export type MachineTokenVerificationErrorCode =
Expand All @@ -82,17 +83,29 @@ export type MachineTokenVerificationErrorCode =
export class MachineTokenVerificationError extends Error {
code: MachineTokenVerificationErrorCode;
long_message?: string;
status: number;
status?: number;
action?: TokenVerificationErrorAction;

constructor({ message, code, status }: { message: string; code: MachineTokenVerificationErrorCode; status: number }) {
constructor({
message,
code,
status,
action,
}: {
message: string;
code: MachineTokenVerificationErrorCode;
status?: number;
action?: TokenVerificationErrorAction;
}) {
super(message);
Object.setPrototypeOf(this, MachineTokenVerificationError.prototype);

this.code = code;
this.status = status;
this.action = action;
}

public getFullMessage() {
return `${this.message} (code=${this.code}, status=${this.status})`;
return `${this.message} (code=${this.code}, status=${this.status || 'n/a'})`;
}
}
12 changes: 12 additions & 0 deletions packages/backend/src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export const mockJwtPayload = {
sub: 'user_2GIpXOEpVyJw51rkZn9Kmnc6Sxr',
};

export const mockOAuthAccessTokenJwtPayload = {
...mockJwtPayload,
iss: 'https://clerk.oauth.example.test',
sub: 'user_2vYVtestTESTtestTESTtestTESTtest',
client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc',
scope: 'read:foo write:bar',
jti: 'oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
exp: mockJwtPayload.iat + 300,
iat: mockJwtPayload.iat,
nbf: mockJwtPayload.iat - 10,
};

export const mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD';

export const mockRsaJwk = {
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/fixtures/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,17 @@ export const mockMachineAuthResponses = {
errorMessage: 'Machine token not found',
},
} as const;

// Valid OAuth access token JWT with typ: "at+jwt"
// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"at+jwt"}
// Payload: {"client_id":"client_2VTWUzvGC5UhdJCNx6xG1D98edc","sub":"user_2vYVtestTESTtestTESTtestTESTtest","jti":"oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE","iat":1666648250,"exp":1666648550,"scope":"read:foo write:bar"}
// Signed with signingJwks, verifiable with mockJwks
export const mockSignedOAuthAccessTokenJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJhdCtqd3QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODU1MCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLm9hdXRoLmV4YW1wbGUudGVzdCIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJ2WVZ0ZXN0VEVTVHRlc3RURVNUdGVzdFRFU1R0ZXN0IiwiY2xpZW50X2lkIjoiY2xpZW50XzJWVFdVenZHQzVVaGRKQ054NnhHMUQ5OGVkYyIsInNjb3BlIjoicmVhZDpmb28gd3JpdGU6YmFyIiwianRpIjoib2F0XzJ4S2E5Qmd2N054TVJERnlRdzhMcFozY1RtVTF2SGpFIn0.Wgw5L2u0nGkxF9Y-5Dje414UEkxq2Fu3_VePeh1-GehCugi0eIXV-QyiXp1ba4pxWWbCfIC_hihzKjwnVb5wrhzqyw8FJpvnvtrHEjt-zSijpS7WlO7ScJDY-PE8zgH-CICnS2CKYSkP3Rbzka9XY_Z6ieUzmBSFdA_0K8pQOdDHv70y04dnL1CjL6XToncnvezioL388Y1UTqlhll8b2Pm4EI7rGdHVKzLcKnKoYpgsBPZLmO7qGPJ5BkHvmg3gOSkmIiziFaEZkoXvjbvEUAt5qEqzaADSaFP6QhRYNtr1s4OD9uj0SK6QaoZTj69XYFuNMNnm7zN_WxvPBMTq9g';

// Valid OAuth access token JWT with typ: "application/at+jwt"
// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"application/at+jwt"}
// Payload: {"client_id":"client_2VTWUzvGC5UhdJCNx6xG1D98edc","sub":"user_2vYVtestTESTtestTESTtestTESTtest","jti":"oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE","iat":1666648250,"exp":1666648550,"scope":"read:foo write:bar"}
// Signed with signingJwks, verifiable with mockJwks
export const mockSignedOAuthAccessTokenJwtApplicationTyp =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJhcHBsaWNhdGlvbi9hdCtqd3QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODU1MCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLm9hdXRoLmV4YW1wbGUudGVzdCIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJ2WVZ0ZXN0VEVTVHRlc3RURVNUdGVzdFRFU1R0ZXN0IiwiY2xpZW50X2lkIjoiY2xpZW50XzJWVFdVenZHQzVVaGRKQ054NnhHMUQ5OGVkYyIsInNjb3BlIjoicmVhZDpmb28gd3JpdGU6YmFyIiwianRpIjoib2F0XzJ4S2E5Qmd2N054TVJERnlRdzhMcFozY1RtVTF2SGpFIn0.GPTvB4doScjzQD0kRMhMebVDREjwcrMWK73OP_kFc3pl0gST29BlWrKMBi8wRxoSJBc2ukO10BPhGxnh15PxCNLyk6xQFWhFBA7XpVxY4T_VHPDU5FEOocPQuqcqZ4cA1GDJST-BH511fxoJnv4kfha46IvQiUMvWCacIj_w12qfZigeb208mTDIeoJQtlYb-sD9u__CVvB4uZOqGb0lIL5-cCbhMPFg-6GQ2DhZ-Eq5tw7oyO6lPrsAaFN9u-59SLvips364ieYNpgcr9Dbo5PDvUSltqxoIXTDFo4esWw6XwUjnGfqCh34LYAhv_2QF2U0-GASBEn4GK-Wfv3wXg';
36 changes: 34 additions & 2 deletions packages/backend/src/jwt/__tests__/assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,47 @@ describe('assertAudienceClaim(audience?, aud?)', () => {
});
});

describe('assertHeaderType(typ?)', () => {
describe('assertHeaderType(typ?, allowedTypes?)', () => {
it('does not throw error if type is missing', () => {
expect(() => assertHeaderType(undefined)).not.toThrow();
expect(() => assertHeaderType(undefined, 'JWT')).not.toThrow();
expect(() => assertHeaderType(undefined, ['JWT', 'at+jwt'])).not.toThrow();
});

it('throws error if type is not JWT', () => {
it('does not throw error if type matches default allowed type (JWT)', () => {
expect(() => assertHeaderType('JWT')).not.toThrow();
});

it('throws error if type is not JWT (default)', () => {
expect(() => assertHeaderType('')).toThrow(`Invalid JWT type "". Expected "JWT".`);
expect(() => assertHeaderType('Aloha')).toThrow(`Invalid JWT type "Aloha". Expected "JWT".`);
});

it('does not throw error if type matches single custom allowed type', () => {
expect(() => assertHeaderType('at+jwt', 'at+jwt')).not.toThrow();
expect(() => assertHeaderType('application/at+jwt', 'application/at+jwt')).not.toThrow();
});

it('throws error if type does not match single custom allowed type', () => {
expect(() => assertHeaderType('JWT', 'at+jwt')).toThrow(`Invalid JWT type "JWT". Expected "at+jwt".`);
expect(() => assertHeaderType('at+jwt', 'JWT')).toThrow(`Invalid JWT type "at+jwt". Expected "JWT".`);
});

it('does not throw error if type matches array of allowed types', () => {
expect(() => assertHeaderType('JWT', ['JWT', 'at+jwt'])).not.toThrow();
expect(() => assertHeaderType('at+jwt', ['JWT', 'at+jwt'])).not.toThrow();
expect(() => assertHeaderType('at+jwt', ['at+jwt', 'application/at+jwt'])).not.toThrow();
expect(() => assertHeaderType('application/at+jwt', ['at+jwt', 'application/at+jwt'])).not.toThrow();
});

it('throws error if type does not match any in array of allowed types', () => {
expect(() => assertHeaderType('JWT', ['at+jwt', 'application/at+jwt'])).toThrow(
`Invalid JWT type "JWT". Expected "at+jwt, application/at+jwt".`,
);
expect(() => assertHeaderType('invalid', ['at+jwt', 'application/at+jwt'])).toThrow(
`Invalid JWT type "invalid". Expected "at+jwt, application/at+jwt".`,
);
});
});

describe('assertHeaderAlgorithm(alg)', () => {
Expand Down
88 changes: 88 additions & 0 deletions packages/backend/src/jwt/__tests__/verifyJwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
createJwt,
mockJwks,
mockJwt,
mockJwtHeader,
mockJwtPayload,
mockOAuthAccessTokenJwtPayload,
pemEncodedPublicKey,
publicJwks,
signedJwt,
someOtherPublicKey,
} from '../../fixtures';
import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine';
import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt';

const invalidTokenError = {
Expand Down Expand Up @@ -129,4 +132,89 @@ describe('verifyJwt(jwt, options)', () => {
const { errors: [error] = [] } = await verifyJwt('invalid-jwt', inputVerifyJwtOptions);
expect(error).toMatchObject(invalidTokenError);
});

it('verifies JWT with default headerType (JWT)', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
issuer: mockJwtPayload.iss,
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
};
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
expect(data).toEqual(mockJwtPayload);
});

it('verifies JWT with explicit headerType as string', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
issuer: mockJwtPayload.iss,
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: 'JWT',
};
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
expect(data).toEqual(mockJwtPayload);
});

it('verifies OAuth JWT with headerType as array including at+jwt', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: ['at+jwt', 'application/at+jwt'],
};
const { data } = await verifyJwt(mockSignedOAuthAccessTokenJwt, inputVerifyJwtOptions);
expect(data).toBeDefined();
expect(data?.sub).toBe('user_2vYVtestTESTtestTESTtestTESTtest');
});

it('verifies OAuth JWT with headerType as array including application/at+jwt', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: ['at+jwt', 'application/at+jwt'],
};
const { data } = await verifyJwt(mockSignedOAuthAccessTokenJwtApplicationTyp, inputVerifyJwtOptions);
expect(data).toBeDefined();
expect(data?.sub).toBe('user_2vYVtestTESTtestTESTtestTESTtest');
});

it('rejects JWT when headerType does not match', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
issuer: mockJwtPayload.iss,
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: 'at+jwt',
};
const { errors: [error] = [] } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
expect(error).toBeDefined();
expect(error?.message).toContain('Invalid JWT type');
expect(error?.message).toContain('Expected "at+jwt"');
});

it('rejects OAuth JWT when headerType does not match', async () => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: 'JWT',
};
const { errors: [error] = [] } = await verifyJwt(mockSignedOAuthAccessTokenJwt, inputVerifyJwtOptions);
expect(error).toBeDefined();
expect(error?.message).toContain('Invalid JWT type');
expect(error?.message).toContain('Expected "JWT"');
});

it('rejects JWT when headerType array does not include the token type', async () => {
const jwtWithCustomTyp = createJwt({
header: { typ: 'custom-type', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
payload: mockOAuthAccessTokenJwtPayload,
});

const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
headerType: ['at+jwt', 'application/at+jwt'],
};
const { errors: [error] = [] } = await verifyJwt(jwtWithCustomTyp, inputVerifyJwtOptions);
expect(error).toBeDefined();
expect(error?.message).toContain('Invalid JWT type');
expect(error?.message).toContain('Expected "at+jwt, application/at+jwt"');
});
});
7 changes: 4 additions & 3 deletions packages/backend/src/jwt/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,17 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => {
}
};

export const assertHeaderType = (typ?: unknown) => {
export const assertHeaderType = (typ?: unknown, allowedTypes: string | string[] = 'JWT') => {
if (typeof typ === 'undefined') {
return;
}

if (typ !== 'JWT') {
const allowed = Array.isArray(allowedTypes) ? allowedTypes : [allowedTypes];
if (!allowed.includes(typ as string)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: `Invalid JWT type ${JSON.stringify(typ)}. Expected "JWT".`,
message: `Invalid JWT type ${JSON.stringify(typ)}. Expected "${allowed.join(', ')}".`,
});
}
};
Expand Down
9 changes: 7 additions & 2 deletions packages/backend/src/jwt/verifyJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,18 @@ export type VerifyJwtOptions = {
* @internal
*/
key: JsonWebKey | string;
/**
* A string or list of allowed [header types](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9).
* @default 'JWT'
*/
headerType?: string | string[];
};

export async function verifyJwt(
token: string,
options: VerifyJwtOptions,
): Promise<JwtReturnType<JwtPayload, TokenVerificationError>> {
const { audience, authorizedParties, clockSkewInMs, key } = options;
const { audience, authorizedParties, clockSkewInMs, key, headerType } = options;
const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_MS;

const { data: decoded, errors } = decodeJwt(token);
Expand All @@ -138,7 +143,7 @@ export async function verifyJwt(
// Header verifications
const { typ, alg } = header;

assertHeaderType(typ);
assertHeaderType(typ, headerType);
assertHeaderAlgorithm(alg);

// Payload verifications
Expand Down
Loading
Loading