From a84950af45a6ac10c0b84752ca684f35c6c13eaf Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 3 Dec 2021 10:55:47 +0100 Subject: [PATCH] feat: support server-provided DPoP nonces (update DPoP to draft-04) --- README.md | 4 +- docs/README.md | 12 +- lib/client.js | 37 ++++++- lib/helpers/process_response.js | 11 +- lib/helpers/request.js | 24 +++- lib/helpers/www_authenticate_parser.js | 14 +++ test/client/dpop.test.js | 148 ++++++++++++++++++++++++- 7 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 lib/helpers/www_authenticate_parser.js diff --git a/README.md b/README.md index a44f7e3b..940b7e19 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ openid-client. - [OpenID Connect RP-Initiated Logout 1.0 - draft 01][feature-rp-logout] - [Financial-grade API Security Profile 1.0 - Part 2: Advanced (FAPI)][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 03][feature-dpop] +- [OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer (DPoP) - draft 04][feature-dpop] - [OAuth 2.0 Authorization Server Issuer Identification - draft-04][feature-iss] Updates to draft specifications (DPoP, JARM, etc) are released as MINOR library versions, @@ -277,7 +277,7 @@ See [Customizing (docs)][documentation-customizing]. [feature-rp-logout]: https://openid.net/specs/openid-connect-rpinitiated-1_0-01.html [feature-jarm]: https://openid.net/specs/openid-financial-api-jarm-ID1.html [feature-fapi]: https://openid.net/specs/openid-financial-api-part-2-1_0.html -[feature-dpop]: https://tools.ietf.org/html/draft-ietf-oauth-dpop-03 +[feature-dpop]: https://tools.ietf.org/html/draft-ietf-oauth-dpop-04 [feature-par]: https://www.rfc-editor.org/rfc/rfc9126.html [feature-jar]: https://www.rfc-editor.org/rfc/rfc9101.html [feature-iss]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-iss-auth-resp-04 diff --git a/docs/README.md b/docs/README.md index 1248fed7..7b147d6c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -302,7 +302,7 @@ Performs the callback for Authorization Server's authorization response. is either `client_secret_jwt` or `private_key_jwt`. - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` Parsed token endpoint response as a TokenSet. @@ -327,7 +327,7 @@ Performs `refresh_token` grant type exchange. is either `client_secret_jwt` or `private_key_jwt`. - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` Parsed token endpoint response as a TokenSet. @@ -351,7 +351,7 @@ will also be checked to match the on in the TokenSet's ID Token. when GET, as x-www-form-urlencoded body when POST). - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` Parsed userinfo response. @@ -372,7 +372,7 @@ Fetches an arbitrary resource with the provided Access Token in an Authorization or the `token_type` property from a passed in TokenSet. - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` Response is a [Got Response](https://github.com/sindresorhus/got/tree/v11.8.0#response) with the `body` property being a `` @@ -393,7 +393,7 @@ Performs an arbitrary `grant_type` exchange at the `token_endpoint`. is either `client_secret_jwt` or `private_key_jwt`. - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` @@ -470,7 +470,7 @@ a handle for subsequent Device Access Token Request polling. is either `client_secret_jwt` or `private_key_jwt`. - `DPoP`: `` 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 + valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically based on the type of key and the issuer metadata. - Returns: `Promise` diff --git a/lib/client.js b/lib/client.js index 9aae1e19..524cadb1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -12,6 +12,7 @@ const isKeyObject = require('./helpers/is_key_object'); const decodeJWT = require('./helpers/decode_jwt'); const base64url = require('./helpers/base64url'); const defaults = require('./helpers/defaults'); +const parseWwwAuthenticate = require('./helpers/www_authenticate_parser'); const { assertSigningAlgValuesSupport, assertIssuerConfiguration } = require('./helpers/assert'); const pick = require('./helpers/pick'); const isPlainObject = require('./helpers/is_plain_object'); @@ -35,6 +36,7 @@ const [major, minor] = process.version .map((str) => parseInt(str, 10)); const rsaPssParams = major >= 17 || (major === 16 && minor >= 9); +const retryAttempt = Symbol(); function pickCb(input) { return pick( @@ -1119,6 +1121,7 @@ class BaseClient { ? accessToken.token_type : 'Bearer', } = {}, + retry, ) { if (accessToken instanceof TokenSet) { if (!accessToken.access_token) { @@ -1143,7 +1146,7 @@ class BaseClient { const mTLS = !!this.tls_client_certificate_bound_access_tokens; - return request.call( + const response = await request.call( this, { ...requestOpts, @@ -1153,6 +1156,24 @@ class BaseClient { }, { accessToken, mTLS, DPoP }, ); + + const wwwAuthenticate = response.headers['www-authenticate']; + if ( + retry !== retryAttempt && + wwwAuthenticate && + wwwAuthenticate.toLowerCase().startsWith('dpop ') && + parseWwwAuthenticate(wwwAuthenticate).error === 'use_dpop_nonce' + ) { + return this.requestResource(resourceUrl, accessToken, { + method, + headers, + body, + DPoP, + tokenType, + }); + } + + return response; } async userinfo(accessToken, { method = 'GET', via = 'header', tokenType, params, DPoP } = {}) { @@ -1301,7 +1322,7 @@ class BaseClient { return new TextEncoder().encode(this.client_secret); } - async grant(body, { clientAssertionPayload, DPoP } = {}) { + async grant(body, { clientAssertionPayload, DPoP } = {}, retry) { assertIssuerConfiguration(this.issuer, 'token_endpoint'); const response = await authenticatedPost.call( this, @@ -1312,7 +1333,15 @@ class BaseClient { }, { clientAssertionPayload, DPoP }, ); - const responseBody = processResponse(response); + let responseBody; + try { + responseBody = processResponse(response); + } catch (err) { + if (retry !== retryAttempt && err instanceof OPError && err.error === 'use_dpop_nonce') { + return this.grant(body, { clientAssertionPayload, DPoP }, retryAttempt); + } + throw err; + } return new TokenSet(responseBody); } @@ -1774,7 +1803,7 @@ Object.defineProperty(BaseClient.prototype, 'dpopProof', { configurable: true, value(...args) { process.emitWarning( - 'The DPoP APIs implements an IETF draft (https://www.ietf.org/archive/id/draft-ietf-oauth-dpop-03.html). 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.', + 'The DPoP APIs implements an IETF draft (https://www.ietf.org/archive/id/draft-ietf-oauth-dpop-04.html). 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', { diff --git a/lib/helpers/process_response.js b/lib/helpers/process_response.js index 7376d1ec..7dad5655 100644 --- a/lib/helpers/process_response.js +++ b/lib/helpers/process_response.js @@ -2,17 +2,10 @@ const { STATUS_CODES } = require('http'); const { format } = require('util'); const { OPError } = require('../errors'); +const parseWwwAuthenticate = require('./www_authenticate_parser'); -const REGEXP = /(\w+)=("[^"]*")/g; const throwAuthenticateErrors = (response) => { - const params = {}; - try { - while (REGEXP.exec(response.headers['www-authenticate']) !== null) { - if (RegExp.$1 && RegExp.$2) { - params[RegExp.$1] = RegExp.$2.slice(1, -1); - } - } - } catch (err) {} + const params = parseWwwAuthenticate(response.headers['www-authenticate']); if (params.error) { throw new OPError(params, response); diff --git a/lib/helpers/request.js b/lib/helpers/request.js index 49b2850b..ad86809a 100644 --- a/lib/helpers/request.js +++ b/lib/helpers/request.js @@ -4,6 +4,8 @@ const http = require('http'); const https = require('https'); const { once } = require('events'); +const LRU = require('lru-cache'); + const pkg = require('../../package.json'); const { RPError } = require('../errors'); @@ -12,6 +14,7 @@ const { deep: defaultsDeep } = require('./defaults'); const { HTTP_OPTIONS } = require('./consts'); let DEFAULT_HTTP_OPTIONS; +const NQCHAR = /^[\x21\x23-\x5B\x5D-\x7E]+$/; const allowed = [ 'agent', @@ -52,6 +55,8 @@ function send(req, body, contentType) { req.end(); } +const nonces = new LRU({ max: 100 }); + module.exports = async function request(options, { accessToken, mTLS = false, DPoP } = {}) { let url; try { @@ -64,12 +69,14 @@ module.exports = async function request(options, { accessToken, mTLS = false, DP const optsFn = this[HTTP_OPTIONS]; let opts = options; + const nonceKey = `${url.origin}${url.pathname}`; if (DPoP && 'dpopProof' in this) { opts.headers = opts.headers || {}; opts.headers.DPoP = await this.dpopProof( { - htu: url, + htu: url.href, htm: options.method, + nonce: nonces.get(nonceKey), }, DPoP, accessToken, @@ -173,10 +180,17 @@ module.exports = async function request(options, { accessToken, mTLS = false, DP } return response; - })().catch((err) => { - Object.defineProperty(err, 'response', { value: response }); - throw err; - }); + })() + .catch((err) => { + if (response) Object.defineProperty(err, 'response', { value: response }); + throw err; + }) + .finally(() => { + const dpopNonce = response && response.headers['dpop-nonce']; + if (dpopNonce && NQCHAR.test(dpopNonce)) { + nonces.set(nonceKey, dpopNonce); + } + }); }; module.exports.setDefaults = setDefaults.bind(undefined, allowed); diff --git a/lib/helpers/www_authenticate_parser.js b/lib/helpers/www_authenticate_parser.js new file mode 100644 index 00000000..a645df86 --- /dev/null +++ b/lib/helpers/www_authenticate_parser.js @@ -0,0 +1,14 @@ +const REGEXP = /(\w+)=("[^"]*")/g; + +module.exports = (wwwAuthenticate) => { + const params = {}; + try { + while (REGEXP.exec(wwwAuthenticate) !== null) { + if (RegExp.$1 && RegExp.$2) { + params[RegExp.$1] = RegExp.$2.slice(1, -1); + } + } + } catch (err) {} + + return params; +}; diff --git a/test/client/dpop.test.js b/test/client/dpop.test.js index 9ec147de..04aa4ef8 100644 --- a/test/client/dpop.test.js +++ b/test/client/dpop.test.js @@ -4,7 +4,11 @@ const { expect } = require('chai'); const nock = require('nock'); const jose2 = require('jose2'); -const { Issuer, custom } = require('../../lib'); +const { + Issuer, + custom, + errors: { OPError }, +} = require('../../lib'); const issuer = new Issuer({ issuer: 'https://op.example.com', @@ -246,6 +250,148 @@ describe('DPoP', () => { expect(proofJWT).to.have.nested.property('payload.ath'); }); + it('handles DPoP nonce in userinfo', async function () { + nock('https://op.example.com') + .get('/me') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.be.undefined; + return true; + }) + .reply(401, undefined, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'eyJ7S_zG.eyJH0-Z.HX4w-7v', + }) + .get('/me') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { sub: 'foo' }) + .get('/me') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { sub: 'foo' }) + .get('/me') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(400, undefined, { + 'WWW-Authenticate': 'DPoP error="invalid_dpop_proof"', + }); + + await this.client.userinfo('foo', { DPoP: privateKey }); + await this.client.userinfo('foo', { DPoP: privateKey }); + return this.client.userinfo('foo', { DPoP: privateKey }).then(fail, (err) => { + expect(err).to.be.an.instanceOf(OPError); + expect(err.error).to.eql('invalid_dpop_proof'); + }); + }); + + it('handles DPoP nonce in grant', async function () { + nock('https://op.example.com') + .post('/token') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.be.undefined; + return true; + }) + .reply( + 400, + { error: 'use_dpop_nonce' }, + { + 'DPoP-Nonce': 'eyJ7S_zG.eyJH0-Z.HX4w-7v', + }, + ) + .post('/token') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { access_token: 'foo' }) + .post('/token') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { access_token: 'foo' }) + .post('/token') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(400, { error: 'invalid_dpop_proof' }); + + await this.client.grant({ grant_type: 'client_credentials' }, { DPoP: privateKey }); + await this.client.grant({ grant_type: 'client_credentials' }, { DPoP: privateKey }); + return this.client + .grant({ grant_type: 'client_credentials' }, { DPoP: privateKey }) + .then(fail, (err) => { + expect(err).to.be.an.instanceOf(OPError); + expect(err.error).to.eql('invalid_dpop_proof'); + }); + }); + + it('handles DPoP nonce in requestResource', async function () { + nock('https://rs.example.com') + .get('/resource') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.be.undefined; + return true; + }) + .reply(401, undefined, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'eyJ7S_zG.eyJH0-Z.HX4w-7v', + }) + .get('/resource') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { sub: 'foo' }) + .get('/resource') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(200, { sub: 'foo' }) + .get('/resource') + .matchHeader('DPoP', (proof) => { + const { nonce } = jose2.JWT.decode(proof); + expect(nonce).to.eq('eyJ7S_zG.eyJH0-Z.HX4w-7v'); + return true; + }) + .reply(400, undefined, { + 'WWW-Authenticate': 'DPoP error="invalid_dpop_proof"', + }); + + await this.client.requestResource('https://rs.example.com/resource', 'foo', { + DPoP: privateKey, + }); + await this.client.requestResource('https://rs.example.com/resource', 'foo', { + DPoP: privateKey, + }); + return this.client + .requestResource('https://rs.example.com/resource', 'foo', { + DPoP: privateKey, + }) + .then((response) => { + expect(response.statusCode).to.eql(400); + }); + }); + it('is enabled for requestResource', async function () { nock('https://rs.example.com') .matchHeader('Transfer-Encoding', isUndefined)