Skip to content

Commit

Permalink
feat: added OAuth 2.0 Pushed Authorization Requests client API
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Mar 25, 2021
1 parent 8033455 commit e7af9f5
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 10 deletions.
2 changes: 2 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 36 additions & 9 deletions docs/README.md
Expand Up @@ -147,22 +147,23 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us
<!-- TOC Client START -->
- [Class: &lt;Client&gt;](#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)
<!-- TOC Client END -->

---
Expand Down Expand Up @@ -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`: `<Object>`
- `client_id`: `<string>` **Default:** client's client_id
- any other request parameters may also be included
- `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`
is either `client_secret_jwt` or `private_key_jwt`.
- Returns: `Promise<Object>` 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)
Expand Down
68 changes: 68 additions & 0 deletions lib/client.js
Expand Up @@ -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, { statusCode: 201 });

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;
134 changes: 133 additions & 1 deletion test/client/client_instance.test.js
Expand Up @@ -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');
Expand Down Expand Up @@ -3956,4 +3956,136 @@ 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(201, { 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 incorrect status code', async function () {
nock('https://op.example.com')
.post('/par')
.reply(200, { expires_in: 60, request_uri: 'urn:ietf:params:oauth:request_uri:random' });

return this.client.pushedAuthorizationRequest().then(fail, (error) => {
expect(error).to.be.instanceof(OPError);
expect(error).to.have.property('message', 'expected 201 Created, got: 200 OK');
});
});

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(201, { 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(201, { 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(201, { 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(201, { 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(201, { 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');
});
});
});
});

0 comments on commit e7af9f5

Please sign in to comment.