Skip to content

Commit

Permalink
feat: support server-provided DPoP nonces (update DPoP to draft-04)
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Dec 3, 2021
1 parent 92ffee5 commit a84950a
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 27 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ Performs the callback for Authorization Server's authorization response.
is either `client_secret_jwt` or `private_key_jwt`.
- `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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

Expand All @@ -327,7 +327,7 @@ Performs `refresh_token` grant type exchange.
is either `client_secret_jwt` or `private_key_jwt`.
- `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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>` Parsed token endpoint response as a TokenSet.

Expand All @@ -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`: `<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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
based on the type of key and the issuer metadata.
- Returns: `Promise<Object>` Parsed userinfo response.

Expand All @@ -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`: `<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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
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 @@ -393,7 +393,7 @@ Performs an arbitrary `grant_type` exchange at the `token_endpoint`.
is either `client_secret_jwt` or `private_key_jwt`.
- `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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
based on the type of key and the issuer metadata.
- Returns: `Promise<TokenSet>`

Expand Down Expand Up @@ -470,7 +470,7 @@ a handle for subsequent Device Access Token Request polling.
is either `client_secret_jwt` or `private_key_jwt`.
- `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
valid crypto.createPrivateKey input. The algorithm is determined[^dpop-exception] automatically
based on the type of key and the issuer metadata.
- Returns: `Promise<DeviceFlowHandle>`

Expand Down
37 changes: 33 additions & 4 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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(
Expand Down Expand Up @@ -1119,6 +1121,7 @@ class BaseClient {
? accessToken.token_type
: 'Bearer',
} = {},
retry,
) {
if (accessToken instanceof TokenSet) {
if (!accessToken.access_token) {
Expand All @@ -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,
Expand All @@ -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 } = {}) {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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', {
Expand Down
11 changes: 2 additions & 9 deletions lib/helpers/process_response.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 19 additions & 5 deletions lib/helpers/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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',
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
14 changes: 14 additions & 0 deletions lib/helpers/www_authenticate_parser.js
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit a84950a

Please sign in to comment.