Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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'})`;
}
}
45 changes: 31 additions & 14 deletions packages/backend/src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ 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_2VTWUzvGC5UhdJCNx6xG1D98edc',
exp: mockJwtPayload.iat + 300,
iat: mockJwtPayload.iat,
nbf: mockJwtPayload.iat - 10,
};

type CreateJwt = (opts?: { header?: any; payload?: any; signature?: string }) => string;
export const createJwt: CreateJwt = ({ header, payload, signature = mockJwtSignature } = {}) => {
const encoder = new TextEncoder();

const stringifiedHeader = JSON.stringify({ ...mockJwtHeader, ...header });
const stringifiedPayload = JSON.stringify({ ...mockJwtPayload, ...payload });

return [
base64url.stringify(encoder.encode(stringifiedHeader), { pad: false }),
base64url.stringify(encoder.encode(stringifiedPayload), { pad: false }),
signature,
].join('.');
};

export const mockOAuthAccessTokenJwt = createJwt({
header: { typ: 'at+jwt' },
payload: mockOAuthAccessTokenJwtPayload,
});

export const mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD';

export const mockRsaJwk = {
Expand Down Expand Up @@ -151,20 +182,6 @@ export const signedJwt =
export const pkTest = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
export const pkLive = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';

type CreateJwt = (opts?: { header?: any; payload?: any; signature?: string }) => string;
export const createJwt: CreateJwt = ({ header, payload, signature = mockJwtSignature } = {}) => {
const encoder = new TextEncoder();

const stringifiedHeader = JSON.stringify({ ...mockJwtHeader, ...header });
const stringifiedPayload = JSON.stringify({ ...mockJwtPayload, ...payload });

return [
base64url.stringify(encoder.encode(stringifiedHeader), { pad: false }),
base64url.stringify(encoder.encode(stringifiedPayload), { pad: false }),
signature,
].join('.');
};

export function createCookieHeader(cookies: Record<string, string>): string {
return Object.keys(cookies)
.reduce((result: string[], cookieName: string) => {
Expand Down
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
46 changes: 46 additions & 0 deletions packages/backend/src/tokens/__tests__/machine.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';

import { createJwt, mockOAuthAccessTokenJwtPayload } from '../../fixtures';
import {
API_KEY_PREFIX,
getMachineTokenType,
isJwtFormat,
isMachineTokenByPrefix,
isMachineTokenType,
isOAuthJwt,
isTokenTypeAccepted,
M2M_TOKEN_PREFIX,
OAUTH_TOKEN_PREFIX,
Expand Down Expand Up @@ -91,3 +94,46 @@ describe('isMachineTokenType', () => {
expect(isMachineTokenType('session_token')).toBe(false);
});
});

describe('isJwtFormat', () => {
it('returns true for valid JWT format', () => {
expect(isJwtFormat('header.payload.signature')).toBe(true);
expect(isJwtFormat('a.b.c')).toBe(true);
});

it('returns false for invalid JWT format', () => {
expect(isJwtFormat('invalid')).toBe(false);
expect(isJwtFormat('invalid.jwt')).toBe(false);
expect(isJwtFormat('invalid.jwt.token.extra')).toBe(false);
});
});

describe('isOAuthJwt', () => {
it('returns true for JWT with typ "at+jwt"', () => {
const token = createJwt({
header: { typ: 'at+jwt', kid: 'ins_whatever' },
payload: mockOAuthAccessTokenJwtPayload,
});
expect(isOAuthJwt(token)).toBe(true);
});

it('returns true for JWT with typ "application/at+jwt"', () => {
const token = createJwt({
header: { typ: 'application/at+jwt', kid: 'ins_whatever' },
payload: mockOAuthAccessTokenJwtPayload,
});
expect(isOAuthJwt(token)).toBe(true);
});

it('returns false for JWT with other typ', () => {
const token = createJwt({
header: { typ: 'JWT', kid: 'ins_whatever' },
payload: mockOAuthAccessTokenJwtPayload,
});
expect(isOAuthJwt(token)).toBe(false);
});

it('returns false for non-JWT token', () => {
expect(isOAuthJwt('not.a.jwt')).toBe(false);
});
});
Loading
Loading