Skip to content

Commit

Permalink
feat: OAuth 2.0 DPoP in various relevant API interfaces
Browse files Browse the repository at this point in the history
Private Key to sign a DPoP Proof JWT with can now be passed to:

- client.callback()
- client.oauthCallback()
- client.refresh()
- client.grant()
- client.userinfo()
- client.requestResource()
- client.deviceAuthorization()

This is an IETF draft implementation with a once in a process warning
and breaking changes to the draft implementation will be shipped as
minor library versions.
  • Loading branch information
panva committed Sep 11, 2020
1 parent f453683 commit 44a0de7
Show file tree
Hide file tree
Showing 9 changed files with 344 additions and 28 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ openid-client.
- RP-Initiated Logout
- [Financial-grade API - Part 2: Read and Write API Security Profile (FAPI) - ID2][feature-fapi]
- [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) - ID1][feature-jarm]
- [OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer (DPoP) - draft 01][feature-dpop]

Updates to draft specifications (JARM, and FAPI) are released as MINOR library versions,
Updates to draft specifications (DPoP, JARM, and FAPI) are released as MINOR library versions,
if you utilize these specification implementations consider using the tilde `~` operator in your
package.json since breaking changes may be introduced as part of these version updates.

Expand Down Expand Up @@ -304,6 +305,7 @@ See [Customizing (docs)](https://github.com/panva/node-openid-client/blob/master
[feature-rp-logout]: https://openid.net/specs/openid-connect-session-1_0.html#RPLogout
[feature-jarm]: https://openid.net/specs/openid-financial-api-jarm-ID1.html
[feature-fapi]: https://openid.net/specs/openid-financial-api-part-2-ID2.html
[feature-dpop]: https://tools.ietf.org/html/draft-ietf-oauth-dpop-01
[openid-certified-link]: https://openid.net/certification/
[passport-url]: http://passportjs.org
[npm-url]: https://www.npmjs.com/package/openid-client
Expand Down
18 changes: 18 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,9 @@ 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.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

Tip: If you're using pure
Expand All @@ -323,6 +326,9 @@ 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.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

---
Expand All @@ -343,6 +349,9 @@ 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.
- Returns: `Promise<Object>` Parsed userinfo response.

---
Expand All @@ -360,6 +369,9 @@ Fetches an arbitrary resource with the provided Access Token in an Authorization
- `method`: `<string>` The HTTP verb 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.
- Returns: `Promise<Response>` Response is a [Got Response](https://github.com/sindresorhus/got/tree/v11.6.2#response)
with the `body` property being a `<Buffer>`

Expand All @@ -376,6 +388,9 @@ Performs an arbitrary `grant_type` exchange at the `token_endpoint`.
- `extras`: `<Object>`
- `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`
- `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.
is either `client_secret_jwt` or `private_key_jwt`.
- Returns: `Promise<TokenSet>`

Expand Down Expand Up @@ -450,6 +465,9 @@ 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.
- Returns: `Promise<DeviceFlowHandle>`

---
Expand Down
81 changes: 70 additions & 11 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
redirectUri,
parameters,
checks = {},
{ exchangeBody, clientAssertionPayload } = {},
{ exchangeBody, clientAssertionPayload, DPoP } = {},
) {
let params = pickCb(parameters);

Expand Down Expand Up @@ -463,7 +463,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
code: params.code,
redirect_uri: redirectUri,
code_verifier: checks.code_verifier,
}, { clientAssertionPayload });
}, { clientAssertionPayload, DPoP });

await this.decryptIdToken(tokenset);
await this.validateIdToken(tokenset, checks.nonce, 'token', checks.max_age);
Expand All @@ -486,7 +486,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
redirectUri,
parameters,
checks = {},
{ exchangeBody, clientAssertionPayload } = {},
{ exchangeBody, clientAssertionPayload, DPoP } = {},
) {
let params = pickCb(parameters);

Expand Down Expand Up @@ -563,7 +563,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
code: params.code,
redirect_uri: redirectUri,
code_verifier: checks.code_verifier,
}, { clientAssertionPayload });
}, { clientAssertionPayload, DPoP });
}

return new TokenSet(params);
Expand Down Expand Up @@ -971,7 +971,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
* @name refresh
* @api public
*/
async refresh(refreshToken, { exchangeBody, clientAssertionPayload } = {}) {
async refresh(refreshToken, { exchangeBody, clientAssertionPayload, DPoP } = {}) {
let token = refreshToken;

if (token instanceof TokenSet) {
Expand All @@ -985,7 +985,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
...exchangeBody,
grant_type: 'refresh_token',
refresh_token: String(token),
}, { clientAssertionPayload });
}, { clientAssertionPayload, DPoP });

if (tokenset.id_token) {
await this.decryptIdToken(tokenset);
Expand Down Expand Up @@ -1014,6 +1014,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
headers,
body,
tokenType = accessToken instanceof TokenSet ? accessToken.token_type : 'Bearer',
DPoP,
} = {},
) {
if (accessToken instanceof TokenSet) {
Expand All @@ -1038,20 +1039,21 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
responseType: 'buffer',
method,
url: resourceUrl,
}, { mTLS });
}, { mTLS, DPoP });
}

/**
* @name userinfo
* @api public
*/
async userinfo(accessToken, {
method = 'GET', via = 'header', tokenType, params,
method = 'GET', via = 'header', tokenType, params, DPoP,
} = {}) {
assertIssuerConfiguration(this.issuer, 'userinfo_endpoint');
const options = {
tokenType,
method: String(method).toUpperCase(),
DPoP,
};

if (options.method !== 'GET' && options.method !== 'POST') {
Expand Down Expand Up @@ -1221,7 +1223,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
* @name grant
* @api public
*/
async grant(body, { clientAssertionPayload } = {}) {
async grant(body, { clientAssertionPayload, DPoP } = {}) {
assertIssuerConfiguration(this.issuer, 'token_endpoint');
const response = await authenticatedPost.call(
this,
Expand All @@ -1230,7 +1232,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
form: body,
responseType: 'json',
},
{ clientAssertionPayload },
{ clientAssertionPayload, DPoP },
);
const responseBody = processResponse(response);

Expand All @@ -1241,7 +1243,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
* @name deviceAuthorization
* @api public
*/
async deviceAuthorization(params = {}, { exchangeBody, clientAssertionPayload } = {}) {
async deviceAuthorization(params = {}, { exchangeBody, clientAssertionPayload, DPoP } = {}) {
assertIssuerConfiguration(this.issuer, 'device_authorization_endpoint');
assertIssuerConfiguration(this.issuer, 'token_endpoint');

Expand Down Expand Up @@ -1269,6 +1271,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
clientAssertionPayload,
response: responseBody,
maxAge: params.max_age,
DPoP,
});
}

Expand Down Expand Up @@ -1598,4 +1601,60 @@ Object.defineProperty(BaseClient.prototype, 'validateJARM', {
},
});

/**
* @name dpopProof
* @api private
*/
function dpopProof(payload, jwk) {
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 { 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 (!alg) {
[alg] = key.algorithms('sign');
}

return jose.JWS.sign({
iat: now(),
jti: random(),
...payload,
}, jwk, {
alg,
typ: 'dpop+jwt',
jwk: pick(key, 'kty', 'crv', 'x', 'y', 'e', 'n'),
});
}

Object.defineProperty(BaseClient.prototype, 'dpopProof', {
enumerable: true,
configurable: true,
value(...args) {
process.emitWarning(
'The DPoP APIs implements an IETF draft. Breaking draft implementations are included as minor versions of the openid-client library, therefore, the ~ semver operator should be used and close attention be payed to library changelog as well as the drafts themselves.',
'DraftWarning',
);
Object.defineProperty(BaseClient.prototype, 'dpopProof', {
enumerable: true,
configurable: true,
value: dpopProof,
});
return this.dpopProof(...args);
},
});

module.exports.BaseClient = BaseClient;
5 changes: 3 additions & 2 deletions lib/device_flow_handle.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const TokenSet = require('./token_set');

class DeviceFlowHandle {
constructor({
client, exchangeBody, clientAssertionPayload, response, maxAge,
client, exchangeBody, clientAssertionPayload, response, maxAge, DPoP,
}) {
['verification_uri', 'user_code', 'device_code'].forEach((prop) => {
if (typeof response[prop] !== 'string' || !response[prop]) {
Expand All @@ -24,6 +24,7 @@ class DeviceFlowHandle {

instance(this).expires_at = now() + response.expires_in;
instance(this).client = client;
instance(this).DPoP = DPoP;
instance(this).maxAge = maxAge;
instance(this).exchangeBody = exchangeBody;
instance(this).clientAssertionPayload = clientAssertionPayload;
Expand All @@ -49,7 +50,7 @@ class DeviceFlowHandle {
},
responseType: 'json',
},
{ clientAssertionPayload: instance(this).clientAssertionPayload },
{ clientAssertionPayload: instance(this).clientAssertionPayload, DPoP: instance(this).DPoP },
);

let responseBody;
Expand Down
4 changes: 2 additions & 2 deletions lib/helpers/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function resolveRedirectUri() {
}

async function authenticatedPost(endpoint, opts, {
clientAssertionPayload, endpointAuthMethod = endpoint,
clientAssertionPayload, endpointAuthMethod = endpoint, DPoP,
} = {}) {
const auth = await authFor.call(this, endpointAuthMethod, { clientAssertionPayload });
const requestOpts = merge(opts, auth);
Expand All @@ -149,7 +149,7 @@ async function authenticatedPost(endpoint, opts, {
...requestOpts,
method: 'POST',
url: targetUrl,
}, { mTLS });
}, { mTLS, DPoP });
}

module.exports = {
Expand Down
18 changes: 13 additions & 5 deletions lib/helpers/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,22 @@ setDefaults({
throwHttpErrors: false,
});

module.exports = function request(options, { mTLS = false } = {}) {
module.exports = async function request(options, { mTLS = false, DPoP } = {}) {
const { url } = options;
isAbsoluteUrl(url);
const optsFn = this[HTTP_OPTIONS];
let opts;
let opts = options;

if (DPoP && 'dpopProof' in this) {
opts.headers = opts.headers || {};
opts.headers.DPoP = this.dpopProof({
htu: url,
htm: options.method,
}, DPoP);
}

if (optsFn) {
opts = optsFn.call(this, defaultsDeep({}, options, DEFAULT_HTTP_OPTIONS));
} else {
opts = options;
opts = optsFn.call(this, defaultsDeep({}, opts, DEFAULT_HTTP_OPTIONS));
}

if (
Expand All @@ -42,6 +49,7 @@ module.exports = function request(options, { mTLS = false } = {}) {
) {
throw new TypeError('mutual-TLS certificate and key not set');
}

return got(opts);
};

Expand Down
Loading

0 comments on commit 44a0de7

Please sign in to comment.