From aca5813a5b7e669f30894102ad925b1aec3f3467 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 18 Jul 2021 17:55:28 +0200 Subject: [PATCH] feat: support v3.local, v3.public, and v4.public paseto access tokens format Note: these are only available when the Node.js runtime is >= v16.0.0 --- README.md | 7 +- docs/README.md | 5 +- lib/helpers/defaults.js | 5 +- lib/models/formats/paseto.js | 120 +++++++++++++++++++++-------- package.json | 5 +- test/formats/formats.config.js | 3 + test/formats/jwt.test.js | 2 +- test/formats/paseto.test.js | 137 +++++++++++++++++++++++---------- 8 files changed, 205 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0b9542afb..3a6dadca4 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,14 @@ enabled by default, check the configuration section on how to enable them. - [RFC8628 - OAuth 2.0 Device Authorization Grant (Device Flow)][device-flow] - [RFC8705 - OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens (MTLS)][mtls] - [RFC8707 - OAuth 2.0 Resource Indicators][resource-indicators] -- [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens][jwt-at] - [Financial-grade API Security Profile 1.0 - Part 2: Advanced (FAPI)][fapi] - [Financial-grade API - Part 2: Read and Write API Security Profile (FAPI) - Implementer's Draft 02][fapi-id2] +Supported Access Token formats: +- Opaque +- [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens][jwt-at] +- [Platform-Agnostic Security Tokens (PASETO)][paseto-at] + The following draft specifications are implemented by oidc-provider. - [JWT Response for OAuth Token Introspection - draft 10][jwt-introspection] - [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) - Implementer's Draft 01][jarm] @@ -153,6 +157,7 @@ See the list of available emitted [event names](/docs/events.md) and their descr [resource-indicators]: https://tools.ietf.org/html/rfc8707 [jarm]: https://openid.net/specs/openid-financial-api-jarm-ID1.html [jwt-at]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-11 +[paseto-at]: https://paseto.io [support-sponsor]: https://github.com/sponsors/panva [par]: https://tools.ietf.org/html/draft-ietf-oauth-par-08 [rpinitiated-logout]: https://openid.net/specs/openid-connect-rpinitiated-1_0-01.html diff --git a/docs/README.md b/docs/README.md index ada1fc702..0bb84148b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1819,7 +1819,7 @@ and a JWT Access Token Format. } // PASETO Access Token Format (when accessTokenFormat is 'paseto') paseto?: { - version: 1 | 2, + version: 1 | 2 | 3 | 4, purpose: 'local' | 'public', key?: crypto.KeyObject, // required when purpose is 'local' kid?: string, // OPTIONAL `kid` to aid in signing key selection or to put in the footer for 'local' @@ -2325,7 +2325,7 @@ _**default value**_: } ``` -
(Click to expand) To push a payload and a footer to a PASETO structured access token +
(Click to expand) To push a payload, a footer, and use an implicit assertion with a PASETO structured access token
```js @@ -2334,6 +2334,7 @@ _**default value**_: paseto(ctx, token, structuredToken) { structuredToken.payload.foo = 'bar'; structuredToken.footer = { foo: 'bar' }; + structuredToken.assertion = 'foo'; // v3 and v4 tokens only } } } diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index 4f335390b..496c12443 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -1722,7 +1722,7 @@ function getDefaults() { * * // PASETO Access Token Format (when accessTokenFormat is 'paseto') * paseto?: { - * version: 1 | 2, + * version: 1 | 2 | 3 | 4, * purpose: 'local' | 'public', * key?: crypto.KeyObject, // required when purpose is 'local' * kid?: string, // OPTIONAL `kid` to aid in signing key selection or to put in the footer for 'local' @@ -1948,13 +1948,14 @@ function getDefaults() { * } * ``` * - * example: To push a payload and a footer to a PASETO structured access token + * example: To push a payload, a footer, and use an implicit assertion with a PASETO structured access token * ```js * { * customizers: { * paseto(ctx, token, structuredToken) { * structuredToken.payload.foo = 'bar'; * structuredToken.footer = { foo: 'bar' }; + * structuredToken.assertion = 'foo'; // v3 and v4 tokens only * } * } * } diff --git a/lib/models/formats/paseto.js b/lib/models/formats/paseto.js index 7166bd231..d1b3b4e5b 100644 --- a/lib/models/formats/paseto.js +++ b/lib/models/formats/paseto.js @@ -1,7 +1,22 @@ const { strict: assert } = require('assert'); const crypto = require('crypto'); -const paseto = require('paseto'); +let paseto; +let paseto3 = process.version.substr(1).split('.').map((x) => parseInt(x, 10))[0] >= 16; + +if (paseto3) { + try { + // eslint-disable-next-line + paseto = require('paseto3'); + } catch (err) { + paseto3 = false; + } +} + +if (!paseto3) { + // eslint-disable-next-line + paseto = require('paseto2'); +} const instance = require('../../helpers/weak_cache'); const nanoid = require('../../helpers/nanoid'); @@ -18,41 +33,62 @@ module.exports = (provider, { opaque }) => { const { version, purpose } = token.resourceServer.paseto; let { key, kid } = token.resourceServer.paseto; + let alg; - if (version !== 1 && version !== 2) { - throw new Error('unsupported "paseto.version"'); + if (version > 2 && !paseto3) { + throw new Error('PASETO v3 and v4 tokens are only supported in Node.js >= 16.0.0 runtimes'); } - if (purpose === 'local' && version === 1) { - if (key === undefined) { - throw new Error('local purpose PASETO Resource Server requires a "paseto.key"'); - } - if (!(key instanceof crypto.KeyObject)) { - key = crypto.createSecretKey(key); - } - if (key.type !== 'secret' || key.symmetricKeySize !== 32) { - throw new Error('local purpose PASETO Resource Server "paseto.key" must be 256 bits long secret key'); - } - } else if (purpose === 'public') { - if (version === 1) { - [key] = keystore.selectForSign({ alg: 'PS384', kid }); - } else if (version === 2) { - [key] = keystore.selectForSign({ alg: 'EdDSA', crv: 'Ed25519', kid }); - } + switch (true) { + case version === 1 && purpose === 'local': + case version === 3 && purpose === 'local': + if (!key) { + throw new Error('local purpose PASETO Resource Server requires a "paseto.key"'); + } + if (!(key instanceof crypto.KeyObject)) { + key = crypto.createSecretKey(key); + } + if (key.type !== 'secret' || key.symmetricKeySize !== 32) { + throw new Error('local purpose PASETO Resource Server "paseto.key" must be 256 bits long secret key'); + } + break; + case version === 1 && purpose === 'public': + alg = 'PS384'; + [key] = keystore.selectForSign({ + alg, kid, kty: 'RSA', + }); + break; + case (version === 2 || version === 4) && purpose === 'public': + alg = 'EdDSA'; + [key] = keystore.selectForSign({ + alg, crv: 'Ed25519', kid, kty: 'OKP', + }); + break; + case version === 3 && purpose === 'public': + alg = 'ES384'; + [key] = keystore.selectForSign({ + alg, crv: 'P-384', kid, kty: 'EC', + }); + break; + default: + throw new Error('unsupported PASETO version and purpose'); + } + + if (purpose === 'public') { if (!key) { throw new Error('resolved Resource Server paseto configuration has no corresponding key in the provider\'s keystore'); } - kid = key.kid; - key = await keystore.getKeyObject(key, version === 1 ? 'RS384' : 'EdDSA').catch(() => { + ({ kid } = key); + // eslint-disable-next-line no-nested-ternary + key = await keystore.getKeyObject(key, alg).catch(() => { throw new Error(`provider key (kid: ${kid}) is invalid`); }); - } else { - throw new Error('unsupported PASETO version and purpose'); } if (kid !== undefined && typeof kid !== 'string') { throw new Error('paseto.kid must be a string when provided'); } + return { version, purpose, key, kid, }; @@ -104,6 +140,7 @@ module.exports = (provider, { opaque }) => { const structuredToken = { footer: undefined, payload: tokenPayload, + assertion: undefined, }; const customizer = instance(provider).configuration('formats.customizers.paseto'); @@ -112,25 +149,41 @@ module.exports = (provider, { opaque }) => { } if (!structuredToken.payload.aud) { - throw new Error('JWT Access Tokens must contain an audience, for Access Tokens without audience (only usable at the userinfo_endpoint) use an opaque format'); + throw new Error('PASETO Access Tokens must contain an audience, for Access Tokens without audience (only usable at the userinfo_endpoint) use an opaque format'); } - const config = await getResourceServerConfig(this); + const { + version, purpose, kid, key, + } = await getResourceServerConfig(this); let issue; - if (config.version === 1) { - issue = config.purpose === 'local' ? paseto.V1.encrypt : paseto.V1.sign; - } else { - issue = paseto.V2.sign; + // eslint-disable-next-line default-case + switch (version) { + case 1: + issue = purpose === 'local' ? paseto.V1.encrypt : paseto.V1.sign; + break; + case 2: + issue = paseto.V2.sign; + break; + case 3: + issue = purpose === 'local' ? paseto.V3.encrypt : paseto.V3.sign; + break; + case 4: + issue = paseto.V4.sign; + break; + } + + if (structuredToken.assertion !== undefined && version < 3) { + throw new Error('only PASETO v3 and v4 tokens support an implicit assertion'); } /* eslint-disable no-unused-expressions */ - if (config.kid) { + if (kid) { structuredToken.footer || (structuredToken.footer = {}); - structuredToken.footer.kid || (structuredToken.footer.kid = config.kid); + structuredToken.footer.kid || (structuredToken.footer.kid = kid); } - if (config.purpose === 'local') { + if (purpose === 'local') { structuredToken.footer || (structuredToken.footer = {}); structuredToken.footer.iss || (structuredToken.footer.iss = provider.issuer); structuredToken.footer.aud || (structuredToken.footer.aud = structuredToken.payload.aud); @@ -139,10 +192,11 @@ module.exports = (provider, { opaque }) => { const token = await issue( structuredToken.payload, - config.key, + key, { footer: structuredToken.footer ? JSON.stringify(structuredToken.footer) : undefined, iat: false, + assertion: structuredToken.assertion ? structuredToken.assertion : undefined, }, ); diff --git a/package.json b/package.json index c9b3d8456..602d6f2d0 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,13 @@ "nanoid": "^3.1.15", "object-hash": "^2.0.3", "oidc-token-hash": "^5.0.1", - "paseto": "^2.1.0", + "paseto2": "npm:paseto@^2.1.3", "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, + "optionalDependencies": { + "paseto3": "npm:paseto@^3.0.0" + }, "devDependencies": { "@hapi/hapi": "^20.0.1", "babel-eslint": "^10.1.0", diff --git a/test/formats/formats.config.js b/test/formats/formats.config.js index 01635b987..67df6f1df 100644 --- a/test/formats/formats.config.js +++ b/test/formats/formats.config.js @@ -1,8 +1,11 @@ const cloneDeep = require('lodash/cloneDeep'); const merge = require('lodash/merge'); +const jose = require('jose2'); const config = cloneDeep(require('../default.config')); +config.jwks = global.keystore.toJWKS(true); +config.jwks.keys.push(jose.JWK.generateSync('EC', 'P-384', { use: 'sig' }).toJWK(true)); config.extraTokenClaims = () => ({ foo: 'bar' }); merge(config.features, { registration: { diff --git a/test/formats/jwt.test.js b/test/formats/jwt.test.js index 63d29d783..1cc46b146 100644 --- a/test/formats/jwt.test.js +++ b/test/formats/jwt.test.js @@ -425,7 +425,7 @@ describe('jwt format', () => { audience: 'foo', jwt: { sign: { - alg: 'ES384', + alg: 'ES512', }, }, }; diff --git a/test/formats/paseto.test.js b/test/formats/paseto.test.js index 4137a1805..78c720fd9 100644 --- a/test/formats/paseto.test.js +++ b/test/formats/paseto.test.js @@ -6,7 +6,16 @@ const util = require('util'); const sinon = require('sinon').createSandbox(); const { expect } = require('chai'); -const paseto = require('paseto'); + +let paseto; +const above16 = process.version.substr(1).split('.').map((x) => parseInt(x, 10))[0] >= 16; +if (above16) { + // eslint-disable-next-line + paseto = require('paseto3'); +} else { + // eslint-disable-next-line + paseto = require('paseto2'); +} const epochTime = require('../../lib/helpers/epoch_time'); const bootstrap = require('../test_helper'); @@ -96,6 +105,40 @@ describe('paseto format', () => { expect(await token.save()).to.match(/^v2\.public\./); }); + if (above16) { + it('v3.public', async function () { + const resourceServer = { + accessTokenFormat: 'paseto', + audience: 'foo', + paseto: { + version: 3, + purpose: 'public', + }, + }; + + const client = await this.provider.Client.find(clientId); + const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); + expect(await token.save()).to.match(/^v3\.public\./); + }); + } + + if (above16) { + it('v4.public', async function () { + const resourceServer = { + accessTokenFormat: 'paseto', + audience: 'foo', + paseto: { + version: 4, + purpose: 'public', + }, + }; + + const client = await this.provider.Client.find(clientId); + const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); + expect(await token.save()).to.match(/^v4\.public\./); + }); + } + it('v1.local', async function () { const resourceServer = { accessTokenFormat: 'paseto', @@ -128,25 +171,39 @@ describe('paseto format', () => { expect(await token.save()).to.match(/^v1\.local\./); }); - it('v2.local is not supported', async function () { - const resourceServer = { - accessTokenFormat: 'paseto', - audience: 'foo', - paseto: { - version: 2, - purpose: 'local', - key: crypto.randomBytes(32), - }, - }; + if (above16) { + it('v3.local', async function () { + const resourceServer = { + accessTokenFormat: 'paseto', + audience: 'foo', + paseto: { + version: 3, + purpose: 'local', + key: crypto.randomBytes(32), + }, + }; + + const client = await this.provider.Client.find(clientId); + const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); + expect(await token.save()).to.match(/^v3\.local\./); + }); - const client = await this.provider.Client.find(clientId); - const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); - return assert.rejects(token.save(), (err) => { - expect(err).to.be.an('error'); - expect(err.message).to.equal('unsupported PASETO version and purpose'); - return true; + it('v3.local (keyObject)', async function () { + const resourceServer = { + accessTokenFormat: 'paseto', + audience: 'foo', + paseto: { + version: 3, + purpose: 'local', + key: crypto.createSecretKey(crypto.randomBytes(32)), + }, + }; + + const client = await this.provider.Client.find(clientId); + const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); + expect(await token.save()).to.match(/^v3\.local\./); }); - }); + } it('public kid selection failing', async function () { const resourceServer = { @@ -194,8 +251,8 @@ describe('paseto format', () => { accessTokenFormat: 'paseto', audience: 'foo', paseto: { - version: 1, - purpose: 'foobar', + version: 2, + purpose: 'local', }, }; @@ -208,25 +265,6 @@ describe('paseto format', () => { }); }); - it('unsupported "paseto.version"', async function () { - const resourceServer = { - accessTokenFormat: 'paseto', - audience: 'foo', - paseto: { - version: 3, - purpose: 'foobar', - }, - }; - - const client = await this.provider.Client.find(clientId); - const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); - return assert.rejects(token.save(), (err) => { - expect(err).to.be.an('error'); - expect(err.message).to.equal('unsupported "paseto.version"'); - return true; - }); - }); - it('local paseto requires a key', async function () { const resourceServer = { accessTokenFormat: 'paseto', @@ -321,6 +359,27 @@ describe('paseto format', () => { }); }); + if (!above16) { + it('only >= 16.0.0 node supports v3 and v4', async function () { + const resourceServer = { + accessTokenFormat: 'paseto', + audience: 'foo', + paseto: { + version: 3, + purpose: 'public', + }, + }; + + const client = await this.provider.Client.find(clientId); + const token = new this.provider.AccessToken({ client, ...fullPayload, resourceServer }); + return assert.rejects(token.save(), (err) => { + expect(err).to.be.an('error'); + expect(err.message).to.equal('PASETO v3 and v4 tokens are only supported in Node.js >= 16.0.0 runtimes'); + return true; + }); + }); + } + it('invalid paseto configuration type', async function () { const resourceServer = { accessTokenFormat: 'paseto',