Skip to content

Commit

Permalink
refactor: move JWT profile specifics outside of generic JWT
Browse files Browse the repository at this point in the history
BREAKING CHANGE: the `JWT.verify` profile option was removed, use e.g.
`JWT.IdToken.verify` instead.

BREAKING CHANGE: removed the `maxAuthAge` `JWT.verify` option, this
option is now only present at the specific JWT profile APIs where the
`auth_time` property applies.

BREAKING CHANGE: removed the `nonce` `JWT.verify` option, this
option is now only present at the specific JWT profile APIs where the
`nonce` property applies.

BREAKING CHANGE: the `acr`, `amr`, `nonce` and `azp` claim value types
will only be checked when verifying a specific JWT profile using its
dedicated API.

BREAKING CHANGE: using the draft implementing APIs will emit a one-time
warning per process using `process.emitWarning`
  • Loading branch information
panva committed Sep 8, 2020
1 parent c4267cc commit fd69d7f
Show file tree
Hide file tree
Showing 8 changed files with 494 additions and 332 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
- run: npm run lint-ts

test:
env:
NODE_NO_WARNINGS: 1
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand Down Expand Up @@ -81,6 +83,8 @@ jobs:
run: npx codecov

test-electron:
env:
NODE_NO_WARNINGS: 1
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ jose.JWT.verify(
<summary><em><strong>Verifying OIDC ID Tokens</strong></em> (Click to expand)</summary><br>

ID Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an
ID Token and it is pretty easy to omit some, use the `profile` option of `JWT.verify` or the
`JWT.IdToken.verify` shorthand to make sure what you're accepting is really an ID Token meant to
ID Token and it is pretty easy to omit some, use the
`JWT.IdToken.verify` API to make sure what you're accepting is really an ID Token meant to
your Client. This will then perform all doable validations given the input. See the
[documentation][documentation-jwt] for more.

Expand Down Expand Up @@ -175,8 +175,8 @@ attention to changelog and the drafts themselves.

When accepting a JWT-formatted OAuth 2.0 Access Token there are additional requirements for the JWT
to be accepted as an Access Token according to the [specification][draft-ietf-oauth-access-token-jwt]
and it is pretty easy to omit some. Use the `profile` option of `JWT.verify` or the
`JWT.AccessToken.verify` shorthand to make sure what you're accepting is really a JWT Access Token
and it is pretty easy to omit some. Use the
`JWT.AccessToken.verify` API to make sure what you're accepting is really a JWT Access Token
meant for your Resource Server. This will then perform all doable validations given the input. See
the [documentation][documentation-jwt] for more.

Expand All @@ -202,8 +202,8 @@ since they may have breaking changes use the `~` semver operator when using thes
attention to changelog and the drafts themselves.

Logout Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an
Logout Token and it is pretty easy to omit some, use the `profile` option of `JWT.verify` or the
`JWT.LogoutToken.verify` to make sure what you're accepting is really an Logout Token meant to your
Logout Token and it is pretty easy to omit some, use the
`JWT.LogoutToken.verify` API to make sure what you're accepting is really an Logout Token meant to your
Client. This will then perform all doable validations given the input. See the
[documentation][documentation-jwt] for more.

Expand Down
50 changes: 38 additions & 12 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -908,12 +908,6 @@ Verifies the claims and signature of a JSON Web Token.
- `algorithms`: `string[]` Array of expected signing algorithms. JWT signed with an algorithm not
found in this option will be rejected. **Default:** accepts all algorithms available on the
passed key (or keys in the keystore)
- `profile`: `<string>` To validate a JWT according to a specific profile, e.g. as an ID Token.
Supported values are 'id_token', 'at+JWT' (draft), and 'logout_token' (draft). **Default:** 'undefined'
(generic JWT). Combine this option with the other ones like `maxAuthAge` and `nonce` or
`subject` depending on the use-case. Draft profiles are updated as minor versions of the library,
therefore, since they may have breaking changes use the `~` semver operator when using these and
pay close attention to changelog and the drafts themselves.
- `audience`: `<string>` &vert; `string[]` Expected audience value(s). When string an exact match must
be found in the payload, when array at least one must be matched.
- `typ`: `<string>` Expected JWT "typ" Header Parameter value. An exact match must be found in the
Expand All @@ -935,14 +929,9 @@ Verifies the claims and signature of a JSON Web Token.
- `issuer`: `<string>` &vert; `string[]` Expected issuer value(s). When string an exact match must
be found in the payload, when array at least one must be matched.
- `jti`: `<string>` Expected jti value. An exact match must be found in the payload.
- `maxAuthAge`: `<string>` When provided the payload is checked to have the "auth_time" claim and
its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See
[OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option.
- `maxTokenAge`: `<string>` When provided the payload is checked to have the "iat" claim and its
value is validated not to be older than the provided timespan string e.g. `30m`, `24 hours`.
Do not confuse with maxAuthAge option.
- `nonce`: `<string>` Expected nonce value. An exact match must be found in the payload. See
[OpenID Connect Core 1.0][connect-core] for details.
- `now`: `<Date>` Date object to be used instead of the current unix epoch timestamp.
**Default:** 'new Date()'
- `subject`: `<string>` Expected subject value. An exact match must be found in the payload.
Expand Down Expand Up @@ -1014,7 +1003,22 @@ JWT.decode(token, { complete: true })

#### `JWT.AccessToken.verify(token, keyOrStore, options])`

A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `at+JWT`.
A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with additional constraints and options
to verify an Access Token according to
[JWT Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-06).
This is an IETF **draft** implementation. Breaking draft implementations are included as minor versions of
the jose library, therefore, the ~ semver operator should be used and close attention be payed to library
changelog as well as the drafts themselves.

The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference
is that `issuer` and `audience` options are required and the additional option:

- see [`JWT.verify`](#jwtverifytoken-keyorstore-options)
- `issuer`: `<string>` REQUIRED
- `audience`: `<string>` REQUIRED
- `maxAuthAge`: `<string>` When provided the payload is checked to have the "auth_time" claim and
its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See
[OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option.

<details>
<summary><em><strong>Example</strong></em> (Click to expand)</summary>
Expand All @@ -1038,6 +1042,18 @@ jose.JWT.AccessToken.verify(

A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `id_token`.

The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference
is that `issuer` and `audience` options are required and the additional options:

- see [`JWT.verify`](#jwtverifytoken-keyorstore-options)
- `issuer`: `<string>` REQUIRED
- `audience`: `<string>` REQUIRED
- `maxAuthAge`: `<string>` When provided the payload is checked to have the "auth_time" claim and
its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See
[OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option.
- `nonce`: `<string>` Expected nonce value. An exact match must be found in the payload. See
[OpenID Connect Core 1.0][connect-core] for details.

<details>
<summary><em><strong>Example</strong></em> (Click to expand)</summary>

Expand All @@ -1060,6 +1076,16 @@ jose.JWT.IdToken.verify(
#### `JWT.LogoutToken.verify(token, keyOrStore, options])`

A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `logout_token`.
This is an OIDF **draft** implementation. Breaking draft implementations are included as minor versions of
the jose library, therefore, the ~ semver operator should be used and close attention be payed to library
changelog as well as the drafts themselves.

The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference
is that `issuer` and `audience` options are required.

- see [`JWT.verify`](#jwtverifytoken-keyorstore-options)
- `issuer`: `<string>` REQUIRED
- `audience`: `<string>` REQUIRED

<details>
<summary><em><strong>Example</strong></em> (Click to expand)</summary>
Expand Down
167 changes: 164 additions & 3 deletions lib/jwt/profiles.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,168 @@
const { JWTClaimInvalid } = require('../errors')
const secs = require('../help/secs')
const epoch = require('../help/epoch')
const isObject = require('../help/is_object')

const verify = require('./verify')
const {
isString,
isRequired,
isTimestamp,
isStringOrArrayOfStrings
} = require('./shared_validations')

const isPayloadRequired = isRequired.bind(undefined, JWTClaimInvalid)
const isPayloadString = isString.bind(undefined, JWTClaimInvalid)
const isOptionString = isString.bind(undefined, TypeError)

const defineLazyExportWithWarning = (obj, property, name, definition) => {
Object.defineProperty(obj, property, {
enumerable: true,
configurable: true,
value (...args) {
process.emitWarning(
`The ${name} API implements an IETF draft. Breaking draft implementations are included as minor versions of the jose library, therefore, the ~ semver operator should be used and close attention be payed to library changelog as well as the drafts themselves.`,
'DraftWarning'
)
Object.defineProperty(obj, property, {
enumerable: true,
configurable: true,
value: definition
})
return obj[property](...args)
}
})
}

const validateCommonOptions = (options, profile) => {
if (!isObject(options)) {
throw new TypeError('options must be an object')
}

if (!options.issuer) {
throw new TypeError(`"issuer" option is required to validate ${profile}`)
}

if (!options.audience) {
throw new TypeError(`"audience" option is required to validate ${profile}`)
}
}

module.exports = {
IdToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'id_token' }) },
LogoutToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'logout_token' }) },
AccessToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'at+JWT' }) }
IdToken: {
verify: (token, key, options = {}) => {
validateCommonOptions(options, 'an ID Token')

if ('maxAuthAge' in options) {
isOptionString(options.maxAuthAge, 'options.maxAuthAge')
}
if ('nonce' in options) {
isOptionString(options.nonce, 'options.nonce')
}

const unix = epoch(options.now || new Date())
const result = verify(token, key, { ...options })
const payload = options.complete ? result.payload : result

if (Array.isArray(payload.aud) && payload.aud.length > 1) {
isPayloadRequired(payload.azp, '"azp" claim', 'azp')
}
isPayloadRequired(payload.iat, '"iat" claim', 'iat')
isPayloadRequired(payload.sub, '"sub" claim', 'sub')
isPayloadRequired(payload.exp, '"exp" claim', 'exp')
isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge)
isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce)
isPayloadString(payload.acr, '"acr" claim', 'acr')
isStringOrArrayOfStrings(payload.amr, 'amr')

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

const tolerance = options.clockTolerance ? secs(options.clockTolerance) : 0

if (options.maxAuthAge) {
const maxAuthAgeSeconds = secs(options.maxAuthAge)
if (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)', 'auth_time', 'check_failed')
}
}

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

return result
}
},
LogoutToken: {},
AccessToken: {}
}

defineLazyExportWithWarning(module.exports.LogoutToken, 'verify', 'jose.JWT.LogoutToken.verify', (token, key, options = {}) => {
validateCommonOptions(options, 'a Logout Token')

const result = verify(token, key, { ...options })
const payload = options.complete ? result.payload : result

isPayloadRequired(payload.iat, '"iat" claim', 'iat')
isPayloadRequired(payload.jti, '"jti" claim', 'jti')
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', 'nonce', 'prohibited')
}

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

if (!isObject(payload.events)) {
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', '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', 'events', 'invalid')
}

return result
})

defineLazyExportWithWarning(module.exports.AccessToken, 'verify', 'jose.JWT.AccessToken.verify', (token, key, options = {}) => {
validateCommonOptions(options, 'a JWT Access Token')

isOptionString(options.maxAuthAge, 'options.maxAuthAge')

const unix = epoch(options.now || new Date())
const typ = 'at+JWT'
const result = verify(token, key, { ...options, typ })
const payload = options.complete ? result.payload : result

isPayloadRequired(payload.iat, '"iat" claim', 'iat')
isPayloadRequired(payload.exp, '"exp" claim', 'exp')
isPayloadRequired(payload.sub, '"sub" claim', 'sub')
isPayloadRequired(payload.jti, '"jti" claim', 'jti')
isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true)
isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge)
isPayloadString(payload.acr, '"acr" claim', 'acr')
isStringOrArrayOfStrings(payload.amr, 'amr')

const tolerance = options.clockTolerance ? secs(options.clockTolerance) : 0

if (options.maxAuthAge) {
const maxAuthAgeSeconds = secs(options.maxAuthAge)
if (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)', 'auth_time', 'check_failed')
}
}

return result
})
41 changes: 37 additions & 4 deletions lib/jwt/shared_validations.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
const isNotString = val => typeof val !== 'string' || val.length === 0
const { JWTClaimInvalid } = require('../errors')

module.exports.isNotString = isNotString
module.exports.isString = function isString (Err, value, label, claim, required = false) {
if (required && value === undefined) {
const isNotString = val => typeof val !== 'string' || val.length === 0
const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString)
const isRequired = (Err, value, label, claim) => {
if (value === undefined) {
throw new Err(`${label} is missing`, claim, 'missing')
}
}
const isString = (Err, value, label, claim, required = false) => {
if (required) {
isRequired(Err, value, label, claim)
}

if (value !== undefined && isNotString(value)) {
throw new Err(`${label} must be a string`, claim, 'invalid')
}
}
const isTimestamp = (value, label, required = false) => {
if (required && value === undefined) {
throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing')
}

if (value !== undefined && (typeof value !== 'number')) {
throw new JWTClaimInvalid(`"${label}" claim must be a JSON numeric value`, label, 'invalid')
}
}
const isStringOrArrayOfStrings = (value, label, required = false) => {
if (required && value === undefined) {
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`, label, 'invalid')
}
}

module.exports = {
isNotArrayOfStrings,
isRequired,
isNotString,
isString,
isTimestamp,
isStringOrArrayOfStrings
}
Loading

0 comments on commit fd69d7f

Please sign in to comment.