Skip to content

Commit

Permalink
refactor: DPoP input must be a private KeyObject or valid crypto.crea…
Browse files Browse the repository at this point in the history
…tePrivateKey input

BREAKING CHANGE: DPoP option inputs must be a private crypto.KeyObject
or a valid crypto.createPrivateKey input.
  • Loading branch information
panva committed Oct 27, 2021
1 parent 0c23248 commit d69af6f
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 98 deletions.
45 changes: 27 additions & 18 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,10 @@ Performs the callback for Authorization Server's authorization response.
- `clientAssertionPayload`: `<Object>` extra client assertion payload parameters to be sent as
part of a client JWT assertion. This is only used when the client's `token_endpoint_auth_method`
is either `client_secret_jwt` or `private_key_jwt`.
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

Tip: If you're using pure
Expand All @@ -322,9 +323,10 @@ Performs `refresh_token` grant type exchange.
- `clientAssertionPayload`: `<Object>` extra client assertion payload parameters to be sent as
part of a client JWT assertion. This is only used when the client's `token_endpoint_auth_method`
is either `client_secret_jwt` or `private_key_jwt`.
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

---
Expand All @@ -345,9 +347,10 @@ will also be checked to match the on in the TokenSet's ID Token.
or the `token_type` property from a passed in TokenSet.
- `params`: `<Object>` additional parameters to send with the userinfo request (as query string
when GET, as x-www-form-urlencoded body when POST).
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Userinfo Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Userinfo Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<Object>` Parsed userinfo response.

---
Expand All @@ -365,9 +368,10 @@ Fetches an arbitrary resource with the provided Access Token in an Authorization
- `method`: `<string>` The HTTP method to use for the request. **Default:** 'GET'
- `tokenType`: `<string>` The token type as the Authorization Header scheme. **Default:** 'Bearer'
or the `token_type` property from a passed in TokenSet.
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Userinfo Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Userinfo Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<Response>` Response is a [Got Response](https://github.com/sindresorhus/got/tree/v11.8.0#response)
with the `body` property being a `<Buffer>`

Expand All @@ -385,9 +389,10 @@ Performs an arbitrary `grant_type` exchange at the `token_endpoint`.
- `clientAssertionPayload`: `<Object>` extra client assertion payload parameters to be sent as
part of a client JWT assertion. This is only used when the client's `token_endpoint_auth_method`
is either `client_secret_jwt` or `private_key_jwt`.
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>`

---
Expand Down Expand Up @@ -461,9 +466,10 @@ a handle for subsequent Device Access Token Request polling.
- `clientAssertionPayload`: `<Object>` extra client assertion payload parameters to be sent as
part of a client JWT assertion. This is only used when the client's `token_endpoint_auth_method`
is either `client_secret_jwt` or `private_key_jwt`.
- `DPoP`: `<KeyObject>` &vert; `<Object>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value should be a private key to sign a DPoP Proof JWT with. This can be
a crypto.KeyObject, crypto.createPrivateKey valid inputs, or a JWK formatted private key.
- `DPoP`: `<KeyObject>` When provided the client will send a DPoP Proof JWT to the
Token Endpoint. The value must be a private key in the form of a crypto.KeyObject, or any
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] by the client
based on the type of key and the issuer metadata.
- Returns: `Promise<DeviceFlowHandle>`

---
Expand Down Expand Up @@ -1023,3 +1029,6 @@ request instance.
[webfinger-discovery]: https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery
[got-library]: https://github.com/sindresorhus/got/tree/v11.8.0
[client-authentication]: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication

[^dpop-exception]: Ed25519, Ed448, and all Elliptic Curve keys have a fixed algorithm. RSA and RSA-PSS keys
look for an algorithm supported by the issuer metadata, if none is found PS256 is used as fallback.
141 changes: 122 additions & 19 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ const crypto = require('crypto');
const { strict: assert } = require('assert');
const querystring = require('querystring');
const url = require('url');
const util = require('util');

const QuickLRU = require('quick-lru');
const jose = require('jose');
const jose4 = require('jose4');
const tokenHash = require('oidc-token-hash');

const base64url = require('./helpers/base64url');
Expand All @@ -29,6 +32,15 @@ const instance = require('./helpers/weak_cache');
const { authenticatedPost, resolveResponseType, resolveRedirectUri } = require('./helpers/client');
const DeviceFlowHandle = require('./device_flow_handle');

const [major, minor] = process.version
.substr(1)
.split('.')
.map((str) => parseInt(str, 10));

const rsaPssParams = major >= 17 || (major === 16 && minor >= 9);

const isKeyObject = util.types.isKeyObject || ((obj) => obj && obj instanceof crypto.KeyObject);

function pickCb(input) {
return pick(input, ...CALLBACK_PROPERTIES);
}
Expand Down Expand Up @@ -1677,44 +1689,135 @@ Object.defineProperty(BaseClient.prototype, 'validateJARM', {
},
});

const RSPS = /^(?:RS|PS)(?:256|384|512)$/;
function determineRsaAlgorithm(privateKey, privateKeyInput, valuesSupported) {
if (typeof privateKeyInput === 'object' && typeof privateKeyInput.key === 'object' && privateKeyInput.key.alg) {
return privateKeyInput.key.alg;
}

if (Array.isArray(valuesSupported)) {
let candidates = valuesSupported.filter(RegExp.prototype.test.bind(RSPS));
if (privateKey.asymmetricKeyType === 'rsa-pss') {
candidates = candidates.filter((value) => value.startsWith('PS'));
}
return ['PS256', 'PS384', 'PS512', 'RS256', 'RS384', 'RS384'].find((preferred) => candidates.includes(preferred));
}

return 'PS256';
}

const p256 = Buffer.from([42, 134, 72, 206, 61, 3, 1, 7]);
const p384 = Buffer.from([43, 129, 4, 0, 34]);
const p521 = Buffer.from([43, 129, 4, 0, 35]);
const secp256k1 = Buffer.from([43, 129, 4, 0, 10]);

function determineEcAlgorithm(privateKey, privateKeyInput) {
// If input was a JWK
switch (typeof privateKeyInput === 'object' && typeof privateKeyInput.key === 'object' && privateKeyInput.key.crv) {
case 'P-256': return 'ES256';
case 'secp256k1': return 'ES256K';
case 'P-384': return 'ES384';
case 'P-512': return 'ES512';
default:
break;
}

const buf = privateKey.export({ format: 'der', type: 'pkcs8' });
const i = buf[1] < 128 ? 17 : 18;
const len = buf[i];
const curveOid = buf.slice(i + 1, i + 1 + len);
if (curveOid.equals(p256)) {
return 'ES256';
}

if (curveOid.equals(p384)) {
return 'ES384';
}
if (curveOid.equals(p521)) {
return 'ES512';
}

if (curveOid.equals(secp256k1)) {
return 'ES256K';
}

throw new TypeError('unsupported DPoP private key curve');
}

const jwkCache = new QuickLRU({ maxSize: 100 });
async function getJwk(privateKey, privateKeyInput) {
if (typeof privateKeyInput === 'object' && typeof privateKeyInput.key === 'object' && privateKeyInput.key.crv) {
return pick(privateKeyInput.key, 'kty', 'crv', 'x', 'y', 'e', 'n');
}

if (jwkCache.has(privateKeyInput)) {
return jwkCache.get(privateKeyInput);
}

const jwk = pick(await jose4.exportJWK(privateKey), 'kty', 'crv', 'x', 'y', 'e', 'n');

if (isKeyObject(privateKeyInput)) {
jwkCache.set(privateKeyInput, jwk);
}

return jwk;
}

/**
* @name dpopProof
* @api private
*/
function dpopProof(payload, jwk, accessToken) {
async function dpopProof(payload, privateKeyInput, accessToken) {
if (!isPlainObject(payload)) {
throw new TypeError('payload must be a plain object');
}

let key;
try {
key = jose.JWK.asKey(jwk);
assert(key.type === 'private');
} catch (err) {
throw new TypeError('"DPoP" option must be an asymmetric private key to sign the DPoP Proof JWT with');
let privateKey;
if (isKeyObject(privateKeyInput)) {
privateKey = privateKeyInput;
} else {
privateKey = crypto.createPrivateKey(privateKeyInput);
}

let { alg } = key;

if (!alg && this.issuer.dpop_signing_alg_values_supported) {
const algs = key.algorithms('sign');
alg = this.issuer.dpop_signing_alg_values_supported.find((a) => algs.has(a));
if (privateKey.type !== 'private') {
throw new TypeError('"DPoP" option must be a private key');
}
let alg;
switch (privateKey.asymmetricKeyType) {
case 'ed25519':
case 'ed448':
alg = 'EdDSA';
break;
case 'ec':
alg = determineEcAlgorithm(privateKey, privateKeyInput);
break;
case 'rsa':
case rsaPssParams && 'rsa-pss':
alg = determineRsaAlgorithm(
privateKey,
privateKeyInput,
this.issuer.dpop_signing_alg_values_supported,
);
break;
default:
throw new TypeError('unsupported DPoP private key asymmetric key type');
}

if (!alg) {
[alg] = key.algorithms('sign');
throw new TypeError('could not determine DPoP JWS Algorithm');
}

return jose.JWS.sign({
iat: now(),
jti: random(),
return new jose4.SignJWT({
ath: accessToken ? base64url.encode(crypto.createHash('sha256').update(accessToken).digest()) : undefined,
...payload,
}, jwk, {
}).setProtectedHeader({
alg,
typ: 'dpop+jwt',
jwk: pick(key, 'kty', 'crv', 'x', 'y', 'e', 'n'),
});
jwk: await getJwk(privateKey, privateKeyInput),
})
.setIssuedAt()
.setJti(random())
.sign(privateKey);
}

Object.defineProperty(BaseClient.prototype, 'dpopProof', {
Expand Down
2 changes: 1 addition & 1 deletion lib/helpers/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ module.exports = async function request(options, { accessToken, mTLS = false, DP

if (DPoP && 'dpopProof' in this) {
opts.headers = opts.headers || {};
opts.headers.DPoP = this.dpopProof({
opts.headers.DPoP = await this.dpopProof({
htu: url,
htm: options.method,
}, DPoP, accessToken);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"dependencies": {
"cacheable-lookup": "^6.0.4",
"jose": "^2.0.5",
"jose4": "npm:jose@^4.1.0",
"make-error": "^1.3.6",
"object-hash": "^2.0.1",
"oidc-token-hash": "^5.0.1",
Expand Down
Loading

0 comments on commit d69af6f

Please sign in to comment.