Skip to content

Commit

Permalink
feat: add response_modes client metadata allow list
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Mar 30, 2023
1 parent 5212609 commit 76f9af0
Show file tree
Hide file tree
Showing 20 changed files with 111 additions and 50 deletions.
8 changes: 8 additions & 0 deletions certification/fapi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ function jar(metadata) {
};
}

function jarm(metadata) {
return {
...metadata,
response_modes: ['jwt'],
};
}

function fapi1(metadata) {
return mtlsPoP(jar({
...metadata,
Expand Down Expand Up @@ -160,6 +167,7 @@ const adapter = (name) => {
break;
case 'messagesigning':
metadata = jar(metadata);
metadata = jarm(metadata);
break;
default:
return orig.call(this, id);
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ _**default value**_:
<a id="clients-available-metadata"></a><details><summary>(Click to expand) Available Metadata</summary><br>


application_type, client_id, client_name, client_secret, client_uri, contacts, default_acr_values, default_max_age, grant_types, id_token_signed_response_alg, initiate_login_uri, jwks, jwks_uri, logo_uri, policy_uri, post_logout_redirect_uris, redirect_uris, require_auth_time, response_types, scope, sector_identifier_uri, subject_type, token_endpoint_auth_method, tos_uri, userinfo_signed_response_alg <br/><br/>The following metadata is available but may not be recognized depending on your provider's configuration.<br/><br/> authorization_encrypted_response_alg, authorization_encrypted_response_enc, authorization_signed_response_alg, backchannel_logout_session_required, backchannel_logout_uri, id_token_encrypted_response_alg, id_token_encrypted_response_enc, introspection_encrypted_response_alg, introspection_encrypted_response_enc, introspection_signed_response_alg, request_object_encryption_alg, request_object_encryption_enc, request_object_signing_alg, request_uris, tls_client_auth_san_dns, tls_client_auth_san_email, tls_client_auth_san_ip, tls_client_auth_san_uri, tls_client_auth_subject_dn, tls_client_certificate_bound_access_tokens, token_endpoint_auth_signing_alg, userinfo_encrypted_response_alg, userinfo_encrypted_response_enc, web_message_uris
application_type, client_id, client_name, client_secret, client_uri, contacts, default_acr_values, default_max_age, grant_types, id_token_signed_response_alg, initiate_login_uri, jwks, jwks_uri, logo_uri, policy_uri, post_logout_redirect_uris, redirect_uris, require_auth_time, response_types, response_modes, scope, sector_identifier_uri, subject_type, token_endpoint_auth_method, tos_uri, userinfo_signed_response_alg <br/><br/>The following metadata is available but may not be recognized depending on your provider's configuration.<br/><br/> authorization_encrypted_response_alg, authorization_encrypted_response_enc, authorization_signed_response_alg, backchannel_logout_session_required, backchannel_logout_uri, id_token_encrypted_response_alg, id_token_encrypted_response_enc, introspection_encrypted_response_alg, introspection_encrypted_response_enc, introspection_signed_response_alg, request_object_encryption_alg, request_object_encryption_enc, request_object_signing_alg, request_uris, tls_client_auth_san_dns, tls_client_auth_san_email, tls_client_auth_san_ip, tls_client_auth_san_uri, tls_client_auth_subject_dn, tls_client_certificate_bound_access_tokens, token_endpoint_auth_signing_alg, userinfo_encrypted_response_alg, userinfo_encrypted_response_enc, web_message_uris


</details>
Expand Down
20 changes: 9 additions & 11 deletions lib/actions/authorization/check_response_mode.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InvalidRequest, UnsupportedResponseMode } from '../../helpers/errors.js';
import { InvalidRequest, UnauthorizedClient, UnsupportedResponseMode } from '../../helpers/errors.js';
import instance from '../../helpers/weak_cache.js';
import { isFrontChannel } from '../../helpers/resolve_response_mode.js';

Expand All @@ -8,7 +8,7 @@ import { isFrontChannel } from '../../helpers/resolve_response_mode.js';
*
* @throws: invalid_request
*/
export default function checkResponseMode(ctx, next, forceCheck) {
export default function checkResponseMode(ctx, next) {
const { params, client } = ctx.oidc;

const frontChannel = isFrontChannel(params.response_type);
Expand All @@ -23,6 +23,10 @@ export default function checkResponseMode(ctx, next, forceCheck) {
throw new UnsupportedResponseMode();
}

if (!ctx.oidc.client.responseModeAllowed(mode, params.response_type, ctx.oidc.fapiProfile)) {
throw new UnauthorizedClient('requested response_mode is not allowed for this client or request');
}

const JWT = /jwt/.test(mode);

if (
Expand All @@ -41,17 +45,11 @@ export default function checkResponseMode(ctx, next, forceCheck) {
}
}

const msg = 'requested response_mode is not allowed for the requested response_type';
if (mode === 'query' && frontChannel) {
throw new InvalidRequest('response_mode not allowed for this response_type');
throw new InvalidRequest(msg);
} else if (mode === 'query.jwt' && frontChannel && !client.authorizationEncryptedResponseAlg) {
throw new InvalidRequest('response_mode not allowed for this response_type unless encrypted');
}

const fapiProfile = ctx.oidc.isFapi('1.0 Final', '1.0 ID2');
if (params.response_type && fapiProfile) {
if (((!params.request && !params.request_uri) || forceCheck) && !params.response_type.includes('id_token') && !JWT) {
throw new InvalidRequest(`requested response_mode not allowed for the requested response_type in FAPI ${fapiProfile}`);
}
throw new InvalidRequest(`${msg} unless encrypted`);
}

return next();
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/authorization/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ export default function authorizationAction(provider, endpoint) {
use(() => stripOutsideJarParams, PAR, BA);
use(() => checkClient, A, DA, R, CV, DR );
use(() => checkClientGrantType, DA, BA);
use(() => checkResponseMode, A, PAR );
use(() => pushedAuthorizationRequestRemapErrors, PAR );
use(() => backchannelRequestRemapErrors, BA);
use(() => fetchRequestUri, A );
use(() => processRequestObject.bind(
undefined, allowList, rejectDupesMiddleware,
), A, DA, PAR, BA);
use(() => checkResponseMode, A, PAR );
use(() => oneRedirectUriClients, A, PAR );
use(() => oauthRequired, A, PAR );
use(() => rejectRegistration, A, DA, PAR, BA);
Expand Down
23 changes: 7 additions & 16 deletions lib/actions/authorization/process_request_object.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import isPlainObject from '../../helpers/_/is_plain_object.js';
import dpopValidate from '../../helpers/validate_dpop.js';
import epochTime from '../../helpers/epoch_time.js';

import checkResponseMode from './check_response_mode.js';

/*
* Decrypts and validates the content of provided request parameter and replaces the parameters
* provided via OAuth2.0 authorization request with these
Expand Down Expand Up @@ -107,29 +105,22 @@ export default async function processRequestObject(PARAM_LIST, rejectDupesMiddle

rejectDupesMiddleware({ oidc: { params: request } }, () => {});

if (request.state !== undefined) {
params.state = request.state;
}

const isFapi1 = ctx.oidc.isFapi('1.0 Final', '1.0 ID2');
if (request.response_mode !== undefined || isFapi1) {
if (request.response_mode !== undefined) {
params.response_mode = request.response_mode;
}
if (request.response_type !== undefined) {
params.response_type = request.response_type;
const original = {};
for (const param of ['state', 'response_mode', 'response_type']) {
original[param] = params[param];
if (request[param] !== undefined) {
params[param] = request[param];
}
checkResponseMode(ctx, () => {}, isFapi1);
}

if (request.request !== undefined || request.request_uri !== undefined) {
throw new InvalidRequestObject('Request Object must not contain request or request_uri properties');
}

if (
params.response_type
original.response_type
&& request.response_type !== undefined
&& request.response_type !== params.response_type
&& request.response_type !== original.response_type
) {
throw new InvalidRequestObject('request response_type must equal the one in request parameters');
}
Expand Down
3 changes: 3 additions & 0 deletions lib/consts/client_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const RECOGNIZED_METADATA = [
'redirect_uris',
'require_auth_time',
'response_types',
'response_modes',
'scope',
'sector_identifier_uri',
'subject_type',
Expand Down Expand Up @@ -70,6 +71,7 @@ const ARYS = [
'redirect_uris',
'request_uris',
'response_types',
'response_modes',
'web_message_uris',
];

Expand Down Expand Up @@ -121,6 +123,7 @@ const STRING = [
'redirect_uris',
'request_uris',
'response_types',
'response_modes',
'web_message_uris',
];

Expand Down
5 changes: 5 additions & 0 deletions lib/helpers/client_schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export default function getSchema(provider) {
request_object_encryption_alg: () => configuration.requestObjectEncryptionAlgValues,
request_object_encryption_enc: () => configuration.requestObjectEncryptionEncValues,
response_types: () => configuration.responseTypes,
response_modes: () => [...instance(provider).responseModes.keys()],
subject_type: () => configuration.subjectTypes,
token_endpoint_auth_method: (metadata) => {
if (metadata.subject_type === 'pairwise') {
Expand Down Expand Up @@ -253,6 +254,10 @@ export default function getSchema(provider) {
this.invalidate('redirect_uris must contain members');
}

if (this.redirect_uris.length && this.response_modes?.length === 0) {
this.invalidate('response_modes must contain members');
}

if (responseTypes.includes('code') && !this.grant_types.includes('authorization_code')) {
this.invalidate("grant_types must contain 'authorization_code' when code is amongst response_types");
}
Expand Down
4 changes: 2 additions & 2 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,8 +613,8 @@ function makeDefaults() {
* application_type, client_id, client_name, client_secret, client_uri, contacts,
* default_acr_values, default_max_age, grant_types, id_token_signed_response_alg,
* initiate_login_uri, jwks, jwks_uri, logo_uri, policy_uri, post_logout_redirect_uris,
* redirect_uris, require_auth_time, response_types, scope, sector_identifier_uri, subject_type,
* token_endpoint_auth_method, tos_uri, userinfo_signed_response_alg
* redirect_uris, require_auth_time, response_types, response_modes, scope, sector_identifier_uri,
* subject_type, token_endpoint_auth_method, tos_uri, userinfo_signed_response_alg
*
* <br/><br/>The following metadata is available but may not be recognized depending on your
* provider's configuration.<br/><br/>
Expand Down
9 changes: 9 additions & 0 deletions lib/models/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,15 @@ export default function getClient(provider) {
return this.responseTypes.includes(type);
}

// eslint-disable-next-line no-unused-vars
responseModeAllowed(responseMode, responseType, fapiProfile) {
if ((fapiProfile === '1.0 Final' || fapiProfile === '1.0 ID2') && !responseType.includes('id_token') && !responseMode.includes('jwt')) {
return false;
}

return this.responseModes?.includes(responseMode) !== false;
}

grantTypeAllowed(type) {
return this.grantTypes.includes(type);
}
Expand Down
41 changes: 40 additions & 1 deletion test/configuration/client_metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe('Client metadata validation', () => {
});
};

const defaultsTo = (prop, value, metadata, configuration) => {
const defaultsTo = (prop, value, metadata, configuration, additionalAssertion) => {
let msg = util.format('defaults to %s', value);
if (metadata) msg = util.format(`${msg}, [client %j]`, omit(metadata, ['jwks.keys']));
if (configuration) msg = util.format(`${msg}, [provider %j]`, configuration);
Expand All @@ -166,6 +166,12 @@ describe('Client metadata validation', () => {
} else {
expect(client.metadata()).to.have.property(prop).and.eql(value);
}

if (additionalAssertion) {
return additionalAssertion(client);
}

return undefined;
}));
};

Expand Down Expand Up @@ -749,6 +755,39 @@ describe('Client metadata validation', () => {
rejects(this.title, ['not-a-type', 'none']);
});

context('response_modes', function () {
defaultsTo(this.title, undefined, undefined, undefined, (client) => {
expect(client.responseModeAllowed('query')).to.be.true;
expect(client.responseModeAllowed('fragment')).to.be.true;
expect(client.responseModeAllowed('form_post')).to.be.true;
});
mustBeArray(this.title);

allows(this.title, ['query', 'fragment', 'form_post']);
allows(this.title, ['query']);
allows(this.title, ['fragment']);
allows(this.title, ['form_post']);

allows(this.title, ['query', 'fragment'], undefined, undefined, (client) => {
expect(client.responseModeAllowed('query')).to.be.true;
expect(client.responseModeAllowed('fragment')).to.be.true;
expect(client.responseModeAllowed('form_post')).to.be.false;
});

allows(this.title, ['jwt'], undefined, { features: { jwtResponseModes: { enabled: true } } });
allows(this.title, ['fragment.jwt'], undefined, { features: { jwtResponseModes: { enabled: true } } });
allows(this.title, ['query.jwt'], undefined, { features: { jwtResponseModes: { enabled: true } } });
allows(this.title, ['form_post.jwt'], undefined, { features: { jwtResponseModes: { enabled: true } } });

allows(this.title, ['web_message'], undefined, { features: { webMessageResponseMode: { enabled: true } } });
allows(this.title, ['web_message.jwt'], undefined, { features: { webMessageResponseMode: { enabled: true }, jwtResponseModes: { enabled: true } } });

rejects(this.title, [123], /must only contain strings$/);
rejects(this.title, [], /must contain members$/);
rejects(this.title, ['not-a-mode']);
rejects(this.title, ['not-a-mode']);
});

context('sector_identifier_uri', function () {
mustBeString(this.title);
// must be a valid sector uri => GOTO: pairwise_clients.test.js
Expand Down
2 changes: 1 addition & 1 deletion test/core/basic/code.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ describe('BASIC code', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});

['request', 'request_uri', 'registration'].forEach((param) => {
Expand Down
2 changes: 1 addition & 1 deletion test/core/hybrid/code+id_token+token.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe('HYBRID code+id_token+token', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});

it('missing mandatory parameter nonce', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/core/hybrid/code+id_token.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('HYBRID code+id_token', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});

it('missing mandatory parameter nonce', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/core/hybrid/code+token.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('HYBRID code+token', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion test/core/implicit/id_token+token.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe('IMPLICIT id_token+token', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});

it('missing mandatory parameter nonce', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/core/implicit/id_token.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('IMPLICIT id_token', () => {
.expect(auth.validateState)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('response_mode not allowed for this response_type'));
.expect(auth.validateErrorDescription('requested response_mode is not allowed for the requested response_type'));
});

it('HMAC ID Token Hint with expired secret errors', async function () {
Expand Down
12 changes: 8 additions & 4 deletions test/fapi/fapi-final.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
})
.expect(303)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('requested response_mode not allowed for the requested response_type in FAPI 1.0 Final'));
.expect(auth.validateError('unauthorized_client'))
.expect(auth.validateErrorDescription('requested response_mode is not allowed for this client or request'));
});

it('requires jwt response mode to be used when id token is not issued by authorization endpoint (JAR)', async function () {
Expand All @@ -66,6 +66,7 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
client_id: 'client',
response_type: 'code',
nonce: 'foo',
iss: 'client',
aud: this.provider.issuer,
exp: epochTime() + 60,
nbf: epochTime(),
Expand All @@ -84,8 +85,8 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
})
.expect(303)
.expect(auth.validateClientLocation)
.expect(auth.validateError('invalid_request'))
.expect(auth.validateErrorDescription('requested response_mode not allowed for the requested response_type in FAPI 1.0 Final'));
.expect(auth.validateError('unauthorized_client'))
.expect(auth.validateErrorDescription('requested response_mode is not allowed for this client or request'));
});
});

Expand Down Expand Up @@ -133,6 +134,7 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
aud: this.provider.issuer,
// exp: epochTime() + 60,
nbf: epochTime(),
iss: 'client',
client_id: 'client',
scope: 'openid',
response_type: 'code id_token',
Expand Down Expand Up @@ -168,6 +170,7 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
// nbf: epochTime(),
client_id: 'client',
scope: 'openid',
iss: 'client',
response_type: 'code id_token',
nonce: 'foo',
}).setProtectedHeader({ alg: 'ES256' }).sign(keypair.privateKey);
Expand Down Expand Up @@ -201,6 +204,7 @@ describe('Financial-grade API Security Profile 1.0 - Part 2: Advanced (FINAL) be
aud: this.provider.issuer,
client_id: 'client',
scope: 'openid',
iss: 'client',
response_type: 'code id_token',
nonce: 'foo',
}).setProtectedHeader({ alg: 'ES256' }).sign(keypair.privateKey);
Expand Down
Loading

0 comments on commit 76f9af0

Please sign in to comment.