Skip to content

Commit

Permalink
feat: validate JWTs according to a JWT profile - ID Token
Browse files Browse the repository at this point in the history
It is now possible to pass a profile to `JWT.verify` and have the JWT
validated according to it. This makes sure you pass all the right
options and that required claims are present, prohibited claims are
missing and that the right JWT typ is used.

More profiles will be added in the future.
  • Loading branch information
panva committed Jul 23, 2019
1 parent baa2f4d commit 6c98b61
Show file tree
Hide file tree
Showing 8 changed files with 247 additions and 40 deletions.
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ The following specifications are implemented by @panva/jose
The test suite utilizes examples defined in [RFC7520][spec-cookbook] to confirm its JOSE
implementation is correct.

Available JWT validation profiles

- Generic JWT
- ID Token (id_token) - [OpenID Connect Core 1.0][spec-oidc-id_token]

<details>
<summary><em><strong>Detailed feature matrix</strong></em> (Click to expand)</summary><br>

Legend:
- **** Implemented
- **** Missing node crypto support / won't implement
- **** not planned (yet?) / PR / Use-Case first welcome
- **** TBD

| JWK Key Types | Supported ||
| -- | -- | -- |
Expand Down Expand Up @@ -67,6 +72,13 @@ Legend:
| AES GCM || A128GCM, A192GCM, A256GCM |
| AES_CBC_HMAC_SHA2 || A128CBC-HS256, A192CBC-HS384, A256CBC-HS512 |

| JWT profile validation | Supported | profile option value |
| -- | -- | -- |
| ID Token - [OpenID Connect Core 1.0][spec-oidc-id_token] || `id_token` |
| JWT Access Tokens [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt] |||
| Logout Token - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] |||
| JARM - [JWT Secured Authorization Response Mode for OAuth 2.0][draft-jarm] |||

---

Pending Node.js Support 🤞:
Expand Down Expand Up @@ -182,7 +194,7 @@ jose.JWT.sign(
)
```

#### JWT Verification
#### JWT Verifying

Verify with a public or symmetric key with plethora of convenience options. See the
[documentation][documentation-jwt] for more.
Expand All @@ -199,6 +211,31 @@ jose.JWT.verify(
)
```

#### ID Token Verifying

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` 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.

```js
jose.JWT.verify(
'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InIxTGtiQm8zOTI1UmIyWkZGckt5VTNNVmV4OVQyODE3S3gwdmJpNmlfS2MifQ.eyJzdWIiOiJmb28iLCJub25jZSI6ImE1MWNjZjA4ZjRiYmIwNmU4ODcxNWRkYzRiYmI0MWQ4IiwiYXVkIjoidXJuOmV4YW1wbGU6Y2xpZW50X2lkIiwiZXhwIjoxNTYzODg4ODMwLCJpYXQiOjE1NjM4ODUyMzAsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20ifQ.RKCZczgICF5G9XdNDSwe4dolGauQHptpFKPzahA2wYGG2HKrKhyC8ZzqpeVc8cbntuqFBgABJVv6_9YICRx_dgwPYydTpZfZYjHnxrdWF9QsIPEGs672mrnhqIXUnXoseZ0TF6GOq6P7Qbf6gk1ru7TAbr_ieyJnNWcJhh5iHpz1k3mFz0TyTh7UNXshtQXftPUipqz4OBni5r9UaZXHw8B3QYOnms8__GJ3owOxaqkr1jgRs_EWqMlBNjPaj7ElVaeBWljDKuoK673tH0heSpgzUmUX_W8IDUVqs33uglpZwAQC7cAA5mGEg2odcRpvpP5M-WaP4RE9dl9jzcYmrw',
keystore,
{
profile: 'id_token',
issuer: 'https://op.example.com',
audience: 'urn:example:client_id',
nonce: 'a51ccf08f4bbb06e88715ddc4bbb41d8',
algorithms: ['PS256']
}
)
```

Note: Depending on the channel you receive an ID Token from the following claims may be required
and must also be checked: `at_hash`, `c_hash` or `s_hash`. Use e.g. [`oidc-token-hash`][oidc-token-hash]
to validate those hashes after getting the ID Token payload and signature validated by @panva/jose.

#### JWS Signing

Sign with a private or symmetric key using compact serialization. See the
Expand Down Expand Up @@ -343,7 +380,12 @@ in terms of performance and API (not having well defined errors).
[spec-jwt]: https://tools.ietf.org/html/rfc7519
[spec-okp]: https://tools.ietf.org/html/rfc8037
[draft-secp256k1]: https://tools.ietf.org/html/draft-ietf-cose-webauthn-algorithms-01
[draft-ietf-oauth-access-token-jwt]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt
[draft-jarm]: https://openid.net/specs/openid-financial-api-jarm.html
[spec-thumbprint]: https://tools.ietf.org/html/rfc7638
[spec-oidc-id_token]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
[spec-oidc-logout_token]: https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken
[oidc-token-hash]: https://www.npmjs.com/package/oidc-token-hash
[suggest-feature]: https://github.com/panva/jose/issues/new?labels=enhancement&template=feature-request.md&title=proposal%3A+
[support-patreon]: https://www.patreon.com/panva
[support-paypal]: https://www.paypal.me/panva
Expand Down
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,10 @@ 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' for now. **Default:** 'undefined' (generic JWT). Combine this
option with the other ones like `maxAuthAge` and `nonce` or `subject` depending on the
use-case.
- `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.
- `clockTolerance`: `<string>` Clock Tolerance for comparing timestamps, provided as timespan
Expand Down
4 changes: 3 additions & 1 deletion lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type OKPCurve = 'Ed25519' | 'Ed448' | 'X25519' | 'X448'
type keyType = 'RSA' | 'EC' | 'OKP' | 'oct'
type asymmetricKeyObjectTypes = 'private' | 'public'
type keyObjectTypes = asymmetricKeyObjectTypes | 'secret'
type JWTProfiles = 'id_token'

interface JWKOctKey extends BasicParameters { // no x5c
kty: 'oct',
Expand Down Expand Up @@ -348,7 +349,8 @@ export namespace JWT {
algorithms?: string[],
nonce?: string,
now?: Date,
crit?: string[]
crit?: string[],
profile?: JWTProfiles
}
export function verify(jwt: string, key: JWK.Key | JWKS.KeyStore, options?: VerifyOptions<false>): object
export function verify(jwt: string, key: JWK.Key | JWKS.KeyStore, options?: VerifyOptions<true>): completeResult
Expand Down
6 changes: 5 additions & 1 deletion lib/jwt/shared_validations.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
const isNotString = val => typeof val !== 'string' || val.length === 0

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

if (value !== undefined && isNotString(value)) {
throw new Err(`${label} must be a string`)
}
Expand Down
16 changes: 8 additions & 8 deletions lib/jwt/sign.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const secs = require('../help/secs')
const epoch = require('../help/epoch')
const JWS = require('../jws')

const isStringOptional = require('./shared_validations').isStringOptional.bind(undefined, TypeError)
const isString = require('./shared_validations').isString.bind(undefined, TypeError)

const validateOptions = (options) => {
if (typeof options.iat !== 'boolean') {
Expand All @@ -14,8 +14,8 @@ const validateOptions = (options) => {
throw new TypeError('options.kid must be a boolean')
}

isStringOptional(options.subject, 'options.subject')
isStringOptional(options.issuer, 'options.issuer')
isString(options.subject, 'options.subject')
isString(options.issuer, 'options.issuer')

if (
options.audience !== undefined &&
Expand All @@ -31,11 +31,11 @@ const validateOptions = (options) => {
throw new TypeError('options.header must be an object')
}

isStringOptional(options.algorithm, 'options.algorithm')
isStringOptional(options.expiresIn, 'options.expiresIn')
isStringOptional(options.notBefore, 'options.notBefore')
isStringOptional(options.jti, 'options.jti')
isStringOptional(options.nonce, 'options.nonce')
isString(options.algorithm, 'options.algorithm')
isString(options.expiresIn, 'options.expiresIn')
isString(options.notBefore, 'options.notBefore')
isString(options.jti, 'options.jti')
isString(options.nonce, 'options.nonce')

if (!(options.now instanceof Date) || !options.now.getTime()) {
throw new TypeError('options.now must be a valid Date object')
Expand Down
105 changes: 77 additions & 28 deletions lib/jwt/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@ const JWS = require('../jws')
const { KeyStore } = require('../jwks')
const { JWTClaimInvalid } = require('../errors')

const { isStringOptional, isNotString } = require('./shared_validations')
const { isString, isNotString } = require('./shared_validations')
const decode = require('./decode')

const isPayloadStringOptional = isStringOptional.bind(undefined, JWTClaimInvalid)
const isOptionStringOptional = isStringOptional.bind(undefined, TypeError)
const isPayloadString = isString.bind(undefined, JWTClaimInvalid)
const isOptionString = isString.bind(undefined, TypeError)

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

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

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

if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) {
throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`)
}
Expand All @@ -26,26 +34,30 @@ const isStringOrArrayOfStringsOptional = (value, label) => {
const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString)

const validateOptions = (options) => {
isOptionString(options.profile, 'options.profile')

if (typeof options.complete !== 'boolean') {
throw new TypeError('options.complete must be a boolean')
}

if (typeof options.ignoreExp !== 'boolean') {
throw new TypeError('options.ignoreExp must be a boolean')
}

if (typeof options.ignoreNbf !== 'boolean') {
throw new TypeError('options.ignoreNbf must be a boolean')
}

if (typeof options.ignoreIat !== 'boolean') {
throw new TypeError('options.ignoreIat must be a boolean')
}

isOptionStringOptional(options.maxTokenAge, 'options.maxTokenAge')
isOptionStringOptional(options.subject, 'options.subject')
isOptionStringOptional(options.issuer, 'options.issuer')
isOptionStringOptional(options.maxAuthAge, 'options.maxAuthAge')
isOptionStringOptional(options.jti, 'options.jti')
isOptionStringOptional(options.clockTolerance, 'options.clockTolerance')
isOptionString(options.maxTokenAge, 'options.maxTokenAge')
isOptionString(options.subject, 'options.subject')
isOptionString(options.issuer, 'options.issuer')
isOptionString(options.maxAuthAge, 'options.maxAuthAge')
isOptionString(options.jti, 'options.jti')
isOptionString(options.clockTolerance, 'options.clockTolerance')

if (options.audience !== undefined && (isNotString(options.audience) && isNotArrayOfStrings(options.audience))) {
throw new TypeError('options.audience must be a string or an array of strings')
Expand All @@ -55,7 +67,7 @@ const validateOptions = (options) => {
throw new TypeError('options.algorithms must be an array of strings')
}

isOptionStringOptional(options.nonce, 'options.nonce')
isOptionString(options.nonce, 'options.nonce')

if (!(options.now instanceof Date) || !options.now.getTime()) {
throw new TypeError('options.now must be a valid Date object')
Expand All @@ -68,21 +80,38 @@ const validateOptions = (options) => {
if (options.crit !== undefined && isNotArrayOfStrings(options.crit)) {
throw new TypeError('options.crit must be an array of strings')
}

switch (options.profile) {
case 'id_token':
if (!options.issuer) {
throw new TypeError('"issuer" option is required to validate an ID Token')
}

if (!options.audience) {
throw new TypeError('"audience" option is required to validate an ID Token')
}

break
case undefined:
break
default:
throw new TypeError(`unsupported options.profile value "${options.profile}"`)
}
}

const validatePayloadTypes = (payload) => {
isTimestampOptional(payload.iat, 'iat')
isTimestampOptional(payload.exp, 'exp')
isTimestampOptional(payload.auth_time, 'auth_time')
isTimestampOptional(payload.nbf, 'nbf')
isPayloadStringOptional(payload.jti, '"jti" claim')
isPayloadStringOptional(payload.acr, '"acr" claim')
isPayloadStringOptional(payload.nonce, '"nonce" claim')
isPayloadStringOptional(payload.iss, '"iss" claim')
isPayloadStringOptional(payload.sub, '"sub" claim')
isPayloadStringOptional(payload.azp, '"azp" claim')
isStringOrArrayOfStringsOptional(payload.aud, 'aud')
isStringOrArrayOfStringsOptional(payload.amr, 'amr')
const validatePayloadTypes = (payload, profile) => {
isTimestamp(payload.iat, 'iat', profile === 'id_token')
isTimestamp(payload.exp, 'exp', profile === 'id_token')
isTimestamp(payload.auth_time, 'auth_time')
isTimestamp(payload.nbf, 'nbf')
isPayloadString(payload.jti, '"jti" claim')
isPayloadString(payload.acr, '"acr" claim')
isPayloadString(payload.nonce, '"nonce" claim')
isPayloadString(payload.iss, '"iss" claim', profile === 'id_token')
isPayloadString(payload.sub, '"sub" claim', profile === 'id_token')
isStringOrArrayOfStrings(payload.aud, 'aud', profile === 'id_token')
isPayloadString(payload.azp, '"azp" claim', profile === 'id_token' && Array.isArray(payload.aud) && payload.aud.length > 1)
isStringOrArrayOfStrings(payload.amr, 'amr')
}

const checkAudiencePresence = (audPayload, audOption) => {
Expand All @@ -101,17 +130,33 @@ module.exports = (token, key, options = {}) => {

const {
algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false,
ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(), subject
ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(),
subject, profile
} = options

validateOptions({
algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer, jti, maxAuthAge, maxTokenAge, nonce, now, subject
algorithms,
audience,
clockTolerance,
complete,
crit,
ignoreExp,
ignoreIat,
ignoreNbf,
issuer,
jti,
maxAuthAge,
maxTokenAge,
nonce,
now,
profile,
subject
})

const unix = epoch(now)

const decoded = decode(token, { complete: true })
validatePayloadTypes(decoded.payload)
validatePayloadTypes(decoded.payload, profile)

if (issuer && decoded.payload.iss !== issuer) {
throw new JWTClaimInvalid('issuer mismatch')
Expand Down Expand Up @@ -168,6 +213,10 @@ module.exports = (token, key, options = {}) => {
}
}

if (profile === 'id_token' && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) {
throw new JWTClaimInvalid('azp mismatch')
}

if (complete && key instanceof KeyStore) {
({ key } = JWS.verify(token, key, { crit, algorithms, complete: true }))
} else {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"encrypt",
"flattened",
"general",
"id token",
"id_token",
"jose",
"json web token",
"jsonwebtoken",
Expand Down
Loading

0 comments on commit 6c98b61

Please sign in to comment.