diff --git a/README.md b/README.md index 2f988932f..6c7821094 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The following draft specifications are implemented by oidc-provider. - [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) - draft 02][jarm] - [OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer (DPoP) - draft 01][dpop] - [OAuth 2.0 JWT Secured Authorization Request (JAR)][jar] -- [OAuth 2.0 Pushed Authorization Requests - draft 01][par] +- [OAuth 2.0 Pushed Authorization Requests - draft 02][par] - [OAuth 2.0 Resource Indicators - draft 08][resource-indicators] - [OAuth 2.0 Web Message Response Mode - individual draft 00][wmrm] - [OpenID Connect Back-Channel Logout 1.0 - draft 04][backchannel-logout] @@ -173,4 +173,4 @@ See the list of available emitted [event names](/docs/events.md) and their descr [jarm]: https://openid.net/specs/openid-financial-api-jarm-wd-02.html [jwt-at]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-05 [support-sponsor]: https://github.com/sponsors/panva -[par]: https://tools.ietf.org/html/draft-ietf-oauth-par-01 +[par]: https://tools.ietf.org/html/draft-ietf-oauth-par-02 diff --git a/docs/README.md b/docs/README.md index f0d04d59e..69e24126d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1281,10 +1281,26 @@ Enables the use of `pushed_authorization_request_endpoint` defined by the Pushed _**default value**_: ```js { - enabled: false + enabled: false, + requirePushedAuthorizationRequests: false } ``` +
(Click to expand) features.pushedAuthorizationRequests options details
+ + +#### requirePushedAuthorizationRequests + +Makes the use of PAR required for all authorization requests as an OP policy. + + +_**default value**_: +```js +false +``` + +
+ ### features.registration [Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) diff --git a/lib/actions/authorization/process_request_object.js b/lib/actions/authorization/process_request_object.js index 52c199f7c..45984386d 100644 --- a/lib/actions/authorization/process_request_object.js +++ b/lib/actions/authorization/process_request_object.js @@ -14,10 +14,14 @@ const checkResponseMode = require('./check_response_mode'); module.exports = async function processRequestObject(PARAM_LIST, rejectDupesMiddleware, ctx, next) { const { params, client, route } = ctx.oidc; + const pushedRequestObject = 'PushedAuthorizationRequest' in ctx.oidc.entities; + if (client.requirePushedAuthorizationRequests && route !== 'pushed_authorization_request' && !pushedRequestObject) { + throw new InvalidRequest('Pushed Authorization Request must be used'); + } + if ( client.requestObjectSigningAlg && params.request === undefined - && route !== 'pushed_authorization_request' ) { throw new InvalidRequest('Request Object must be used by this client'); } @@ -129,16 +133,12 @@ module.exports = async function processRequestObject(PARAM_LIST, rejectDupesMidd } } - const pushedRequestObject = 'PushedAuthorizationRequest' in ctx.oidc.entities; - - if (!(alg === 'none' && pushedRequestObject)) { - if (client.requestObjectSigningAlg && client.requestObjectSigningAlg !== alg) { - throw new InvalidRequestObject('the preregistered alg must be used in request or request_uri'); - } + if (client.requestObjectSigningAlg && alg !== client.requestObjectSigningAlg) { + throw new InvalidRequestObject('the preregistered alg must be used in request or request_uri'); + } - if (!conf('requestObjectSigningAlgValues').includes(alg)) { - throw new InvalidRequestObject('unsupported signed request alg'); - } + if (!pushedRequestObject && !conf('requestObjectSigningAlgValues').includes(alg)) { + throw new InvalidRequestObject('unsupported signed request alg'); } const opts = { diff --git a/lib/actions/authorization/pushed_authorization_request_params.js b/lib/actions/authorization/pushed_authorization_request_params.js index 9935be822..a494403f2 100644 --- a/lib/actions/authorization/pushed_authorization_request_params.js +++ b/lib/actions/authorization/pushed_authorization_request_params.js @@ -11,14 +11,6 @@ const { InvalidRequest } = require('../../helpers/errors'); module.exports = function pushedAuthorizationRequestParams(ctx, next) { const JAR = !!ctx.oidc.params.request; - if ( - ctx.oidc.client.requestObjectSigningAlg - && ctx.oidc.client.tokenEndpointAuthMethod === 'none' - && !JAR - ) { - throw new InvalidRequest('Request Object must be used by this client'); - } - for (const [param, value] of Object.entries(ctx.oidc.params)) { // eslint-disable-line no-restricted-syntax, max-len if (value !== undefined) { if (param === 'request_uri') { diff --git a/lib/actions/discovery.js b/lib/actions/discovery.js index e155d321a..84dc44b3a 100644 --- a/lib/actions/discovery.js +++ b/lib/actions/discovery.js @@ -28,7 +28,6 @@ module.exports = function discovery(ctx, next) { request_parameter_supported: features.requestObjects.request, request_uri_parameter_supported: features.requestObjects.requestUri, require_request_uri_registration: features.requestObjects.requestUri && features.requestObjects.requireUriRegistration ? true : undefined, - pushed_authorization_request_endpoint: features.pushedAuthorizationRequests.enabled ? ctx.oidc.urlFor('pushed_authorization_request') : undefined, response_modes_supported: ['form_post', 'fragment', 'query'], response_types_supported: config.responseTypes, scopes_supported: [...config.scopes].concat([...config.dynamicScopes].map((s) => s[DYNAMIC_SCOPE_LABEL]).filter(Boolean)), @@ -38,6 +37,11 @@ module.exports = function discovery(ctx, next) { token_endpoint: ctx.oidc.urlFor('token'), }; + if (features.pushedAuthorizationRequests.enabled) { + ctx.body.pushed_authorization_request_endpoint = ctx.oidc.urlFor('pushed_authorization_request'); + ctx.body.require_pushed_authorization_requests = features.pushedAuthorizationRequests.requirePushedAuthorizationRequests ? true : undefined; + } + if (features.userinfo.enabled) { ctx.body.userinfo_endpoint = ctx.oidc.urlFor('userinfo'); if (features.jwtUserinfo.enabled) { diff --git a/lib/consts/client_attributes.js b/lib/consts/client_attributes.js index fe642088c..c1165154d 100644 --- a/lib/consts/client_attributes.js +++ b/lib/consts/client_attributes.js @@ -40,6 +40,7 @@ const DEFAULT = { post_logout_redirect_uris: [], backchannel_logout_session_required: false, frontchannel_logout_session_required: false, + require_pushed_authorization_requests: false, }; const REQUIRED = [ @@ -53,6 +54,7 @@ const BOOL = [ 'frontchannel_logout_session_required', 'require_auth_time', 'tls_client_certificate_bound_access_tokens', + 'require_pushed_authorization_requests', ]; const ARYS = [ diff --git a/lib/helpers/client_schema.js b/lib/helpers/client_schema.js index f62cc26e4..c64d61cb2 100644 --- a/lib/helpers/client_schema.js +++ b/lib/helpers/client_schema.js @@ -119,6 +119,10 @@ module.exports = function getSchema(provider) { } } + if (features.pushedAuthorizationRequests.enabled) { + RECOGNIZED_METADATA.push('require_pushed_authorization_requests'); + } + if (features.encryption.enabled) { RECOGNIZED_METADATA.push('id_token_encrypted_response_alg'); RECOGNIZED_METADATA.push('id_token_encrypted_response_enc'); @@ -212,7 +216,9 @@ module.exports = function getSchema(provider) { }; class Schema { - constructor(metadata, ctx, processCustomMetadata = !!configuration.extraClientMetadata.properties.length) { + constructor( + metadata, ctx, processCustomMetadata = !!configuration.extraClientMetadata.properties.length, + ) { // unless explicitly provided use token_* values ['revocation', 'introspection'].forEach((endpoint) => { if (metadata[`${endpoint}_endpoint_auth_method`] === undefined) { @@ -247,6 +253,7 @@ module.exports = function getSchema(provider) { this.webMessageUris(); this.checkContacts(); this.backchannelLogoutNeedsIdTokenAlg(); + this.parPolicy(); // max_age and client_secret_expires_at format ['default_max_age', 'client_secret_expires_at'].forEach((prop) => { @@ -575,6 +582,13 @@ module.exports = function getSchema(provider) { }); } + parPolicy() { + const par = configuration.features.pushedAuthorizationRequests; + if (par.enabled && par.requirePushedAuthorizationRequests) { + this.require_pushed_authorization_requests = true; + } + } + ensureStripUnrecognized() { const whitelisted = [...RECOGNIZED_METADATA, ...configuration.extraClientMetadata.properties]; Object.keys(this).forEach((prop) => { diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index d09e9095f..55fc92758 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -1127,7 +1127,17 @@ function getDefaults() { * Authorization Requests draft. * */ - pushedAuthorizationRequests: { enabled: false }, + pushedAuthorizationRequests: { + enabled: false, + + /* + * features.pushedAuthorizationRequests.requirePushedAuthorizationRequests + * + * description: Makes the use of PAR required for all authorization + * requests as an OP policy. + */ + requirePushedAuthorizationRequests: false, + }, /* * features.registration diff --git a/lib/helpers/features.js b/lib/helpers/features.js index 6d6aa217a..0a1994e6c 100644 --- a/lib/helpers/features.js +++ b/lib/helpers/features.js @@ -58,10 +58,10 @@ const DRAFTS = new Map(Object.entries({ version: [1, 2, 'draft-02'], }, pushedAuthorizationRequests: { - name: 'OAuth 2.0 Pushed Authorization Requests - draft 01', + name: 'OAuth 2.0 Pushed Authorization Requests - draft 02', type: 'IETF OAuth Working Group draft', - url: 'https://tools.ietf.org/html/draft-ietf-oauth-par-01', - version: [0, 'individual-draft-01', 'draft-00', 'draft-01'], + url: 'https://tools.ietf.org/html/draft-ietf-oauth-par-02', + version: [0, 'individual-draft-01', 'draft-00', 'draft-01', 'draft-02'], }, resourceIndicators: { name: 'Resource Indicators for OAuth 2.0 - draft 08', diff --git a/test/configuration/client_metadata.test.js b/test/configuration/client_metadata.test.js index 40b316e46..c351719e2 100644 --- a/test/configuration/client_metadata.test.js +++ b/test/configuration/client_metadata.test.js @@ -873,6 +873,30 @@ describe('Client metadata validation', () => { }); }); + describe('features.pushedAuthorizationRequests', () => { + context('require_pushed_authorization_requests', function () { + const configuration = (value = false) => ({ + features: { + pushedAuthorizationRequests: { + enabled: true, + requirePushedAuthorizationRequests: value, + }, + }, + }); + mustBeBoolean(this.title, undefined, configuration()); + mustBeBoolean(this.title, undefined, configuration(true)); + defaultsTo(this.title, false, undefined, configuration()); + defaultsTo(this.title, true, undefined, configuration(true)); + defaultsTo(this.title, true, { + require_pushed_authorization_requests: false, + }, configuration(true)); + defaultsTo(this.title, true, undefined, { + ...configuration(), + clientDefaults: { require_pushed_authorization_requests: true }, + }); + }); + }); + context('jwks', function () { const configuration = { features: { diff --git a/test/pushed_authorization_requests/pushed_authorization_requests.config.js b/test/pushed_authorization_requests/pushed_authorization_requests.config.js index 4d0c6ab73..70d21737b 100644 --- a/test/pushed_authorization_requests/pushed_authorization_requests.config.js +++ b/test/pushed_authorization_requests/pushed_authorization_requests.config.js @@ -6,7 +6,13 @@ const config = cloneDeep(require('../default.config')); config.whitelistedJWA.requestObjectSigningAlgValues = config.whitelistedJWA.requestObjectSigningAlgValues.filter((alg) => alg !== 'none'); merge(config.features, { - pushedAuthorizationRequests: { enabled: true }, + pushedAuthorizationRequests: { + requirePushedAuthorizationRequests: false, + enabled: true, + }, + requestObjects: { + request: true, + }, }); module.exports = { @@ -14,13 +20,16 @@ module.exports = { clients: [{ client_id: 'client', client_secret: 'secret', - request_object_signing_alg: 'HS256', redirect_uris: ['https://rp.example.com/cb'], }, { - client_id: 'client-none', - request_object_signing_alg: 'RS256', + client_id: 'client-par-required', + client_secret: 'secret', + redirect_uris: ['https://rp.example.com/cb'], + require_pushed_authorization_requests: true, + }, { + client_id: 'client-alg-registered', + client_secret: 'secret', + request_object_signing_alg: 'HS256', redirect_uris: ['https://rp.example.com/cb'], - token_endpoint_auth_method: 'none', - jwks_uri: 'https://rp.example.com/jwks', }], }; diff --git a/test/pushed_authorization_requests/pushed_authorization_requests.test.js b/test/pushed_authorization_requests/pushed_authorization_requests.test.js index 723ac8c41..8b383307e 100644 --- a/test/pushed_authorization_requests/pushed_authorization_requests.test.js +++ b/test/pushed_authorization_requests/pushed_authorization_requests.test.js @@ -16,13 +16,27 @@ describe('Pushed Request Object', () => { }); describe('discovery', () => { - it('extends the well known config', function () { + it('extends the well known config', async function () { + await this.agent.get('/.well-known/openid-configuration') + .expect((response) => { + expect(response.body).not.to.have.property('request_object_endpoint'); + expect(response.body).to.have.property('pushed_authorization_request_endpoint'); + expect(response.body).not.to.have.property('require_pushed_authorization_requests'); + }); + + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = true; + return this.agent.get('/.well-known/openid-configuration') .expect((response) => { expect(response.body).not.to.have.property('request_object_endpoint'); expect(response.body).to.have.property('pushed_authorization_request_endpoint'); + expect(response.body).to.have.property('require_pushed_authorization_requests', true); }); }); + + after(function () { + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = false; + }); }); it('can only be enabled with request objects', () => { @@ -39,400 +53,430 @@ describe('Pushed Request Object', () => { }).to.throw('pushedAuthorizationRequests is only available in conjuction with requestObjects.requestUri'); }); - describe('using a JAR request parameter', () => { - describe('Pushed Authorization Request Endpoint', () => { - it('populates ctx.oidc.entities', function (done) { - this.provider.use(this.assertOnce((ctx) => { - expect(ctx.oidc.entities).to.have.keys('Client', 'PushedAuthorizationRequest'); - }, done)); - - JWT.sign({ - response_type: 'code', - client_id: 'client', - }, this.key, 'HS256').then((request) => { - this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ request }) - .end(() => {}); - }); - }); + ['client', 'client-par-required'].forEach((clientId) => { + const requirePushedAuthorizationRequests = clientId === 'client-par-required'; - it('stores a request object and returns a uri', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); + describe(`when require_pushed_authorization_requests=${requirePushedAuthorizationRequests}`, () => { + describe('using a JAR request parameter', () => { + describe('Pushed Authorization Request Endpoint', () => { + it('populates ctx.oidc.entities', function (done) { + this.provider.use(this.assertOnce((ctx) => { + expect(ctx.oidc.entities).to.have.keys('Client', 'PushedAuthorizationRequest'); + }, done)); - await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ + JWT.sign({ response_type: 'code', - client_id: 'client', - }, this.key, 'HS256'), - }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in', 300); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + client_id: clientId, + }, this.key, 'HS256').then((request) => { + this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ request }) + .end(() => {}); + }); }); - expect(spy).to.have.property('calledOnce', true); - }); - - it('ignores regular parameters when passing a JAR request', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.saved', spy); - - await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - nonce: 'foo', - response_type: 'code token', - request: await JWT.sign({ - response_type: 'code', - client_id: 'client', - }, this.key, 'HS256'), - }) - .expect(201); - - expect(spy).to.have.property('calledOnce', true); - const { request } = spy.args[0][0]; - const payload = jose.JWT.decode(request); - expect(payload).not.to.have.property('nonce'); - expect(payload).to.have.property('response_type', 'code'); - }); - - it('requires the registered request object signing alg be used', async function () { - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: 'client', - }, undefined, 'none'), - }) - .expect(400) - .expect({ - error: 'invalid_request_object', - error_description: 'the preregistered alg must be used in request or request_uri', + it('stores a request object and returns a uri', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.success', spy); + + await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in', 300); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); }); - }); - it('requires the request object client_id to equal the authenticated client one', async function () { - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: 'client-foo', - }, this.key, 'HS256'), - }) - .expect(400) - .expect({ - error: 'invalid_request_object', - error_description: "request client_id must equal the authenticated client's client_id", + it('ignores regular parameters when passing a JAR request', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.saved', spy); + + await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + nonce: 'foo', + response_type: 'code token', + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }) + .expect(201); + + expect(spy).to.have.property('calledOnce', true); + const { request } = spy.args[0][0]; + const payload = jose.JWT.decode(request); + expect(payload).not.to.have.property('nonce'); + expect(payload).to.have.property('response_type', 'code'); }); - }); - it('remaps request validation errors to be related to the request object', async function () { - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: 'client', - redirect_uri: 'https://rp.example.com/unlisted', - }, this.key, 'HS256'), - }) - .expect(400) - .expect({ - error: 'invalid_request_object', - error_description: "redirect_uri did not match any of the client's registered redirect_uris", + it('requires the registered request object signing alg be used', async function () { + return this.agent.post(route) + .auth('client-alg-registered', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client-alg-registered', + }, undefined, 'none'), + }) + .expect(400) + .expect({ + error: 'invalid_request_object', + error_description: 'the preregistered alg must be used in request or request_uri', + }); }); - }); - it('leaves non OIDCProviderError alone', async function () { - const adapterThrow = new Error('adapter throw!'); - sinon.stub(this.TestAdapter.for('PushedAuthorizationRequest'), 'upsert').callsFake(async () => { throw adapterThrow; }); - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: 'client', - }, this.key, 'HS256'), - }) - .expect(() => { - this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); - }) - .expect(500) - .expect({ - error: 'server_error', - error_description: 'oops! something went wrong', + it('requires the request object client_id to equal the authenticated client one', async function () { + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client-foo', + }, this.key, 'HS256'), + }) + .expect(400) + .expect({ + error: 'invalid_request_object', + error_description: "request client_id must equal the authenticated client's client_id", + }); }); - }); - }); - describe('Using Pushed Authorization Requests', () => { - before(function () { return this.login(); }); - after(function () { return this.logout(); }); - - it('allows the request_uri to be used', async function () { - const { body: { request_uri } } = await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - scope: 'openid', - response_type: 'code', - client_id: 'client', - }, this.key, 'HS256'), + it('remaps request validation errors to be related to the request object', async function () { + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + redirect_uri: 'https://rp.example.com/unlisted', + }, this.key, 'HS256'), + }) + .expect(400) + .expect({ + error: 'invalid_request_object', + error_description: "redirect_uri did not match any of the client's registered redirect_uris", + }); }); - let id = request_uri.split(':'); - id = id[id.length - 1]; - - expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; - - const auth = new this.AuthorizationRequest({ - client_id: 'client', - state: undefined, - redirect_uri: undefined, - request_uri, - }); - - await this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(302) - .expect(auth.validatePresence(['code'])); - - expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; - }); - - it('handles expired or invalid pushed authorization request object', async function () { - const auth = new this.AuthorizationRequest({ - request_uri: 'urn:ietf:params:oauth:request_uri:foobar', - }); - - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(302) - .expect(auth.validatePresence(['error', 'error_description', 'state'])) - .expect(auth.validateState) - .expect(auth.validateClientLocation) - .expect(auth.validateError('invalid_request_uri')) - .expect(auth.validateErrorDescription('request_uri is invalid or expired')); - }); - - it('handles expired or invalid pushed authorization request object (when no client_id in the request)', async function () { - const renderSpy = sinon.spy(i(this.provider).configuration(), 'renderError'); - - const auth = new this.AuthorizationRequest({ - client_id: undefined, - request_uri: 'urn:ietf:params:oauth:request_uri:foobar', + it('leaves non OIDCProviderError alone', async function () { + const adapterThrow = new Error('adapter throw!'); + sinon.stub(this.TestAdapter.for('PushedAuthorizationRequest'), 'upsert').callsFake(async () => { throw adapterThrow; }); + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }) + .expect(() => { + this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); + }) + .expect(500) + .expect({ + error: 'server_error', + error_description: 'oops! something went wrong', + }); + }); }); - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(() => { - renderSpy.restore(); - }) - .expect(400) - .expect(() => { - expect(renderSpy.calledOnce).to.be.true; - const renderArgs = renderSpy.args[0]; - expect(renderArgs[1]).to.have.property('error', 'invalid_request_uri'); - expect(renderArgs[1]).to.have.property('error_description', 'request_uri is invalid or expired'); + describe('Using Pushed Authorization Requests', () => { + before(function () { return this.login(); }); + after(function () { return this.logout(); }); + + it('allows the request_uri to be used', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }); + + let id = request_uri.split(':'); + id = id[id.length - 1]; + + expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; + + const auth = new this.AuthorizationRequest({ + client_id: clientId, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); + + expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; }); - }); - it('allows the request_uri to be used without passing client_id to the request', async function () { - const { body: { request_uri } } = await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - scope: 'openid', - response_type: 'code', - client_id: 'client', - }, this.key, 'HS256'), + if (requirePushedAuthorizationRequests) { + it('forbids plain Authorization Request use', async function () { + const auth = new this.AuthorizationRequest({ + client_id: clientId, + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['error', 'error_description', 'state'])) + .expect(auth.validateState) + .expect(auth.validateClientLocation) + .expect(auth.validateError('invalid_request')) + .expect(auth.validateErrorDescription('Pushed Authorization Request must be used')); + }); + } else { + it('still allows plain Authorization Request use', async function () { + const auth = new this.AuthorizationRequest({ + client_id: clientId, + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code', 'state'])) + .expect(auth.validateState) + .expect(auth.validateClientLocation); + }); + } + + it('handles expired or invalid pushed authorization request object', async function () { + const auth = new this.AuthorizationRequest({ + request_uri: 'urn:ietf:params:oauth:request_uri:foobar', + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['error', 'error_description', 'state'])) + .expect(auth.validateState) + .expect(auth.validateClientLocation) + .expect(auth.validateError('invalid_request_uri')) + .expect(auth.validateErrorDescription('request_uri is invalid or expired')); }); - const auth = new this.AuthorizationRequest({ - client_id: undefined, - state: undefined, - redirect_uri: undefined, - request_uri, - }); - - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(302) - .expect(auth.validatePresence(['code'])); - }); - }); - }); - - describe('using a plain pushed authorization request', () => { - describe('Pushed Authorization Request Endpoint', () => { - it('populates ctx.oidc.entities', function (done) { - this.provider.use(this.assertOnce((ctx) => { - expect(ctx.oidc.entities).to.have.keys('Client', 'PushedAuthorizationRequest'); - }, done)); - - this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: 'client', - }) - .end(() => {}); - }); - - it('stores a request object and returns a uri', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); - - await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: 'client', - }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in', 300); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + it('handles expired or invalid pushed authorization request object (when no client_id in the request)', async function () { + const renderSpy = sinon.spy(i(this.provider).configuration(), 'renderError'); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + request_uri: 'urn:ietf:params:oauth:request_uri:foobar', + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(() => { + renderSpy.restore(); + }) + .expect(400) + .expect(() => { + expect(renderSpy.calledOnce).to.be.true; + const renderArgs = renderSpy.args[0]; + expect(renderArgs[1]).to.have.property('error', 'invalid_request_uri'); + expect(renderArgs[1]).to.have.property('error_description', 'request_uri is invalid or expired'); + }); }); - expect(spy).to.have.property('calledOnce', true); - }); - - it('requires the registered request object signing alg be used', async function () { - return this.agent.post(route) - .type('form') - .send({ - response_type: 'code', - client_id: 'client-none', - }) - .expect(400) - .expect({ - error: 'invalid_request', - error_description: 'Request Object must be used by this client', + it('allows the request_uri to be used without passing client_id to the request', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }, this.key, 'HS256'), + }); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); }); + }); }); - it('forbids request_uri to be used', async function () { - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - response_type: 'code', - request_uri: 'https://rp.example.com/jar#foo', - }) - .expect(400) - .expect({ - error: 'invalid_request', - error_description: '`request_uri` parameter must not be used at the pushed_authorization_request_endpoint', + describe('using a plain pushed authorization request', () => { + describe('Pushed Authorization Request Endpoint', () => { + it('populates ctx.oidc.entities', function (done) { + this.provider.use(this.assertOnce((ctx) => { + expect(ctx.oidc.entities).to.have.keys('Client', 'PushedAuthorizationRequest'); + }, done)); + + this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + response_type: 'code', + client_id: clientId, + }) + .end(() => {}); }); - }); - it('does not remap request validation errors to be related to the request object', async function () { - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: 'client', - redirect_uri: 'https://rp.example.com/unlisted', - }) - .expect(400) - .expect({ - error: 'redirect_uri_mismatch', - error_description: "redirect_uri did not match any of the client's registered redirect_uris", + it('stores a request object and returns a uri', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.success', spy); + + await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + response_type: 'code', + client_id: clientId, + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in', 300); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); }); - }); - it('leaves non OIDCProviderError alone', async function () { - const adapterThrow = new Error('adapter throw!'); - sinon.stub(this.TestAdapter.for('PushedAuthorizationRequest'), 'upsert').callsFake(async () => { throw adapterThrow; }); - return this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: 'client', - }) - .expect(() => { - this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); - }) - .expect(500) - .expect({ - error: 'server_error', - error_description: 'oops! something went wrong', + it('forbids request_uri to be used', async function () { + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + response_type: 'code', + request_uri: 'https://rp.example.com/jar#foo', + }) + .expect(400) + .expect({ + error: 'invalid_request', + error_description: '`request_uri` parameter must not be used at the pushed_authorization_request_endpoint', + }); }); - }); - }); - describe('Using Pushed Authorization Requests', () => { - before(function () { return this.login(); }); - after(function () { return this.logout(); }); - - it('allows the request_uri to be used', async function () { - const { body: { request_uri } } = await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - scope: 'openid', - response_type: 'code', - client_id: 'client', + it('does not remap request validation errors to be related to the request object', async function () { + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + response_type: 'code', + client_id: clientId, + redirect_uri: 'https://rp.example.com/unlisted', + }) + .expect(400) + .expect({ + error: 'redirect_uri_mismatch', + error_description: "redirect_uri did not match any of the client's registered redirect_uris", + }); }); - let id = request_uri.split(':'); - id = id[id.length - 1]; - - expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; - - const auth = new this.AuthorizationRequest({ - client_id: 'client', - state: undefined, - redirect_uri: undefined, - request_uri, + it('leaves non OIDCProviderError alone', async function () { + const adapterThrow = new Error('adapter throw!'); + sinon.stub(this.TestAdapter.for('PushedAuthorizationRequest'), 'upsert').callsFake(async () => { throw adapterThrow; }); + return this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + response_type: 'code', + client_id: clientId, + }) + .expect(() => { + this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); + }) + .expect(500) + .expect({ + error: 'server_error', + error_description: 'oops! something went wrong', + }); + }); }); - await this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(302) - .expect(auth.validatePresence(['code'])); - - expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; - }); - - it('allows the request_uri to be used without passing client_id to the request', async function () { - const { body: { request_uri } } = await this.agent.post(route) - .auth('client', 'secret') - .type('form') - .send({ - scope: 'openid', - response_type: 'code', - client_id: 'client', + describe('Using Pushed Authorization Requests', () => { + before(function () { return this.login(); }); + after(function () { return this.logout(); }); + + it('allows the request_uri to be used', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }); + + let id = request_uri.split(':'); + id = id[id.length - 1]; + + expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; + + const auth = new this.AuthorizationRequest({ + client_id: clientId, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); + + expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; }); - const auth = new this.AuthorizationRequest({ - client_id: undefined, - state: undefined, - redirect_uri: undefined, - request_uri, + it('allows the request_uri to be used without passing client_id to the request', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth(clientId, 'secret') + .type('form') + .send({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + }); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); + }); }); - - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(302) - .expect(auth.validatePresence(['code'])); }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 25aa598ea..6e6dc9d5a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -123,6 +123,8 @@ export interface AnyClientMetadata { web_message_uris?: string[]; tls_client_certificate_bound_access_tokens?: boolean; + require_pushed_authorization_requests?: boolean; + [key: string]: any; } @@ -975,8 +977,9 @@ export interface Configuration { }, pushedAuthorizationRequests?: { + requirePushedAuthorizationRequests?: boolean; enabled?: boolean, - ack?: 0 | 'individual-draft-01' | 'draft-00' | 'draft-01' + ack?: 'draft-02' }, mTLS?: { diff --git a/types/oidc-provider-tests.ts b/types/oidc-provider-tests.ts index 01c5b720c..052466d2b 100644 --- a/types/oidc-provider-tests.ts +++ b/types/oidc-provider-tests.ts @@ -341,7 +341,7 @@ const provider = new Provider('https://op.example.com', { sessionManagement: { enabled: false, ack: 28, keepHeaders: false, scriptNonce() { return "foo"; } }, jwtIntrospection: { enabled: false, ack: 'draft-09' }, jwtResponseModes: { enabled: false, ack: 2 }, - pushedAuthorizationRequests: { enabled: false, ack: 0 }, + pushedAuthorizationRequests: { enabled: false, ack: 'draft-02' }, registration: { enabled: true, initialAccessToken: true,