Skip to content

Commit 45ba3bf

Browse files
authored
Merge commit from fork
* feat(jwt): add audience validation for JWT tokens * remove unused import * fix(jwt): update to support verify aud string[] * fix(jwt): return boolean in verify function * fix(jwt): rename JwtPayloadRequireAud to JwtPayloadRequiresAud
1 parent 4cbad8b commit 45ba3bf

3 files changed

Lines changed: 317 additions & 0 deletions

File tree

src/utils/jwt/jwt.test.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import * as JWT from './jwt'
77
import { verifyWithJwks } from './jwt'
88
import {
99
JwtAlgorithmNotImplemented,
10+
JwtPayloadRequiresAud,
11+
JwtTokenAudience,
1012
JwtTokenExpired,
1113
JwtTokenInvalid,
1214
JwtTokenIssuedAt,
@@ -226,6 +228,265 @@ describe('JWT', () => {
226228
expect(authorized?.iss).toEqual('hello')
227229
})
228230

231+
it('JwtPayloadRequireAud', async () => {
232+
const tok =
233+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiaWF0IjoxfQ.3Yd0dDicCKA6zu_G6AvxMX_fRH5wMz9gMCedOsYNAGc'
234+
const secret = 'a-secret'
235+
let err
236+
let authorized
237+
try {
238+
authorized = await JWT.verify(tok, secret, {
239+
alg: AlgorithmTypes.HS256,
240+
aud: 'correct-audience',
241+
})
242+
} catch (e) {
243+
err = e
244+
}
245+
expect(err).toEqual(new JwtPayloadRequiresAud({ iss: 'https://issuer.example', iat: 1 }))
246+
expect(authorized).toBeUndefined()
247+
})
248+
249+
it('JwtTokenAudience(correct string - string)', async () => {
250+
const tok =
251+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiY29ycmVjdC1hdWRpZW5jZSIsImlhdCI6MX0.z8T6szX-k66de4xB9OFbpWAOfx0RTqKSUPBcdpSY5nk'
252+
const secret = 'a-secret'
253+
let err
254+
let authorized
255+
try {
256+
authorized = await JWT.verify(tok, secret, {
257+
alg: AlgorithmTypes.HS256,
258+
aud: 'correct-audience',
259+
})
260+
} catch (e) {
261+
err = e
262+
}
263+
expect(err).toBeUndefined()
264+
expect(authorized?.aud).toEqual('correct-audience')
265+
})
266+
267+
it('JwtTokenAudience(correct string - string[])', async () => {
268+
const tok =
269+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiY29ycmVjdC1hdWRpZW5jZSIsImlhdCI6MX0.z8T6szX-k66de4xB9OFbpWAOfx0RTqKSUPBcdpSY5nk'
270+
const secret = 'a-secret'
271+
let err
272+
let authorized
273+
try {
274+
authorized = await JWT.verify(tok, secret, {
275+
alg: AlgorithmTypes.HS256,
276+
aud: ['correct-audience', 'other-audience'],
277+
})
278+
} catch (e) {
279+
err = e
280+
}
281+
expect(err).toBeUndefined()
282+
expect(authorized?.aud).toEqual('correct-audience')
283+
})
284+
285+
it('JwtTokenAudience(correct string - RegExp)', async () => {
286+
const tok =
287+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiY29ycmVjdC1hdWRpZW5jZSIsImlhdCI6MX0.z8T6szX-k66de4xB9OFbpWAOfx0RTqKSUPBcdpSY5nk'
288+
const secret = 'a-secret'
289+
let err
290+
let authorized
291+
try {
292+
authorized = await JWT.verify(tok, secret, {
293+
alg: AlgorithmTypes.HS256,
294+
aud: /^correct-audience$/,
295+
})
296+
} catch (e) {
297+
err = e
298+
}
299+
expect(err).toBeUndefined()
300+
expect(authorized?.aud).toEqual('correct-audience')
301+
})
302+
303+
it('JwtTokenAudience(correct string[] - string)', async () => {
304+
const tok =
305+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbImNvcnJlY3QtYXVkaWVuY2UiLCJvdGhlci1hdWRpZW5jZSJdLCJpYXQiOjF9.l73pNR5zMMAyuoN3f32hKtRJkoxZNzgTcVBZ2A2EsJY'
306+
const secret = 'a-secret'
307+
let err
308+
let authorized
309+
try {
310+
authorized = await JWT.verify(tok, secret, {
311+
alg: AlgorithmTypes.HS256,
312+
aud: 'correct-audience',
313+
})
314+
} catch (e) {
315+
err = e
316+
}
317+
expect(err).toBeUndefined()
318+
expect(authorized?.aud).toEqual(['correct-audience', 'other-audience'])
319+
})
320+
321+
it('JwtTokenAudience(correct string[] - string[])', async () => {
322+
const tok =
323+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbImNvcnJlY3QtYXVkaWVuY2UiLCJvdGhlci1hdWRpZW5jZSJdLCJpYXQiOjF9.l73pNR5zMMAyuoN3f32hKtRJkoxZNzgTcVBZ2A2EsJY'
324+
const secret = 'a-secret'
325+
let err
326+
let authorized
327+
try {
328+
authorized = await JWT.verify(tok, secret, {
329+
alg: AlgorithmTypes.HS256,
330+
aud: ['correct-audience', 'test'],
331+
})
332+
} catch (e) {
333+
err = e
334+
}
335+
expect(err).toBeUndefined()
336+
expect(authorized?.aud).toEqual(['correct-audience', 'other-audience'])
337+
})
338+
339+
it('JwtTokenAudience(correct string[] - RegExp)', async () => {
340+
const tok =
341+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbImNvcnJlY3QtYXVkaWVuY2UiLCJvdGhlci1hdWRpZW5jZSJdLCJpYXQiOjF9.l73pNR5zMMAyuoN3f32hKtRJkoxZNzgTcVBZ2A2EsJY'
342+
const secret = 'a-secret'
343+
let err
344+
let authorized
345+
try {
346+
authorized = await JWT.verify(tok, secret, {
347+
alg: AlgorithmTypes.HS256,
348+
aud: /^correct-audience$/,
349+
})
350+
} catch (e) {
351+
err = e
352+
}
353+
expect(err).toBeUndefined()
354+
expect(authorized?.aud).toEqual(['correct-audience', 'other-audience'])
355+
})
356+
357+
it('JwtTokenAudience(wrong string - string)', async () => {
358+
const tok =
359+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoid3JvbmctYXVkaWVuY2UiLCJpYXQiOjF9.2vTYLiYL5r6qN-iRQ0VSfXh4ioLFtNzo0qc-OoPZmow'
360+
const secret = 'a-secret'
361+
let err
362+
let authorized
363+
try {
364+
authorized = await JWT.verify(tok, secret, {
365+
alg: AlgorithmTypes.HS256,
366+
aud: 'correct-audience',
367+
})
368+
} catch (e) {
369+
err = e
370+
}
371+
expect(err).toEqual(new JwtTokenAudience('correct-audience', 'wrong-audience'))
372+
expect(authorized).toBeUndefined()
373+
})
374+
375+
it('JwtTokenAudience(wrong string - string[])', async () => {
376+
const tok =
377+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoid3JvbmctYXVkaWVuY2UiLCJpYXQiOjF9.2vTYLiYL5r6qN-iRQ0VSfXh4ioLFtNzo0qc-OoPZmow'
378+
const secret = 'a-secret'
379+
let err
380+
let authorized
381+
try {
382+
authorized = await JWT.verify(tok, secret, {
383+
alg: AlgorithmTypes.HS256,
384+
aud: ['correct-audience', 'other-audience'],
385+
})
386+
} catch (e) {
387+
err = e
388+
}
389+
expect(err).toEqual(
390+
new JwtTokenAudience(['correct-audience', 'other-audience'], 'wrong-audience')
391+
)
392+
expect(authorized).toBeUndefined()
393+
})
394+
395+
it('JwtTokenAudience(wrong string - RegExp)', async () => {
396+
const tok =
397+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoid3JvbmctYXVkaWVuY2UiLCJpYXQiOjF9.2vTYLiYL5r6qN-iRQ0VSfXh4ioLFtNzo0qc-OoPZmow'
398+
const secret = 'a-secret'
399+
let err
400+
let authorized
401+
try {
402+
authorized = await JWT.verify(tok, secret, {
403+
alg: AlgorithmTypes.HS256,
404+
aud: /^correct-audience$/,
405+
})
406+
} catch (e) {
407+
err = e
408+
}
409+
expect(err).toEqual(new JwtTokenAudience(/^correct-audience$/, 'wrong-audience'))
410+
expect(authorized).toBeUndefined()
411+
})
412+
413+
it('JwtTokenAudience(wrong string[] - string)', async () => {
414+
const tok =
415+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbIndyb25nLWF1ZGllbmNlIiwib3RoZXItYXVkaWVuY2UiXSwiaWF0IjoxfQ.YTAM1xtKP4AeEeQSFQ81rcJM1leW_uDayQcTE6LxoP0'
416+
const secret = 'a-secret'
417+
let err
418+
let authorized
419+
try {
420+
authorized = await JWT.verify(tok, secret, {
421+
alg: AlgorithmTypes.HS256,
422+
aud: 'correct-audience',
423+
})
424+
} catch (e) {
425+
err = e
426+
}
427+
expect(err).toEqual(
428+
new JwtTokenAudience('correct-audience', ['wrong-audience', 'other-audience'])
429+
)
430+
expect(authorized).toBeUndefined()
431+
})
432+
433+
it('JwtTokenAudience(wrong string[] - string[])', async () => {
434+
const tok =
435+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbIndyb25nLWF1ZGllbmNlIiwib3RoZXItYXVkaWVuY2UiXSwiaWF0IjoxfQ.YTAM1xtKP4AeEeQSFQ81rcJM1leW_uDayQcTE6LxoP0'
436+
const secret = 'a-secret'
437+
let err
438+
let authorized
439+
try {
440+
authorized = await JWT.verify(tok, secret, {
441+
alg: AlgorithmTypes.HS256,
442+
aud: ['correct-audience', 'test'],
443+
})
444+
} catch (e) {
445+
err = e
446+
}
447+
expect(err).toEqual(
448+
new JwtTokenAudience(['correct-audience', 'test'], ['wrong-audience', 'other-audience'])
449+
)
450+
expect(authorized).toBeUndefined()
451+
})
452+
453+
it('JwtTokenAudience(wrong string[] - RegExp)', async () => {
454+
const tok =
455+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjpbIndyb25nLWF1ZGllbmNlIiwib3RoZXItYXVkaWVuY2UiXSwiaWF0IjoxfQ.YTAM1xtKP4AeEeQSFQ81rcJM1leW_uDayQcTE6LxoP0'
456+
const secret = 'a-secret'
457+
let err
458+
let authorized
459+
try {
460+
authorized = await JWT.verify(tok, secret, {
461+
alg: AlgorithmTypes.HS256,
462+
aud: /^correct-audience$/,
463+
})
464+
} catch (e) {
465+
err = e
466+
}
467+
expect(err).toEqual(
468+
new JwtTokenAudience(/^correct-audience$/, ['wrong-audience', 'other-audience'])
469+
)
470+
expect(authorized).toBeUndefined()
471+
})
472+
473+
it('JwtTokenAudience (no aud option and wrong aud in payload)', async () => {
474+
const tok =
475+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoid3JvbmctYXVkaWVuY2UiLCJpYXQiOjF9.2vTYLiYL5r6qN-iRQ0VSfXh4ioLFtNzo0qc-OoPZmow'
476+
const secret = 'a-secret'
477+
let err
478+
let authorized
479+
try {
480+
authorized = await JWT.verify(tok, secret, {
481+
alg: AlgorithmTypes.HS256,
482+
})
483+
} catch (e) {
484+
err = e
485+
}
486+
expect(err).toBeUndefined()
487+
expect(authorized?.aud).toEqual('wrong-audience')
488+
})
489+
229490
it('HS256 sign & verify & decode', async () => {
230491
const payload = { message: 'hello world' }
231492
const secret = 'a-secret'

src/utils/jwt/jwt.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type { HonoJsonWebKey, SignatureKey } from './jws'
1212
import {
1313
JwtHeaderInvalid,
1414
JwtHeaderRequiresKid,
15+
JwtPayloadRequiresAud,
16+
JwtTokenAudience,
1517
JwtTokenExpired,
1618
JwtTokenInvalid,
1719
JwtTokenIssuedAt,
@@ -78,6 +80,8 @@ export type VerifyOptions = {
7880
exp?: boolean
7981
/** Verify the `iat` claim (default: `true`) */
8082
iat?: boolean
83+
/** Acceptable audience(s) for the token */
84+
aud?: string | string[] | RegExp
8185
}
8286

8387
export type VerifyOptionsWithAlg = {
@@ -90,6 +94,7 @@ type StrictVerifyOptions = {
9094
nbf: boolean
9195
exp: boolean
9296
iat: boolean
97+
aud?: string | string[] | RegExp
9398
}
9499

95100
type StrictVerifyOptionsWithAlg = {
@@ -108,6 +113,7 @@ export const verify = async (
108113
nbf: optsIn.nbf ?? true,
109114
exp: optsIn.exp ?? true,
110115
iat: optsIn.iat ?? true,
116+
aud: optsIn.aud,
111117
}
112118

113119
const tokenParts = token.split('.')
@@ -141,6 +147,33 @@ export const verify = async (
141147
}
142148
}
143149

150+
if (opts.aud) {
151+
if (!payload.aud) {
152+
throw new JwtPayloadRequiresAud(payload)
153+
}
154+
}
155+
156+
if (payload.aud) {
157+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]
158+
const matched = audiences.some((aud): boolean => {
159+
if (opts.aud instanceof RegExp && opts.aud.test(aud)) {
160+
return true
161+
} else if (typeof opts.aud === 'string') {
162+
if (aud === opts.aud) {
163+
return true
164+
}
165+
} else if (Array.isArray(opts.aud)) {
166+
if (opts.aud.includes(aud)) {
167+
return true
168+
}
169+
}
170+
return false
171+
})
172+
if (opts.aud && !matched) {
173+
throw new JwtTokenAudience(opts.aud, payload.aud)
174+
}
175+
}
176+
144177
const headerPayload = token.substring(0, token.lastIndexOf('.'))
145178
const verified = await verifying(
146179
publicKey,

src/utils/jwt/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export class JwtTokenSignatureMismatched extends Error {
6868
}
6969
}
7070

71+
export class JwtPayloadRequiresAud extends Error {
72+
constructor(payload: object) {
73+
super(`required "aud" in jwt payload: ${JSON.stringify(payload)}`)
74+
this.name = 'JwtPayloadRequiresAud'
75+
}
76+
}
77+
78+
export class JwtTokenAudience extends Error {
79+
constructor(expected: string | string[] | RegExp, aud: string | string[]) {
80+
super(
81+
`expected audience "${
82+
Array.isArray(expected) ? expected.join(', ') : expected
83+
}", got "${aud}"`
84+
)
85+
this.name = 'JwtTokenAudience'
86+
}
87+
}
88+
7189
export enum CryptoKeyUsage {
7290
Encrypt = 'encrypt',
7391
Decrypt = 'decrypt',
@@ -100,6 +118,11 @@ export type JWTPayload = {
100118
* The token is checked to ensure it has been issued by a trusted issuer.
101119
*/
102120
iss?: string
121+
122+
/**
123+
* The token is checked to ensure it is intended for a specific audience.
124+
*/
125+
aud?: string | string[]
103126
}
104127

105128
export type { HonoJsonWebKey } from './jws'

0 commit comments

Comments
 (0)