From 089fa43e62ee2c1d3df4a21e16650d2e501fd0d3 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 30 Nov 2022 11:36:49 +0100 Subject: [PATCH] refactor!: CIBA and PAR do not automatically turn on JAR BREAKING CHANGE: The combination of FAPI and CIBA features no longer forces CIBA clients to use JAR. To continue conforming to a given FAPI CIBA profile that requires the use of JAR either set `features.requestObjects.requireSignedRequestObject` to `true` as a global policy or set `require_signed_request_object` or `backchannel_authentication_request_signing_alg` client metadata. BREAKING CHANGE: PAR no longer automatically enables the support for JAR. To support PAR with JAR configure both `features.pushedAuthorizationRequests` and `features.requestObjects.request`. BREAKING CHANGE: CIBA no longer automatically enables the support for JAR. To support CIBA with JAR configure both `features.ciba` and `features.requestObjects.request`. --- docs/README.md | 2 +- .../authorization/fetch_request_uri.js | 9 +- .../authorization/process_request_object.js | 1 - .../authorization/reject_unsupported.js | 10 +- lib/actions/discovery.js | 10 +- lib/helpers/client_schema.js | 10 +- lib/helpers/defaults.js | 2 +- test/ciba/ciba.test.js | 1287 +++++++++-------- test/ciba/ciba_jar.config.js | 8 + test/configuration/client_metadata.test.js | 29 +- test/dpop/dpop.config.js | 1 + .../pushed_authorization_requests.test.js | 923 ++++++------ ...ushed_authorization_requests_jar.config.js | 8 + 13 files changed, 1206 insertions(+), 1094 deletions(-) create mode 100644 test/ciba/ciba_jar.config.js create mode 100644 test/pushed_authorization_requests/pushed_authorization_requests_jar.config.js diff --git a/docs/README.md b/docs/README.md index 33836f52b..d3de86466 100644 --- a/docs/README.md +++ b/docs/README.md @@ -638,7 +638,7 @@ _**default value**_: [OpenID Connect Client Initiated Backchannel Authentication Flow - Core 1.0](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0-final.html) -Enables Core CIBA Flow, when combined with `features.fapi` enables [Financial-grade API: Client Initiated Backchannel Authentication Profile - Implementer's Draft 01](https://openid.net/specs/openid-financial-api-ciba-ID1.html) as well. +Enables Core CIBA Flow, when combined with `features.fapi` and `features.requestObjects.request` enables [Financial-grade API: Client Initiated Backchannel Authentication Profile - Implementer's Draft 01](https://openid.net/specs/openid-financial-api-ciba-ID1.html) as well. diff --git a/lib/actions/authorization/fetch_request_uri.js b/lib/actions/authorization/fetch_request_uri.js index d4ba95da7..fb97be697 100644 --- a/lib/actions/authorization/fetch_request_uri.js +++ b/lib/actions/authorization/fetch_request_uri.js @@ -1,6 +1,6 @@ import { URL } from 'node:url'; -import { InvalidRequestUri } from '../../helpers/errors.js'; +import { InvalidRequestUri, RequestUriNotSupported } from '../../helpers/errors.js'; import instance from '../../helpers/weak_cache.js'; import { PUSHED_REQUEST_URN } from '../../consts/index.js'; @@ -15,11 +15,8 @@ const allowedSchemes = new Set(['http:', 'https:', 'urn:']); * uses the response body as a value for the request parameter to be validated by a downstream * middleware * - * - * @throws: invalid_request * @throws: invalid_request_uri - * @throws: request_not_supported - * @throws: request_uri_not_supported + * @throws: request_uri_not_allowed */ export default async function fetchRequestUri(ctx, next) { const { pushedAuthorizationRequests, requestObjects } = instance(ctx.oidc.provider).configuration('features'); @@ -44,7 +41,7 @@ export default async function fetchRequestUri(ctx, next) { ) { loadedRequestObject = await loadPushedAuthorizationRequest(ctx); } else if (!loadedRequestObject && !requestObjects.requestUri) { - throw new InvalidRequestUri('only request_uri values from the pushed_authorization_request_endpoint are allowed'); + throw new RequestUriNotSupported(); } else if (!loadedRequestObject && ctx.oidc.client.requestUris) { if (!ctx.oidc.client.requestUriAllowed(params.request_uri)) { throw new InvalidRequestUri('provided request_uri is not allowed'); diff --git a/lib/actions/authorization/process_request_object.js b/lib/actions/authorization/process_request_object.js index 77819fe86..46a31b3e0 100644 --- a/lib/actions/authorization/process_request_object.js +++ b/lib/actions/authorization/process_request_object.js @@ -28,7 +28,6 @@ export default async function processRequestObject(PARAM_LIST, rejectDupesMiddle && ( client.requireSignedRequestObject || (client.backchannelAuthenticationRequestSigningAlg && isBackchannelAuthentication) - || (ctx.oidc.fapiProfile !== undefined && isBackchannelAuthentication) ) ) { throw new InvalidRequest('Request Object must be used by this client'); diff --git a/lib/actions/authorization/reject_unsupported.js b/lib/actions/authorization/reject_unsupported.js index 0815e299d..31f87d9ef 100644 --- a/lib/actions/authorization/reject_unsupported.js +++ b/lib/actions/authorization/reject_unsupported.js @@ -11,17 +11,13 @@ export default function rejectUnsupported(ctx, next) { const { requestObjects, pushedAuthorizationRequests } = instance(ctx.oidc.provider).configuration('features'); const { params } = ctx.oidc; - if ( - !requestObjects.request - && params.request !== undefined - && (ctx.oidc.route !== 'pushed_authorization_request' && ctx.oidc.route !== 'backchannel_authentication') - ) { + if (params.request !== undefined && !requestObjects.request) { throw new RequestNotSupported(); } if ( - (!requestObjects.requestUri && !pushedAuthorizationRequests.enabled) - && params.request_uri !== undefined + params.request_uri !== undefined + && !(requestObjects.requestUri || pushedAuthorizationRequests.enabled) ) { throw new RequestUriNotSupported(); } diff --git a/lib/actions/discovery.js b/lib/actions/discovery.js index 5d44da7ba..72f1b4c1e 100644 --- a/lib/actions/discovery.js +++ b/lib/actions/discovery.js @@ -37,10 +37,10 @@ export default function discovery(ctx, next) { ctx.body.require_pushed_authorization_requests = pushedAuthorizationRequests.requirePushedAuthorizationRequests ? true : undefined; } - if (requestObjects.request || requestObjects.requestUri || pushedAuthorizationRequests.enabled) { + ctx.body.request_parameter_supported = requestObjects.request; + ctx.body.request_uri_parameter_supported = requestObjects.requestUri; + if (requestObjects.request || requestObjects.requestUri) { ctx.body.request_object_signing_alg_values_supported = config.requestObjectSigningAlgValues; - ctx.body.request_parameter_supported = requestObjects.request; - ctx.body.request_uri_parameter_supported = requestObjects.requestUri; ctx.body.require_request_uri_registration = requestObjects.requestUri && requestObjects.requireUriRegistration ? true : undefined; ctx.body.require_signed_request_object = requestObjects.requireSignedRequestObject ? true : undefined; } @@ -105,7 +105,7 @@ export default function discovery(ctx, next) { ctx.body.authorization_encryption_enc_values_supported = config.authorizationEncryptionEncValues; } - if (requestObjects.request || requestObjects.requestUri || pushedAuthorizationRequests.enabled) { + if (requestObjects.request || requestObjects.requestUri) { ctx.body.request_object_encryption_alg_values_supported = config.requestObjectEncryptionAlgValues; ctx.body.request_object_encryption_enc_values_supported = config.requestObjectEncryptionEncValues; } @@ -124,7 +124,7 @@ export default function discovery(ctx, next) { ctx.body.backchannel_authentication_endpoint = ctx.oidc.urlFor('backchannel_authentication'); ctx.body.backchannel_token_delivery_modes_supported = [...features.ciba.deliveryModes]; ctx.body.backchannel_user_code_parameter_supported = true; - ctx.body.backchannel_authentication_request_signing_alg_values_supported = config.requestObjectSigningAlgValues.filter((alg) => !alg.startsWith('HS')); + ctx.body.backchannel_authentication_request_signing_alg_values_supported = requestObjects.request ? config.requestObjectSigningAlgValues.filter((alg) => !alg.startsWith('HS')) : undefined; } defaults(ctx.body, config.discovery); diff --git a/lib/helpers/client_schema.js b/lib/helpers/client_schema.js index 87d59beec..c8399afd6 100644 --- a/lib/helpers/client_schema.js +++ b/lib/helpers/client_schema.js @@ -88,7 +88,6 @@ export default function getSchema(provider) { if ( features.requestObjects.request || features.requestObjects.requestUri - || features.pushedAuthorizationRequests.enabled ) { RECOGNIZED_METADATA.push('request_object_signing_alg'); RECOGNIZED_METADATA.push('require_signed_request_object'); @@ -141,7 +140,9 @@ export default function getSchema(provider) { RECOGNIZED_METADATA.push('backchannel_token_delivery_mode'); RECOGNIZED_METADATA.push('backchannel_user_code_parameter'); RECOGNIZED_METADATA.push('backchannel_client_notification_endpoint'); - RECOGNIZED_METADATA.push('backchannel_authentication_request_signing_alg'); + if (features.requestObjects.request) { + RECOGNIZED_METADATA.push('backchannel_authentication_request_signing_alg'); + } } if (features.dPoP.enabled) { @@ -597,11 +598,10 @@ export default function getSchema(provider) { } jarPolicy() { - const { features: { requestObjects, pushedAuthorizationRequests } } = configuration; + const { features: { requestObjects } } = configuration; const enabled = requestObjects.request - || requestObjects.requestUri - || pushedAuthorizationRequests.enabled; + || requestObjects.requestUri; if (enabled) { if (requestObjects.requireSignedRequestObject) { diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index b3549d154..8a2539b77 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -921,7 +921,7 @@ function makeDefaults() { * * title: [OpenID Connect Client Initiated Backchannel Authentication Flow - Core 1.0](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0-final.html) * - * description: Enables Core CIBA Flow, when combined with `features.fapi` enables [Financial-grade API: Client Initiated Backchannel Authentication Profile - Implementer's Draft 01](https://openid.net/specs/openid-financial-api-ciba-ID1.html) as well. + * description: Enables Core CIBA Flow, when combined with `features.fapi` and `features.requestObjects.request` enables [Financial-grade API: Client Initiated Backchannel Authentication Profile - Implementer's Draft 01](https://openid.net/specs/openid-financial-api-ciba-ID1.html) as well. * */ ciba: { diff --git a/test/ciba/ciba.test.js b/test/ciba/ciba.test.js index 7f2ec384a..c5c356e2d 100644 --- a/test/ciba/ciba.test.js +++ b/test/ciba/ciba.test.js @@ -11,432 +11,304 @@ import bootstrap from '../test_helper.js'; import { emitter } from './ciba.config.js'; -describe('configuration features.ciba', () => { - before(bootstrap(import.meta.url)); +describe('features.ciba', () => { + context('w/o request objects', () => { + before(bootstrap(import.meta.url)); - afterEach(() => { - expect(nock.isDone()).to.be.true; - }); - - it('extends discovery', function () { - return this.agent.get('/.well-known/openid-configuration') - .expect(200) - .expect((response) => { - expect(response.body).to.have.property('backchannel_authentication_endpoint').matches(/\/backchannel$/); - expect(response.body).to.have.property('backchannel_authentication_request_signing_alg_values_supported').not.contains('HS256'); - expect(response.body).to.have.property('backchannel_token_delivery_modes_supported').deep.equal(['poll', 'ping']); - expect(response.body).to.have.property('backchannel_user_code_parameter_supported', true); - }); - }); - - describe('Provider.prototype.backchannelResult', () => { - it('"request" can be a string (BackchannelAuthenticationRequest jti)', async function () { - const result = new AccessDenied(); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client' }); - await request.save(); - await this.provider.backchannelResult(request.jti, result); - return assert.rejects(this.provider.backchannelResult('notfound', result), { name: 'Error', message: 'BackchannelAuthenticationRequest not found' }); + afterEach(() => { + expect(nock.isDone()).to.be.true; }); - it('"request" can be a BackchannelAuthenticationRequest instance', async function () { - const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); - await request.save(); - return this.provider.backchannelResult(request, result); + it('extends discovery', function () { + return this.agent.get('/.well-known/openid-configuration') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('backchannel_authentication_endpoint').matches(/\/backchannel$/); + expect(response.body).not.to.have.property('backchannel_authentication_request_signing_alg_values_supported'); + expect(response.body).to.have.property('backchannel_token_delivery_modes_supported').deep.equal(['poll', 'ping']); + expect(response.body).to.have.property('backchannel_user_code_parameter_supported', true); + }); }); - it('"request" must be a supported type', async function () { - const result = new AccessDenied(); - // eslint-disable-next-line no-restricted-syntax - for (const request of [{}, [], 0, 1, true, false, new Set(), new Error()]) { - // eslint-disable-next-line no-await-in-loop - await assert.rejects(this.provider.backchannelResult(request, result), { name: 'TypeError', message: 'invalid "request" argument' }); - } - }); + describe('Provider.prototype.backchannelResult', () => { + it('"request" can be a string (BackchannelAuthenticationRequest jti)', async function () { + const result = new AccessDenied(); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client' }); + await request.save(); + await this.provider.backchannelResult(request.jti, result); + return assert.rejects(this.provider.backchannelResult('notfound', result), { name: 'Error', message: 'BackchannelAuthenticationRequest not found' }); + }); - it('"result" can be a string (Grant jti)', async function () { - const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); - await result.save(); - await this.provider.backchannelResult(request, result.jti); - return assert.rejects(this.provider.backchannelResult(request, 'notfound'), { name: 'Error', message: 'Grant not found' }); - }); + it('"request" can be a BackchannelAuthenticationRequest instance', async function () { + const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); + await request.save(); + return this.provider.backchannelResult(request, result); + }); - it('"result" must be a supported type', async function () { - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client' }); - // eslint-disable-next-line no-restricted-syntax - for (const result of [{}, [], 0, 1, true, false, new Set(), new Error()]) { + it('"request" must be a supported type', async function () { + const result = new AccessDenied(); + // eslint-disable-next-line no-restricted-syntax + for (const request of [{}, [], 0, 1, true, false, new Set(), new Error()]) { // eslint-disable-next-line no-await-in-loop - await assert.rejects(this.provider.backchannelResult(request, result), { name: 'TypeError', message: 'invalid "result" argument' }); - } - }); - - it('request.clientId must be a valid client', async function () { - const result = new AccessDenied(); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'notfound' }); - return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'Client not found' }); - }); - - it('request.clientId must match result.clientId', async function () { - const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId' }); - return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'client mismatch' }); - }); - - it('request.accountId must match result.accountId', async function () { - const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId-2' }); - return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'accountId mismatch' }); - }); - - it('saves the "request"', async function () { - const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); - expect(request.jti).not.to.be.ok; - await this.provider.backchannelResult(request, result); - expect(request.jti).to.be.ok; - }); - - it('pings the client (204)', async function () { - const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); - nock('https://rp.example.com/') - .post('/ping') - .reply(204); - await this.provider.backchannelResult(request, result); - }); - - it('pings the client (200)', async function () { - const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); - nock('https://rp.example.com/') - .post('/ping') - .reply(200); - await this.provider.backchannelResult(request, result); - }); - - it('pings the client (400)', async function () { - const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); - const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); - nock('https://rp.example.com/') - .post('/ping') - .reply(400); - return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'expected 204 No Content from https://rp.example.com/ping, got: 400 Bad Request' }); - }); - }); + await assert.rejects(this.provider.backchannelResult(request, result), { name: 'TypeError', message: 'invalid "request" argument' }); + } + }); - describe('backchannel_authentication_endpoint', () => { - const route = '/backchannel'; + it('"result" can be a string (Grant jti)', async function () { + const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); + await result.save(); + await this.provider.backchannelResult(request, result.jti); + return assert.rejects(this.provider.backchannelResult(request, 'notfound'), { name: 'Error', message: 'Grant not found' }); + }); - it('minimal w/ login_hint', async function () { - const [, [, request, account, client]] = await Promise.all([ - this.agent.post(route) - .send({ - scope: 'openid', - login_hint: 'accountId', - client_id: 'client', - unrecognized: true, - }) - .type('form') - .expect(200) - .expect('content-type', /application\/json/) - .expect((response) => { - expect(response.body).to.have.keys('expires_in', 'auth_req_id'); - expect(response.body.expires_in).to.be.a('number'); - expect(response.body.auth_req_id).to.be.a('string'); - }), - once(emitter, 'triggerAuthenticationDevice'), - once(emitter, 'processLoginHint'), - once(emitter, 'validateBindingMessage'), - once(emitter, 'validateRequestContext'), - once(emitter, 'verifyUserCode'), - ]); - - expect(request.accountId).to.eql(account.accountId); - expect(request.clientId).to.eql(client.clientId); - expect(request.resource).to.be.undefined; - expect(request.claims).to.deep.eql({}); - expect(request.nonce).to.be.undefined; - expect(request.scope).to.be.eql('openid'); - expect(request.params).to.deep.eql({ client_id: 'client', login_hint: 'accountId', scope: 'openid' }); - }); + it('"result" must be a supported type', async function () { + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client' }); + // eslint-disable-next-line no-restricted-syntax + for (const result of [{}, [], 0, 1, true, false, new Set(), new Error()]) { + // eslint-disable-next-line no-await-in-loop + await assert.rejects(this.provider.backchannelResult(request, result), { name: 'TypeError', message: 'invalid "result" argument' }); + } + }); - it('minimal w/ login_hint_token', async function () { - const [, [, request, account, client]] = await Promise.all([ - this.agent.post(route) - .send({ - scope: 'openid', - login_hint_token: 'accountId', - client_id: 'client', - unrecognized: true, - }) - .type('form') - .expect(200) - .expect('content-type', /application\/json/) - .expect((response) => { - expect(response.body).to.have.keys('expires_in', 'auth_req_id'); - expect(response.body.expires_in).to.be.a('number'); - expect(response.body.auth_req_id).to.be.a('string'); - }), - once(emitter, 'triggerAuthenticationDevice'), - once(emitter, 'processLoginHintToken'), - once(emitter, 'validateBindingMessage'), - once(emitter, 'validateRequestContext'), - once(emitter, 'verifyUserCode'), - ]); - - expect(request.accountId).to.eql(account.accountId); - expect(request.clientId).to.eql(client.clientId); - expect(request.resource).to.be.undefined; - expect(request.claims).to.deep.eql({}); - expect(request.nonce).to.be.undefined; - expect(request.scope).to.be.eql('openid'); - expect(request.params).to.deep.eql({ client_id: 'client', login_hint_token: 'accountId', scope: 'openid' }); - }); + it('request.clientId must be a valid client', async function () { + const result = new AccessDenied(); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'notfound' }); + return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'Client not found' }); + }); - it('minimal w/ id_token_hint', async function () { - const [, [, request]] = await Promise.all([ - this.agent.post(route) - .send({ - scope: 'openid', - login_hint_token: 'accountId', - client_id: 'client', - unrecognized: true, - }) - .type('form') - .expect(200) - .expect('content-type', /application\/json/) - .expect((response) => { - expect(response.body).to.have.keys('expires_in', 'auth_req_id'); - expect(response.body.expires_in).to.be.a('number'); - expect(response.body.auth_req_id).to.be.a('string'); - }), - once(emitter, 'triggerAuthenticationDevice'), - ]); - const grant = new this.provider.Grant({ accountId: 'accountId', clientId: 'client' }); - grant.addOIDCScope('openid'); - await grant.save(); - await this.provider.backchannelResult(request, grant); - - const { body: { id_token } } = await this.agent.post('/token') - .send({ - client_id: 'client', - grant_type: 'urn:openid:params:grant-type:ciba', - auth_req_id: request.jti, - }) - .type('form') - .expect(200); - - const [, [, request2, account, client]] = await Promise.all([ - this.agent.post(route) - .send({ - scope: 'openid', - id_token_hint: id_token, - client_id: 'client', - unrecognized: true, - }) - .type('form') - .expect(200) - .expect('content-type', /application\/json/) - .expect((response) => { - expect(response.body).to.have.keys('expires_in', 'auth_req_id'); - expect(response.body.expires_in).to.be.a('number'); - expect(response.body.auth_req_id).to.be.a('string'); - }), - once(emitter, 'triggerAuthenticationDevice'), - once(emitter, 'validateBindingMessage'), - once(emitter, 'validateRequestContext'), - once(emitter, 'verifyUserCode'), - ]); - - expect(request2.accountId).to.eql(account.accountId); - expect(request2.clientId).to.eql(client.clientId); - expect(request2.resource).to.be.undefined; - expect(request2.claims).to.deep.eql({}); - expect(request2.nonce).to.be.undefined; - expect(request2.scope).to.be.eql('openid'); - expect(request2.params).to.deep.eql({ client_id: 'client', id_token_hint: id_token, scope: 'openid' }); - }); + it('request.clientId must match result.clientId', async function () { + const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId' }); + return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'client mismatch' }); + }); - describe('client validation', () => { - it('only responds to clients with urn:openid:params:grant-type:ciba enabled', function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('request.accountId must match result.accountId', async function () { + const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId-2' }); + return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'accountId mismatch' }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-not-allowed', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect({ - error: 'unauthorized_client', - error_description: 'urn:openid:params:grant-type:ciba is not allowed for this client', - }) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }); + it('saves the "request"', async function () { + const result = new this.provider.Grant({ clientId: 'client', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client', accountId: 'accountId' }); + expect(request.jti).not.to.be.ok; + await this.provider.backchannelResult(request, result); + expect(request.jti).to.be.ok; }); - it('rejects invalid clients', function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('pings the client (204)', async function () { + const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); + nock('https://rp.example.com/') + .post('/ping') + .reply(204); + await this.provider.backchannelResult(request, result); + }); - return this.agent.post(route) - .send({ - client_id: 'not-found-client', - }) - .type('form') - .expect(401) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_client', - error_description: 'client authentication failed', - }); + it('pings the client (200)', async function () { + const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); + nock('https://rp.example.com/') + .post('/ping') + .reply(200); + await this.provider.backchannelResult(request, result); }); - }); - it('rejects other than application/x-www-form-urlencoded', function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); - - return this.agent.post(route) - .send({ - client_id: 'client', - }) - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'only application/x-www-form-urlencoded content-type bodies are supported on POST /backchannel', - }); + it('pings the client (400)', async function () { + const result = new this.provider.Grant({ clientId: 'client-ping', accountId: 'accountId' }); + const request = new this.provider.BackchannelAuthenticationRequest({ clientId: 'client-ping', accountId: 'accountId', params: { client_notification_token: 'foo' } }); + nock('https://rp.example.com/') + .post('/ping') + .reply(400); + return assert.rejects(this.provider.backchannelResult(request, result), { name: 'Error', message: 'expected 204 No Content from https://rp.example.com/ping, got: 400 Bad Request' }); + }); }); - describe('param validation', () => { - it('could not resolve Account', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); - - return this.agent.post(route) - .send({ - scope: 'openid', - login_hint: 'notfound', - client_id: 'client', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'unknown_user_id', - error_description: 'could not identify end-user', - }); + describe('backchannel_authentication_endpoint', () => { + const route = '/backchannel'; + + it('minimal w/ login_hint', async function () { + const [, [, request, account, client]] = await Promise.all([ + this.agent.post(route) + .send({ + scope: 'openid', + login_hint: 'accountId', + client_id: 'client', + unrecognized: true, + }) + .type('form') + .expect(200) + .expect('content-type', /application\/json/) + .expect((response) => { + expect(response.body).to.have.keys('expires_in', 'auth_req_id'); + expect(response.body.expires_in).to.be.a('number'); + expect(response.body.auth_req_id).to.be.a('string'); + }), + once(emitter, 'triggerAuthenticationDevice'), + once(emitter, 'processLoginHint'), + once(emitter, 'validateBindingMessage'), + once(emitter, 'validateRequestContext'), + once(emitter, 'verifyUserCode'), + ]); + + expect(request.accountId).to.eql(account.accountId); + expect(request.clientId).to.eql(client.clientId); + expect(request.resource).to.be.undefined; + expect(request.claims).to.deep.eql({}); + expect(request.nonce).to.be.undefined; + expect(request.scope).to.be.eql('openid'); + expect(request.params).to.deep.eql({ client_id: 'client', login_hint: 'accountId', scope: 'openid' }); }); - it('could not resolve account identifier', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); - - return this.agent.post(route) - .send({ - scope: 'openid', - login_hint_token: 'notfound', - client_id: 'client', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'unknown_user_id', - error_description: 'could not identify end-user', - }); + it('minimal w/ login_hint_token', async function () { + const [, [, request, account, client]] = await Promise.all([ + this.agent.post(route) + .send({ + scope: 'openid', + login_hint_token: 'accountId', + client_id: 'client', + unrecognized: true, + }) + .type('form') + .expect(200) + .expect('content-type', /application\/json/) + .expect((response) => { + expect(response.body).to.have.keys('expires_in', 'auth_req_id'); + expect(response.body.expires_in).to.be.a('number'); + expect(response.body.auth_req_id).to.be.a('string'); + }), + once(emitter, 'triggerAuthenticationDevice'), + once(emitter, 'processLoginHintToken'), + once(emitter, 'validateBindingMessage'), + once(emitter, 'validateRequestContext'), + once(emitter, 'verifyUserCode'), + ]); + + expect(request.accountId).to.eql(account.accountId); + expect(request.clientId).to.eql(client.clientId); + expect(request.resource).to.be.undefined; + expect(request.claims).to.deep.eql({}); + expect(request.nonce).to.be.undefined; + expect(request.scope).to.be.eql('openid'); + expect(request.params).to.deep.eql({ client_id: 'client', login_hint_token: 'accountId', scope: 'openid' }); }); - it('requires the scope param', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); - - return this.agent.post(route) + it('minimal w/ id_token_hint', async function () { + const [, [, request]] = await Promise.all([ + this.agent.post(route) + .send({ + scope: 'openid', + login_hint_token: 'accountId', + client_id: 'client', + unrecognized: true, + }) + .type('form') + .expect(200) + .expect('content-type', /application\/json/) + .expect((response) => { + expect(response.body).to.have.keys('expires_in', 'auth_req_id'); + expect(response.body.expires_in).to.be.a('number'); + expect(response.body.auth_req_id).to.be.a('string'); + }), + once(emitter, 'triggerAuthenticationDevice'), + ]); + const grant = new this.provider.Grant({ accountId: 'accountId', clientId: 'client' }); + grant.addOIDCScope('openid'); + await grant.save(); + await this.provider.backchannelResult(request, grant); + + const { body: { id_token } } = await this.agent.post('/token') .send({ client_id: 'client', + grant_type: 'urn:openid:params:grant-type:ciba', + auth_req_id: request.jti, }) .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "missing required parameter 'scope'", - }); - }); - - it('requires the client_notification_token param when using ping', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + .expect(200); - return this.agent.post(route) - .send({ - client_id: 'client-ping', - scope: 'openid', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "missing required parameter 'client_notification_token'", - }); + const [, [, request2, account, client]] = await Promise.all([ + this.agent.post(route) + .send({ + scope: 'openid', + id_token_hint: id_token, + client_id: 'client', + unrecognized: true, + }) + .type('form') + .expect(200) + .expect('content-type', /application\/json/) + .expect((response) => { + expect(response.body).to.have.keys('expires_in', 'auth_req_id'); + expect(response.body.expires_in).to.be.a('number'); + expect(response.body.auth_req_id).to.be.a('string'); + }), + once(emitter, 'triggerAuthenticationDevice'), + once(emitter, 'validateBindingMessage'), + once(emitter, 'validateRequestContext'), + once(emitter, 'verifyUserCode'), + ]); + + expect(request2.accountId).to.eql(account.accountId); + expect(request2.clientId).to.eql(client.clientId); + expect(request2.resource).to.be.undefined; + expect(request2.claims).to.deep.eql({}); + expect(request2.nonce).to.be.undefined; + expect(request2.scope).to.be.eql('openid'); + expect(request2.params).to.deep.eql({ client_id: 'client', id_token_hint: id_token, scope: 'openid' }); }); - it('requires the scope param with openid', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + describe('client validation', () => { + it('only responds to clients with urn:openid:params:grant-type:ciba enabled', function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-not-allowed', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect({ + error: 'unauthorized_client', + error_description: 'urn:openid:params:grant-type:ciba is not allowed for this client', + }) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client', - scope: 'foo', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'openid scope must be requested for this request', - }); + it('rejects invalid clients', function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'not-found-client', + }) + .type('form') + .expect(401) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_client', + error_description: 'client authentication failed', + }); + }); }); - it('validates requested_expiry', async function () { + it('rejects other than application/x-www-form-urlencoded', function () { const spy = sinon.spy(); this.provider.once('backchannel_authentication.error', spy); return this.agent.post(route) .send({ client_id: 'client', - scope: 'openid', - requested_expiry: 0, }) - .type('form') .expect(400) .expect('content-type', /application\/json/) .expect(() => { @@ -444,263 +316,436 @@ describe('configuration features.ciba', () => { }) .expect({ error: 'invalid_request', - error_description: 'invalid requested_expiry parameter value', + error_description: 'only application/x-www-form-urlencoded content-type bodies are supported on POST /backchannel', }); }); - it('validates one of the hints is provided', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); - - return this.agent.post(route) - .send({ - client_id: 'client', - scope: 'openid', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'missing one of required parameters login_hint_token, id_token_hint, or login_hint', - }); - }); + describe('param validation', () => { + it('could not resolve Account', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + scope: 'openid', + login_hint: 'notfound', + client_id: 'client', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'unknown_user_id', + error_description: 'could not identify end-user', + }); + }); - it('validates exactly one of the hints is provided', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('could not resolve account identifier', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + scope: 'openid', + login_hint_token: 'notfound', + client_id: 'client', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'unknown_user_id', + error_description: 'could not identify end-user', + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client', - scope: 'openid', - login_hint_token: 'foo', - login_hint: 'foo', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'only one of required parameters login_hint_token, id_token_hint, or login_hint must be provided', - }); - }); + it('requires the scope param', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "missing required parameter 'scope'", + }); + }); - it('validates request object is used', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('requires the client_notification_token param when using ping', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-ping', + scope: 'openid', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "missing required parameter 'client_notification_token'", + }); + }); - await this.agent.post(route) - .send({ - client_id: 'client-signed', - scope: 'openid', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'Request Object must be used by this client', - }); - const jwk = await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }); + it('requires the scope param with openid', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + scope: 'foo', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'openid scope must be requested for this request', + }); + }); - nock('https://rp.example.com/') - .get('/jwks') - .reply(200, { keys: [jwk.toJWK(false)] }); + it('validates requested_expiry', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + scope: 'openid', + requested_expiry: 0, + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'invalid requested_expiry parameter value', + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - request: jose.JWT.sign( - { - client_id: 'client-signed', - scope: 'openid', - jti: 'foo', - login_hint: 'accountId', - }, - jwk, - { - expiresIn: '5m', - notBefore: '0s', - issuer: 'client-signed', - audience: this.provider.issuer, - }, - ), - }) - .type('form') - .expect(200); - }); + it('validates one of the hints is provided', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + scope: 'openid', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'missing one of required parameters login_hint_token, id_token_hint, or login_hint', + }); + }); - it('validates request object claims are present (exp)', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('validates exactly one of the hints is provided', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + scope: 'openid', + login_hint_token: 'foo', + login_hint: 'foo', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'only one of required parameters login_hint_token, id_token_hint, or login_hint must be provided', + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - request: jose.JWT.sign( - { - client_id: 'client-signed', - scope: 'openid', - jti: 'foo', - login_hint: 'accountId', - }, - await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), - { - // expiresIn: '5m', - notBefore: '0s', - issuer: 'client-signed', - audience: this.provider.issuer, - }, - ), - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "Request Object is missing the 'exp' claim", - }); + it('request_not_supported', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client', + request: 'this.should.be.a.jwt', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'request_not_supported', + error_description: 'request parameter provided but not supported', + }); + }); }); + }); + }); - it('validates request object claims are present (nbf)', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + context('with request objects', () => { + before(bootstrap(import.meta.url, { config: 'ciba_jar' })); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - request: jose.JWT.sign( - { - client_id: 'client-signed', - scope: 'openid', - jti: 'foo', - login_hint: 'accountId', - }, - await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), - { - expiresIn: '5m', - // notBefore: '0s', - issuer: 'client-signed', - audience: this.provider.issuer, - }, - ), - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "Request Object is missing the 'nbf' claim", - }); - }); + afterEach(() => { + expect(nock.isDone()).to.be.true; + }); - it('validates request object claims are present (jti)', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('extends discovery', function () { + return this.agent.get('/.well-known/openid-configuration') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('backchannel_authentication_request_signing_alg_values_supported').not.contains('HS256'); + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - request: jose.JWT.sign( - { - client_id: 'client-signed', - scope: 'openid', - // jti: 'foo', - login_hint: 'accountId', - }, - await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), - { - expiresIn: '5m', - notBefore: '0s', - issuer: 'client-signed', - audience: this.provider.issuer, - }, - ), - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "Request Object is missing the 'jti' claim", - }); - }); + describe('backchannel_authentication_endpoint', () => { + const route = '/backchannel'; + + describe('param validation', () => { + it('validates request object is used', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + await this.agent.post(route) + .send({ + client_id: 'client-signed', + scope: 'openid', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'Request Object must be used by this client', + }); + const jwk = await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }); + + nock('https://rp.example.com/') + .get('/jwks') + .reply(200, { keys: [jwk.toJWK(false)] }); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + request: jose.JWT.sign( + { + client_id: 'client-signed', + scope: 'openid', + jti: 'foo', + login_hint: 'accountId', + }, + jwk, + { + expiresIn: '5m', + notBefore: '0s', + issuer: 'client-signed', + audience: this.provider.issuer, + }, + ), + }) + .type('form') + .expect(200); + }); - it('validates request object claims are present (iat)', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('validates request object claims are present (exp)', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + request: jose.JWT.sign( + { + client_id: 'client-signed', + scope: 'openid', + jti: 'foo', + login_hint: 'accountId', + }, + await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), + { + // expiresIn: '5m', + notBefore: '0s', + issuer: 'client-signed', + audience: this.provider.issuer, + }, + ), + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "Request Object is missing the 'exp' claim", + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - request: jose.JWT.sign( - { - client_id: 'client-signed', - scope: 'openid', - jti: 'foo', - login_hint: 'accountId', - }, - await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), - { - expiresIn: '5m', - notBefore: '0s', - iat: false, - issuer: 'client-signed', - audience: this.provider.issuer, - }, - ), - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: "Request Object is missing the 'iat' claim", - }); - }); + it('validates request object claims are present (nbf)', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + request: jose.JWT.sign( + { + client_id: 'client-signed', + scope: 'openid', + jti: 'foo', + login_hint: 'accountId', + }, + await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), + { + expiresIn: '5m', + // notBefore: '0s', + issuer: 'client-signed', + audience: this.provider.issuer, + }, + ), + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "Request Object is missing the 'nbf' claim", + }); + }); - it('validates Encrypted Request Objects are not used', async function () { - const spy = sinon.spy(); - this.provider.once('backchannel_authentication.error', spy); + it('validates request object claims are present (jti)', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + request: jose.JWT.sign( + { + client_id: 'client-signed', + scope: 'openid', + // jti: 'foo', + login_hint: 'accountId', + }, + await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), + { + expiresIn: '5m', + notBefore: '0s', + issuer: 'client-signed', + audience: this.provider.issuer, + }, + ), + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "Request Object is missing the 'jti' claim", + }); + }); - return this.agent.post(route) - .send({ - client_id: 'client-signed', - scope: 'openid', - request: '....', - }) - .type('form') - .expect(400) - .expect('content-type', /application\/json/) - .expect(() => { - expect(spy.calledOnce).to.be.true; - }) - .expect({ - error: 'invalid_request', - error_description: 'Encrypted Request Objects are not supported by CIBA', - }); + it('validates request object claims are present (iat)', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + request: jose.JWT.sign( + { + client_id: 'client-signed', + scope: 'openid', + jti: 'foo', + login_hint: 'accountId', + }, + await jose.JWK.generate('EC', 'P-256', { alg: 'ES256' }), + { + expiresIn: '5m', + notBefore: '0s', + iat: false, + issuer: 'client-signed', + audience: this.provider.issuer, + }, + ), + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: "Request Object is missing the 'iat' claim", + }); + }); + + it('validates Encrypted Request Objects are not used', async function () { + const spy = sinon.spy(); + this.provider.once('backchannel_authentication.error', spy); + + return this.agent.post(route) + .send({ + client_id: 'client-signed', + scope: 'openid', + request: '....', + }) + .type('form') + .expect(400) + .expect('content-type', /application\/json/) + .expect(() => { + expect(spy.calledOnce).to.be.true; + }) + .expect({ + error: 'invalid_request', + error_description: 'Encrypted Request Objects are not supported by CIBA', + }); + }); }); }); }); diff --git a/test/ciba/ciba_jar.config.js b/test/ciba/ciba_jar.config.js new file mode 100644 index 000000000..80a9607d7 --- /dev/null +++ b/test/ciba/ciba_jar.config.js @@ -0,0 +1,8 @@ +import cloneDeep from 'lodash/cloneDeep.js'; +import merge from 'lodash/merge.js'; + +import config from './ciba.config.js'; + +export default merge(cloneDeep(config), { + config: { features: { requestObjects: { request: true } } }, +}); diff --git a/test/configuration/client_metadata.test.js b/test/configuration/client_metadata.test.js index 216309f96..f4dceab87 100644 --- a/test/configuration/client_metadata.test.js +++ b/test/configuration/client_metadata.test.js @@ -468,12 +468,6 @@ describe('Client metadata validation', () => { pushedAuthorizationRequests: { enabled: false }, }, }, - { - features: { - requestObjects: { requestUri: false, request: false }, - pushedAuthorizationRequests: { enabled: true }, - }, - }, ]) { mustBeString(this.title, undefined, undefined, configuration); [ @@ -1078,20 +1072,25 @@ describe('Client metadata validation', () => { }); context('backchannel_authentication_request_signing_alg', function () { - mustBeString(this.title, undefined, metadata, configuration); + const withRequestObjects = merge( + {}, + configuration, + { features: { requestObjects: { request: true } } }, + ); + mustBeString(this.title, undefined, metadata, withRequestObjects); [ 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA', ].forEach((alg) => { - allows(this.title, alg, { ...metadata, jwks: { keys: [sigKey] } }, configuration); + allows(this.title, alg, { ...metadata, jwks: { keys: [sigKey] } }, withRequestObjects); }); - rejects(this.title, 'not-an-alg', undefined, metadata, configuration); - rejects(this.title, 'none', undefined, metadata, configuration); - rejects(this.title, 'none', undefined, metadata, configuration); - rejects(this.title, 'HS256', undefined, metadata, configuration); - rejects(this.title, 'HS384', undefined, metadata, configuration); - rejects(this.title, 'HS512', undefined, metadata, configuration); - defaultsTo(this.title, undefined, undefined, configuration); + rejects(this.title, 'not-an-alg', undefined, metadata, withRequestObjects); + rejects(this.title, 'none', undefined, metadata, withRequestObjects); + rejects(this.title, 'none', undefined, metadata, withRequestObjects); + rejects(this.title, 'HS256', undefined, metadata, withRequestObjects); + rejects(this.title, 'HS384', undefined, metadata, withRequestObjects); + rejects(this.title, 'HS512', undefined, metadata, withRequestObjects); + defaultsTo(this.title, undefined, undefined, withRequestObjects); }); allows('subject_type', 'pairwise', { diff --git a/test/dpop/dpop.config.js b/test/dpop/dpop.config.js index 5f1329c57..5b35d3656 100644 --- a/test/dpop/dpop.config.js +++ b/test/dpop/dpop.config.js @@ -11,6 +11,7 @@ merge(config.features, { introspection: { enabled: true }, deviceFlow: { enabled: true }, pushedAuthorizationRequests: { enabled: true }, + requestObjects: { request: true }, ciba: { enabled: true, processLoginHint(ctx, loginHint) { diff --git a/test/pushed_authorization_requests/pushed_authorization_requests.test.js b/test/pushed_authorization_requests/pushed_authorization_requests.test.js index f6b488f2a..8287151c3 100644 --- a/test/pushed_authorization_requests/pushed_authorization_requests.test.js +++ b/test/pushed_authorization_requests/pushed_authorization_requests.test.js @@ -7,546 +7,605 @@ import * as JWT from '../../lib/helpers/jwt.js'; import bootstrap from '../test_helper.js'; describe('Pushed Request Object', () => { - before(bootstrap(import.meta.url)); + context('w/o Request Objects', () => { + before(bootstrap(import.meta.url)); - before(async function () { - const client = await this.provider.Client.find('client'); - this.key = await importJWK(client.symmetricKeyStore.selectForSign({ alg: 'HS256' })[0]); - }); - - describe('discovery', () => { - 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).to.have.deep.property('request_object_signing_alg_values_supported').with.not.lengthOf(0); - expect(response.body).to.have.property('request_parameter_supported', false); - expect(response.body).to.have.property('request_uri_parameter_supported', false); - 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).to.have.property('require_pushed_authorization_requests', true); - }); - }); - - after(function () { - i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = false; + before(async function () { + const client = await this.provider.Client.find('client'); + this.key = await importJWK(client.symmetricKeyStore.selectForSign({ alg: 'HS256' })[0]); }); - }); - ['client', 'client-par-required'].forEach((clientId) => { - const requirePushedAuthorizationRequests = clientId === 'client-par-required'; - - 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)); - - JWT.sign({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }, this.key, 'HS256').then((request) => { - this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ request }) - .end(() => {}); - }); + describe('discovery', () => { + 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('request_object_signing_alg_values_supported'); + expect(response.body).to.have.property('request_parameter_supported', false); + expect(response.body).to.have.property('request_uri_parameter_supported', false); + expect(response.body).not.to.have.property('require_pushed_authorization_requests'); }); - it('stores a request object and returns a uri', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = true; - await this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }, this.key, 'HS256'), - }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in', 60); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); - }); - - expect(spy).to.have.property('calledOnce', true); + return this.agent.get('/.well-known/openid-configuration') + .expect((response) => { + expect(response.body).to.have.property('require_pushed_authorization_requests', true); }); + }); - it('uses the expiration from JWT when below MAX_TTL', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); + after(function () { + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = false; + }); + }); - await this.agent.post('/request') + ['client', 'client-par-required'].forEach((clientId) => { + const requirePushedAuthorizationRequests = clientId === 'client-par-required'; + + describe(`when require_pushed_authorization_requests=${requirePushedAuthorizationRequests}`, () => { + describe('using a JAR request parameter', () => { + it('is not enabled', async function () { + return this.agent.post('/request') .auth(clientId, 'secret') .type('form') .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }, this.key, 'HS256', { - expiresIn: 20, - }), + client_id: clientId, + request: 'this.should.be.a.jwt', }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in').to.be.closeTo(20, 1); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + .expect(400) + .expect({ + error: 'request_not_supported', + error_description: 'request parameter provided but not supported', }); - - expect(spy).to.have.property('calledOnce', true); }); + }); - it('uses MAX_TTL when the expiration from JWT is above it', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); + 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)); - await this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, - }, this.key, 'HS256', { - expiresIn: 120, - }), - }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in', 60); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); - }); - - expect(spy).to.have.property('calledOnce', true); - }); + }) + .end(() => {}); + }); - it('ignores regular parameters when passing a JAR request', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.saved', spy); + it('stores a request object and returns a uri', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.success', spy); + const spy2 = sinon.spy(); + this.provider.once('pushed_authorization_request.saved', spy2); - await this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - nonce: 'foo', - response_type: 'code token', - request: await JWT.sign({ + await this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, - }, 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('/request') - .auth('client-alg-registered', 'secret') - .type('form') - .send({ - request: await JWT.sign({ - response_type: 'code', - client_id: 'client-alg-registered', - }, this.key, 'HS384'), - }) - .expect(400) - .expect({ - error: 'invalid_request_object', - error_description: 'the preregistered alg must be used in request or request_uri', - }); - }); + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in', 60); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); + expect(spy2).to.have.property('calledOnce', true); + const header = decodeProtectedHeader(spy2.args[0][0].request); + expect(header).to.deep.eql({ alg: 'none' }); + const payload = decodeJwt(spy2.args[0][0].request); + expect(payload).to.contain.keys(['aud', 'exp', 'iat', 'nbf', 'iss']); + }); - it('requires the request object client_id to equal the authenticated client one', async function () { - return this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + it('forbids request_uri to be used', async function () { + return this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ 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", - }); - }); + 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', + }); + }); - it('remaps request validation errors to be related to the request object', async function () { - return this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + it('does not remap request validation errors to be related to the request object', async function () { + return this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, redirect_uri: 'https://rp.example.com/unlisted', - }, this.key, 'HS256'), - }) - .expect(400) - .expect({ - error: 'invalid_request', - error_description: "redirect_uri did not match any of the client's registered redirect_uris", - }); - }); + }) + .expect(400) + .expect({ + error: 'invalid_request', + error_description: "redirect_uri did not match any of the client's registered redirect_uris", + }); + }); - 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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + 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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, - }, this.key, 'HS256'), - }) - .expect(() => { - this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); - }) - .expect(500) - .expect({ - error: 'server_error', - error_description: 'oops! something went wrong', - }); + }) + .expect(() => { + this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); + }) + .expect(500) + .expect({ + error: 'server_error', + error_description: 'oops! something went wrong', + }); + }); }); - }); - describe('Using Pushed Authorization Requests', () => { - before(function () { return this.login(); }); - after(function () { return this.logout(); }); + 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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + it('allows the request_uri to be used', async function () { + const { body: { request_uri } } = await this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ scope: 'openid', response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, - }, this.key, 'HS256'), - }); - - let id = request_uri.split(':'); - id = id[id.length - 1]; + }); - expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; + let id = request_uri.split(':'); + id = id[id.length - 1]; - const auth = new this.AuthorizationRequest({ - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - state: undefined, - redirect_uri: undefined, - request_uri, - }); + expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; - await this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .expect(auth.validatePresence(['code'])); + const auth = new this.AuthorizationRequest({ + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + state: undefined, + redirect_uri: undefined, + request_uri, + }); - expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; - }); + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(303) + .expect(auth.validatePresence(['code'])); - 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', + expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; }); - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .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('allows the request_uri to be used (when request object was not used but client has request_object_signing_alg for its optional use)', async function () { + const { body: { request_uri } } = await this.agent.post('/request') + .auth('client-alg-registered', 'secret') + .type('form') + .send({ + scope: 'openid', + response_type: 'code', + client_id: 'client-alg-registered', + iss: 'client-alg-registered', + aud: this.provider.issuer, + }); - 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'); + let id = request_uri.split(':'); + id = id[id.length - 1]; - const auth = new this.AuthorizationRequest({ - client_id: undefined, - request_uri: 'urn:ietf:params:oauth:request_uri:foobar', - }); + expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; - 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'); + const auth = new this.AuthorizationRequest({ + client_id: 'client-alg-registered', + iss: 'client-alg-registered', + aud: this.provider.issuer, + 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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - request: await JWT.sign({ + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(303) + .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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ scope: 'openid', response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, - }, this.key, 'HS256'), + }); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + state: undefined, + redirect_uri: undefined, + request_uri, }); - const auth = new this.AuthorizationRequest({ - client_id: undefined, - state: undefined, - redirect_uri: undefined, - request_uri, + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(303) + .expect(auth.validatePresence(['code'])); }); - - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }) - .end(() => {}); - }); - - it('stores a request object and returns a uri', async function () { - const spy = sinon.spy(); - this.provider.once('pushed_authorization_request.success', spy); - const spy2 = sinon.spy(); - this.provider.once('pushed_authorization_request.saved', spy2); + context('with Request Objects', () => { + before(bootstrap(import.meta.url, { config: 'pushed_authorization_requests_jar' })); - await this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }) - .expect(201) - .expect(({ body }) => { - expect(body).to.have.keys('expires_in', 'request_uri'); - expect(body).to.have.property('expires_in', 60); - expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); - }); + before(async function () { + const client = await this.provider.Client.find('client'); + this.key = await importJWK(client.symmetricKeyStore.selectForSign({ alg: 'HS256' })[0]); + }); - expect(spy).to.have.property('calledOnce', true); - expect(spy2).to.have.property('calledOnce', true); - const header = decodeProtectedHeader(spy2.args[0][0].request); - expect(header).to.deep.eql({ alg: 'none' }); - const payload = decodeJwt(spy2.args[0][0].request); - expect(payload).to.contain.keys(['aud', 'exp', 'iat', 'nbf', 'iss']); + describe('discovery', () => { + 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).to.have.property('request_object_signing_alg_values_supported').with.not.lengthOf(0); + expect(response.body).to.have.property('request_parameter_supported', true); + expect(response.body).to.have.property('request_uri_parameter_supported', false); + expect(response.body).not.to.have.property('require_pushed_authorization_requests'); }); - it('forbids request_uri to be used', async function () { - return this.agent.post('/request') - .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', - }); - }); + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = true; - it('does not remap request validation errors to be related to the request object', async function () { - return this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - redirect_uri: 'https://rp.example.com/unlisted', - }) - .expect(400) - .expect({ - error: 'invalid_request', - error_description: "redirect_uri did not match any of the client's registered redirect_uris", - }); + return this.agent.get('/.well-known/openid-configuration') + .expect((response) => { + expect(response.body).to.have.property('require_pushed_authorization_requests', 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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - response_type: 'code', - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - }) - .expect(() => { - this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); - }) - .expect(500) - .expect({ - error: 'server_error', - error_description: 'oops! something went wrong', - }); - }); - }); + after(function () { + i(this.provider).configuration('features.pushedAuthorizationRequests').requirePushedAuthorizationRequests = false; + }); + }); - describe('Using Pushed Authorization Requests', () => { - before(function () { return this.login(); }); - after(function () { return this.logout(); }); + ['client', 'client-par-required'].forEach((clientId) => { + const requirePushedAuthorizationRequests = clientId === 'client-par-required'; - it('allows the request_uri to be used', async function () { - const { body: { request_uri } } = await this.agent.post('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - scope: 'openid', + 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)); + + JWT.sign({ response_type: 'code', client_id: clientId, iss: clientId, aud: this.provider.issuer, + }, this.key, 'HS256').then((request) => { + this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ request }) + .end(() => {}); }); + }); - let id = request_uri.split(':'); - id = id[id.length - 1]; + 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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, this.key, 'HS256'), + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in', 60); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); + }); - expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; + it('uses the expiration from JWT when below MAX_TTL', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.success', spy); - const auth = new this.AuthorizationRequest({ - client_id: clientId, - iss: clientId, - aud: this.provider.issuer, - state: undefined, - redirect_uri: undefined, - request_uri, + await this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, this.key, 'HS256', { + expiresIn: 20, + }), + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in').to.be.closeTo(20, 1); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); }); - await this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .expect(auth.validatePresence(['code'])); + it('uses MAX_TTL when the expiration from JWT is above it', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.success', spy); - expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; - }); + await this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, this.key, 'HS256', { + expiresIn: 120, + }), + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('expires_in', 'request_uri'); + expect(body).to.have.property('expires_in', 60); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); + }); - it('allows the request_uri to be used (when request object was not used but client has request_object_signing_alg for its optional use)', async function () { - const { body: { request_uri } } = await this.agent.post('/request') - .auth('client-alg-registered', 'secret') - .type('form') - .send({ - scope: 'openid', - response_type: 'code', - client_id: 'client-alg-registered', - iss: 'client-alg-registered', - aud: this.provider.issuer, - }); + it('ignores regular parameters when passing a JAR request', async function () { + const spy = sinon.spy(); + this.provider.once('pushed_authorization_request.saved', spy); - let id = request_uri.split(':'); - id = id[id.length - 1]; + await this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + nonce: 'foo', + response_type: 'code token', + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, 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'); + }); - expect(await this.provider.PushedAuthorizationRequest.find(id)).to.be.ok; + it('requires the registered request object signing alg be used', async function () { + return this.agent.post('/request') + .auth('client-alg-registered', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client-alg-registered', + }, this.key, 'HS384'), + }) + .expect(400) + .expect({ + error: 'invalid_request_object', + error_description: 'the preregistered alg must be used in request or request_uri', + }); + }); - const auth = new this.AuthorizationRequest({ - client_id: 'client-alg-registered', - iss: 'client-alg-registered', - aud: this.provider.issuer, - state: undefined, - redirect_uri: undefined, - request_uri, + it('requires the request object client_id to equal the authenticated client one', async function () { + return this.agent.post('/request') + .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", + }); }); - await this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .expect(auth.validatePresence(['code'])); + it('remaps request validation errors to be related to the request object', async function () { + return this.agent.post('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + redirect_uri: 'https://rp.example.com/unlisted', + }, this.key, 'HS256'), + }) + .expect(400) + .expect({ + error: 'invalid_request', + error_description: "redirect_uri did not match any of the client's registered redirect_uris", + }); + }); - expect(await this.provider.PushedAuthorizationRequest.find(id)).not.to.be.ok; + 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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, this.key, 'HS256'), + }) + .expect(() => { + this.TestAdapter.for('PushedAuthorizationRequest').upsert.restore(); + }) + .expect(500) + .expect({ + error: 'server_error', + error_description: 'oops! something went wrong', + }); + }); }); - 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('/request') - .auth(clientId, 'secret') - .type('form') - .send({ - scope: 'openid', - response_type: 'code', + 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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, 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, iss: clientId, aud: this.provider.issuer, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(303) + .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(303) + .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', }); - const auth = new this.AuthorizationRequest({ - client_id: undefined, - state: undefined, - redirect_uri: undefined, - request_uri, + 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'); + }); }); - return this.wrap({ route: '/auth', verb: 'get', auth }) - .expect(303) - .expect(auth.validatePresence(['code'])); + 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('/request') + .auth(clientId, 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: clientId, + iss: clientId, + aud: this.provider.issuer, + }, 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(303) + .expect(auth.validatePresence(['code'])); + }); }); }); }); diff --git a/test/pushed_authorization_requests/pushed_authorization_requests_jar.config.js b/test/pushed_authorization_requests/pushed_authorization_requests_jar.config.js new file mode 100644 index 000000000..07dc5460f --- /dev/null +++ b/test/pushed_authorization_requests/pushed_authorization_requests_jar.config.js @@ -0,0 +1,8 @@ +import cloneDeep from 'lodash/cloneDeep.js'; +import merge from 'lodash/merge.js'; + +import config from './pushed_authorization_requests.config.js'; + +export default merge(cloneDeep(config), { + config: { features: { requestObjects: { request: true } } }, +});