diff --git a/.changeset/swift-sheep-notice.md b/.changeset/swift-sheep-notice.md new file mode 100644 index 00000000000..0bf13307a15 --- /dev/null +++ b/.changeset/swift-sheep-notice.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Added support for JWTs in oauth token type 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..9a9d148c6e3 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -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 = { 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/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"'); + }); }); 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..cdd3d5d09b4 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -1,16 +1,21 @@ 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, isTokenTypeAccepted, M2M_TOKEN_PREFIX, 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); }); @@ -34,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'); @@ -43,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'); }); @@ -91,3 +160,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..a0af6401f7f 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,11 +2,25 @@ 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 { mockVerificationResults } from '../../fixtures/machine'; +import { createJwt, mockJwks, mockJwt, mockJwtPayload, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; +import { + mockSignedOAuthAccessTokenJwt, + mockSignedOAuthAccessTokenJwtApplicationTyp, + mockVerificationResults, +} from '../../fixtures/machine'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; +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(); @@ -313,4 +327,143 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors?.[0].code).toBe('unexpected-error'); }); }); + + 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( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwt, { + 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_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); + 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']); + }); + + 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 = createOAuthJwt(mockOAuthAccessTokenJwtPayload, '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('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 result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwtApplicationTyp, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.errors).toBeUndefined(); + }); + + it('handles invalid JWT format', async () => { + const invalidJwt = 'invalid.jwt.token'; + + const result = await verifyMachineAuthToken(invalidJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + 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 26ecd57209d..d57dd9f7aa4 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,42 @@ 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 + */ +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 && + !!data && + 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 +59,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 an OAuth JWT access token (RFC 9068). + * + * @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 +85,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..aafe5d0252c 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,8 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { Simplify } from '@clerk/shared/types'; -import type { JwtPayload } from '@clerk/types'; +import type { Jwt, JwtPayload, Simplify } from '@clerk/shared/types'; -import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; +import { type APIKey, IdPOAuthAccessToken, type M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; import { MachineTokenVerificationError, @@ -16,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, 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'; @@ -206,10 +205,106 @@ async function verifyM2MToken( } } +async function verifyJwtOAuthToken( + accessToken: string, + options: VerifyTokenOptions, +): Promise> { + 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; + + 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({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), + ], + }; + } + + const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { + ...options, + key, + headerType: OAUTH_ACCESS_TOKEN_TYPES, + }); + + if (verifyErrors) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + 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 { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verifyAccessToken(accessToken); @@ -242,7 +337,7 @@ 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)) { 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==}