Skip to content

Commit 190f6e2

Browse files
calloc134yusukebe
andauthored
Merge commit from fork
* feat(utils/jwt): add JwtAlgorithmMismatch and JwtSymmetricAlgorithmNotAllowed error types * fix(utils/jwt): prevent algorithm confusion attacks in verifyWithJwks - Reject symmetric algorithms (HS256/HS384/HS512) in JWK verification - Verify JWK alg matches JWT header alg when JWK has alg field - Use header.alg for verification instead of JWK alg fallback * test(utils/jwt): add security tests for verifyWithJwks - Update header.alg fallback test to use asymmetric algorithm (RS256) - Add tests for symmetric algorithm rejection (HS256/HS384/HS512) - Add test for algorithm mismatch between JWK and JWT header - Add test for algorithm confusion attack prevention * feat(utils/jwt): add JwtAlgorithmNotAllowed error type Add new error class for algorithm whitelist validation. This error is thrown when JWT's algorithm is not in the allowed list. * feat(utils/jwt): add algorithm whitelist support to verifyWithJwks Add optional allowedAlgorithms parameter to verifyWithJwks function. When specified, only tokens signed with algorithms in the whitelist will be accepted. This provides an additional layer of security by explicitly defining which algorithms are permitted. Validation order: 1. Check algorithm against whitelist (if specified) 2. Reject symmetric algorithms (HS256/HS384/HS512) 3. Validate JWK alg matches header alg (if JWK has alg field) * feat(middleware/jwk): add alg option for algorithm whitelist Add alg option to JWK middleware to specify allowed algorithms. This option is passed to verifyWithJwks as allowedAlgorithms. Example usage: jwk({ keys, alg: ['RS256', 'ES256'] }) * test(utils/jwt): add algorithm whitelist tests for verifyWithJwks Add tests for: - Reject algorithm not in whitelist - Accept algorithm in whitelist - Accept any asymmetric algorithm when whitelist not specified - Accept any asymmetric algorithm when whitelist is empty - Reject symmetric algorithm even if in whitelist * test(middleware/jwk): add algorithm whitelist tests Add tests for JWK middleware alg option: - Authorize RS256 token when RS256 is in whitelist - Reject token when algorithm is not in whitelist - Authorize RS256 token when multiple algorithms are in whitelist - Authorize RS256 token when no whitelist is specified * feat(utils/jwt): add AsymmetricAlgorithm and SymmetricAlgorithm type definitions - Added SymmetricAlgorithm type for HMAC algorithms: HS256, HS384, HS512 - Added AsymmetricAlgorithm type for RSA/ECDSA/EdDSA algorithms - Enables compile-time prevention of algorithm confusion attacks * fix(utils/jwt): make allowedAlgorithms required in verifyWithJwks BREAKING CHANGE: allowedAlgorithms is now a required parameter with type AsymmetricAlgorithm[] - Changed allowedAlgorithms from optional to required - Changed type from SignatureAlgorithm[] to AsymmetricAlgorithm[] - Reordered validation: symmetric algorithm rejection before whitelist check - Prevents algorithm confusion attacks at both runtime and compile-time * fix(middleware/jwk): make alg option required BREAKING CHANGE: alg option is now required with type AsymmetricAlgorithm[] - Changed alg from optional to required - Changed type from SignatureAlgorithm[] to AsymmetricAlgorithm[] - Updated JSDoc to reflect breaking change - Ensures users must explicitly specify allowed algorithms * test(utils/jwt): update tests for required allowedAlgorithms - Added allowedAlgorithms: ['RS256'] to all verifyWithJwks calls - Removed tests for 'whitelist not specified' and 'empty whitelist' scenarios - Added comments explaining breaking changes - Renamed test to 'Should reject symmetric algorithm (HS256) in JWT header' * test(middleware/jwk): update tests for required alg option - Added alg: ['RS256'] to all jwk() middleware calls - Removed test for 'no whitelist' scenario (no longer applicable) - Added comments explaining breaking changes - Updated verifyWithJwks test to include allowedAlgorithms * test(utils/jwt): add type tests for algorithm type definitions - Added tests for SymmetricAlgorithm type (HS256, HS384, HS512) - Added tests for AsymmetricAlgorithm type (RS*, PS*, ES*, EdDSA) - Added tests for SignatureAlgorithm type (all 13 algorithms) - Tests verify type constraints at runtime * fix(test): resolve TypeScript errors in JWK middleware tests - Removed unused @ts-expect-error directive on line 40 - Added @ts-expect-error for empty object test (line 210) - Added required alg option to crypto.subtle test (line 220) - All 115 tests still passing * refactor: use SymmetricAlgorithm type for symmetricAlgorithms array * fix(utils/jwt): cast header.alg to SymmetricAlgorithm to prevent type errors * fix(utils/jwt): update comment for clarity on algorithm validation --------- Co-authored-by: Yusuke Wada <[email protected]>
1 parent a48ef18 commit 190f6e2

File tree

7 files changed

+673
-30
lines changed

7 files changed

+673
-30
lines changed

src/middleware/jwk/index.test.ts

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ describe('JWK', () => {
3535
afterAll(() => server.close())
3636

3737
describe('verifyWithJwks', () => {
38-
it('Should throw error on missing options', async () => {
38+
it('Should throw error on missing keys/jwks_uri options', async () => {
3939
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
40-
await expect(verifyWithJwks(credential, {})).rejects.toThrow(
40+
await expect(verifyWithJwks(credential, { allowedAlgorithms: ['RS256'] })).rejects.toThrow(
4141
'verifyWithJwks requires options for either "keys" or "jwks_uri" or both'
4242
)
4343
})
@@ -52,7 +52,7 @@ describe('JWK', () => {
5252

5353
const app = new Hono()
5454

55-
app.use('/backend-auth-or-anon/*', jwk({ keys: verify_keys, allow_anon: true }))
55+
app.use('/backend-auth-or-anon/*', jwk({ keys: verify_keys, allow_anon: true, alg: ['RS256'] }))
5656

5757
app.get('/backend-auth-or-anon/*', (c) => {
5858
handlerExecuted = true
@@ -105,10 +105,10 @@ describe('JWK', () => {
105105

106106
const app = new Hono()
107107

108-
app.use('/auth-with-keys/*', jwk({ keys: verify_keys }))
109-
app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys }))
108+
app.use('/auth-with-keys/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
109+
app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
110110
app.use('/auth-with-keys-nested/*', async (c, next) => {
111-
const auth = jwk({ keys: verify_keys })
111+
const auth = jwk({ keys: verify_keys, alg: ['RS256'] })
112112
return auth(c, next)
113113
})
114114
app.use(
@@ -119,37 +119,43 @@ describe('JWK', () => {
119119
const data = await response.json()
120120
return data.keys
121121
},
122+
alg: ['RS256'],
122123
})
123124
)
124125
app.use(
125126
'/auth-with-jwks_uri/*',
126127
jwk({
127128
jwks_uri: 'http://localhost/.well-known/jwks.json',
129+
alg: ['RS256'],
128130
})
129131
)
130132
app.use(
131133
'/auth-with-keys-and-jwks_uri/*',
132134
jwk({
133135
keys: verify_keys,
134136
jwks_uri: () => 'http://localhost/.well-known/jwks.json',
137+
alg: ['RS256'],
135138
})
136139
)
137140
app.use(
138141
'/auth-with-missing-jwks_uri/*',
139142
jwk({
140143
jwks_uri: 'http://localhost/.well-known/missing-jwks.json',
144+
alg: ['RS256'],
141145
})
142146
)
143147
app.use(
144148
'/auth-with-404-jwks_uri/*',
145149
jwk({
146150
jwks_uri: 'http://localhost/.well-known/404-jwks.json',
151+
alg: ['RS256'],
147152
})
148153
)
149154
app.use(
150155
'/auth-with-bad-jwks_uri/*',
151156
jwk({
152157
jwks_uri: 'http://localhost/.well-known/bad-jwks.json',
158+
alg: ['RS256'],
153159
})
154160
)
155161

@@ -200,6 +206,7 @@ describe('JWK', () => {
200206
})
201207

202208
it('Should throw an error if the middleware is missing both keys and jwks_uri (empty)', async () => {
209+
// @ts-expect-error - Testing runtime error with missing required alg option
203210
expect(() => app.use('/auth-with-empty-middleware/*', jwk({}))).toThrow(
204211
'JWK auth middleware requires options for either "keys" or "jwks_uri"'
205212
)
@@ -210,7 +217,9 @@ describe('JWK', () => {
210217
importKey: undefined,
211218
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212219
} as any)
213-
expect(() => app.use('/auth-with-bad-env/*', jwk({ keys: verify_keys }))).toThrow()
220+
expect(() =>
221+
app.use('/auth-with-bad-env/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
222+
).toThrow()
214223
subtleSpy.mockRestore()
215224
})
216225

@@ -443,7 +452,10 @@ describe('JWK', () => {
443452

444453
const app = new Hono()
445454

446-
app.use('/auth-with-keys/*', jwk({ keys: verify_keys, headerName: 'x-custom-auth-header' }))
455+
app.use(
456+
'/auth-with-keys/*',
457+
jwk({ keys: verify_keys, headerName: 'x-custom-auth-header', alg: ['RS256'] })
458+
)
447459

448460
app.get('/auth-with-keys/*', (c) => {
449461
handlerExecuted = true
@@ -494,15 +506,22 @@ describe('JWK', () => {
494506

495507
const app = new Hono()
496508

497-
app.use('/auth-with-keys/*', jwk({ keys: verify_keys, cookie: 'access_token' }))
498-
app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys, cookie: 'access_token' }))
509+
app.use('/auth-with-keys/*', jwk({ keys: verify_keys, cookie: 'access_token', alg: ['RS256'] }))
510+
app.use(
511+
'/auth-with-keys-unicode/*',
512+
jwk({ keys: verify_keys, cookie: 'access_token', alg: ['RS256'] })
513+
)
499514
app.use(
500515
'/auth-with-keys-prefixed/*',
501-
jwk({ keys: verify_keys, cookie: { key: 'access_token', prefixOptions: 'host' } })
516+
jwk({
517+
keys: verify_keys,
518+
cookie: { key: 'access_token', prefixOptions: 'host' },
519+
alg: ['RS256'],
520+
})
502521
)
503522
app.use(
504523
'/auth-with-keys-unprefixed/*',
505-
jwk({ keys: verify_keys, cookie: { key: 'access_token' } })
524+
jwk({ keys: verify_keys, cookie: { key: 'access_token' }, alg: ['RS256'] })
506525
)
507526

508527
app.get('/auth-with-keys/*', (c) => {
@@ -637,13 +656,18 @@ describe('JWK', () => {
637656

638657
app.use(
639658
'/auth-with-signed-cookie/*',
640-
jwk({ keys: verify_keys, cookie: { key: 'access_token', secret: test_secret } })
659+
jwk({
660+
keys: verify_keys,
661+
cookie: { key: 'access_token', secret: test_secret },
662+
alg: ['RS256'],
663+
})
641664
)
642665
app.use(
643666
'/auth-with-signed-with-prefix-options-cookie/*',
644667
jwk({
645668
keys: verify_keys,
646669
cookie: { key: 'access_token', secret: test_secret, prefixOptions: 'host' },
670+
alg: ['RS256'],
647671
})
648672
)
649673

@@ -721,7 +745,7 @@ describe('JWK', () => {
721745
describe('Error handling with `cause`', () => {
722746
const app = new Hono()
723747

724-
app.use('/auth-with-keys/*', jwk({ keys: verify_keys }))
748+
app.use('/auth-with-keys/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
725749
app.get('/auth-with-keys/*', (c) => c.text('Authorized'))
726750

727751
app.onError((e, c) => {
@@ -761,10 +785,10 @@ describe('JWK', () => {
761785

762786
const app = new Hono()
763787

764-
app.use('/auth-with-keys-default/*', jwk({ keys: verify_keys }))
788+
app.use('/auth-with-keys-default/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
765789
app.use(
766790
'/auth-with-keys-and-issuer/*',
767-
jwk({ keys: verify_keys, verification: { iss: 'http://issuer.test' } })
791+
jwk({ keys: verify_keys, verification: { iss: 'http://issuer.test' }, alg: ['RS256'] })
768792
)
769793

770794
app.get('/auth-with-keys-default/*', (c) => {
@@ -874,4 +898,100 @@ describe('JWK', () => {
874898
expect(handlerExecuted).toBeFalsy()
875899
})
876900
})
901+
902+
describe('Algorithm whitelist (options.alg)', () => {
903+
let handlerExecuted: boolean
904+
905+
beforeEach(() => {
906+
handlerExecuted = false
907+
})
908+
909+
const app = new Hono()
910+
911+
// Only allow RS256
912+
app.use('/auth-whitelist-rs256/*', jwk({ keys: verify_keys, alg: ['RS256'] }))
913+
app.get('/auth-whitelist-rs256/*', (c) => {
914+
handlerExecuted = true
915+
return c.json(c.get('jwtPayload'))
916+
})
917+
918+
// Allow multiple algorithms
919+
app.use('/auth-whitelist-multi/*', jwk({ keys: verify_keys, alg: ['RS256', 'ES256'] }))
920+
app.get('/auth-whitelist-multi/*', (c) => {
921+
handlerExecuted = true
922+
return c.json(c.get('jwtPayload'))
923+
})
924+
925+
// Note: Test for "no whitelist" was removed because alg is now required.
926+
// This is a breaking change that enforces explicit algorithm specification for security.
927+
928+
it('Should authorize RS256 token when RS256 is in whitelist', async () => {
929+
const payload = { message: 'hello world' }
930+
const credential = await Jwt.sign(payload, test_keys.private_keys[0]) // RS256 key
931+
const req = new Request('http://localhost/auth-whitelist-rs256/a')
932+
req.headers.set('Authorization', `Bearer ${credential}`)
933+
const res = await app.request(req)
934+
expect(res).not.toBeNull()
935+
expect(res.status).toBe(200)
936+
expect(await res.json()).toEqual(payload)
937+
expect(handlerExecuted).toBeTruthy()
938+
})
939+
940+
it('Should reject token when algorithm is not in whitelist', async () => {
941+
// Create a token with ES256 algorithm manually
942+
const kid = 'hono-test-kid-1' // Use existing kid but header will have different alg
943+
const payload = { message: 'hello world' }
944+
945+
// Generate ES256 key pair for signing
946+
const keyPair = await crypto.subtle.generateKey(
947+
{
948+
name: 'ECDSA',
949+
namedCurve: 'P-256',
950+
},
951+
true,
952+
['sign', 'verify']
953+
)
954+
955+
// Create JWT with ES256
956+
const header = { alg: 'ES256', typ: 'JWT', kid }
957+
const encode = (obj: object) =>
958+
encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer)
959+
const encodedHeader = encode(header)
960+
const encodedPayload = encode(payload)
961+
const signingInput = `${encodedHeader}.${encodedPayload}`
962+
963+
const signatureBuffer = await signing(
964+
keyPair.privateKey,
965+
'ES256',
966+
utf8Encoder.encode(signingInput)
967+
)
968+
const signature = encodeBase64Url(signatureBuffer)
969+
970+
const token = `${encodedHeader}.${encodedPayload}.${signature}`
971+
972+
const url = 'http://localhost/auth-whitelist-rs256/a'
973+
const req = new Request(url)
974+
req.headers.set('Authorization', `Bearer ${token}`)
975+
const res = await app.request(req)
976+
expect(res).not.toBeNull()
977+
expect(res.status).toBe(401)
978+
expect(res.headers.get('www-authenticate')).toMatch(/token verification failure/)
979+
expect(handlerExecuted).toBeFalsy()
980+
})
981+
982+
it('Should authorize RS256 token when multiple algorithms are in whitelist', async () => {
983+
const payload = { message: 'hello world' }
984+
const credential = await Jwt.sign(payload, test_keys.private_keys[0]) // RS256 key
985+
const req = new Request('http://localhost/auth-whitelist-multi/a')
986+
req.headers.set('Authorization', `Bearer ${credential}`)
987+
const res = await app.request(req)
988+
expect(res).not.toBeNull()
989+
expect(res.status).toBe(200)
990+
expect(await res.json()).toEqual(payload)
991+
expect(handlerExecuted).toBeTruthy()
992+
})
993+
994+
// Note: Test for "no whitelist" was removed because alg is now required.
995+
// This is a breaking change that enforces explicit algorithm specification for security.
996+
})
877997
})

src/middleware/jwk/jwk.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { MiddlewareHandler } from '../../types'
1010
import type { CookiePrefixOptions } from '../../utils/cookie'
1111
import { Jwt } from '../../utils/jwt'
1212
import '../../context'
13+
import type { AsymmetricAlgorithm } from '../../utils/jwt/jwa'
1314
import type { HonoJsonWebKey } from '../../utils/jwt/jws'
1415
import type { VerifyOptions } from '../../utils/jwt/jwt'
1516

@@ -24,6 +25,7 @@ import type { VerifyOptions } from '../../utils/jwt/jwt'
2425
* @param {boolean} [options.allow_anon] - If set to `true`, the middleware allows requests without a token to proceed without authentication.
2526
* @param {string} [options.cookie] - If set, the middleware attempts to retrieve the token from a cookie with these options (optionally signed) only if no token is found in the header.
2627
* @param {string} [options.headerName='Authorization'] - The name of the header to look for the JWT token. Default is 'Authorization'.
28+
* @param {AsymmetricAlgorithm[]} options.alg - An array of allowed asymmetric algorithms for JWT verification. Only tokens signed with these algorithms will be accepted.
2729
* @param {RequestInit} [init] - Optional init options for the `fetch` request when retrieving JWKS from a URI.
2830
* @param {VerifyOptions} [options.verification] - Additional options for JWK payload verification.
2931
* @returns {MiddlewareHandler} The middleware handler function.
@@ -54,6 +56,8 @@ export const jwk = (
5456

5557
headerName?: string
5658

59+
alg: AsymmetricAlgorithm[]
60+
5761
verification?: VerifyOptions
5862
},
5963
init?: RequestInit
@@ -132,7 +136,11 @@ export const jwk = (
132136
const keys = typeof options.keys === 'function' ? await options.keys(ctx) : options.keys
133137
const jwks_uri =
134138
typeof options.jwks_uri === 'function' ? await options.jwks_uri(ctx) : options.jwks_uri
135-
payload = await Jwt.verifyWithJwks(token, { keys, jwks_uri, verification: verifyOpts }, init)
139+
payload = await Jwt.verifyWithJwks(
140+
token,
141+
{ keys, jwks_uri, verification: verifyOpts, allowedAlgorithms: options.alg },
142+
init
143+
)
136144
} catch (e) {
137145
cause = e
138146
}

src/utils/jwt/jwa.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AlgorithmTypes } from './jwa'
2+
import type { AsymmetricAlgorithm, SymmetricAlgorithm, SignatureAlgorithm } from './jwa'
23

34
describe('Types', () => {
45
it('AlgorithmTypes', () => {
@@ -21,4 +22,65 @@ describe('Types', () => {
2122
expect(undefined as AlgorithmTypes).toBe(undefined)
2223
expect('' as AlgorithmTypes).toBe('')
2324
})
25+
26+
it('SymmetricAlgorithm type should only include HMAC algorithms', () => {
27+
// These should be valid SymmetricAlgorithm values
28+
const hs256: SymmetricAlgorithm = 'HS256'
29+
const hs384: SymmetricAlgorithm = 'HS384'
30+
const hs512: SymmetricAlgorithm = 'HS512'
31+
32+
expect(hs256).toBe('HS256')
33+
expect(hs384).toBe('HS384')
34+
expect(hs512).toBe('HS512')
35+
36+
// Type-level test: these would cause compile errors if uncommented
37+
// const rs256: SymmetricAlgorithm = 'RS256' // Error: Type '"RS256"' is not assignable to type 'SymmetricAlgorithm'
38+
})
39+
40+
it('AsymmetricAlgorithm type should only include asymmetric algorithms', () => {
41+
// These should be valid AsymmetricAlgorithm values
42+
const asymmetricAlgs: AsymmetricAlgorithm[] = [
43+
'RS256',
44+
'RS384',
45+
'RS512',
46+
'PS256',
47+
'PS384',
48+
'PS512',
49+
'ES256',
50+
'ES384',
51+
'ES512',
52+
'EdDSA',
53+
]
54+
55+
expect(asymmetricAlgs).toHaveLength(10)
56+
57+
// Verify all asymmetric algorithms are included
58+
expect(asymmetricAlgs).toContain('RS256')
59+
expect(asymmetricAlgs).toContain('ES256')
60+
expect(asymmetricAlgs).toContain('EdDSA')
61+
62+
// Type-level test: these would cause compile errors if uncommented
63+
// const hs256: AsymmetricAlgorithm = 'HS256' // Error: Type '"HS256"' is not assignable to type 'AsymmetricAlgorithm'
64+
})
65+
66+
it('SignatureAlgorithm type should include all algorithms', () => {
67+
// SignatureAlgorithm should include both symmetric and asymmetric algorithms
68+
const allAlgs: SignatureAlgorithm[] = [
69+
'HS256',
70+
'HS384',
71+
'HS512',
72+
'RS256',
73+
'RS384',
74+
'RS512',
75+
'PS256',
76+
'PS384',
77+
'PS512',
78+
'ES256',
79+
'ES384',
80+
'ES512',
81+
'EdDSA',
82+
]
83+
84+
expect(allAlgs).toHaveLength(13)
85+
})
2486
})

0 commit comments

Comments
 (0)