Skip to content

Commit

Permalink
feat: support v3.local, v3.public, and v4.public paseto access tokens…
Browse files Browse the repository at this point in the history
… format

Note: these are only available when the Node.js runtime is >= v16.0.0
  • Loading branch information
panva committed Aug 3, 2021
1 parent 9c3e7bf commit aca5813
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 79 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -2325,7 +2325,7 @@ _**default value**_:
}
```
</details>
<a id="formats-customizers-to-push-a-payload-and-a-footer-to-a-paseto-structured-access-token"></a><details><summary>(Click to expand) To push a payload and a footer to a PASETO structured access token
<a id="formats-customizers-to-push-a-payload-a-footer-and-use-an-implicit-assertion-with-a-paseto-structured-access-token"></a><details><summary>(Click to expand) To push a payload, a footer, and use an implicit assertion with a PASETO structured access token
</summary><br>

```js
Expand All @@ -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
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
* }
* }
* }
Expand Down
120 changes: 87 additions & 33 deletions lib/models/formats/paseto.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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,
};
Expand Down Expand Up @@ -104,6 +140,7 @@ module.exports = (provider, { opaque }) => {
const structuredToken = {
footer: undefined,
payload: tokenPayload,
assertion: undefined,
};

const customizer = instance(provider).configuration('formats.customizers.paseto');
Expand All @@ -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);
Expand All @@ -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,
},
);

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions test/formats/formats.config.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion test/formats/jwt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ describe('jwt format', () => {
audience: 'foo',
jwt: {
sign: {
alg: 'ES384',
alg: 'ES512',
},
},
};
Expand Down
Loading

0 comments on commit aca5813

Please sign in to comment.