diff --git a/src/runtime/browser/fetch_jwks.ts b/src/runtime/browser/fetch_jwks.ts index bd0f104649..d8a9bcfab6 100644 --- a/src/runtime/browser/fetch_jwks.ts +++ b/src/runtime/browser/fetch_jwks.ts @@ -1,26 +1,38 @@ import type { FetchFunction } from '../interfaces.d' -import { JOSEError } from '../../util/errors.js' +import { JOSEError, JWKSTimeout } from '../../util/errors.js' import globalThis, { isCloudflareWorkers } from './global.js' const fetchJwks: FetchFunction = async (url: URL, timeout: number) => { let controller!: AbortController + let id!: ReturnType + let timedOut = false if (typeof AbortController === 'function') { controller = new AbortController() - setTimeout(() => controller.abort(), timeout) + id = setTimeout(() => { + timedOut = true + controller.abort() + }, timeout) } - const response = await globalThis.fetch(url.href, { - signal: controller ? controller.signal : undefined, - redirect: 'manual', - method: 'GET', - ...(!isCloudflareWorkers() - ? { - referrerPolicy: 'no-referrer', - credentials: 'omit', - mode: 'cors', - } - : undefined), - }) + const response = await globalThis + .fetch(url.href, { + signal: controller ? controller.signal : undefined, + redirect: 'manual', + method: 'GET', + ...(!isCloudflareWorkers() + ? { + referrerPolicy: 'no-referrer', + credentials: 'omit', + mode: 'cors', + } + : undefined), + }) + .catch((err) => { + if (timedOut) throw new JWKSTimeout() + throw err + }) + + if (id !== undefined) clearTimeout(id) if (response.status !== 200) { throw new JOSEError('Expected 200 OK from the JSON Web Key Set HTTP response') diff --git a/src/runtime/node/fetch_jwks.ts b/src/runtime/node/fetch_jwks.ts index 43c25782d4..7482be6318 100644 --- a/src/runtime/node/fetch_jwks.ts +++ b/src/runtime/node/fetch_jwks.ts @@ -5,7 +5,7 @@ import type { ClientRequest, IncomingMessage } from 'http' import type { RequestOptions } from 'https' import type { FetchFunction } from '../interfaces.d' -import { JOSEError } from '../../util/errors.js' +import { JOSEError, JWKSTimeout } from '../../util/errors.js' import { concat, decoder } from '../../lib/buffer_utils.js' const protocols: { [protocol: string]: (...args: Parameters) => ClientRequest } = { @@ -30,7 +30,15 @@ const fetchJwks: FetchFunction = async ( timeout, }) - const [response] = <[IncomingMessage]>await once(req, 'response') + const [response] = <[IncomingMessage]>( + await Promise.race([once(req, 'response'), once(req, 'timeout')]) + ) + + // timeout reached + if (!response) { + req.destroy() + throw new JWKSTimeout() + } if (response.statusCode !== 200) { throw new JOSEError('Expected 200 OK from the JSON Web Key Set HTTP response') diff --git a/src/util/errors.ts b/src/util/errors.ts index 667fc5fe22..d12f60c313 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -142,6 +142,17 @@ export class JWKSMultipleMatchingKeys extends JOSEError { message = 'multiple matching keys found in the JSON Web Key Set' } +/** + * Timeout was reached when retrieving the JWKS response. + */ +export class JWKSTimeout extends JOSEError { + static code = 'ERR_JWKS_TIMEOUT' + + code = JWKSTimeout.code + + message = 'request timed out' +} + /** * An error subclass thrown when JWS signature verification fails. */ diff --git a/test-browser/jwks.js b/test-browser/jwks.js index 8e67540132..e76243e898 100644 --- a/test-browser/jwks.js +++ b/test-browser/jwks.js @@ -16,3 +16,9 @@ QUnit.test('fetches the JWKSet', async (assert) => { ); assert.ok(await jwks({ alg, kid })); }); + +const conditional = typeof AbortController === 'function' ? QUnit.test : QUnit.skip; +conditional('timeout', async (assert) => { + const jwks = createRemoteJWKSet(new URL(jwksUri)); + await assert.rejects(jwks({ alg: 'RS256' }, { timeoutDuration: 0 }), 'request timed out'); +}); diff --git a/test-cloudflare-workers/cloudflare.test.mjs b/test-cloudflare-workers/cloudflare.test.mjs index bfd70bf59f..967ed226a2 100644 --- a/test-cloudflare-workers/cloudflare.test.mjs +++ b/test-cloudflare-workers/cloudflare.test.mjs @@ -349,6 +349,21 @@ test('createRemoteJWKSet', macro, async () => { await jwks({ alg, kid }); }); +test('remote jwk set timeout', macro, async () => { + const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs'; + const jwks = jwksRemote(new URL(jwksUri), { timeoutDuration: 0 }); + await jwks({ alg: 'RS256' }).then( + () => { + throw new Error('should fail'); + }, + (err) => { + if (err.code !== 'ERR_JWKS_TIMEOUT') { + throw err; + } + }, + ); +}); + test('ECDH-ES', macro, async () => { const keypair = await utilGenerateKeyPair('ECDH-ES'); await jweAsymmetricTest(keypair, 'ECDH-ES'); diff --git a/test-deno/jwks.test.ts b/test-deno/jwks.test.ts index ea5bfc8023..68d3339d4c 100644 --- a/test-deno/jwks.test.ts +++ b/test-deno/jwks.test.ts @@ -1,7 +1,11 @@ import { assertThrowsAsync } from 'https://deno.land/std@0.109.0/testing/asserts.ts'; import createRemoteJWKSet from '../dist/deno/jwks/remote.ts'; -import { JWKSNoMatchingKey, JWKSMultipleMatchingKeys } from '../dist/deno/util/errors.ts'; +import { + JWKSTimeout, + JWKSNoMatchingKey, + JWKSMultipleMatchingKeys, +} from '../dist/deno/util/errors.ts'; const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs'; @@ -21,3 +25,13 @@ Deno.test('fetches the JWKSet', async () => { ); await jwks({ alg, kid }, null); }); + +Deno.test('timeout', async () => { + const server = Deno.listen({ port: 3000 }); + const jwks = createRemoteJWKSet(new URL('http://localhost:3000'), { timeoutDuration: 0 }); + await assertThrowsAsync(() => jwks({ alg: 'RS256' }, null), JWKSTimeout, 'request timed out').finally(async () => { + const conn = await server.accept(); + conn.close(); + server.close(); + }); +}); diff --git a/test/jwks/remote.test.mjs b/test/jwks/remote.test.mjs index d357cdcc61..d49bfd8a73 100644 --- a/test/jwks/remote.test.mjs +++ b/test/jwks/remote.test.mjs @@ -32,7 +32,7 @@ Promise.all([ test.before(async (t) => { nock.disableNetConnect(); - t.context.server = createServer().listen(3000); + t.context.server = createServer().unref().listen(3000); t.context.server.removeAllListeners('request'); await once(t.context.server, 'listening'); }); @@ -42,6 +42,10 @@ Promise.all([ await new Promise((resolve) => t.context.server.close(resolve)); }); + test.afterEach(() => { + nock.disableNetConnect(); + }); + test.afterEach((t) => { t.context.server.removeAllListeners('request'); t.true(nock.isDone()); @@ -261,7 +265,7 @@ Promise.all([ }); }); - test.serial('handles ENOTFOUND', async (t) => { + test('handles ENOTFOUND', async (t) => { nock.enableNetConnect(); const url = new URL('https://op.example.com/jwks'); const JWKS = createRemoteJWKSet(url); @@ -270,7 +274,8 @@ Promise.all([ }); }); - test.serial('handles ECONNREFUSED', async (t) => { + test('handles ECONNREFUSED', async (t) => { + nock.enableNetConnect(); const url = new URL('http://localhost:3001/jwks'); const JWKS = createRemoteJWKSet(url); await t.throwsAsync(JWKS({ alg: 'RS256' }), { @@ -278,7 +283,8 @@ Promise.all([ }); }); - test.serial('handles ECONNRESET', async (t) => { + test('handles ECONNRESET', async (t) => { + nock.enableNetConnect(); const url = new URL('http://localhost:3000/jwks'); t.context.server.once('connection', (socket) => { socket.destroy(); @@ -288,10 +294,21 @@ Promise.all([ code: 'ECONNRESET', }); }); + + test('handles a timeout', async (t) => { + t.timeout(1000); + nock.enableNetConnect(); + const url = new URL('http://localhost:3000/jwks'); + const JWKS = createRemoteJWKSet(url, { + timeoutDuration: 500, + }); + await t.throwsAsync(JWKS({ alg: 'RS256' }), { + code: 'ERR_JWKS_TIMEOUT', + }); + }); }, (err) => { - test.serial('failed to import', (t) => { - console.error(err); + test('failed to import', (t) => { t.fail(); }); },