Skip to content

Commit

Permalink
feat: add JWT validation profiles for Access Tokens and Logout Tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Dec 31, 2019
1 parent 1f02124 commit 7bb5c95
Show file tree
Hide file tree
Showing 6 changed files with 427 additions and 48 deletions.
64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ implementation is correct.
Available JWT validation profiles

- Generic JWT
- ID Token (id_token) - [OpenID Connect Core 1.0][spec-oidc-id_token]
- OIDC ID Token (`id_token`) - [OpenID Connect Core 1.0][spec-oidc-id_token]
- OAuth 2.0 JWT Access Tokens (`at+JWT`) - [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt]
- OIDC Logout Token (`logout_token`) - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token]

<details>
<summary><em><strong>Detailed feature matrix</strong></em> (Click to expand)</summary><br>
Expand Down Expand Up @@ -77,8 +79,8 @@ Legend:
| 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] | ||
| JWT Access Tokens [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt] | | `at+JWT` |
| Logout Token - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] | | `logout_token` |
| JARM - [JWT Secured Authorization Response Mode for OAuth 2.0][draft-jarm] |||

Notes
Expand Down Expand Up @@ -224,6 +226,9 @@ jose.JWT.verify(
)
```

<details>
<summary><em><strong>Verifying OIDC ID Tokens</strong></em> (Click to expand)</summary><br>

#### ID Token Verifying

ID Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an
Expand All @@ -249,6 +254,59 @@ Note: Depending on the channel you receive an ID Token from the following claims
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 `jose`

</details>

<details>
<summary><em><strong>Verifying OAuth 2.0 JWT Access Tokens</strong></em> (Click to expand)</summary><br>

#### JWT Access Token Verifying

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

```js
jose.JWT.verify(
'eyJhbGciOiJQUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6InIxTGtiQm8zOTI1UmIyWkZGckt5VTNNVmV4OVQyODE3S3gwdmJpNmlfS2MifQ.eyJzdWIiOiJmb28iLCJjbGllbnRfaWQiOiJ1cm46ZXhhbXBsZTpjbGllbnRfaWQiLCJhdWQiOiJ1cm46ZXhhbXBsZTpyZXNvdXJjZS1zZXJ2ZXIiLCJleHAiOjE1NjM4ODg4MzAsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20iLCJzY29wZSI6ImFwaTpyZWFkIn0.UYy8vEGWS0cS24giCYobMMy9-bqI45p807yV1l-2WXX2J4UO-eohV_R58LE2oM88gl414c6XydO6QSYXul5roNPoOs41jpEvreQIP-HmegjbWGutktWJKfvoOblE5FjYwjrwStjLQGUzkq6KWcnDLPGmpFy7n6gZ4LF8YVz4dLEaO335hMNVNrmSPSXYqr7bAWybnLVpLxjDYwNfCO1g0_TlFx8fHh2OftHoOOmJFltFwb8JypkSB-JXVVSEh43IOEjeeMJIG_ylWIOxfLLi5Q7vPWgub83ZTkuGNe4KmlQJKIsH5k0yZSshsLYUOOH0RiXqQ-SA4Ubh3Fowigdu-g',
keystore,
{
profile: 'at+JWT',
issuer: 'https://op.example.com',
audience: 'urn:example:resource-server',
algorithms: ['PS256']
}
)
```

</details>

<details>
<summary><em><strong>Verifying OIDC Logout Token</strong></em> (Click to expand)</summary><br>

#### Logout Token Verifying

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

```js
jose.JWT.verify(
'eyJhbGciOiJQUzI1NiJ9.eyJzdWIiOiJmb28iLCJhdWQiOiJ1cm46ZXhhbXBsZTpjbGllbnRfaWQiLCJpYXQiOjE1NjM4ODg4MzAsImp0aSI6ImhqazMyN2RzYSIsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20iLCJldmVudHMiOnsiaHR0cDovL3NjaGVtYXMub3BlbmlkLm5ldC9ldmVudC9iYWNrY2hhbm5lbC1sb2dvdXQiOnt9fX0.SBi7uNUvjHL9TFoFzautGgTQ1MjyeGUNYHL7inpgq3XgTv6xc9EAKuPRtpixmhdNhmInGwUvAeqDSJxomwv1KK1cTndrC9zAMZ7h657BGQAwGhu7nTm41fWMpKQdiLa9sqp3yit5_FNBmqUNeOoMPrYT_Vl9ytsoNO89MUQy2aqCd-Z7BrNJZH0QycdW6dmYlrmZL7w3t3TaAXoJDJ4Hgl2Itkkkb6_6gO-VoPIdVD8sDuf1zQzGhIkmcFrk0fXczVYOkeF2hNYBuvsM8LuO-EPA3oyE2In9djai3M7yceTQetRa1vwlqWkg_xmYS59ry-6wT44aN7-Y6p0TdXm-Zg',
keystore,
{
profile: 'logout_token',
issuer: 'https://op.example.com',
audience: 'urn:example:client_id',
algorithms: ['PS256']
}
)
```

</details>

#### JWS Signing

Sign with a private or symmetric key using compact serialization. See the
Expand Down
6 changes: 3 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,9 +834,9 @@ Verifies the claims and signature of a JSON Web Token.
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.
Supported values are 'id_token', 'at+JWT', and 'logout_token'. **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
102 changes: 87 additions & 15 deletions lib/jwt/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const decode = require('./decode')
const isPayloadString = isString.bind(undefined, JWTClaimInvalid)
const isOptionString = isString.bind(undefined, TypeError)

const IDTOKEN = 'id_token'
const LOGOUTTOKEN = 'logout_token'
const ATJWT = 'at+JWT'

const isTimestamp = (value, label, required = false) => {
if (required && value === undefined) {
throw new JWTClaimInvalid(`"${label}" claim is missing`)
Expand Down Expand Up @@ -83,7 +87,7 @@ const validateOptions = (options) => {
}

switch (options.profile) {
case 'id_token':
case IDTOKEN:
if (!options.issuer) {
throw new TypeError('"issuer" option is required to validate an ID Token')
}
Expand All @@ -92,6 +96,26 @@ const validateOptions = (options) => {
throw new TypeError('"audience" option is required to validate an ID Token')
}

break
case ATJWT:
if (!options.issuer) {
throw new TypeError('"issuer" option is required to validate a JWT Access Token')
}

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

break
case LOGOUTTOKEN:
if (!options.issuer) {
throw new TypeError('"issuer" option is required to validate a Logout Token')
}

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

break
case undefined:
break
Expand All @@ -100,28 +124,72 @@ const validateOptions = (options) => {
}
}

const validatePayloadTypes = (payload, profile) => {
isTimestamp(payload.iat, 'iat', profile === 'id_token')
isTimestamp(payload.exp, 'exp', profile === 'id_token')
const validateTypes = ({ header, payload }, profile) => {
isPayloadString(header.alg, '"alg" header parameter', true)

isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN)
isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT)
isTimestamp(payload.auth_time, 'auth_time')
isTimestamp(payload.nbf, 'nbf')
isPayloadString(payload.jti, '"jti" claim')
isPayloadString(payload.jti, '"jti" claim', profile === LOGOUTTOKEN)
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)
isPayloadString(payload.iss, '"iss" claim', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN)
isPayloadString(payload.sub, '"sub" claim', profile === IDTOKEN || profile === ATJWT)
isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN)
isPayloadString(payload.azp, '"azp" claim', 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)
}

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

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')
}

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

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

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

if (!isObject(payload.events['http:https://schemas.openid.net/event/backchannel-logout'])) {
throw new JWTClaimInvalid('"http:https://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object')
}
}
}

const checkAudiencePresence = (audPayload, audOption) => {
const checkAudiencePresence = (audPayload, audOption, profile) => {
if (typeof audPayload === 'string') {
return audOption.includes(audPayload)
}

audPayload = new Set(audPayload)
return audOption.some(Set.prototype.has.bind(audPayload))
if (profile === ATJWT) {
// reject if it contains additional audiences that are not known aliases of the resource
// indicator of the current resource server
audOption = new Set(audOption)
return audPayload.every(Set.prototype.has.bind(audOption))
} else {
// Each principal intended to process the JWT MUST
// identify itself with a value in the audience claim
audPayload = new Set(audPayload)
return audOption.some(Set.prototype.has.bind(audPayload))
}
}

module.exports = (token, key, options = {}) => {
Expand Down Expand Up @@ -157,7 +225,7 @@ module.exports = (token, key, options = {}) => {
const unix = epoch(now)

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

if (issuer && decoded.payload.iss !== issuer) {
throw new JWTClaimInvalid('issuer mismatch')
Expand All @@ -175,7 +243,7 @@ module.exports = (token, key, options = {}) => {
throw new JWTClaimInvalid('jti mismatch')
}

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

Expand Down Expand Up @@ -214,10 +282,14 @@ module.exports = (token, key, options = {}) => {
}
}

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

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

key = getKey(key, true)

if (complete && key instanceof KeyStore) {
Expand Down
20 changes: 12 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,25 @@
"jwks",
"jws",
"jwt",
"access_token",
"access token",
"logout_token",
"logout token",
"secp256k1",
"sign",
"validate",
"verify"
],
"homepage": "https://github.com/panva/jose",
"repository": "panva/jose",
"funding": "https://github.com/sponsors/panva",
"license": "MIT",
"author": "Filip Skokan <[email protected]>",
"files": [
"lib",
"LICENSE_THIRD_PARTY",
"types/index.d.ts"
],
"funding": "https://github.com/sponsors/panva",
"main": "lib/index.js",
"types": "types/index.d.ts",
"scripts": {
Expand All @@ -56,6 +60,13 @@
"@commitlint/config-conventional"
]
},
"ava": {
"babel": false,
"compileEnhancements": false,
"files": [
"test/**/*.test.js"
]
},
"dependencies": {
"asn1.js": "^5.2.0"
},
Expand All @@ -72,13 +83,6 @@
"engines": {
"node": ">=10.13.0"
},
"ava": {
"babel": false,
"compileEnhancements": false,
"files": [
"test/**/*.test.js"
]
},
"standard": {
"parser": "babel-eslint"
}
Expand Down
Loading

0 comments on commit 7bb5c95

Please sign in to comment.