diff --git a/docs/README.md b/docs/README.md index 55f14c336..53958342e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2096,6 +2096,11 @@ _**default value**_: { AccessToken: 'opaque', ClientCredentials: 'opaque', + customizers: { + 'jwt-ietf': undefined, + jwt: undefined, + paseto: undefined + }, jwtAccessTokenSigningAlg: [AsyncFunction: jwtAccessTokenSigningAlg] } ``` @@ -2140,6 +2145,99 @@ server Configure `formats`: ``` +### formats.customizers + +helper function used by the OP before signing a structured Access Token of a given type, such as a JWT or PASETO one. Customizing here only changes the structured Access Token, not your storage, introspection or anything else. For such extras use [`extraAccessTokenClaims`](#extraaccesstokenclaims) instead. + + + +_**default value**_: +```js +{ + 'jwt-ietf': undefined, + jwt: undefined, + paseto: undefined +} +``` +
+ (Click to expand) To push additional claims to a `jwt` format Access Token payload + +
+ +```js +{ + customizers: { + jwt(ctx, token, jwt) { + jwt.payload.foo = 'bar'; + } + } +} +``` +
+
+ (Click to expand) To push additional headers to a `jwt` format Access Token + +
+ +```js +{ + customizers: { + jwt(ctx, token, jwt) { + jwt.header = { foo: 'bar' }; + } + } +} +``` +
+
+ (Click to expand) To push additional claims to a `jwt-ietf` format Access Token payload + +
+ +```js +{ + customizers: { + ['jwt-ietf'](ctx, token, jwt) { + jwt.payload.foo = 'bar'; + } + } +} +``` +
+
+ (Click to expand) To push additional headers to a `jwt-ietf` format Access Token + +
+ +```js +{ + customizers: { + ['jwt-ietf'](ctx, token, jwt) { + jwt.header = { foo: 'bar' }; + } + } +} +``` +
+
+ (Click to expand) To push a payload and a footer to a PASETO structured access token + +
+ +```js +{ + customizers: { + paseto(ctx, token, structuredToken) { + structuredToken.payload.foo = 'bar'; + structuredToken.footer = 'foo' + structuredToken.footer = Buffer.from('foo') + structuredToken.footer = { foo: 'bar' } // will get stringified + } + } +} +``` +
+ ### formats.jwtAccessTokenSigningAlg helper used by the provider to resolve a JWT Access Token signing algorithm. The resolved algorithm must be an asymmetric one supported by the provider's keys in jwks. diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index dd3b9e8b6..7292b715d 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -1318,6 +1318,78 @@ const DEFAULTS = { }, AccessToken: 'opaque', ClientCredentials: 'opaque', + + /* + * formats.customizers + * + * description: helper function used by the OP before signing a structured Access Token of a + * given type, such as a JWT or PASETO one. Customizing here only changes the structured Access + * Token, not your storage, introspection or anything else. For such extras use + * [`extraAccessTokenClaims`](#extraaccesstokenclaims) instead. + * + * example: To push additional claims to a `jwt` format Access Token payload + * ```js + * { + * customizers: { + * jwt(ctx, token, jwt) { + * jwt.payload.foo = 'bar'; + * } + * } + * } + * ``` + * + * example: To push additional headers to a `jwt` format Access Token + * ```js + * { + * customizers: { + * jwt(ctx, token, jwt) { + * jwt.header = { foo: 'bar' }; + * } + * } + * } + * ``` + * + * example: To push additional claims to a `jwt-ietf` format Access Token payload + * ```js + * { + * customizers: { + * ['jwt-ietf'](ctx, token, jwt) { + * jwt.payload.foo = 'bar'; + * } + * } + * } + * ``` + * + * example: To push additional headers to a `jwt-ietf` format Access Token + * ```js + * { + * customizers: { + * ['jwt-ietf'](ctx, token, jwt) { + * jwt.header = { foo: 'bar' }; + * } + * } + * } + * ``` + * + * example: To push a payload and a footer to a PASETO structured access token + * ```js + * { + * customizers: { + * paseto(ctx, token, structuredToken) { + * structuredToken.payload.foo = 'bar'; + * structuredToken.footer = 'foo' + * structuredToken.footer = Buffer.from('foo') + * structuredToken.footer = { foo: 'bar' } // will get stringified + * } + * } + * } + * ``` + */ + customizers: { + jwt: undefined, + 'jwt-ietf': undefined, + paseto: undefined, + }, }, /* diff --git a/lib/helpers/jwt.js b/lib/helpers/jwt.js index 46fd54229..7b5f547ed 100644 --- a/lib/helpers/jwt.js +++ b/lib/helpers/jwt.js @@ -27,7 +27,7 @@ function verifyAudience({ aud, azp }, expected, checkAzp) { class JWT { // TODO: this does not need to be async anymore static async sign(payload, key, alg, options = {}) { - const header = { alg, typ: options.typ !== undefined ? options.typ : typ }; + const header = { ...options.fields, alg, typ: options.typ !== undefined ? options.typ : typ }; const timestamp = epochTime(); const iat = options.noIat ? undefined : timestamp; diff --git a/lib/helpers/paseto.js b/lib/helpers/paseto.js index 25b892f18..b303bb0c5 100644 --- a/lib/helpers/paseto.js +++ b/lib/helpers/paseto.js @@ -27,7 +27,13 @@ const pae = (...pieces) => { return accumulator; }; -const pack = (header, payload) => `${header}${base64url.encodeBuffer(Buffer.concat(payload))}`; +const pack = (header, payload, footer) => { + if (footer.length !== 0) { + return `${header}${base64url.encodeBuffer(Buffer.concat(payload))}.${base64url.encodeBuffer(footer)}`; + } + + return `${header}${base64url.encodeBuffer(Buffer.concat(payload))}`; +}; const decode = (paseto) => { @@ -42,11 +48,21 @@ const decode = (paseto) => { return JSON.parse(base64url.decodeToBuffer(sPayload).slice(0, -64)); }; -const sign = (payload, key) => { +const sign = ({ payload, footer }, key) => { const h = `${VERSION}.${PURPOSE}.`; const m = Buffer.from(JSON.stringify(payload), 'utf8'); - const sig = signOneShot(undefined, pae(h, m, ''), key); - return pack(h, [m, sig]); + let f; + if (typeof footer === 'string') { + f = Buffer.from(footer, 'utf8'); + } else if (Buffer.isBuffer(footer)) { + f = footer; + } else if (footer) { + f = Buffer.from(JSON.stringify(footer)); + } else { + f = Buffer.from(''); + } + const sig = signOneShot(undefined, pae(h, m, f), key); + return pack(h, [m, sig], f); }; module.exports = { sign, decode }; diff --git a/lib/models/formats/jwt.js b/lib/models/formats/jwt.js index 68fe330b6..09e96f996 100644 --- a/lib/models/formats/jwt.js +++ b/lib/models/formats/jwt.js @@ -85,7 +85,19 @@ module.exports = (provider, { opaque }) => { tokenPayload.cnf['jkt#S256'] = jkt; } - value = await JWT.sign(tokenPayload, key, alg); + const structuredToken = { + header: undefined, + payload: tokenPayload, + }; + + const customizer = instance(provider).configuration('formats.customizers.jwt'); + if (customizer) { + await customizer(ctx, this, structuredToken); + } + + value = await JWT.sign(structuredToken.payload, key, alg, { + fields: structuredToken.header, + }); } payload.jwt = value; diff --git a/lib/models/formats/jwt_ietf.js b/lib/models/formats/jwt_ietf.js index dc20c177e..07e93356b 100644 --- a/lib/models/formats/jwt_ietf.js +++ b/lib/models/formats/jwt_ietf.js @@ -62,7 +62,19 @@ module.exports = (provider, { opaque, jwt }) => ({ tokenPayload.cnf['jkt#S256'] = jkt; } - value = await JWT.sign(tokenPayload, key, alg, { typ: 'at+jwt' }); + const structuredToken = { + header: undefined, + payload: tokenPayload, + }; + + const customizer = instance(provider).configuration('formats.customizers.jwt-ietf'); + if (customizer) { + await customizer(ctx, this, structuredToken); + } + + value = await JWT.sign(structuredToken.payload, key, alg, { + typ: 'at+jwt', fields: structuredToken.header, + }); } payload[PROPERTY] = value; diff --git a/lib/models/formats/paseto.js b/lib/models/formats/paseto.js index 8584f83c9..4034c2680 100644 --- a/lib/models/formats/paseto.js +++ b/lib/models/formats/paseto.js @@ -53,12 +53,13 @@ module.exports = (provider, { opaque }) => { getSigningKey(); } + const ctx = ctxRef.get(this); + if (sub) { // TODO: in v7.x require token.client to be set const client = this.client || await provider.Client.find(clientId); assert(client && client.clientId === clientId); if (client.sectorIdentifier) { - const ctx = ctxRef.get(this); const pairwiseIdentifier = instance(provider).configuration('pairwiseIdentifier'); sub = await pairwiseIdentifier(ctx, sub, client); } @@ -86,7 +87,17 @@ module.exports = (provider, { opaque }) => { tokenPayload.cnf['jkt#S256'] = jkt; } - value = await paseto.sign(tokenPayload, key); + const structuredToken = { + payload: tokenPayload, + footer: undefined, + }; + + const customizer = instance(provider).configuration('formats.customizers.paseto'); + if (customizer) { + await customizer(ctx, this, structuredToken); + } + + value = await paseto.sign(structuredToken, key); } payload.paseto = value; diff --git a/test/formats/paseto.test.js b/test/formats/paseto.test.js index 147c15293..fc672cc4b 100644 --- a/test/formats/paseto.test.js +++ b/test/formats/paseto.test.js @@ -1,19 +1,17 @@ /* eslint-disable no-param-reassign */ +const { createPublicKey } = require('crypto'); + const { spy, match: { string, number }, assert } = require('sinon'); const { expect } = require('chai'); -const base64url = require('base64url'); -const pasetoLib = require('paseto'); const { formats: { AccessToken: FORMAT } } = require('../../lib/helpers/defaults'); const epochTime = require('../../lib/helpers/epoch_time'); const bootstrap = require('../test_helper'); -function decode(paseto) { - return JSON.parse(base64url.toBuffer(paseto.split('.')[2]).slice(0, -64)); -} - if (FORMAT === 'paseto') { + const pasetoLib = require('paseto'); // eslint-disable-line global-require + const key = createPublicKey(global.keystore.get({ kty: 'OKP' }).toPEM(false)); describe('paseto storage', () => { before(bootstrap(__dirname)); const accountId = 'account'; @@ -97,7 +95,7 @@ if (FORMAT === 'paseto') { }); const { iat, jti, exp } = upsert.getCall(0).args[1]; - const payload = decode(paseto); + const payload = await pasetoLib.V2.verify(paseto, key); expect(payload).to.eql({ ...extra, aud, @@ -145,7 +143,7 @@ if (FORMAT === 'paseto') { }); const { iat, jti, exp } = upsert.getCall(0).args[1]; - const payload = decode(paseto); + const payload = await pasetoLib.V2.verify(paseto, key); expect(payload).to.eql({ ...extra, aud, @@ -185,7 +183,7 @@ if (FORMAT === 'paseto') { }); const { iat, jti, exp } = upsert.getCall(0).args[1]; - const payload = decode(paseto); + const payload = await pasetoLib.V2.verify(paseto, key); expect(payload).to.eql({ ...extra, aud, @@ -218,7 +216,7 @@ if (FORMAT === 'paseto') { }; let paseto = await accessToken.save(); - const { payload } = pasetoLib.decode(paseto); + const { payload } = await pasetoLib.V2.verify(paseto, key, { complete: true }); expect(payload).to.have.property('customized', true); i(this.provider).configuration('formats.customizers').paseto = (ctx, token, t) => { @@ -227,7 +225,7 @@ if (FORMAT === 'paseto') { }; paseto = await accessToken.save(); - let { footer } = pasetoLib.decode(paseto); + let { footer } = await pasetoLib.V2.verify(paseto, key, { complete: true }); expect(footer).to.be.instanceOf(Buffer); expect(JSON.parse(footer)).to.have.property('customized', true); @@ -236,7 +234,7 @@ if (FORMAT === 'paseto') { }; paseto = await accessToken.save(); - ({ footer } = pasetoLib.decode(paseto)); + ({ footer } = await pasetoLib.V2.verify(paseto, key, { complete: true })); expect(footer).to.be.instanceOf(Buffer); expect(footer.toString()).to.eql('foobar'); @@ -245,7 +243,7 @@ if (FORMAT === 'paseto') { }; paseto = await accessToken.save(); - ({ footer } = pasetoLib.decode(paseto)); + ({ footer } = await pasetoLib.V2.verify(paseto, key, { complete: true })); expect(footer).to.be.instanceOf(Buffer); expect(footer.toString()).to.eql('foobarbaz'); });