From ac71cb01627471ada40ca6043b0592ecb1a5d03c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 07:49:09 -0800 Subject: [PATCH 01/19] feat(backend): Add support to JWTs in oauth token type --- .../src/api/resources/IdPOAuthAccessToken.ts | 32 ++++ packages/backend/src/errors.ts | 19 +- packages/backend/src/fixtures/index.ts | 45 +++-- packages/backend/src/jwt/assertions.ts | 7 +- packages/backend/src/jwt/verifyJwt.ts | 9 +- .../src/tokens/__tests__/machine.test.ts | 46 +++++ .../src/tokens/__tests__/verify.test.ts | 179 +++++++++++++++++- packages/backend/src/tokens/machine.ts | 37 +++- packages/backend/src/tokens/request.ts | 8 +- packages/backend/src/tokens/verify.ts | 112 ++++++++++- 10 files changed, 464 insertions(+), 30 deletions(-) diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 7c1b3d5193e..9adc870ad31 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -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, @@ -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, + ); + } } diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 34b2d67d19c..d9434e711a7 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -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 = @@ -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'})`; } } diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index a644936d604..adced0e7550 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -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 = { @@ -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 { return Object.keys(cookies) .reduce((result: string[], cookieName: string) => { diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 4153de625f4..5cc4325f98e 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -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(', ')}".`, }); } }; diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index d1b4a9cbcf5..1b0cd46b67f 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -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> { - 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); @@ -138,7 +143,7 @@ export async function verifyJwt( // Header verifications const { typ, alg } = header; - assertHeaderType(typ); + assertHeaderType(typ, headerType); assertHeaderAlgorithm(alg); // Payload verifications diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index 57b0a3e7893..28dfe15128c 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -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, @@ -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); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index f48cfae8e57..7d9df88dd4c 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,8 +2,9 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; -import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; +import { createJwt, mockJwks, mockJwt, mockJwtPayload, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; import { mockVerificationResults } from '../../fixtures/machine'; +import * as VerifyJwtModule from '../../jwt/verifyJwt'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; @@ -313,4 +314,180 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors?.[0].code).toBe('unexpected-error'); }); }); + + describe('verifyOAuthToken with JWT', () => { + it('verifies a valid OAuth JWT', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const validJtiPayload = { + ...mockOAuthAccessTokenJwtPayload, + jti: 'oat_12345678901234567890123456789012', + }; + + const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ + data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, + }); + + const oauthJwt = createJwt({ + header: { typ: 'at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload: validJtiPayload, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as IdPOAuthAccessToken; + expect(data.id).toBe('oat_12345678901234567890123456789012'); + expect(data.clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(data.type).toBe('oauth_token'); + expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.scopes).toEqual(['read:foo', 'write:bar']); + + expect(spy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headerType: ['at+jwt', 'application/at+jwt'], + }), + ); + }); + + it('fails if jti is missing or invalid format', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const invalidPayload = { ...mockOAuthAccessTokenJwtPayload, jti: 'invalid' }; + vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ + data: { ...invalidPayload, __raw: 'mock_jwt_string' }, + }); + + const oauthJwt = createJwt({ + header: { typ: 'at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload: invalidPayload, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].code).toBe('token-verification-failed'); + expect(result.errors?.[0].message).toContain('Invalid JWT jti claim'); + }); + + it('fails if JWT type is not at+jwt or application/at+jwt', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const oauthJwt = createJwt({ + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, // Wrong type + payload: mockOAuthAccessTokenJwtPayload, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('Invalid JWT type'); + }); + + it('verifies JWT with typ application/at+jwt', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const validJtiPayload = { + ...mockOAuthAccessTokenJwtPayload, + jti: 'oat_12345678901234567890123456789012', + }; + + // We mock verifyJwt to ensure we don't hit the real signature check failure (since we use createJwt which signs with a dummy key/algo) + // But we want to test headerType validation logic. The real verifyJwt does signature check LAST. + // assertHeaderType is called BEFORE signature check. + // So if we mock verifyJwt, we skip assertHeaderType inside verifyJwt unless we spy on assertions? + // No, VerifyJwtModule.verifyJwt IS the function we call. + // If we mock it, we bypass the real logic entirely. + + // To test that verifyOAuthToken accepts 'application/at+jwt', we should let it call verifyJwt. + // But verifyJwt will fail signature if we don't provide correct key/signature. + // However, if we Mock verifyJwt, we are just testing verifyMachineAuthToken -> verifyOAuthToken flow, not verifyJwt internals. + + // Let's rely on the fact that verifyOAuthToken calls verifyJwt with headerType: ['at+jwt', 'application/at+jwt']. + // We verified that call in the first test ("verifies a valid OAuth JWT"). + + // But we can add a test case where we simulate a successful verifyJwt call with application/at+jwt. + + const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ + data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, + }); + + const oauthJwt = createJwt({ + header: { typ: 'application/at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload: validJtiPayload, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.errors).toBeUndefined(); + + expect(spy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headerType: ['at+jwt', 'application/at+jwt'], + }), + ); + }); + + it('handles invalid JWT format', async () => { + const invalidJwt = 'invalid.jwt.token'; + // Mock isJwt to return true so it enters verifyOAuthToken, + // OR rely on regex. Regex requires 3 parts. + // 'invalid.jwt.token' matches 3 parts. + + // But decodeJwt will fail if base64 is bad. + + const result = await verifyMachineAuthToken(invalidJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + }); + }); }); diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index 26ecd57209d..f298148a486 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -1,3 +1,4 @@ +import { decodeJwt } from '../jwt/verifyJwt'; import type { AuthenticateRequestOptions } from '../tokens/types'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -8,6 +9,30 @@ export const API_KEY_PREFIX = 'ak_'; 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\-_]+$/; + +export function isJwtFormat(token: string): boolean { + return JwtFormatRegExp.test(token); +} + +/** + * Valid OAuth 2.0 JWT access token type values per RFC 9068. + * @see https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1 + */ +const OAUTH_ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt'] as const; + +export function isOAuthJwt(token: string): boolean { + if (!isJwtFormat(token)) { + return false; + } + try { + const { data, errors } = decodeJwt(token); + return !errors && OAUTH_ACCESS_TOKEN_TYPES.includes(data.header.typ as (typeof OAUTH_ACCESS_TOKEN_TYPES)[number]); + } catch { + return false; + } +} + /** * Checks if a token is a machine token by looking at its prefix. * @@ -22,6 +47,16 @@ export function isMachineTokenByPrefix(token: string): boolean { return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix)); } +/** + * Checks if a token is a machine token by looking at its prefix or if it's a JWT with header typ 'at+jwt'. + * + * @param token - The token string to check + * @returns true if the token is a machine token + */ +export function isMachineToken(token: string): boolean { + return isMachineTokenByPrefix(token) || isOAuthJwt(token); +} + /** * Gets the specific type of machine token based on its prefix. * @@ -38,7 +73,7 @@ export function getMachineTokenType(token: string): MachineTokenType { return TokenType.M2MToken; } - if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { return TokenType.OAuthToken; } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 1d2aaaa6d1e..863afeabaa4 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -14,7 +14,7 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken import { createClerkRequest } from './clerkRequest'; import { getCookieName, getCookieValue } from './cookie'; import { HandshakeService } from './handshake'; -import { getMachineTokenType, isMachineTokenByPrefix, isTokenTypeAccepted } from './machine'; +import { getMachineTokenType, isMachineToken, isTokenTypeAccepted } from './machine'; import { OrganizationMatcher } from './organizationMatcher'; import type { MachineTokenType, SessionTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -102,7 +102,7 @@ function isTokenTypeInAcceptedArray(acceptsToken: TokenType[], authenticateConte let parsedTokenType: TokenType | null = null; const { tokenInHeader } = authenticateContext; if (tokenInHeader) { - if (isMachineTokenByPrefix(tokenInHeader)) { + if (isMachineToken(tokenInHeader)) { parsedTokenType = getMachineTokenType(tokenInHeader); } else { parsedTokenType = TokenType.SessionToken; @@ -704,7 +704,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( } // Handle case where tokenType is any and the token is not a machine token - if (!isMachineTokenByPrefix(tokenInHeader)) { + if (!isMachineToken(tokenInHeader)) { return signedOut({ tokenType: acceptsToken as TokenType, authenticateContext, @@ -739,7 +739,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( } // Handle as a machine token - if (isMachineTokenByPrefix(tokenInHeader)) { + if (isMachineToken(tokenInHeader)) { const parsedTokenType = getMachineTokenType(tokenInHeader); const mismatchState = checkTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); if (mismatchState) { diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index dfc22cc4d66..ca473e725c2 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,8 +1,8 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { Simplify } from '@clerk/shared/types'; -import type { JwtPayload } from '@clerk/types'; +import type { Jwt, JwtPayload } from '@clerk/types'; -import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; +import { type APIKey, IdPOAuthAccessToken, type M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; import { MachineTokenVerificationError, @@ -210,6 +210,105 @@ async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { + if (isJwt(accessToken)) { + let decoded: JwtReturnType; + try { + decoded = decodeJwt(accessToken); + } catch (e) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }), + ], + }; + } + + const { data: decodedResult, errors } = decoded; + if (errors) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: errors[0].message, + }), + ], + }; + } + + const { header } = decodedResult; + const { kid } = header; + let key: JsonWebKey; + + try { + if (options.jwtKey) { + key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + } else if (options.secretKey) { + key = await loadClerkJWKFromRemote({ ...options, kid }); + } else { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), + ], + }; + } + + const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { + ...options, + key, + headerType: ['at+jwt', 'application/at+jwt'], + }); + + if (verifyErrors) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: verifyErrors[0].message, + }), + ], + }; + } + + const jti = payload.jti as string; + if (!jti || !/^oat_[0-9A-Za-z]{32}$/.test(jti)) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + action: TokenVerificationErrorAction.EnsureClerkJWT, + message: `Invalid JWT jti claim ${JSON.stringify(jti)}. Expected a valid OAuth access token ID.`, + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), + ], + }; + } + + const token = IdPOAuthAccessToken.fromJwtPayload(payload, options.clockSkewInMs); + + return { data: token, tokenType: TokenType.OAuthToken, errors: undefined }; + } catch (error) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } + } + try { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verifyAccessToken(accessToken); @@ -248,6 +347,15 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken if (token.startsWith(API_KEY_PREFIX)) { return verifyAPIKey(token, options); } + if (isJwt(token)) { + return verifyOAuthToken(token, options); + } throw new Error('Unknown machine token type'); } + +const JwtFormatRegExp = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/; + +function isJwt(token: string): boolean { + return JwtFormatRegExp.test(token); +} From d914b617697e8aa407da245e266d1fd669f052bb Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 07:50:01 -0800 Subject: [PATCH 02/19] chore: add changeset --- .changeset/swift-sheep-notice.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-sheep-notice.md diff --git a/.changeset/swift-sheep-notice.md b/.changeset/swift-sheep-notice.md new file mode 100644 index 00000000000..645769f90d0 --- /dev/null +++ b/.changeset/swift-sheep-notice.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": patch +--- + +Added support to JWTs in oauth token type From e86c3ab6771f713913bd0f7756c4c9bc3d9a08ed Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 07:50:42 -0800 Subject: [PATCH 03/19] update changeset --- .changeset/swift-sheep-notice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/swift-sheep-notice.md b/.changeset/swift-sheep-notice.md index 645769f90d0..dda4fb5c38f 100644 --- a/.changeset/swift-sheep-notice.md +++ b/.changeset/swift-sheep-notice.md @@ -1,5 +1,5 @@ --- -"@clerk/backend": patch +"@clerk/backend": minor --- Added support to JWTs in oauth token type From bc57e962c28169b7dff64f89168a2bae4a4db0bb Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 07:55:50 -0800 Subject: [PATCH 04/19] remove duplicate jwt helper --- .../src/tokens/__tests__/verify.test.ts | 52 ++++++------------- packages/backend/src/tokens/verify.ts | 12 ++--- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 7d9df88dd4c..af0efb89bb5 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -8,6 +8,17 @@ import * as VerifyJwtModule from '../../jwt/verifyJwt'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; +// Moved helper from fixtures to test file as requested +function createOAuthJwt( + payload = mockOAuthAccessTokenJwtPayload, + typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt', +) { + return createJwt({ + header: { typ, kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload, + }); +} + describe('tokens.verify(token, options)', () => { beforeEach(() => { vi.useFakeTimers(); @@ -335,10 +346,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, }); - const oauthJwt = createJwt({ - header: { typ: 'at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, - payload: validJtiPayload, - }); + const oauthJwt = createOAuthJwt(validJtiPayload, 'at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -379,10 +387,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { data: { ...invalidPayload, __raw: 'mock_jwt_string' }, }); - const oauthJwt = createJwt({ - header: { typ: 'at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, - payload: invalidPayload, - }); + const oauthJwt = createOAuthJwt(invalidPayload, 'at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -404,10 +409,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const oauthJwt = createJwt({ - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, // Wrong type - payload: mockOAuthAccessTokenJwtPayload, - }); + const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'JWT'); // Wrong type const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -433,30 +435,11 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { jti: 'oat_12345678901234567890123456789012', }; - // We mock verifyJwt to ensure we don't hit the real signature check failure (since we use createJwt which signs with a dummy key/algo) - // But we want to test headerType validation logic. The real verifyJwt does signature check LAST. - // assertHeaderType is called BEFORE signature check. - // So if we mock verifyJwt, we skip assertHeaderType inside verifyJwt unless we spy on assertions? - // No, VerifyJwtModule.verifyJwt IS the function we call. - // If we mock it, we bypass the real logic entirely. - - // To test that verifyOAuthToken accepts 'application/at+jwt', we should let it call verifyJwt. - // But verifyJwt will fail signature if we don't provide correct key/signature. - // However, if we Mock verifyJwt, we are just testing verifyMachineAuthToken -> verifyOAuthToken flow, not verifyJwt internals. - - // Let's rely on the fact that verifyOAuthToken calls verifyJwt with headerType: ['at+jwt', 'application/at+jwt']. - // We verified that call in the first test ("verifies a valid OAuth JWT"). - - // But we can add a test case where we simulate a successful verifyJwt call with application/at+jwt. - const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, }); - const oauthJwt = createJwt({ - header: { typ: 'application/at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, - payload: validJtiPayload, - }); + const oauthJwt = createOAuthJwt(validJtiPayload, 'application/at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -476,11 +459,6 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { it('handles invalid JWT format', async () => { const invalidJwt = 'invalid.jwt.token'; - // Mock isJwt to return true so it enters verifyOAuthToken, - // OR rely on regex. Regex requires 3 parts. - // 'invalid.jwt.token' matches 3 parts. - - // But decodeJwt will fail if base64 is bad. const result = await verifyMachineAuthToken(invalidJwt, { apiUrl: 'https://api.clerk.test', diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index ca473e725c2..5c3bea05944 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -16,7 +16,7 @@ import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; -import { API_KEY_PREFIX, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine'; +import { API_KEY_PREFIX, isJwtFormat, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -210,7 +210,7 @@ async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { - if (isJwt(accessToken)) { + if (isJwtFormat(accessToken)) { let decoded: JwtReturnType; try { decoded = decodeJwt(accessToken); @@ -347,15 +347,9 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken if (token.startsWith(API_KEY_PREFIX)) { return verifyAPIKey(token, options); } - if (isJwt(token)) { + if (isJwtFormat(token)) { return verifyOAuthToken(token, options); } throw new Error('Unknown machine token type'); } - -const JwtFormatRegExp = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/; - -function isJwt(token: string): boolean { - return JwtFormatRegExp.test(token); -} From 1b6de49e5b89fa29c0ae0a4b49e150b6472a1060 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 07:56:16 -0800 Subject: [PATCH 05/19] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .changeset/swift-sheep-notice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/swift-sheep-notice.md b/.changeset/swift-sheep-notice.md index dda4fb5c38f..0bf13307a15 100644 --- a/.changeset/swift-sheep-notice.md +++ b/.changeset/swift-sheep-notice.md @@ -2,4 +2,4 @@ "@clerk/backend": minor --- -Added support to JWTs in oauth token type +Added support for JWTs in oauth token type From bf6d6e2a59aa399e3fe00d2b3c4157043b15bf3c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 08:06:52 -0800 Subject: [PATCH 06/19] fix issue with jti --- .../src/tokens/__tests__/verify.test.ts | 20 +++++-------------- packages/backend/src/tokens/verify.ts | 5 ++--- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index af0efb89bb5..ee5afb51154 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -337,16 +337,11 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const validJtiPayload = { - ...mockOAuthAccessTokenJwtPayload, - jti: 'oat_12345678901234567890123456789012', - }; - const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ - data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, + data: { ...mockOAuthAccessTokenJwtPayload, __raw: 'mock_jwt_string' }, }); - const oauthJwt = createOAuthJwt(validJtiPayload, 'at+jwt'); + const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -358,7 +353,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeUndefined(); const data = result.data as IdPOAuthAccessToken; - expect(data.id).toBe('oat_12345678901234567890123456789012'); + expect(data.id).toBe('oat_2VTWUzvGC5UhdJCNx6xG1D98edc'); expect(data.clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); expect(data.type).toBe('oauth_token'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); @@ -430,16 +425,11 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const validJtiPayload = { - ...mockOAuthAccessTokenJwtPayload, - jti: 'oat_12345678901234567890123456789012', - }; - const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ - data: { ...validJtiPayload, __raw: 'mock_jwt_string' }, + data: { ...mockOAuthAccessTokenJwtPayload, __raw: 'mock_jwt_string' }, }); - const oauthJwt = createOAuthJwt(validJtiPayload, 'application/at+jwt'); + const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'application/at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 5c3bea05944..dfc065b45e1 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,6 +1,5 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { Simplify } from '@clerk/shared/types'; -import type { Jwt, JwtPayload } from '@clerk/types'; +import type { Jwt, JwtPayload, Simplify } from '@clerk/shared/types'; import { type APIKey, IdPOAuthAccessToken, type M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; @@ -280,7 +279,7 @@ async function verifyOAuthToken( } const jti = payload.jti as string; - if (!jti || !/^oat_[0-9A-Za-z]{32}$/.test(jti)) { + if (!jti || !/^oat_[0-9A-Za-z]+$/.test(jti)) { return { tokenType: TokenType.OAuthToken, errors: [ From 8aad12beb98661422d063a12b023029c8729ecb5 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 08:35:27 -0800 Subject: [PATCH 07/19] Update packages/backend/src/fixtures/index.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/backend/src/fixtures/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index adced0e7550..9d1c5d57896 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -39,7 +39,7 @@ export const mockOAuthAccessTokenJwtPayload = { sub: 'user_2vYVtestTESTtestTESTtestTESTtest', client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', scope: 'read:foo write:bar', - jti: 'oat_2VTWUzvGC5UhdJCNx6xG1D98edc', + jti: 'oat_01234567890123456789012345678901', exp: mockJwtPayload.iat + 300, iat: mockJwtPayload.iat, nbf: mockJwtPayload.iat - 10, From 41b1c737ac052eae8ce13cf2bed641a59dba9774 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 08:37:01 -0800 Subject: [PATCH 08/19] chore: clean up condition --- packages/backend/src/tokens/verify.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index dfc065b45e1..7ee2835f67a 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -340,15 +340,12 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken if (token.startsWith(M2M_TOKEN_PREFIX)) { return verifyM2MToken(token, options); } - if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + if (token.startsWith(OAUTH_TOKEN_PREFIX) || isJwtFormat(token)) { return verifyOAuthToken(token, options); } if (token.startsWith(API_KEY_PREFIX)) { return verifyAPIKey(token, options); } - if (isJwtFormat(token)) { - return verifyOAuthToken(token, options); - } throw new Error('Unknown machine token type'); } From fed6afbdf40372a7aec29b150ba075afd647d0c5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 08:39:24 -0800 Subject: [PATCH 09/19] fix tests --- packages/backend/src/fixtures/index.ts | 2 +- packages/backend/src/tokens/__tests__/verify.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 9d1c5d57896..84f80fb3f9b 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -39,7 +39,7 @@ export const mockOAuthAccessTokenJwtPayload = { sub: 'user_2vYVtestTESTtestTESTtestTESTtest', client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', scope: 'read:foo write:bar', - jti: 'oat_01234567890123456789012345678901', + jti: 'oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE', exp: mockJwtPayload.iat + 300, iat: mockJwtPayload.iat, nbf: mockJwtPayload.iat - 10, diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index ee5afb51154..f3570f43b1a 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -353,7 +353,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeUndefined(); const data = result.data as IdPOAuthAccessToken; - expect(data.id).toBe('oat_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(data.id).toBe('oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); expect(data.clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); expect(data.type).toBe('oauth_token'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); From f8645f3d8aa090638ecc941e2e6c647c9408c640 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 11:51:37 -0800 Subject: [PATCH 10/19] Update packages/backend/src/tokens/__tests__/verify.test.ts Co-authored-by: Jacob Foshee --- packages/backend/src/tokens/__tests__/verify.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index f3570f43b1a..48f49412d8e 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -8,7 +8,6 @@ import * as VerifyJwtModule from '../../jwt/verifyJwt'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; -// Moved helper from fixtures to test file as requested function createOAuthJwt( payload = mockOAuthAccessTokenJwtPayload, typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt', From 2eccc3391961b39c1be75b91cd6186b4e5b92238 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 13:40:41 -0800 Subject: [PATCH 11/19] chore: Move jwt oauth verification logic to its own function --- .../src/tokens/__tests__/verify.test.ts | 79 +++++---- packages/backend/src/tokens/machine.ts | 2 +- packages/backend/src/tokens/verify.ts | 155 +++++++++--------- 3 files changed, 128 insertions(+), 108 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 48f49412d8e..5a3e5842087 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -366,33 +366,6 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ); }); - it('fails if jti is missing or invalid format', async () => { - server.use( - http.get( - 'https://api.clerk.test/v1/jwks', - validateHeaders(() => { - return HttpResponse.json(mockJwks); - }), - ), - ); - - const invalidPayload = { ...mockOAuthAccessTokenJwtPayload, jti: 'invalid' }; - vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ - data: { ...invalidPayload, __raw: 'mock_jwt_string' }, - }); - - const oauthJwt = createOAuthJwt(invalidPayload, 'at+jwt'); - - const result = await verifyMachineAuthToken(oauthJwt, { - apiUrl: 'https://api.clerk.test', - secretKey: 'a-valid-key', - }); - - expect(result.errors).toBeDefined(); - expect(result.errors?.[0].code).toBe('token-verification-failed'); - expect(result.errors?.[0].message).toContain('Invalid JWT jti claim'); - }); - it('fails if JWT type is not at+jwt or application/at+jwt', async () => { server.use( http.get( @@ -403,7 +376,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'JWT'); // Wrong type + const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'JWT'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -456,5 +429,55 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeDefined(); }); + + it('rejects JWT with alg: none', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const oauthJwt = createJwt({ + header: { typ: 'at+jwt', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload: mockOAuthAccessTokenJwtPayload, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('Invalid JWT algorithm'); + }); + + it('rejects expired JWT', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const expiredPayload = { + ...mockOAuthAccessTokenJwtPayload, + exp: mockOAuthAccessTokenJwtPayload.iat - 100, + }; + + const oauthJwt = createOAuthJwt(expiredPayload, 'at+jwt'); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('expired'); + }); }); }); diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index f298148a486..cc9920a0cab 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -19,7 +19,7 @@ export function isJwtFormat(token: string): boolean { * Valid OAuth 2.0 JWT access token type values per RFC 9068. * @see https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1 */ -const OAUTH_ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt'] as const; +export const OAUTH_ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt']; export function isOAuthJwt(token: string): boolean { if (!isJwtFormat(token)) { diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 7ee2835f67a..aafe5d0252c 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -15,7 +15,7 @@ import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; -import { API_KEY_PREFIX, isJwtFormat, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine'; +import { API_KEY_PREFIX, isJwtFormat, M2M_TOKEN_PREFIX, OAUTH_ACCESS_TOKEN_TYPES, OAUTH_TOKEN_PREFIX } from './machine'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -205,107 +205,104 @@ async function verifyM2MToken( } } -async function verifyOAuthToken( +async function verifyJwtOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { - if (isJwtFormat(accessToken)) { - let decoded: JwtReturnType; - try { - decoded = decodeJwt(accessToken); - } catch (e) { - return { - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: (e as Error).message, - }), - ], - }; - } + let decoded: JwtReturnType; + try { + decoded = decodeJwt(accessToken); + } catch (e) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }), + ], + }; + } + + const { data: decodedResult, errors } = decoded; + if (errors) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: errors[0].message, + }), + ], + }; + } + + const { header } = decodedResult; + const { kid } = header; + let key: JsonWebKey; - const { data: decodedResult, errors } = decoded; - if (errors) { + try { + if (options.jwtKey) { + key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + } else if (options.secretKey) { + key = await loadClerkJWKFromRemote({ ...options, kid }); + } else { return { + data: undefined, tokenType: TokenType.OAuthToken, errors: [ new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: errors[0].message, + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, }), ], }; } - const { header } = decodedResult; - const { kid } = header; - let key: JsonWebKey; - - try { - if (options.jwtKey) { - key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); - } else if (options.secretKey) { - key = await loadClerkJWKFromRemote({ ...options, kid }); - } else { - return { - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - action: TokenVerificationErrorAction.SetClerkJWTKey, - message: 'Failed to resolve JWK during verification.', - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - }), - ], - }; - } - - const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { - ...options, - key, - headerType: ['at+jwt', 'application/at+jwt'], - }); + const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { + ...options, + key, + headerType: OAUTH_ACCESS_TOKEN_TYPES, + }); - if (verifyErrors) { - return { - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: verifyErrors[0].message, - }), - ], - }; - } - - const jti = payload.jti as string; - if (!jti || !/^oat_[0-9A-Za-z]+$/.test(jti)) { - return { - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - action: TokenVerificationErrorAction.EnsureClerkJWT, - message: `Invalid JWT jti claim ${JSON.stringify(jti)}. Expected a valid OAuth access token ID.`, - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - }), - ], - }; - } - - const token = IdPOAuthAccessToken.fromJwtPayload(payload, options.clockSkewInMs); - - return { data: token, tokenType: TokenType.OAuthToken, errors: undefined }; - } catch (error) { + if (verifyErrors) { return { + data: undefined, tokenType: TokenType.OAuthToken, errors: [ new MachineTokenVerificationError({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, + message: verifyErrors[0].message, }), ], }; } + + const token = IdPOAuthAccessToken.fromJwtPayload(payload, options.clockSkewInMs); + + return { data: token, tokenType: TokenType.OAuthToken, errors: undefined }; + } catch (error) { + return { + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } +} + +async function verifyOAuthToken( + accessToken: string, + options: VerifyTokenOptions, +): Promise> { + if (isJwtFormat(accessToken)) { + return verifyJwtOAuthToken(accessToken, options); } try { From 4550c8d3553e17ceaf5614c8808edb97c5b3192c Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 25 Nov 2025 14:03:01 -0800 Subject: [PATCH 12/19] Update packages/backend/src/tokens/machine.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/backend/src/tokens/machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index cc9920a0cab..7e5a59be6c3 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -48,7 +48,7 @@ export function isMachineTokenByPrefix(token: string): boolean { } /** - * Checks if a token is a machine token by looking at its prefix or if it's a JWT with header typ 'at+jwt'. + * Checks if a token is a machine token by looking at its prefix or if it's an OAuth JWT access token (RFC 9068). * * @param token - The token string to check * @returns true if the token is a machine token From d0d47ba84141089f11d164625137e398b606bd93 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 14:09:08 -0800 Subject: [PATCH 13/19] chore: skip mocking verifyJwt --- packages/backend/src/fixtures/index.ts | 5 -- packages/backend/src/fixtures/machine.ts | 14 ++++++ .../src/tokens/__tests__/verify.test.ts | 46 +++++++------------ 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 84f80fb3f9b..30593d55bcd 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -59,11 +59,6 @@ export const createJwt: CreateJwt = ({ header, payload, signature = mockJwtSigna ].join('.'); }; -export const mockOAuthAccessTokenJwt = createJwt({ - header: { typ: 'at+jwt' }, - payload: mockOAuthAccessTokenJwtPayload, -}); - export const mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD'; export const mockRsaJwk = { diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index 170ba7eec34..fb5c50cd5c5 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -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'; diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 5a3e5842087..a0af6401f7f 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -3,8 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; import { createJwt, mockJwks, mockJwt, mockJwtPayload, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; -import { mockVerificationResults } from '../../fixtures/machine'; -import * as VerifyJwtModule from '../../jwt/verifyJwt'; +import { + mockSignedOAuthAccessTokenJwt, + mockSignedOAuthAccessTokenJwtApplicationTyp, + mockVerificationResults, +} from '../../fixtures/machine'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; @@ -326,6 +329,15 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { }); describe('verifyOAuthToken with JWT', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('verifies a valid OAuth JWT', async () => { server.use( http.get( @@ -336,13 +348,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ - data: { ...mockOAuthAccessTokenJwtPayload, __raw: 'mock_jwt_string' }, - }); - - const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'at+jwt'); - - const result = await verifyMachineAuthToken(oauthJwt, { + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwt, { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', }); @@ -357,13 +363,6 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.type).toBe('oauth_token'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(data.scopes).toEqual(['read:foo', 'write:bar']); - - expect(spy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headerType: ['at+jwt', 'application/at+jwt'], - }), - ); }); it('fails if JWT type is not at+jwt or application/at+jwt', async () => { @@ -397,26 +396,13 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const spy = vi.spyOn(VerifyJwtModule, 'verifyJwt').mockResolvedValueOnce({ - data: { ...mockOAuthAccessTokenJwtPayload, __raw: 'mock_jwt_string' }, - }); - - const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'application/at+jwt'); - - const result = await verifyMachineAuthToken(oauthJwt, { + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwtApplicationTyp, { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', }); expect(result.tokenType).toBe('oauth_token'); expect(result.errors).toBeUndefined(); - - expect(spy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headerType: ['at+jwt', 'application/at+jwt'], - }), - ); }); it('handles invalid JWT format', async () => { From ac9c94d6c6487519109db9f1cb9fbd36f504e8ea Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 25 Nov 2025 14:12:27 -0800 Subject: [PATCH 14/19] chore: add test for oauth header type assertions --- .../src/jwt/__tests__/assertions.test.ts | 36 +++++++- .../src/jwt/__tests__/verifyJwt.test.ts | 88 +++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index 2119df33a38..0dea86341d6 100644 --- a/packages/backend/src/jwt/__tests__/assertions.test.ts +++ b/packages/backend/src/jwt/__tests__/assertions.test.ts @@ -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)', () => { diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index 74ac79a1da6..4fd4022a884 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -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 = { @@ -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"'); + }); }); From 78e960352a6e26e22649cfa922da5bef5b75edf5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 26 Nov 2025 08:24:06 -0800 Subject: [PATCH 15/19] restore createJwt helper --- packages/backend/src/fixtures/index.ts | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 30593d55bcd..9a9d148c6e3 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -45,20 +45,6 @@ export const mockOAuthAccessTokenJwtPayload = { 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 mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD'; export const mockRsaJwk = { @@ -177,6 +163,20 @@ 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 { return Object.keys(cookies) .reduce((result: string[], cookieName: string) => { From 9ceae69dc851e921955036eba7165ad5dfb2e2cd Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 26 Nov 2025 08:25:32 -0800 Subject: [PATCH 16/19] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/backend/src/tokens/machine.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index 7e5a59be6c3..9ae82ea57c6 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -21,13 +21,21 @@ export function isJwtFormat(token: string): boolean { */ export const OAUTH_ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt']; +/** + * Checks if a token is an OAuth 2.0 JWT access token. + * Validates the JWT format and verifies the header 'typ' field matches RFC 9068 values. + * + * @param token - The token string to check + * @returns true if the token is a valid OAuth JWT access token + * @see https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1 + */ export function isOAuthJwt(token: string): boolean { if (!isJwtFormat(token)) { return false; } try { const { data, errors } = decodeJwt(token); - return !errors && OAUTH_ACCESS_TOKEN_TYPES.includes(data.header.typ as (typeof OAUTH_ACCESS_TOKEN_TYPES)[number]); + return !errors && !!data && OAUTH_ACCESS_TOKEN_TYPES.includes(data.header.typ as (typeof OAUTH_ACCESS_TOKEN_TYPES)[number]); } catch { return false; } From 0ddb1ab168743ed635dcebd2b42a90e5b311eba3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 26 Nov 2025 08:29:18 -0800 Subject: [PATCH 17/19] dedupe --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4e64573239..3c5c14c87f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2596,7 +2596,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} From c2eb4a53e73402d2f52735e2458af80401645f50 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 26 Nov 2025 13:56:10 -0800 Subject: [PATCH 18/19] chore: format --- packages/backend/src/tokens/machine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index 9ae82ea57c6..d57dd9f7aa4 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -35,7 +35,11 @@ export function isOAuthJwt(token: string): boolean { } try { const { data, errors } = decodeJwt(token); - return !errors && !!data && OAUTH_ACCESS_TOKEN_TYPES.includes(data.header.typ as (typeof OAUTH_ACCESS_TOKEN_TYPES)[number]); + return ( + !errors && + !!data && + OAUTH_ACCESS_TOKEN_TYPES.includes(data.header.typ as (typeof OAUTH_ACCESS_TOKEN_TYPES)[number]) + ); } catch { return false; } From c42731619ce8e3e3b5165b11da5138273025a24b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 26 Nov 2025 14:11:22 -0800 Subject: [PATCH 19/19] chore: add machine helper unit tests --- .../src/tokens/__tests__/machine.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index 28dfe15128c..cdd3d5d09b4 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { createJwt, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; +import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine'; import { API_KEY_PREFIX, getMachineTokenType, isJwtFormat, + isMachineToken, isMachineTokenByPrefix, isMachineTokenType, isOAuthJwt, @@ -13,7 +15,7 @@ import { OAUTH_TOKEN_PREFIX, } from '../machine'; -describe('isMachineToken', () => { +describe('isMachineTokenByPrefix', () => { it('returns true for tokens with M2M prefix', () => { expect(isMachineTokenByPrefix(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe(true); }); @@ -37,6 +39,54 @@ describe('isMachineToken', () => { }); }); +describe('isMachineToken', () => { + it('returns true for tokens with M2M prefix', () => { + expect(isMachineToken(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns true for tokens with OAuth prefix', () => { + expect(isMachineToken(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns true for tokens with API key prefix', () => { + expect(isMachineToken(`${API_KEY_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns true for OAuth JWT with typ "at+jwt"', () => { + expect(isMachineToken(mockSignedOAuthAccessTokenJwt)).toBe(true); + }); + + it('returns true for OAuth JWT with typ "application/at+jwt"', () => { + expect(isMachineToken(mockSignedOAuthAccessTokenJwtApplicationTyp)).toBe(true); + }); + + it('returns true for OAuth JWT created with createJwt', () => { + const token = createJwt({ + header: { typ: 'at+jwt', kid: 'ins_whatever' }, + payload: mockOAuthAccessTokenJwtPayload, + }); + expect(isMachineToken(token)).toBe(true); + }); + + it('returns false for tokens without a recognized prefix or OAuth JWT format', () => { + expect(isMachineToken('unknown_prefix_token')).toBe(false); + expect(isMachineToken('session_token_value')).toBe(false); + expect(isMachineToken('jwt_token_value')).toBe(false); + }); + + it('returns false for regular JWT tokens (not OAuth JWT)', () => { + const regularJwt = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: mockOAuthAccessTokenJwtPayload, + }); + expect(isMachineToken(regularJwt)).toBe(false); + }); + + it('returns false for empty tokens', () => { + expect(isMachineToken('')).toBe(false); + }); +}); + describe('getMachineTokenType', () => { it('returns "m2m_token" for tokens with M2M prefix', () => { expect(getMachineTokenType(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe('m2m_token'); @@ -46,6 +96,22 @@ describe('getMachineTokenType', () => { expect(getMachineTokenType(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe('oauth_token'); }); + it('returns "oauth_token" for OAuth JWT with typ "at+jwt"', () => { + expect(getMachineTokenType(mockSignedOAuthAccessTokenJwt)).toBe('oauth_token'); + }); + + it('returns "oauth_token" for OAuth JWT with typ "application/at+jwt"', () => { + expect(getMachineTokenType(mockSignedOAuthAccessTokenJwtApplicationTyp)).toBe('oauth_token'); + }); + + it('returns "oauth_token" for OAuth JWT created with createJwt', () => { + const token = createJwt({ + header: { typ: 'at+jwt', kid: 'ins_whatever' }, + payload: mockOAuthAccessTokenJwtPayload, + }); + expect(getMachineTokenType(token)).toBe('oauth_token'); + }); + it('returns "api_key" for tokens with API key prefix', () => { expect(getMachineTokenType(`${API_KEY_PREFIX}some-token-value`)).toBe('api_key'); });