diff --git a/.changeset/angry-cycles-cheer.md b/.changeset/angry-cycles-cheer.md new file mode 100644 index 00000000000..5c831d151bd --- /dev/null +++ b/.changeset/angry-cycles-cheer.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Send API version through request headers. diff --git a/package-lock.json b/package-lock.json index e099e17a10a..64489ab6f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/jest": "^29.3.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@vitest/coverage-v8": "2.1.4", "citty": "^0.1.4", "conventional-changelog-conventionalcommits": "^4.6.3", "cpy-cli": "^5.0.0", @@ -15258,6 +15259,141 @@ "vite": "^4.3.9" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.4.tgz", + "integrity": "sha512-FPKQuJfR6VTfcNMcGpqInmtJuVXFSCd9HQltYncfR01AzXhLucMEtQ5SinPdZxsT5x/5BK7I5qFJ5/ApGCmyTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.7.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.4", + "vitest": "2.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@vitest/expect": { "version": "2.1.4", "dev": true, @@ -28207,7 +28343,9 @@ "license": "MIT" }, "node_modules/istanbul-lib-coverage": { - "version": "3.2.1", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -28277,7 +28415,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { @@ -31712,7 +31852,6 @@ "node_modules/magicast": { "version": "0.3.5", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", diff --git a/package.json b/package.json index 345a7a4f3a5..ac883152323 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/jest": "^29.3.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@vitest/coverage-v8": "2.1.4", "citty": "^0.1.4", "conventional-changelog-conventionalcommits": "^4.6.3", "cpy-cli": "^5.0.0", diff --git a/packages/backend/src/api/__tests__/factory.test.ts b/packages/backend/src/api/__tests__/factory.test.ts index cb5735e1142..103b078ea09 100644 --- a/packages/backend/src/api/__tests__/factory.test.ts +++ b/packages/backend/src/api/__tests__/factory.test.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; import userJson from '../../fixtures/user.json'; -import { server } from '../../mock-server'; +import { server, validateHeaders } from '../../mock-server'; import { createBackendApiClient } from '../factory'; describe('api.client', () => { @@ -13,9 +13,12 @@ describe('api.client', () => { it('executes a successful backend API request for a single resource and parses the response', async () => { server.use( - http.get(`https://api.clerk.test/v1/users/user_deadbeef`, () => { - return HttpResponse.json(userJson); - }), + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(() => { + return HttpResponse.json(userJson); + }), + ), ); const response = await apiClient.users.getUser('user_deadbeef'); @@ -30,12 +33,18 @@ describe('api.client', () => { it('executes 2 backend API request for users.getUserList()', async () => { server.use( - http.get(`https://api.clerk.test/v1/users`, () => { - return HttpResponse.json([userJson]); - }), - http.get(`https://api.clerk.test/v1/users/count`, () => { - return HttpResponse.json({ object: 'total_count', total_count: 2 }); - }), + http.get( + `https://api.clerk.test/v1/users`, + validateHeaders(() => { + return HttpResponse.json([userJson]); + }), + ), + http.get( + `https://api.clerk.test/v1/users/count`, + validateHeaders(() => { + return HttpResponse.json({ object: 'total_count', total_count: 2 }); + }), + ), ); const { data, totalCount } = await apiClient.users.getUserList({ offset: 2, @@ -51,12 +60,15 @@ describe('api.client', () => { it('executes a successful backend API request for a paginated response', async () => { server.use( - http.get(`https://api.clerk.test/v1/users/user_123/organization_memberships`, () => { - return HttpResponse.json({ - data: [{ id: '1' }], - total_count: 3, - }); - }), + http.get( + `https://api.clerk.test/v1/users/user_123/organization_memberships`, + validateHeaders(() => { + return HttpResponse.json({ + data: [{ id: '1' }], + total_count: 3, + }); + }), + ), ); const { data: response, totalCount } = await apiClient.users.getOrganizationMembershipList({ @@ -72,9 +84,12 @@ describe('api.client', () => { it('executes a successful backend API request to create a new resource', async () => { server.use( - http.post(`https://api.clerk.test/v1/users`, () => { - return HttpResponse.json(userJson); - }), + http.post( + `https://api.clerk.test/v1/users`, + validateHeaders(() => { + return HttpResponse.json(userJson); + }), + ), ); const response = await apiClient.users.createUser({ @@ -100,12 +115,15 @@ describe('api.client', () => { const traceId = 'trace_id_123'; server.use( - http.get(`https://api.clerk.test/v1/users/user_deadbeef`, () => { - return HttpResponse.json( - { errors: [mockErrorPayload], clerk_trace_id: traceId }, - { status: 422, headers: { 'cf-ray': traceId } }, - ); - }), + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(() => { + return HttpResponse.json( + { errors: [mockErrorPayload], clerk_trace_id: traceId }, + { status: 422, headers: { 'cf-ray': traceId } }, + ); + }), + ), ); const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err); @@ -120,9 +138,12 @@ describe('api.client', () => { it('executes a failed backend API request and include cf ray id when trace not present', async () => { server.use( - http.get(`https://api.clerk.test/v1/users/user_deadbeef`, () => { - return HttpResponse.json({ errors: [] }, { status: 500, headers: { 'cf-ray': 'mock_cf_ray' } }); - }), + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(() => { + return HttpResponse.json({ errors: [] }, { status: 500, headers: { 'cf-ray': 'mock_cf_ray' } }); + }), + ), ); const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err); @@ -134,13 +155,16 @@ describe('api.client', () => { it('executes a successful backend API request to delete a domain', async () => { const DOMAIN_ID = 'dmn_123'; server.use( - http.delete(`https://api.clerk.test/v1/domains/${DOMAIN_ID}`, () => { - return HttpResponse.json({ - object: 'domain', - id: DOMAIN_ID, - deleted: true, - }); - }), + http.delete( + `https://api.clerk.test/v1/domains/${DOMAIN_ID}`, + validateHeaders(() => { + return HttpResponse.json({ + object: 'domain', + id: DOMAIN_ID, + deleted: true, + }); + }), + ), ); await apiClient.domains.deleteDomain(DOMAIN_ID); @@ -148,28 +172,31 @@ describe('api.client', () => { it('successfully retrieves user access tokens from backend API for a specific provider', async () => { server.use( - http.get('https://api.clerk.test/v1/users/user_deadbeef/oauth_access_tokens/oauth_google', ({ request }) => { - const paginated = new URL(request.url).searchParams.get('paginated'); - - if (!paginated) { - return new HttpResponse(null, { status: 404 }); - } - - return HttpResponse.json({ - data: [ - { - external_account_id: 'eac_2dYS7stz9bgxQsSRvNqEAHhuxvW', - object: 'oauth_access_token', - token: '', - provider: 'oauth_google', - public_metadata: {}, - label: null, - scopes: ['email', 'profile'], - }, - ], - total_count: 1, - }); - }), + http.get( + 'https://api.clerk.test/v1/users/user_deadbeef/oauth_access_tokens/oauth_google', + validateHeaders(({ request }): any => { + const paginated = new URL(request.url).searchParams.get('paginated'); + + if (!paginated) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json({ + data: [ + { + external_account_id: 'eac_2dYS7stz9bgxQsSRvNqEAHhuxvW', + object: 'oauth_access_token', + token: '', + provider: 'oauth_google', + public_metadata: {}, + label: null, + scopes: ['email', 'profile'], + }, + ], + total_count: 1, + }); + }), + ), ); const { data } = await apiClient.users.getUserOauthAccessToken('user_deadbeef', 'oauth_google'); diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index 01faff350d8..df1d48b5870 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -2,7 +2,7 @@ import { ClerkAPIResponseError, parseError } from '@clerk/shared/error'; import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; import snakecaseKeys from 'snakecase-keys'; -import { API_URL, API_VERSION, constants, USER_AGENT } from '../constants'; +import { API_URL, API_VERSION, constants, SUPPORTED_BAPI_VERSION, USER_AGENT } from '../constants'; import { runtime } from '../runtime'; import { assertValidSecretKey } from '../util/optionsAssertions'; import { joinPaths } from '../util/path'; @@ -79,6 +79,7 @@ export function buildRequest(options: BuildRequestOptions) { // Build headers const headers: Record = { Authorization: `Bearer ${secretKey}`, + 'Clerk-API-Version': SUPPORTED_BAPI_VERSION, 'User-Agent': userAgent, ...headerParams, }; @@ -86,8 +87,7 @@ export function buildRequest(options: BuildRequestOptions) { let res: Response | undefined; try { if (formData) { - // FIXME: We need to use the global fetch in tests because the runtime.fetch() is not intercepted by MSW - res = await (process.env.NODE_ENV === 'test' ? fetch : runtime.fetch)(finalUrl.href, { + res = await runtime.fetch(finalUrl.href, { method, headers, body: formData, @@ -99,8 +99,7 @@ export function buildRequest(options: BuildRequestOptions) { const hasBody = method !== 'GET' && bodyParams && Object.keys(bodyParams).length > 0; const body = hasBody ? { body: JSON.stringify(snakecaseKeys(bodyParams, { deep: false })) } : null; - // FIXME: We need to use the global fetch in tests because the runtime.fetch() is not intercepted by MSW - res = await (process.env.NODE_ENV === 'test' ? fetch : runtime.fetch)(finalUrl.href, { + res = await runtime.fetch(finalUrl.href, { method, headers, ...body, diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 9b7d92e83e1..766e2f62fba 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -4,6 +4,7 @@ export const API_VERSION = 'v1'; export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60; export const JWKS_CACHE_TTL_MS = 1000 * 60 * 60; +export const SUPPORTED_BAPI_VERSION = '2021-02-05'; const Attributes = { AuthToken: '__clerkAuthToken', diff --git a/packages/backend/src/mock-server.ts b/packages/backend/src/mock-server.ts index 7fe0f5539da..10bee5a939d 100644 --- a/packages/backend/src/mock-server.ts +++ b/packages/backend/src/mock-server.ts @@ -1,5 +1,47 @@ +import { type DefaultBodyType, HttpResponse, type HttpResponseResolver, type PathParams } from 'msw'; import { setupServer } from 'msw/node'; const globalHandlers: any[] = []; export const server = setupServer(...globalHandlers); + +// A higher-order response resolver that validates the request headers before proceeding +export function validateHeaders< + Params extends PathParams, + RequestBodyType extends DefaultBodyType, + ResponseBodyType extends DefaultBodyType, +>( + resolver: HttpResponseResolver, +): HttpResponseResolver> { + return async ({ request, requestId, params, cookies }) => { + if (!request.headers.get('Authorization')) { + return HttpResponse.json( + { + error: 'Unauthorized', + message: 'Missing Authorization header', + }, + { status: 401 }, + ); + } + if (!request.headers.get('Clerk-API-Version')) { + return HttpResponse.json( + { + error: 'Bad request', + message: 'Missing Clerk-API-Version header', + }, + { status: 400 }, + ); + } + if (!request.headers.get('User-Agent') || request.headers.get('User-Agent') !== '@clerk/backend@0.0.0-test') { + return HttpResponse.json( + { + error: 'Bad request', + message: 'Missing or invalid User-Agent header', + }, + { status: 400 }, + ); + } + + return resolver({ request, requestId, params, cookies }); + }; +} diff --git a/packages/backend/src/runtime.ts b/packages/backend/src/runtime.ts index 71650154a51..5f5895ca994 100644 --- a/packages/backend/src/runtime.ts +++ b/packages/backend/src/runtime.ts @@ -33,9 +33,13 @@ type Runtime = { // // https://github.com/supabase/supabase/issues/4417 const globalFetch = fetch.bind(globalThis); + export const runtime: Runtime = { crypto, - fetch: globalFetch, + get fetch() { + // We need to use the globalFetch for Cloudflare Workers but the fetch for testing + return process.env.NODE_ENV === 'test' ? fetch : globalFetch; + }, AbortController: globalThis.AbortController, Blob: globalThis.Blob, FormData: globalThis.FormData, diff --git a/packages/backend/src/tokens/__tests__/authStatus.test.ts b/packages/backend/src/tokens/__tests__/authStatus.test.ts index 397c55d9fe3..87b50c567b1 100644 --- a/packages/backend/src/tokens/__tests__/authStatus.test.ts +++ b/packages/backend/src/tokens/__tests__/authStatus.test.ts @@ -5,6 +5,7 @@ import { handshake, signedIn, signedOut } from '../authStatus'; describe('signed-in', () => { it('does not include debug headers', () => { const authObject = signedIn({} as any, {} as any, undefined, 'token'); + expect(authObject.headers.get('x-clerk-auth-status')).toBeNull(); expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); @@ -13,6 +14,7 @@ describe('signed-in', () => { it('authObject returned by toAuth() returns the token passed', async () => { const signedInAuthObject = signedIn({} as any, { sid: 'sid' } as any, undefined, 'token').toAuth(); const token = await signedInAuthObject.getToken(); + expect(token).toBe('token'); }); }); diff --git a/packages/backend/src/tokens/__tests__/keys.test.ts b/packages/backend/src/tokens/__tests__/keys.test.ts index 22974d252c0..79bb10b05fc 100644 --- a/packages/backend/src/tokens/__tests__/keys.test.ts +++ b/packages/backend/src/tokens/__tests__/keys.test.ts @@ -11,7 +11,7 @@ import { mockRsaJwk, mockRsaJwkKid, } from '../../fixtures'; -import { server } from '../../mock-server'; +import { server, validateHeaders } from '../../mock-server'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from '../keys'; describe('tokens.loadClerkJWKFromLocal(localKey)', () => { @@ -48,9 +48,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('loads JWKS from Backend API when secretKey is provided', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); const jwk = await loadClerkJWKFromRemote({ secretKey: 'sk_test_deadbeef', @@ -63,9 +66,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('loads JWKS from Backend API using the provided apiUrl', async () => { server.use( - http.get('https://api.clerk.test/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); const jwk = await loadClerkJWKFromRemote({ @@ -80,9 +86,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('caches JWK by kid', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); let jwk = await loadClerkJWKFromRemote({ @@ -100,9 +109,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('retries five times with exponential back-off policy to fetch JWKS before it fails', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json({}, { status: 503 }); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json({}, { status: 503 }); + }), + ), ); await expect(async () => { @@ -127,9 +139,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('throws an error when no JWK matches the provided kid', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); const kid = 'ins_whatever'; @@ -146,9 +161,12 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { it('cache TTLs do not conflict', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); let jwk = await loadClerkJWKFromRemote({ diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 85e966f65b6..895f948b064 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; -import { server } from '../../mock-server'; +import { server, validateHeaders } from '../../mock-server'; import { verifyToken } from '../verify'; describe('tokens.verify(token, options)', () => { @@ -16,9 +16,12 @@ describe('tokens.verify(token, options)', () => { it('verifies the provided session JWT', async () => { server.use( - http.get('https://api.clerk.test/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); const { data } = await verifyToken(mockJwt, { @@ -33,9 +36,12 @@ describe('tokens.verify(token, options)', () => { it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => { server.use( - http.get('https://api.clerk.com/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), ); const { data } = await verifyToken(mockJwt, { diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 1512ab48018..a9b7d97cad1 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -1,4 +1,10 @@ -import { API_URL, API_VERSION, MAX_CACHE_LAST_UPDATED_AT_SECONDS } from '../constants'; +import { + API_URL, + API_VERSION, + MAX_CACHE_LAST_UPDATED_AT_SECONDS, + SUPPORTED_BAPI_VERSION, + USER_AGENT, +} from '../constants'; import { TokenVerificationError, TokenVerificationErrorAction, @@ -162,11 +168,12 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string const url = new URL(apiUrl); url.pathname = joinPaths(url.pathname, apiVersion, '/jwks'); - // FIXME: We need to use the global fetch in tests because the runtime.fetch() is not intercepted by MSW - const response = await (process.env.NODE_ENV === 'test' ? fetch : runtime.fetch)(url.href, { + const response = await runtime.fetch(url.href, { headers: { Authorization: `Bearer ${key}`, + 'Clerk-API-Version': SUPPORTED_BAPI_VERSION, 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, }, }); diff --git a/packages/backend/vitest.config.mts b/packages/backend/vitest.config.mts index e1a3d868176..dc4f277fb5e 100644 --- a/packages/backend/vitest.config.mts +++ b/packages/backend/vitest.config.mts @@ -3,7 +3,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [], test: { - setupFiles: './vitest.setup.mts', + coverage: { + provider: 'v8', + }, includeSource: ['**/*.{js,ts,jsx,tsx}'], + setupFiles: './vitest.setup.mts', }, });