fix: createRemoteJWKSet handles all JWS syntaxes

This commit is contained in:
Filip Skokan 2021-11-11 21:47:46 +01:00
parent 2e2b79d486
commit aaba8f3000
5 changed files with 36 additions and 30 deletions

View file

@ -103,23 +103,28 @@ class RemoteJWKSet {
return Date.now() < this._cooldownStarted + this._cooldownDuration
}
async getKey(protectedHeader: JWSHeaderParameters): Promise<KeyLike> {
async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise<KeyLike> {
const joseHeader = {
...protectedHeader,
...token.header,
}
if (!this._jwks) {
await this.reload()
}
const candidates = this._jwks!.keys.filter((jwk) => {
// filter keys based on the mapping of signature algorithms to Key Type
let candidate = jwk.kty === getKtyFromAlg(protectedHeader.alg)
let candidate = jwk.kty === getKtyFromAlg(joseHeader.alg)
// filter keys based on the JWK Key ID in the header
if (candidate && typeof protectedHeader.kid === 'string') {
candidate = protectedHeader.kid === jwk.kid
if (candidate && typeof joseHeader.kid === 'string') {
candidate = joseHeader.kid === jwk.kid
}
// filter keys based on the key's declared Algorithm
if (candidate && typeof jwk.alg === 'string') {
candidate = protectedHeader.alg === jwk.alg
candidate = joseHeader.alg === jwk.alg
}
// filter keys based on the key's declared Public Key Use
@ -133,13 +138,13 @@ class RemoteJWKSet {
}
// filter out non-applicable OKP Sub Types
if (candidate && protectedHeader.alg === 'EdDSA') {
if (candidate && joseHeader.alg === 'EdDSA') {
candidate = jwk.crv === 'Ed25519' || jwk.crv === 'Ed448'
}
// filter out non-applicable EC curves
if (candidate) {
switch (protectedHeader.alg) {
switch (joseHeader.alg) {
case 'ES256':
candidate = jwk.crv === 'P-256'
break
@ -164,7 +169,7 @@ class RemoteJWKSet {
if (length === 0) {
if (this.coolingDown() === false) {
await this.reload()
return this.getKey(protectedHeader)
return this.getKey(joseHeader, token)
}
throw new JWKSNoMatchingKey()
} else if (length !== 1) {
@ -172,17 +177,17 @@ class RemoteJWKSet {
}
const cached = this._cached.get(jwk) || this._cached.set(jwk, {}).get(jwk)!
if (cached[protectedHeader.alg!] === undefined) {
const keyObject = await importJWK({ ...jwk, ext: true }, protectedHeader.alg!)
if (cached[joseHeader.alg!] === undefined) {
const keyObject = await importJWK({ ...jwk, ext: true }, joseHeader.alg!)
if (keyObject instanceof Uint8Array || keyObject.type !== 'public') {
throw new JWKSInvalid('JSON Web Key Set members must be public keys')
}
cached[protectedHeader.alg!] = keyObject
cached[joseHeader.alg!] = keyObject
}
return cached[protectedHeader.alg!]
return cached[joseHeader.alg!]
}
async reload() {

View file

@ -7,11 +7,11 @@ QUnit.test('fetches the JWKSet', async (assert) => {
const { alg, kid } = response.keys[0]
const jwks = createRemoteJWKSet(new URL(jwksUri))
await assert.rejects(
jwks({ alg: 'RS256' }),
jwks({ alg: 'RS256' }, {}),
'multiple matching keys found in the JSON Web Key Set',
)
await assert.rejects(
jwks({ kid: 'foo', alg: 'RS256' }),
jwks({ kid: 'foo', alg: 'RS256' }, {}),
'no applicable key found in the JSON Web Key Set',
)
assert.ok(await jwks({ alg, kid }))

View file

@ -346,13 +346,13 @@ test('createRemoteJWKSet', macro, async () => {
const response = await fetch(jwksUri).then((r) => r.json())
const { alg, kid } = response.keys[0]
const jwks = jose.createRemoteJWKSet(new URL(jwksUri))
await jwks({ alg, kid })
await jwks({ alg, kid }, {})
})
test('remote jwk set timeout', macro, async () => {
const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs'
const jwks = jose.createRemoteJWKSet(new URL(jwksUri), { timeoutDuration: 0 })
await jwks({ alg: 'RS256' }).then(
await jwks({ alg: 'RS256' }, {}).then(
() => {
throw new Error('should fail')
},

View file

@ -1,6 +1,7 @@
import { assertThrowsAsync } from 'https://deno.land/std@0.109.0/testing/asserts.ts'
import { createRemoteJWKSet, errors } from '../dist/deno/index.ts'
import type { FlattenedJWSInput } from '../dist/deno/index.ts'
const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs'
@ -9,23 +10,23 @@ Deno.test('fetches the JWKSet', async () => {
const { alg, kid } = response.keys[0]
const jwks = createRemoteJWKSet(new URL(jwksUri))
await assertThrowsAsync(
() => jwks({ alg: 'RS256' }, <any>null),
() => jwks({ alg: 'RS256' }, <FlattenedJWSInput>{}),
errors.JWKSMultipleMatchingKeys,
'multiple matching keys found in the JSON Web Key Set',
)
await assertThrowsAsync(
() => jwks({ kid: 'foo', alg: 'RS256' }, <any>null),
() => jwks({ kid: 'foo', alg: 'RS256' }, <FlattenedJWSInput>{}),
errors.JWKSNoMatchingKey,
'no applicable key found in the JSON Web Key Set',
)
await jwks({ alg, kid }, <any>null)
await jwks({ alg, kid }, <FlattenedJWSInput>{})
})
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' }, <any>null),
() => jwks({ alg: 'RS256' }, <FlattenedJWSInput>{}),
errors.JWKSTimeout,
'request timed out',
).finally(async () => {

View file

@ -209,19 +209,19 @@ test.serial('throws on invalid JWKSet', async (t) => {
const url = new URL('https://as.example.com/jwks')
const JWKS = createRemoteJWKSet(url)
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JWKS_INVALID',
message: 'JSON Web Key Set malformed',
})
scope.get('/jwks').once().reply(200, {})
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JWKS_INVALID',
message: 'JSON Web Key Set malformed',
})
scope.get('/jwks').once().reply(200, { keys: null })
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JWKS_INVALID',
message: 'JSON Web Key Set malformed',
})
@ -230,19 +230,19 @@ test.serial('throws on invalid JWKSet', async (t) => {
.get('/jwks')
.once()
.reply(200, { keys: [null] })
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JWKS_INVALID',
message: 'JSON Web Key Set malformed',
})
scope.get('/jwks').once().reply(404)
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JOSE_GENERIC',
message: 'Expected 200 OK from the JSON Web Key Set HTTP response',
})
scope.get('/jwks').once().reply(200, '{')
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JOSE_GENERIC',
message: 'Failed to parse the JSON Web Key Set HTTP response as JSON',
})
@ -252,7 +252,7 @@ test('handles ENOTFOUND', async (t) => {
nock.enableNetConnect()
const url = new URL('https://op.example.com/jwks')
const JWKS = createRemoteJWKSet(url)
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ENOTFOUND',
})
})
@ -261,7 +261,7 @@ 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' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ECONNREFUSED',
})
})
@ -273,7 +273,7 @@ test('handles ECONNRESET', async (t) => {
socket.destroy()
})
const JWKS = createRemoteJWKSet(url)
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ECONNRESET',
})
})
@ -285,7 +285,7 @@ test('handles a timeout', async (t) => {
const JWKS = createRemoteJWKSet(url, {
timeoutDuration: 500,
})
await t.throwsAsync(JWKS({ alg: 'RS256' }), {
await t.throwsAsync(JWKS({ alg: 'RS256' }, {}), {
code: 'ERR_JWKS_TIMEOUT',
})
})