diff --git a/README.md b/README.md index 66663b6d..1d800d63 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ openid-client. - [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] +- [OAuth 2.0 Pushed Authorization Requests (PAR) - draft 06][feature-par] 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 @@ -296,6 +297,7 @@ See [Customizing (docs)](https://github.com/panva/node-openid-client/blob/master [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 +[feature-par]: https://tools.ietf.org/html/draft-ietf-oauth-par-06 [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 9a4392d8..204c662a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -147,22 +147,23 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us - [Class: <Client>](#class-client) - [new Client(metadata[, jwks[, options]])](#new-clientmetadata-jwks-options) - - [client.metadata](#clientmetadata) - [client.authorizationUrl(parameters)](#clientauthorizationurlparameters) - - [client.endSessionUrl(parameters)](#clientendsessionurlparameters) - - [client.callbackParams(input)](#clientcallbackparamsinput) - [client.callback(redirectUri, parameters[, checks[, extras]])](#clientcallbackredirecturi-parameters-checks-extras) - - [client.refresh(refreshToken[, extras])](#clientrefreshrefreshtoken-extras) - - [client.userinfo(accessToken[, options])](#clientuserinfoaccesstoken-options) - - [client.requestResource(resourceUrl, accessToken, [, options])](#clientrequestresourceresourceurl-accesstoken-options) + - [client.callbackParams(input)](#clientcallbackparamsinput) + - [client.deviceAuthorization(parameters[, extras])](#clientdeviceauthorizationparameters-extras) + - [client.endSessionUrl(parameters)](#clientendsessionurlparameters) - [client.grant(body[, extras])](#clientgrantbody-extras) - [client.introspect(token[, tokenTypeHint[, extras]])](#clientintrospecttoken-tokentypehint-extras) - - [client.revoke(token[, tokenTypeHint[, extras]])](#clientrevoketoken-tokentypehint-extras) + - [client.metadata](#clientmetadata) + - [client.refresh(refreshToken[, extras])](#clientrefreshrefreshtoken-extras) - [client.requestObject(payload)](#clientrequestobjectpayload) - - [client.deviceAuthorization(parameters[, extras])](#clientdeviceauthorizationparameters-extras) + - [client.requestResource(resourceUrl, accessToken, [, options])](#clientrequestresourceresourceurl-accesstoken-options) + - [client.revoke(token[, tokenTypeHint[, extras]])](#clientrevoketoken-tokentypehint-extras) + - [client.userinfo(accessToken[, options])](#clientuserinfoaccesstoken-options) + - [client.pushedAuthorizationRequest(parameters[, extras])](#pushedauthorizationrequestparameters-extras) - [Client Authentication Methods](#client-authentication-methods) -- [Client.register(metadata[, other])](#clientregistermetadata-other) - [Client.fromUri(registrationClientUri, registrationAccessToken[, jwks[, clientOptions]])](#clientfromuriregistrationclienturi-registrationaccesstoken-jwks-clientoptions) +- [Client.register(metadata[, other])](#clientregistermetadata-other) --- @@ -477,6 +478,32 @@ a handle for subsequent Device Access Token Request polling. --- +#### `client.pushedAuthorizationRequest(parameters[, extras])` + +[OAuth 2.0 Pushed Authorization Requests (PAR) - draft 06](https://tools.ietf.org/html/draft-ietf-oauth-par-06) + +Performs a Pushed Authorization Request at the issuer's `pushed_authorization_request_endpoint` +with the provided parameters. The resolved object contains a `request_uri` that you will +afterwards pass to [client.authorizationUrl(parameters)](#clientauthorizationurlparameters) as the `request_uri` parameter. + +The parameters sent to `pushed_authorization_request_endpoint` default to the same values +as [client.authorizationUrl(parameters)](#clientauthorizationurlparameters) unless +`request` (a Request Object) parameter e.g. from [client.requestObject(payload)](#clientrequestobjectpayload) is present. + +The client will use it's `token_endpoint_auth_method` to authenticate at the `pushed_authorization_request_endpoint`. + +- `parameters`: `` + - `client_id`: `` **Default:** client's client_id + - any other request parameters may also be included +- `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` + is either `client_secret_jwt` or `private_key_jwt`. +- Returns: `Promise` Parsed Pushed Authorization Request Response with `request_uri` + and `expires_in` properties validated to be present and correct types. + +--- + #### Client Authentication Methods Defined in [Core 1.0][client-authentication] and [RFC 8705](https://tools.ietf.org/html/rfc8705) diff --git a/lib/client.js b/lib/client.js index f22f5f05..805901df 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1660,4 +1660,72 @@ Object.defineProperty(BaseClient.prototype, 'dpopProof', { }, }); +/** + * @name pushedAuthorizationRequest + * @api public + */ +async function pushedAuthorizationRequest(params = {}, { clientAssertionPayload } = {}) { + assertIssuerConfiguration(this.issuer, 'pushed_authorization_request_endpoint'); + + const body = { + ...('request' in params ? params : authorizationParams.call(this, params)), + client_id: this.client_id, + }; + + const response = await authenticatedPost.call( + this, + 'pushed_authorization_request', + { + responseType: 'json', + form: body, + }, + { clientAssertionPayload, endpointAuthMethod: 'token' }, + ); + const responseBody = processResponse(response); + + if (!('expires_in' in responseBody)) { + throw new RPError({ + message: 'expected expires_in in Pushed Authorization Successful Response', + response, + }); + } + if (typeof responseBody.expires_in !== 'number') { + throw new RPError({ + message: 'invalid expires_in value in Pushed Authorization Successful Response', + response, + }); + } + if (!('request_uri' in responseBody)) { + throw new RPError({ + message: 'expected request_uri in Pushed Authorization Successful Response', + response, + }); + } + if (typeof responseBody.request_uri !== 'string') { + throw new RPError({ + message: 'invalid request_uri value in Pushed Authorization Successful Response', + response, + }); + } + + return responseBody; +} + +Object.defineProperty(BaseClient.prototype, 'pushedAuthorizationRequest', { + enumerable: true, + configurable: true, + value(...args) { + process.emitWarning( + 'The Pushed Authorization Requests 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, 'pushedAuthorizationRequest', { + enumerable: true, + configurable: true, + value: pushedAuthorizationRequest, + }); + return this.pushedAuthorizationRequest(...args); + }, +}); + module.exports.BaseClient = BaseClient; diff --git a/test/client/client_instance.test.js b/test/client/client_instance.test.js index 43ee3b60..7a350dbd 100644 --- a/test/client/client_instance.test.js +++ b/test/client/client_instance.test.js @@ -11,7 +11,7 @@ const jose = require('jose'); const timekeeper = require('timekeeper'); const TokenSet = require('../../lib/token_set'); -const { OPError } = require('../../lib/errors'); +const { OPError, RPError } = require('../../lib/errors'); const now = require('../../lib/helpers/unix_timestamp'); const { Registry, Issuer, custom } = require('../../lib'); const clientInternal = require('../../lib/helpers/client'); @@ -3956,4 +3956,125 @@ describe('Client', () => { }); }); }); + + describe('#pushedAuthorizationRequest', function () { + before(function () { + this.issuer = new Issuer({ + issuer: 'https://op.example.com', + pushed_authorization_request_endpoint: 'https://op.example.com/par', + }); + this.client = new this.issuer.Client({ + client_id: 'identifier', + client_secret: 'secure', + response_type: ['code'], + grant_types: ['authorization_code'], + redirect_uris: ['https://rp.example.com/cb'], + }); + }); + + it('requires the issuer to have pushed_authorization_request_endpoint declared', async () => { + const issuer = new Issuer({ issuer: 'https://op.example.com' }); + const client = new issuer.Client({ client_id: 'identifier' }); + + return client.pushedAuthorizationRequest() + .then(fail, (error) => { + expect(error).to.be.instanceof(TypeError); + expect(error.message).to.eql('pushed_authorization_request_endpoint must be configured on the issuer'); + }); + }); + + it('performs an authenticated post and returns the response', async function () { + nock('https://op.example.com') + .filteringRequestBody(function (body) { + expect(querystring.parse(body)).to.eql({ + client_id: 'identifier', + redirect_uri: 'https://rp.example.com/cb', + response_type: 'code', + scope: 'openid', + }); + }) + .post('/par', () => true) // to make sure filteringRequestBody works + .reply(200, { expires_in: 60, request_uri: 'urn:ietf:params:oauth:request_uri:random' }); + + return this.client.pushedAuthorizationRequest() + .then((response) => { + expect(response).to.have.property('expires_in', 60); + expect(response).to.have.property('request_uri', 'urn:ietf:params:oauth:request_uri:random'); + }); + }); + + it('handles request being part of the params', async function () { + nock('https://op.example.com') + .filteringRequestBody(function (body) { + expect(querystring.parse(body)).to.eql({ + client_id: 'identifier', + request: 'jwt', + }); + }) + .post('/par', () => true) // to make sure filteringRequestBody works + .reply(200, { expires_in: 60, request_uri: 'urn:ietf:params:oauth:request_uri:random' }); + + return this.client.pushedAuthorizationRequest({ request: 'jwt' }); + }); + + it('rejects with OPError when part of the response', function () { + nock('https://op.example.com') + .post('/par') + .reply(400, { error: 'invalid_request', error_description: 'description' }); + + return this.client.pushedAuthorizationRequest({ request: 'jwt' }).then(fail, (error) => { + expect(error).to.be.instanceof(OPError); + expect(error).to.have.property('error', 'invalid_request'); + expect(error).to.have.property('error_description', 'description'); + }); + }); + + it('rejects with RPError when request_uri is missing from the response', function () { + nock('https://op.example.com') + .post('/par') + .reply(200, { expires_in: 60 }); + + return this.client.pushedAuthorizationRequest().then(fail, (error) => { + expect(error).to.be.instanceof(RPError); + expect(error).to.have.property('response'); + expect(error).to.have.property('message', 'expected request_uri in Pushed Authorization Successful Response'); + }); + }); + + it('rejects with RPError when request_uri is not a string', function () { + nock('https://op.example.com') + .post('/par') + .reply(200, { request_uri: null, expires_in: 60 }); + + return this.client.pushedAuthorizationRequest().then(fail, (error) => { + expect(error).to.be.instanceof(RPError); + expect(error).to.have.property('response'); + expect(error).to.have.property('message', 'invalid request_uri value in Pushed Authorization Successful Response'); + }); + }); + + it('rejects with RPError when expires_in is missing from the response', function () { + nock('https://op.example.com') + .post('/par') + .reply(200, { request_uri: 'urn:ietf:params:oauth:request_uri:random' }); + + return this.client.pushedAuthorizationRequest().then(fail, (error) => { + expect(error).to.be.instanceof(RPError); + expect(error).to.have.property('response'); + expect(error).to.have.property('message', 'expected expires_in in Pushed Authorization Successful Response'); + }); + }); + + it('rejects with RPError when expires_in is not a string', function () { + nock('https://op.example.com') + .post('/par') + .reply(200, { expires_in: null, request_uri: 'urn:ietf:params:oauth:request_uri:random' }); + + return this.client.pushedAuthorizationRequest().then(fail, (error) => { + expect(error).to.be.instanceof(RPError); + expect(error).to.have.property('response'); + expect(error).to.have.property('message', 'invalid expires_in value in Pushed Authorization Successful Response'); + }); + }); + }); });