Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/internal/auth/jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,99 @@ describe('JWT', () => {
})
})

const algFixtures: { type: AsymmetricKeyType; alg: AsymmetricKeyFixture['alg'] }[] = [
{ type: 'rsa', alg: 'RS256' },
{ type: 'ec', alg: 'ES256' },
{ type: 'ed25519', alg: 'EdDSA' },
]

describe('JWK alg field matching', () => {
algFixtures.forEach(({ type, alg }) => {
test(`it should verify a ${alg} JWT when the matching jwk has no alg field`, async () => {
const { publicKey, privateKey } = asymmetricKeyPairFactories[type]()
const kid = `no-alg-${alg}`

const { alg: _omitted, ...jwkWithoutAlg } = {
...(publicKey.export({ format: 'jwk' }) as JwksConfigKey),
kid,
}

const token = await new SignJWT({ sub: `test-${alg}` })
.setIssuedAt()
.setExpirationTime('1h')
.setProtectedHeader({ alg, kid })
.sign(privateKey)

const result = await verifyJWT(token, 'unused-secret', {
keys: [jwkWithoutAlg as JwksConfigKey],
})
expect(result.sub).toEqual(`test-${alg}`)
})
})

test('it should reject an asymmetric JWT when the matching jwk has a different alg', async () => {
const { publicKey, privateKey } = asymmetricKeyPairFactories['rsa']()
const kid = 'alg-mismatch-rsa'

const jwkRS256 = {
...(publicKey.export({ format: 'jwk' }) as JwksConfigKey),
kid,
alg: 'RS256' as const,
}

const token = await new SignJWT({ sub: 'alg-mismatch-test' })
.setIssuedAt()
.setExpirationTime('1h')
.setProtectedHeader({ alg: 'RS512', kid })
.sign(privateKey)

await expect(verifyJWT(token, 'unused-secret', { keys: [jwkRS256] })).rejects.toThrow()
})

test('it should reject a HMAC JWT when the matching jwk has a different alg', async () => {
const rawKey = crypto.randomBytes(32)
const kid = 'alg-mismatch-hmac'

const jwkHS512: JwksConfigKey = {
kty: 'oct',
k: rawKey.toString('base64url'),
kid,
alg: 'HS512',
} as JwksConfigKey

const token = await new SignJWT({ sub: 'hmac-alg-mismatch' })
.setIssuedAt()
.setExpirationTime('1h')
.setProtectedHeader({ alg: 'HS256', kid })
.sign(rawKey)

await expect(verifyJWT(token, 'wrong-secret', { keys: [jwkHS512] })).rejects.toThrow()
})
})

algFixtures.forEach(({ type, alg }) => {
test(`it should reject a ${alg} JWT when the token was signed with a key not in the jwks`, async () => {
const { publicKey } = asymmetricKeyPairFactories[type]()
const { privateKey: wrongPrivateKey } = asymmetricKeyPairFactories[type]()
const kid = `wrong-key-${alg}`

const { alg: _omitted, ...jwkWithoutAlg } = {
...(publicKey.export({ format: 'jwk' }) as JwksConfigKey),
kid,
}

const token = await new SignJWT({ sub: `test-${alg}` })
.setIssuedAt()
.setExpirationTime('1h')
.setProtectedHeader({ alg, kid })
.sign(wrongPrivateKey)

await expect(
verifyJWT(token, 'unused-secret', { keys: [jwkWithoutAlg as JwksConfigKey] })
).rejects.toThrow()
})
})

test('it should try secret if no matching jwk kty/alg found in jwks', async () => {
const jwk = await generateHS512JWK()
jwk.kid = 'abc123'
Expand Down
18 changes: 13 additions & 5 deletions src/internal/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ async function findJWKFromHeader(
return encoder.encode(secret)
}

// find the first key without a kid or with the matching kid and the "oct" type
// find the first key without a kid or with the matching kid, the "oct" type, and a compatible alg
const jwk = jwks.keys.find(
(key) => (!key.kid || key.kid === header.kid) && key.kty === 'oct' && key.k
(key) =>
(!key.kid || key.kid === header.kid) &&
key.kty === 'oct' &&
key.k &&
(!key.alg || key.alg === header.alg)
)

if (!jwk) {
Expand All @@ -81,16 +85,20 @@ async function findJWKFromHeader(
kty = 'OKP'
}

// find the first key with a matching kid (or no kid if none is specified in the JWT header) and the correct key type
// find the first key with a matching kid (or no kid if none is specified in the JWT header), the correct key type, and a compatible alg
const jwk = jwks.keys.find((key) => {
return ((!key.kid && !header.kid) || key.kid === header.kid) && key.kty === kty
return (
((!key.kid && !header.kid) || key.kid === header.kid) &&
key.kty === kty &&
(!key.alg || key.alg === header.alg)
)
})

if (!jwk) {
// couldn't find a matching JWK, try to use the secret
return encoder.encode(secret)
}
return await importJWK(jwk)
return await importJWK(jwk, jwk.alg || header.alg)
}
Comment on lines 98 to 102
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we need to see some tests as well

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • accept asymmetric jwt when matching jwk has no alg
  • reject asymmetric jwt when matching jwk has a different alg
  • reject hmac jwt when matching jwk has a different alg


function getJWTVerificationKey(secret: string, jwks: JwksConfig | null): JWTVerifyGetKey {
Expand Down