From 44a0de7ceb62cabacd62798ac136f1c394907028 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 8 Sep 2020 19:01:09 +0200 Subject: [PATCH] feat: OAuth 2.0 DPoP in various relevant API interfaces 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. --- README.md | 4 +- docs/README.md | 18 ++++ lib/client.js | 81 +++++++++++++--- lib/device_flow_handle.js | 5 +- lib/helpers/client.js | 4 +- lib/helpers/request.js | 18 +++- test/client/dpop.test.js | 176 +++++++++++++++++++++++++++++++++++ types/index.d.ts | 37 ++++++-- types/openid-client-tests.ts | 29 ++++++ 9 files changed, 344 insertions(+), 28 deletions(-) create mode 100644 test/client/dpop.test.js diff --git a/README.md b/README.md index 1e06a946..65154857 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/README.md b/docs/README.md index 5abe8413..c095dad6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -302,6 +302,9 @@ Performs the callback for Authorization Server's authorization response. - `clientAssertionPayload`: `` 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` | `` 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` Parsed token endpoint response as a TokenSet. Tip: If you're using pure @@ -323,6 +326,9 @@ Performs `refresh_token` grant type exchange. - `clientAssertionPayload`: `` 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` | `` 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` Parsed token endpoint response as a TokenSet. --- @@ -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`: `` additional parameters to send with the userinfo request (as query string 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 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` Parsed userinfo response. --- @@ -360,6 +369,9 @@ Fetches an arbitrary resource with the provided Access Token in an Authorization - `method`: `` The HTTP verb to use for the request. **Default:** 'GET' - `tokenType`: `` The token type as the Authorization Header scheme. **Default:** 'Bearer' 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 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 is a [Got Response](https://github.com/sindresorhus/got/tree/v11.6.2#response) with the `body` property being a `` @@ -376,6 +388,9 @@ Performs an arbitrary `grant_type` exchange at the `token_endpoint`. - `extras`: `` - `clientAssertionPayload`: `` 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` | `` 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` @@ -450,6 +465,9 @@ a handle for subsequent Device Access Token Request polling. - `clientAssertionPayload`: `` 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` | `` 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` --- diff --git a/lib/client.js b/lib/client.js index 9deb576e..ff6dab66 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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); @@ -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); @@ -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); @@ -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); @@ -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) { @@ -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); @@ -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) { @@ -1038,7 +1039,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base responseType: 'buffer', method, url: resourceUrl, - }, { mTLS }); + }, { mTLS, DPoP }); } /** @@ -1046,12 +1047,13 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base * @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') { @@ -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, @@ -1230,7 +1232,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base form: body, responseType: 'json', }, - { clientAssertionPayload }, + { clientAssertionPayload, DPoP }, ); const responseBody = processResponse(response); @@ -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'); @@ -1269,6 +1271,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base clientAssertionPayload, response: responseBody, maxAge: params.max_age, + DPoP, }); } @@ -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; diff --git a/lib/device_flow_handle.js b/lib/device_flow_handle.js index b2d8c60f..2c47e179 100644 --- a/lib/device_flow_handle.js +++ b/lib/device_flow_handle.js @@ -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]) { @@ -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; @@ -49,7 +50,7 @@ class DeviceFlowHandle { }, responseType: 'json', }, - { clientAssertionPayload: instance(this).clientAssertionPayload }, + { clientAssertionPayload: instance(this).clientAssertionPayload, DPoP: instance(this).DPoP }, ); let responseBody; diff --git a/lib/helpers/client.js b/lib/helpers/client.js index 2886f440..e389c79f 100644 --- a/lib/helpers/client.js +++ b/lib/helpers/client.js @@ -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); @@ -149,7 +149,7 @@ async function authenticatedPost(endpoint, opts, { ...requestOpts, method: 'POST', url: targetUrl, - }, { mTLS }); + }, { mTLS, DPoP }); } module.exports = { diff --git a/lib/helpers/request.js b/lib/helpers/request.js index 477cc6d5..87e66468 100644 --- a/lib/helpers/request.js +++ b/lib/helpers/request.js @@ -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 ( @@ -42,6 +49,7 @@ module.exports = function request(options, { mTLS = false } = {}) { ) { throw new TypeError('mutual-TLS certificate and key not set'); } + return got(opts); }; diff --git a/test/client/dpop.test.js b/test/client/dpop.test.js new file mode 100644 index 00000000..031dcad9 --- /dev/null +++ b/test/client/dpop.test.js @@ -0,0 +1,176 @@ +const crypto = require('crypto'); + +const { expect } = require('chai'); +const nock = require('nock'); +const jose = require('jose'); + +const { Issuer, custom } = require('../../lib'); + +const issuer = new Issuer({ + issuer: 'https://op.example.com', + userinfo_endpoint: 'https://op.example.com/me', + token_endpoint: 'https://op.example.com/token', + introspection_endpoint: 'https://op.example.com/token/introspect', + revocation_endpoint: 'https://op.example.com/token/revoke', + device_authorization_endpoint: 'https://op.example.com/device', + dpop_signing_alg_values_supported: ['PS512', 'PS384', 'PS256'], +}); + +const jwk = jose.JWK.generateSync('RSA'); + +describe('DPoP', () => { + beforeEach(function () { + this.client = new issuer.Client({ + client_id: 'client', + token_endpoint_auth_method: 'none', + }); + this.client[custom.http_options] = (opts) => { + this.httpOpts = opts; + return opts; + }; + }); + + afterEach(function () { + delete this.httpOpts; + }); + + describe('dpopProof', () => { + it('must be passed a payload object', function () { + expect(() => this.client.dpopProof('foo')).to.throw('payload must be a plain object'); + }); + + it('must be passed a private key', function () { + const msg = '"DPoP" option must be an asymmetric private key to sign the DPoP Proof JWT with'; + expect(() => this.client.dpopProof({}, 'foo')).to.throw(msg); + expect(() => this.client.dpopProof({}, Buffer.from('foo'))).to.throw(msg); + expect(() => this.client.dpopProof({}, jose.JWK.generateSync('oct').toJWK(true))).to.throw(msg); + expect(() => this.client.dpopProof({}, jose.JWK.generateSync('EC').toJWK())).to.throw(msg); + expect(() => this.client.dpopProof({}, jose.JWK.generateSync('EC').toPEM())).to.throw(msg); + }); + + it('DPoP Proof JWT', function () { + const proof = this.client.dpopProof({ + htu: 'foo', + htm: 'bar', + baz: true, + }, jose.JWK.generateSync('RSA', 2048)); + const decoded = jose.JWT.decode(proof, { complete: true }); + expect(decoded).to.have.nested.property('header.typ', 'dpop+jwt'); + expect(decoded).to.have.nested.property('payload.iat'); + expect(decoded).to.have.nested.property('payload.jti'); + expect(decoded).to.have.nested.property('payload.htu', 'foo'); + expect(decoded).to.have.nested.property('payload.htm', 'bar'); + expect(decoded).to.have.nested.property('payload.baz', true); + expect(decoded).to.have.nested.property('header.jwk').that.has.keys('kty', 'e', 'n'); + + expect( + jose.JWT.decode( + this.client.dpopProof({}, jose.JWK.generateSync('EC')), + { complete: true }, + ), + ).to.have.nested.property('header.jwk').that.has.keys('kty', 'crv', 'x', 'y'); + + if ('sign' in crypto) { + expect( + jose.JWT.decode( + this.client.dpopProof({}, jose.JWK.generateSync('OKP')), + { complete: true }, + ), + ).to.have.nested.property('header.jwk').that.has.keys('kty', 'crv', 'x'); + } + }); + + it('key.alg is used if present', function () { + const proof = this.client.dpopProof({}, jose.JWK.generateSync('RSA', 2048, { alg: 'PS384' })); + expect(jose.JWT.decode(proof, { complete: true })).to.have.nested.property('header.alg', 'PS384'); + }); + + it('else this.issuer.dpop_signing_alg_values_supported is used', function () { + const proof = this.client.dpopProof({}, jose.JWK.generateSync('RSA', 2048)); + expect(jose.JWT.decode(proof, { complete: true })).to.have.nested.property('header.alg', 'PS512'); + }); + + it('else key.algorithms("sign")[0] is used', function () { + const proof = this.client.dpopProof({}, jose.JWK.generateSync('EC')); + expect(jose.JWT.decode(proof, { complete: true })).to.have.nested.property('header.alg', 'ES256'); + }); + }); + + it('is enabled for userinfo', async function () { + nock('https://op.example.com') + .get('/me').reply(200, { sub: 'foo' }); + + await this.client.userinfo('foo', { DPoP: jwk }); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for requestResource', async function () { + nock('https://rs.example.com') + .post('/resource').reply(200, { sub: 'foo' }); + + await this.client.requestResource('https://rs.example.com/resource', 'foo', { DPoP: jwk, method: 'POST' }); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for grant', async function () { + nock('https://op.example.com') + .post('/token').reply(200, { access_token: 'foo' }); + + await this.client.grant({ grant_type: 'foo' }, { DPoP: jwk }); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for refresh', async function () { + nock('https://op.example.com') + .post('/token').reply(200, { access_token: 'foo' }); + + await this.client.refresh('foo', { DPoP: jwk }); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for oauthCallback', async function () { + nock('https://op.example.com') + .post('/token').reply(200, { access_token: 'foo' }); + + await this.client.oauthCallback('foo', { code: 'foo' }, {}, { DPoP: jwk }); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for callback', async function () { + nock('https://op.example.com') + .post('/token').reply(200, { access_token: 'foo' }); + + try { + await this.client.callback('foo', { code: 'foo' }, {}, { DPoP: jwk }); + } catch (err) {} + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); + + it('is enabled for deviceAuthorization', async function () { + nock('https://op.example.com') + .post('/device').reply(200, { + expires_in: 60, + device_code: 'foo', + user_code: 'foo', + verification_uri: 'foo', + interval: 1, + }); + + const handle = await this.client.deviceAuthorization({}, { DPoP: jwk }); + + expect(this.httpOpts).not.to.have.nested.property('headers.DPoP'); + + nock('https://op.example.com') + .post('/token').reply(200, { access_token: 'foo' }); + + await handle.poll(); + + expect(this.httpOpts).to.have.nested.property('headers.DPoP'); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index f6daba9d..1bf949f2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -9,12 +9,14 @@ import * as http2 from 'http2'; import { Options as GotOptions, CancelableRequest, Response } from 'got'; import { URL } from 'url'; -import { JWKS, JSONWebKeySet } from 'jose'; +import jose from 'jose'; +import crypto from 'crypto'; export type HttpOptions = GotOptions; export type RetryFunction = (retry: number, error: Error) => number; export type CustomHttpOptionsProvider = (options: HttpOptions) => HttpOptions; export type TokenTypeHint = 'access_token' | 'refresh_token' | string; +export type DPoPInput = crypto.KeyObject | crypto.PrivateKeyInput | jose.JWKRSAKey | jose.JWKECKey | jose.JWKOKPKey; /** * @see https://github.com/panva/node-openid-client/blob/master/lib/index.js @@ -182,6 +184,11 @@ export interface CallbackExtras { * when the client's token_endpoint_auth_method is either client_secret_jwt or private_key_jwt. */ clientAssertionPayload?: object; + /** + * Private key to sign the DPoP Proof JWT with. This can be a crypto.KeyObject, crypto.createPrivateKey valid + * inputs, or a JWK formatted private key. + */ + DPoP?: DPoPInput; } export interface RefreshExtras { @@ -194,6 +201,11 @@ export interface RefreshExtras { * This is only used when the client's token_endpoint_auth_method is either client_secret_jwt or private_key_jwt. */ clientAssertionPayload?: object; + /** + * Private key to sign the DPoP Proof JWT with. This can be a crypto.KeyObject, crypto.createPrivateKey valid + * inputs, or a JWK formatted private key. + */ + DPoP?: DPoPInput; } export interface GrantBody { @@ -208,6 +220,11 @@ export interface GrantExtras { * This is only used when the client's token_endpoint_auth_method is either client_secret_jwt or private_key_jwt. */ clientAssertionPayload?: object; + /** + * Private key to sign the DPoP Proof JWT with. This can be a crypto.KeyObject, crypto.createPrivateKey valid + * inputs, or a JWK formatted private key. + */ + DPoP?: DPoPInput; } export interface IntrospectExtras { @@ -250,7 +267,7 @@ export interface RegisterOther { * JWK Set formatted object with private keys used for signing client assertions or decrypting responses. * When neither jwks_uri or jwks is present in metadata the key's public parts will be registered as jwks. */ - jwks?: JSONWebKeySet; + jwks?: jose.JSONWebKeySet; /** * Initial Access Token to use as a Bearer token during the registration call. */ @@ -274,6 +291,11 @@ export interface DeviceAuthorizationExtras { * This is only used when the client's token_endpoint_auth_method is either client_secret_jwt or private_key_jwt. */ clientAssertionPayload?: object; + /** + * Private key to sign the DPoP Proof JWT with. This can be a crypto.KeyObject, crypto.createPrivateKey valid + * inputs, or a JWK formatted private key. + */ + DPoP?: DPoPInput; } export interface UserinfoResponse { @@ -340,7 +362,7 @@ export interface ClientOptions { * consuming callbacks, triggering token endpoint grants, revoking and introspecting tokens. */ export class Client { - constructor(metadata: ClientMetadata, jwks?: JSONWebKeySet, options?: ClientOptions); + constructor(metadata: ClientMetadata, jwks?: jose.JSONWebKeySet, options?: ClientOptions); [custom.http_options]: CustomHttpOptionsProvider; [custom.clock_tolerance]: number; metadata: ClientMetadata; @@ -406,7 +428,7 @@ export class Client { * will be used automatically. * @param options Options for the UserInfo request. */ - userinfo(accessToken: TokenSet | string, options?: { method?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string, params?: object }): Promise; + userinfo(accessToken: TokenSet | string, options?: { method?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string, params?: object, DPoP?: DPoPInput }): Promise; /** * Fetches an arbitrary resource with the provided Access Token in an Authorization header. @@ -421,6 +443,7 @@ export class Client { body?: string | Buffer method?: 'GET' | 'POST' | 'PUT' | 'HEAD' | 'DELETE' | 'OPTIONS' | 'TRACE' tokenType?: string + DPoP?: DPoPInput }): CancelableRequest>; /** @@ -451,7 +474,7 @@ export class Client { */ deviceAuthorization(parameters?: DeviceAuthorizationParameters, extras?: DeviceAuthorizationExtras): Promise>; static register(metadata: object, other?: RegisterOther & ClientOptions): Promise; - static fromUri(registrationClientUri: string, registrationAccessToken: string, jwks?: JSONWebKeySet, clientOptions?: ClientOptions): Promise; + static fromUri(registrationClientUri: string, registrationAccessToken: string, jwks?: jose.JSONWebKeySet, clientOptions?: ClientOptions): Promise; static [custom.http_options]: CustomHttpOptionsProvider; [key: string]: unknown; @@ -502,7 +525,7 @@ export interface MtlsEndpointAliases { // https://stackoverflow.com/questions/39622778/what-is-new-in-typescript // https://github.com/Microsoft/TypeScript/issues/204 export interface TypeOfGenericClient { - new (metadata: ClientMetadata, jwks?: JSONWebKeySet, options?: ClientOptions): TClient; + new (metadata: ClientMetadata, jwks?: jose.JSONWebKeySet, options?: ClientOptions): TClient; [custom.http_options]: CustomHttpOptionsProvider; [custom.clock_tolerance]: number; } @@ -534,7 +557,7 @@ export class Issuer { // tslint:disable-line:no-unnecess * Returns the issuer's jwks_uri keys as a `jose` parsed JWKS.Keystore. * @param forceReload forces a reload of the issuer's jwks_uri */ - keystore(forceReload?: boolean): Promise; + keystore(forceReload?: boolean): Promise; /** * Loads OpenID Connect 1.0 and/or OAuth 2.0 Authorization Server Metadata documents. diff --git a/types/openid-client-tests.ts b/types/openid-client-tests.ts index 4d1ee4a2..938cad57 100644 --- a/types/openid-client-tests.ts +++ b/types/openid-client-tests.ts @@ -1,4 +1,7 @@ import { IncomingMessage } from 'http'; +import { generateKeyPairSync } from 'crypto'; + +import { JWKECKey } from 'jose' import { custom, generators, Issuer, Client, Strategy, StrategyVerifyCallback, StrategyOptions, TokenSet, RegisterOther, IssuerMetadata } from './index.d'; import passport from 'passport'; @@ -52,6 +55,32 @@ async (req: IncomingMessage) => { // + const { privateKey: keyobject } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk: JWKECKey = { kty: 'EC', x: 'foo', y: 'bar', d: 'baz', crv: 'P-256' } + + client.callback('https://rp.example.com/cb', {}, {}, { DPoP: keyobject }) + client.callback('https://rp.example.com/cb', {}, {}, { DPoP: jwk }) + + client.oauthCallback('https://rp.example.com/cb', {}, {}, { DPoP: keyobject }) + client.oauthCallback('https://rp.example.com/cb', {}, {}, { DPoP: jwk }) + + client.userinfo('token', { DPoP: keyobject }) + client.userinfo('token', { DPoP: jwk }) + + client.requestResource('https://rs.example.com/resource', 'token', { DPoP: keyobject }) + client.requestResource('https://rs.example.com/resource', 'token', { DPoP: jwk }) + + client.deviceAuthorization({}, { DPoP: keyobject }) + client.deviceAuthorization({}, { DPoP: jwk }) + + client.grant({ grant_type: 'client_credentials' }, { DPoP: keyobject }) + client.grant({ grant_type: 'client_credentials' }, { DPoP: jwk }) + + client.refresh('token', { DPoP: keyobject }) + client.refresh('token', { DPoP: jwk }) + + // + const code_verifier = generators.codeVerifier(); const code_challenge = generators.codeChallenge(code_verifier);