Skip to content

Commit

Permalink
feat: expose JWT's payload in JWTClaimValidationFailed instances
Browse files Browse the repository at this point in the history
closes #680
  • Loading branch information
panva committed Jun 3, 2024
1 parent cc2b2d7 commit 58bcffb
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 20 deletions.
9 changes: 9 additions & 0 deletions docs/classes/util_errors.JWTClaimValidationFailed.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ if (err instanceof jose.errors.JWTClaimValidationFailed) {

- [claim](util_errors.JWTClaimValidationFailed.md#claim)
- [code](util_errors.JWTClaimValidationFailed.md#code)
- [payload](util_errors.JWTClaimValidationFailed.md#payload)
- [reason](util_errors.JWTClaimValidationFailed.md#reason)

## Properties
Expand All @@ -58,6 +59,14 @@ A unique error code for this particular error subclass.

___

### payload

**payload**: [`JWTPayload`](../interfaces/types.JWTPayload.md)

The parsed JWT payload.

___

### reason

**reason**: `string`
Expand Down
9 changes: 9 additions & 0 deletions docs/classes/util_errors.JWTExpired.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ if (err instanceof jose.errors.JWTExpired) {

- [claim](util_errors.JWTExpired.md#claim)
- [code](util_errors.JWTExpired.md#code)
- [payload](util_errors.JWTExpired.md#payload)
- [reason](util_errors.JWTExpired.md#reason)

## Properties
Expand All @@ -58,6 +59,14 @@ A unique error code for this particular error subclass.

___

### payload

**payload**: [`JWTPayload`](../interfaces/types.JWTPayload.md)

The parsed JWT payload.

___

### reason

**reason**: `string`
Expand Down
3 changes: 3 additions & 0 deletions src/jwt/decrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export async function jwtDecrypt(
if (protectedHeader.iss !== undefined && protectedHeader.iss !== payload.iss) {
throw new JWTClaimValidationFailed(
'replicated "iss" claim header parameter mismatch',
payload,
'iss',
'mismatch',
)
Expand All @@ -85,6 +86,7 @@ export async function jwtDecrypt(
if (protectedHeader.sub !== undefined && protectedHeader.sub !== payload.sub) {
throw new JWTClaimValidationFailed(
'replicated "sub" claim header parameter mismatch',
payload,
'sub',
'mismatch',
)
Expand All @@ -96,6 +98,7 @@ export async function jwtDecrypt(
) {
throw new JWTClaimValidationFailed(
'replicated "aud" claim header parameter mismatch',
payload,
'aud',
'mismatch',
)
Expand Down
62 changes: 45 additions & 17 deletions src/lib/jwt_claims_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,6 @@ export default (
encodedPayload: Uint8Array,
options: JWTClaimVerificationOptions = {},
) => {
const { typ } = options
if (
typ &&
(typeof protectedHeader!.typ !== 'string' ||
normalizeTyp(protectedHeader!.typ) !== normalizeTyp(typ))
) {
throw new JWTClaimValidationFailed('unexpected "typ" JWT header value', 'typ', 'check_failed')
}

let payload!: { [propName: string]: unknown }
try {
payload = JSON.parse(decoder.decode(encodedPayload))
Expand All @@ -51,6 +42,20 @@ export default (
throw new JWTInvalid('JWT Claims Set must be a top-level JSON object')
}

const { typ } = options
if (
typ &&
(typeof protectedHeader!.typ !== 'string' ||
normalizeTyp(protectedHeader!.typ) !== normalizeTyp(typ))
) {
throw new JWTClaimValidationFailed(
'unexpected "typ" JWT header value',
payload,
'typ',
'check_failed',
)
}

const { requiredClaims = [], issuer, subject, audience, maxTokenAge } = options

const presenceCheck = [...requiredClaims]
Expand All @@ -62,23 +67,43 @@ export default (

for (const claim of new Set(presenceCheck.reverse())) {
if (!(claim in payload)) {
throw new JWTClaimValidationFailed(`missing required "${claim}" claim`, claim, 'missing')
throw new JWTClaimValidationFailed(
`missing required "${claim}" claim`,
payload,
claim,
'missing',
)
}
}

if (issuer && !(<unknown[]>(Array.isArray(issuer) ? issuer : [issuer])).includes(payload.iss!)) {
throw new JWTClaimValidationFailed('unexpected "iss" claim value', 'iss', 'check_failed')
throw new JWTClaimValidationFailed(
'unexpected "iss" claim value',
payload,
'iss',
'check_failed',
)
}

if (subject && payload.sub !== subject) {
throw new JWTClaimValidationFailed('unexpected "sub" claim value', 'sub', 'check_failed')
throw new JWTClaimValidationFailed(
'unexpected "sub" claim value',
payload,
'sub',
'check_failed',
)
}

if (
audience &&
!checkAudiencePresence(payload.aud, typeof audience === 'string' ? [audience] : audience)
) {
throw new JWTClaimValidationFailed('unexpected "aud" claim value', 'aud', 'check_failed')
throw new JWTClaimValidationFailed(
'unexpected "aud" claim value',
payload,
'aud',
'check_failed',
)
}

let tolerance: number
Expand All @@ -100,16 +125,17 @@ export default (
const now = epoch(currentDate || new Date())

if ((payload.iat !== undefined || maxTokenAge) && typeof payload.iat !== 'number') {
throw new JWTClaimValidationFailed('"iat" claim must be a number', 'iat', 'invalid')
throw new JWTClaimValidationFailed('"iat" claim must be a number', payload, 'iat', 'invalid')
}

if (payload.nbf !== undefined) {
if (typeof payload.nbf !== 'number') {
throw new JWTClaimValidationFailed('"nbf" claim must be a number', 'nbf', 'invalid')
throw new JWTClaimValidationFailed('"nbf" claim must be a number', payload, 'nbf', 'invalid')
}
if (payload.nbf > now + tolerance) {
throw new JWTClaimValidationFailed(
'"nbf" claim timestamp check failed',
payload,
'nbf',
'check_failed',
)
Expand All @@ -118,10 +144,10 @@ export default (

if (payload.exp !== undefined) {
if (typeof payload.exp !== 'number') {
throw new JWTClaimValidationFailed('"exp" claim must be a number', 'exp', 'invalid')
throw new JWTClaimValidationFailed('"exp" claim must be a number', payload, 'exp', 'invalid')
}
if (payload.exp <= now - tolerance) {
throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed')
throw new JWTExpired('"exp" claim timestamp check failed', payload, 'exp', 'check_failed')
}
}

Expand All @@ -132,6 +158,7 @@ export default (
if (age - tolerance > max) {
throw new JWTExpired(
'"iat" claim timestamp check failed (too far in the past)',
payload,
'iat',
'check_failed',
)
Expand All @@ -140,6 +167,7 @@ export default (
if (age < 0 - tolerance) {
throw new JWTClaimValidationFailed(
'"iat" claim timestamp check failed (it should be in the past)',
payload,
'iat',
'check_failed',
)
Expand Down
14 changes: 11 additions & 3 deletions src/util/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { KeyLike } from '../types.d'
import type { JWTPayload, KeyLike } from '../types.d'

/**
* A generic Error that all other JOSE specific Error subclasses extend.
Expand Down Expand Up @@ -72,11 +72,15 @@ export class JWTClaimValidationFailed extends JOSEError {
/** Reason code for the validation failure. */
reason: string

/** The parsed JWT payload. */
payload: JWTPayload

/** @ignore */
constructor(message: string, claim = 'unspecified', reason = 'unspecified') {
constructor(message: string, payload: JWTPayload, claim = 'unspecified', reason = 'unspecified') {
super(message)
this.claim = claim
this.reason = reason
this.payload = payload
}
}

Expand Down Expand Up @@ -117,11 +121,15 @@ export class JWTExpired extends JOSEError implements JWTClaimValidationFailed {
/** Reason code for the validation failure. */
reason: string

/** The parsed JWT payload. */
payload: JWTPayload

/** @ignore */
constructor(message: string, claim = 'unspecified', reason = 'unspecified') {
constructor(message: string, payload: JWTPayload, claim = 'unspecified', reason = 'unspecified') {
super(message)
this.claim = claim
this.reason = reason
this.payload = payload
}
}

Expand Down

0 comments on commit 58bcffb

Please sign in to comment.