Skip to content

Commit

Permalink
feat: add JWTExpired error and JWTClaimInvalid claim and reason props
Browse files Browse the repository at this point in the history
Resolves #62
  • Loading branch information
panva authored Jan 16, 2020
1 parent 79aaf2b commit a0c0c7a
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 112 deletions.
53 changes: 39 additions & 14 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,7 @@ Verifies the provided JWE in either serialization with a given `<JWK.Key>` or `<
- [Class: &lt;JOSEAlgNotWhitelisted&gt;](#class-josealgnotwhitelisted)
- [Class: &lt;JOSECritNotUnderstood&gt;](#class-josecritnotunderstood)
- [Class: &lt;JOSEMultiError&gt;](#class-josemultierror)
- [Class: &lt;JOSEInvalidEncoding&gt;](#class-joseinvalidencoding)
- [Class: &lt;JOSENotSupported&gt;](#class-josenotsupported)
- [Class: &lt;JWEDecryptionFailed&gt;](#class-jwedecryptionfailed)
- [Class: &lt;JWEInvalid&gt;](#class-jweinvalid)
Expand All @@ -1413,6 +1414,7 @@ Verifies the provided JWE in either serialization with a given `<JWK.Key>` or `<
- [Class: &lt;JWSInvalid&gt;](#class-jwsinvalid)
- [Class: &lt;JWSVerificationFailed&gt;](#class-jwsverificationfailed)
- [Class: &lt;JWTClaimInvalid&gt;](#class-jwtclaiminvalid)
- [Class: &lt;JWTExpired&gt;](#class-jwtexpired)
- [Class: &lt;JWTMalformed&gt;](#class-jwtmalformed)
<!-- TOC Errors END -->

Expand All @@ -1435,7 +1437,7 @@ Base Error the others inherit from.
Thrown when an algorithm whitelist is provided but the validated JWE/JWS does not use one from it.

```js
if (err.code === 'ERR_JOSE_ALG_NOT_WHITELISTED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_ALG_NOT_WHITELISTED') {
// ...
}
```
Expand All @@ -1446,7 +1448,7 @@ Thrown when a Critical member is encountered that's not acknowledged. The only b
handler is for "b64", it must still be acknowledged though.

```js
if (err.code === 'ERR_JOSE_CRIT_NOT_UNDERSTOOD') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_CRIT_NOT_UNDERSTOOD') {
// ...
}
```
Expand All @@ -1464,20 +1466,30 @@ The error is an [Iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScri
and yields every single one of the encountered errors.

```js
if (err.code === 'ERR_JOSE_MULTIPLE_ERRORS') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_MULTIPLE_ERRORS') {
for (const e of err) {
console.log(e)
// ...
}
}
```

#### Class: `JOSEInvalidEncoding`

Thrown when invalid base64url encoding is detected.

```js
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_INVALID_ENCODING') {
// ...
}
```

#### Class: `JOSENotSupported`

Thrown when an unsupported "alg", "kty" or specific header value like "zip" is encountered.

```js
if (err.code === 'ERR_JOSE_NOT_SUPPORTED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_NOT_SUPPORTED') {
// ...
}
```
Expand All @@ -1488,7 +1500,7 @@ Thrown when JWE decrypt operations are started but fail to decrypt. Only generic
provided.

```js
if (err.code === 'ERR_JWE_DECRYPTION_FAILED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWE_DECRYPTION_FAILED') {
// ...
}
```
Expand All @@ -1498,7 +1510,7 @@ if (err.code === 'ERR_JWE_DECRYPTION_FAILED') {
Thrown when syntactically incorrect JWE is either requested to be encrypted or decrypted

```js
if (err.code === 'ERR_JWE_INVALID') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWE_INVALID') {
// ...
}
```
Expand All @@ -1508,7 +1520,7 @@ if (err.code === 'ERR_JWE_INVALID') {
Thrown when a key failed to import as `<JWK.Key>`

```js
if (err.code === 'ERR_JWK_IMPORT_FAILED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_IMPORT_FAILED') {
// ...
}
```
Expand All @@ -1518,7 +1530,7 @@ if (err.code === 'ERR_JWK_IMPORT_FAILED') {
Thrown when key's parameters are invalid, e.g. key_ops and use values are inconsistent.

```js
if (err.code === 'ERR_JWK_INVALID') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_INVALID') {
// ...
}
```
Expand All @@ -1528,7 +1540,7 @@ if (err.code === 'ERR_JWK_INVALID') {
Thrown when a key does not support the request algorithm.

```js
if (err.code === 'ERR_JWK_KEY_SUPPORT') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_KEY_SUPPORT') {
// ...
}
```
Expand All @@ -1539,7 +1551,7 @@ Thrown when `<JWKS.KeyStore>` is used as argument for decrypt / verify operation
for the crypto operation is found in it

```js
if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
// ...
}
```
Expand All @@ -1550,7 +1562,7 @@ Thrown when syntactically incorrect JWS is either requested to be signed or
verified

```js
if (err.code === 'ERR_JWS_INVALID') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWS_INVALID') {
// ...
}
```
Expand All @@ -1561,17 +1573,30 @@ Thrown when JWS verify operations are started but fail to verify. Only generic e
provided.

```js
if (err.code === 'ERR_JWS_VERIFICATION_FAILED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWS_VERIFICATION_FAILED') {
// ...
}
```

#### Class: `JWTClaimInvalid`

Thrown when JWT Claim is either of incorrect type or fails to validate by the provided options.
Instances of `<JWTClaimInvalid>` have a `claim<string>` property with the name of the claim as well as
`reason<string>` property with one of the following values `'prohibited' | 'missing' | 'invalid' | 'check_failed' | 'unspecified'`.

```js
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_CLAIM_INVALID') {
// ...
}
```

#### Class: `JWTExpired`

Thrown when the JWT Claims indicate the JWT is expired by the provided options. `<JWTExpired>`
is a descendant of `<JWTClaimInvalid>` with a unique `code` property.

```js
if (err.code === 'ERR_JWT_CLAIM_INVALID') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_EXPIRED') {
// ...
}
```
Expand All @@ -1581,7 +1606,7 @@ if (err.code === 'ERR_JWT_CLAIM_INVALID') {
Thrown when malformed JWT is either being decoded or verified.

```js
if (err.code === 'ERR_JWT_MALFORMED') {
if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_MALFORMED') {
// ...
}
```
Expand Down
11 changes: 10 additions & 1 deletion lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const CODES = {
JWSInvalid: 'ERR_JWS_INVALID',
JWSVerificationFailed: 'ERR_JWS_VERIFICATION_FAILED',
JWTClaimInvalid: 'ERR_JWT_CLAIM_INVALID',
JWTExpired: 'ERR_JWT_EXPIRED',
JWTMalformed: 'ERR_JWT_MALFORMED'
}

Expand Down Expand Up @@ -73,5 +74,13 @@ module.exports.JWKSNoMatchingKey = class JWKSNoMatchingKey extends JOSEError {}
module.exports.JWSInvalid = class JWSInvalid extends JOSEError {}
module.exports.JWSVerificationFailed = class JWSVerificationFailed extends JOSEError {}

module.exports.JWTClaimInvalid = class JWTClaimInvalid extends JOSEError {}
class JWTClaimInvalid extends JOSEError {
constructor (message, claim = 'unspecified', reason = 'unspecified') {
super(message)
this.claim = claim
this.reason = reason
}
}
module.exports.JWTClaimInvalid = JWTClaimInvalid
module.exports.JWTExpired = class JWTExpired extends JWTClaimInvalid {}
module.exports.JWTMalformed = class JWTMalformed extends JOSEError {}
6 changes: 3 additions & 3 deletions lib/jwt/shared_validations.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const isNotString = val => typeof val !== 'string' || val.length === 0

module.exports.isNotString = isNotString
module.exports.isString = function isString (Err, value, label, required = false) {
module.exports.isString = function isString (Err, value, label, claim, required = false) {
if (required && value === undefined) {
throw new Err(`${label} is missing`)
throw new Err(`${label} is missing`, claim, 'missing')
}

if (value !== undefined && isNotString(value)) {
throw new Err(`${label} must be a string`)
throw new Err(`${label} must be a string`, claim, 'invalid')
}
}
66 changes: 33 additions & 33 deletions lib/jwt/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const secs = require('../help/secs')
const getKey = require('../help/get_key')
const JWS = require('../jws')
const { KeyStore } = require('../jwks')
const { JWTClaimInvalid } = require('../errors')
const { JWTClaimInvalid, JWTExpired } = require('../errors')

const { isString, isNotString } = require('./shared_validations')
const decode = require('./decode')
Expand All @@ -18,21 +18,21 @@ const ATJWT = 'at+JWT'

const isTimestamp = (value, label, required = false) => {
if (required && value === undefined) {
throw new JWTClaimInvalid(`"${label}" claim is missing`)
throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing')
}

if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value))) {
throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`)
throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`, label, 'invalid')
}
}

const isStringOrArrayOfStrings = (value, label, required = false) => {
if (required && value === undefined) {
throw new JWTClaimInvalid(`"${label}" claim is missing`)
throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing')
}

if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) {
throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`)
throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`, label, 'invalid')
}
}

Expand Down Expand Up @@ -148,51 +148,51 @@ const validateOptions = ({
}

const validateTypes = ({ header, payload }, profile, options) => {
isPayloadString(header.alg, '"alg" header parameter', true)
isPayloadString(header.alg, '"alg" header parameter', 'alg', true)

isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || !!options.maxTokenAge)
isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT)
isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge)
isTimestamp(payload.nbf, 'nbf')
isPayloadString(payload.jti, '"jti" claim', profile === LOGOUTTOKEN || !!options.jti)
isPayloadString(payload.acr, '"acr" claim')
isPayloadString(payload.nonce, '"nonce" claim', !!options.nonce)
isPayloadString(payload.iss, '"iss" claim', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.issuer)
isPayloadString(payload.sub, '"sub" claim', profile === IDTOKEN || profile === ATJWT || !!options.subject)
isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || !!options.jti)
isPayloadString(payload.acr, '"acr" claim', 'acr')
isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce)
isPayloadString(payload.iss, '"iss" claim', 'iss', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.issuer)
isPayloadString(payload.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject)
isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.audience)
isPayloadString(payload.azp, '"azp" claim', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1)
isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1)
isStringOrArrayOfStrings(payload.amr, 'amr')

if (profile === ATJWT) {
isPayloadString(payload.client_id, '"client_id" claim', true)
isPayloadString(header.typ, '"typ" header parameter', true)
isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true)
isPayloadString(header.typ, '"typ" header parameter', 'typ', true)
}

if (profile === LOGOUTTOKEN) {
isPayloadString(payload.sid, '"sid" claim')
isPayloadString(payload.sid, '"sid" claim', 'sid')

if (!('sid' in payload) && !('sub' in payload)) {
throw new JWTClaimInvalid('either "sid" or "sub" (or both) claims must be present')
}

if ('nonce' in payload) {
throw new JWTClaimInvalid('"nonce" claim is prohibited')
throw new JWTClaimInvalid('"nonce" claim is prohibited', 'nonce', 'prohibited')
}

if (!('events' in payload)) {
throw new JWTClaimInvalid('"events" claim is missing')
throw new JWTClaimInvalid('"events" claim is missing', 'events', 'missing')
}

if (!isObject(payload.events)) {
throw new JWTClaimInvalid('"events" claim must be an object')
throw new JWTClaimInvalid('"events" claim must be an object', 'events', 'invalid')
}

if (!('http://schemas.openid.net/event/backchannel-logout' in payload.events)) {
throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim')
throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim', 'events', 'invalid')
}

if (!isObject(payload.events['http://schemas.openid.net/event/backchannel-logout'])) {
throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object')
throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object', 'events', 'invalid')
}
}
}
Expand Down Expand Up @@ -231,65 +231,65 @@ module.exports = (token, key, options = {}) => {
validateTypes(decoded, profile, options)

if (issuer && decoded.payload.iss !== issuer) {
throw new JWTClaimInvalid('unexpected "iss" claim value')
throw new JWTClaimInvalid('unexpected "iss" claim value', 'iss', 'check_failed')
}

if (nonce && decoded.payload.nonce !== nonce) {
throw new JWTClaimInvalid('unexpected "nonce" claim value')
throw new JWTClaimInvalid('unexpected "nonce" claim value', 'nonce', 'check_failed')
}

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

if (jti && decoded.payload.jti !== jti) {
throw new JWTClaimInvalid('unexpected "jti" claim value')
throw new JWTClaimInvalid('unexpected "jti" claim value', 'jti', 'check_failed')
}

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

const tolerance = clockTolerance ? secs(clockTolerance) : 0

if (maxAuthAge) {
const maxAuthAgeSeconds = secs(maxAuthAge)
if (decoded.payload.auth_time + maxAuthAgeSeconds < unix - tolerance) {
throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)')
throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed')
}
}

if (!ignoreIat && !('exp' in decoded.payload) && 'iat' in decoded.payload && decoded.payload.iat > unix + tolerance) {
throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)')
throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed')
}

if (!ignoreNbf && 'nbf' in decoded.payload && decoded.payload.nbf > unix + tolerance) {
throw new JWTClaimInvalid('"nbf" claim timestamp check failed')
throw new JWTClaimInvalid('"nbf" claim timestamp check failed', 'nbf', 'check_failed')
}

if (!ignoreExp && 'exp' in decoded.payload && decoded.payload.exp <= unix - tolerance) {
throw new JWTClaimInvalid('"exp" claim timestamp check failed')
throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed')
}

if (maxTokenAge) {
const age = unix - decoded.payload.iat
const max = secs(maxTokenAge)

if (age - tolerance > max) {
throw new JWTClaimInvalid('"iat" claim timestamp check failed (too far in the past)')
throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', 'iat', 'check_failed')
}

if (age < 0 - tolerance) {
throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)')
throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed')
}
}

if (profile === IDTOKEN && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) {
throw new JWTClaimInvalid('unexpected "azp" claim value')
throw new JWTClaimInvalid('unexpected "azp" claim value', 'azp', 'check_failed')
}

if (profile === ATJWT && decoded.header.typ !== ATJWT) {
throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile')
throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile', 'typ', 'check_failed')
}

key = getKey(key, true)
Expand Down
Loading

0 comments on commit a0c0c7a

Please sign in to comment.