Skip to content

Commit

Permalink
feat: allow extraParams to define validations for extra parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Jan 17, 2024
1 parent 7a636a4 commit b7d3322
Show file tree
Hide file tree
Showing 16 changed files with 192 additions and 11 deletions.
31 changes: 30 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2268,13 +2268,42 @@ function extraClientMetadataValidator(ctx, key, value, metadata) {

### extraParams

Pass an iterable object (i.e. Array or Set of strings) to extend the parameters recognised by the authorization, device authorization, and pushed authorization request endpoints. These parameters are then available in `ctx.oidc.params` as well as passed to interaction session details.
Pass an iterable object (i.e. Array or Set of strings) to extend the parameters recognised by the authorization, device authorization, backchannel authentication, and pushed authorization request endpoints. These parameters are then available in `ctx.oidc.params` as well as passed to interaction session details.

This may also be a plain object with string properties representing parameter names and values being either a function or async function to validate said parameter value. These validators are executed regardless of the parameters' presence or value such that this can be used to validate presence of custom parameters as well as to assign default values for them. If the value is `null` or `undefined` the parameter is added without a validator. Note that these validators execute near the very end of the request's validation process and changes to (such as assigning default values) other parameters will not trigger any re-validation of the whole request again.



_**default value**_:
```js
[]
```
<a id="extra-params-registering-an-extra-origin-parameter-with-its-validator"></a><details><summary>(Click to expand) registering an extra `origin` parameter with its validator
</summary><br>

```js
import { errors } from 'oidc-provider';
const extraParams = {
async origin(ctx, value, client) {
// @param ctx - koa request context
// @param value - the `origin` parameter value (string or undefined)
// @param client - client making the request
if (hasDefaultOrigin(client)) {
// assign default
ctx.oidc.params.origin ||= value ||= getDefaultOrigin(client);
}
if (!value && requiresOrigin(ctx, client)) {
// reject when missing but required
throw new errors.InvalidRequest('"origin" is required for this request')
}
if (!allowedOrigin(value, client)) {
// reject when not allowed
throw new errors.InvalidRequest('requested "origin" is not allowed for this client')
}
}
}
```
</details>

### extraTokenClaims

Expand Down
18 changes: 18 additions & 0 deletions lib/actions/authorization/check_extra_params.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import instance from '../../helpers/weak_cache.js';

/*
* Executes registered extraParams validators.
*/
export default async function checkExtraParams(ctx, next) {
const { extraParamsValidations } = instance(ctx.oidc.provider).configuration();

if (!extraParamsValidations) {
return next();
}

for (const [param, validator] of extraParamsValidations) {
await validator(ctx, ctx.oidc.params[param], ctx.oidc.client);
}

return next();
}
3 changes: 3 additions & 0 deletions lib/actions/authorization/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import checkRequestedExpiry from './check_requested_expiry.js';
import backchannelRequestResponse from './backchannel_request_response.js';
import checkCibaContext from './check_ciba_context.js';
import checkDpopJkt from './check_dpop_jkt.js';
import checkExtraParams from './check_extra_params.js';

const A = 'authorization';
const R = 'resume';
Expand Down Expand Up @@ -92,6 +93,7 @@ export default function authorizationAction(provider, endpoint) {
}

extraParams.forEach(Set.prototype.add.bind(allowList));

if ([DA, CV, DR, BA].includes(endpoint)) {
allowList.delete('web_message_uri');
allowList.delete('response_type');
Expand Down Expand Up @@ -172,6 +174,7 @@ export default function authorizationAction(provider, endpoint) {
use(() => checkCibaContext, BA);
use(() => checkIdTokenHint, A, DA, PAR );
use(() => checkDpopJkt, PAR );
use(() => checkExtraParams, A, DA, PAR, BA);
use(() => interactionEmit, A, R, CV, DR );
use(() => assignClaims, A, R, CV, DR, BA);
use(() => cibaLoadAccount, BA);
Expand Down
21 changes: 21 additions & 0 deletions lib/helpers/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Configuration {

this.logDraftNotice();

this.registerExtraParamsValidations();
this.ensureSets();

this.checkResponseTypes();
Expand Down Expand Up @@ -82,6 +83,26 @@ class Configuration {
}
}

registerExtraParamsValidations() {
if (!isPlainObject(this.extraParams)) {
return;
}

this.extraParamsValidations = Object.entries(this.extraParams).map(([key, value]) => {
if (value == null) {
return undefined;
}

if (typeof value !== 'function' || !['Function', 'AsyncFunction'].includes(value.constructor.name)) {
throw new TypeError(`invalid extraParams.${key} type, it must be a function, null, or undefined`);
}

return [key, value];
}).filter(Boolean);

this.extraParams = new Set(Object.keys(this.extraParams));
}

ensureSets() {
[
'scopes', 'subjectTypes', 'extraParams', 'acrValues', 'clientAuthMethods', 'features.ciba.deliveryModes',
Expand Down
44 changes: 41 additions & 3 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -868,9 +868,47 @@ function makeDefaults() {
* extraParams
*
* description: Pass an iterable object (i.e. array or Set of strings) to extend the parameters
* recognised by the authorization, device authorization, and pushed authorization request
* endpoints. These parameters are then available in `ctx.oidc.params` as well as passed to
* interaction session details.
* recognised by the authorization, device authorization, backchannel authentication, and
* pushed authorization request endpoints. These parameters are then available in `ctx.oidc.params`
* as well as passed to interaction session details.
*
*
* This may also be a plain object with string properties representing parameter names and values being
* either a function or async function to validate said parameter value. These validators are executed
* regardless of the parameters' presence or value such that this can be used to validate presence of
* custom parameters as well as to assign default values for them. If the value is `null` or
* `undefined` the parameter is added without a validator. Note that these validators execute near the very end
* of the request's validation process and changes to (such as assigning default values) other parameters
* will not trigger any re-validation of the whole request again.
*
* example: registering an extra `origin` parameter with its validator
*
* ```js
* import { errors } from 'oidc-provider';
*
* const extraParams = {
* async origin(ctx, value, client) {
* // @param ctx - koa request context
* // @param value - the `origin` parameter value (string or undefined)
* // @param client - client making the request
*
* if (hasDefaultOrigin(client)) {
* // assign default
* ctx.oidc.params.origin ||= value ||= getDefaultOrigin(client);
* }
*
* if (!value && requiresOrigin(ctx, client)) {
* // reject when missing but required
* throw new errors.InvalidRequest('"origin" is required for this request')
* }
*
* if (!allowedOrigin(value, client)) {
* // reject when not allowed
* throw new errors.InvalidRequest('requested "origin" is not allowed for this client')
* }
* }
* }
* ```
*/
extraParams: [],

Expand Down
1 change: 1 addition & 0 deletions recipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ If you or your company use this module, or you need help using/upgrading the mod

- [Accepting Dynamic OP Scope Values](dynamic_op_scope.md)
- [Allowing HTTP and/or localhost for implicit response types](implicit_http_localhost.md)
- [Applying default client scope](default_scope.md)
- [Claim configuration](claim_configuration.md)
- [Client-based CORS origins](client_based_origins.md)
- [Debugging and events](debugging-and-events.md)
Expand Down
16 changes: 16 additions & 0 deletions recipes/default_scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Applying default client scope

- built for version: ^8.0.0
- no guarantees this is bug-free, no support will be provided for this, you've been warned, you're on
your own

```js
const oidcConfiguration = {
extraParams: {
scope(ctx, value, client) {
ctx.oidc.params.scope ||= value ||= client.scope;
}
}
};
const provider = new Provider(ISSUER, oidcConfiguration); // finally, configure your provider
```
8 changes: 8 additions & 0 deletions test/ciba/ciba.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const config = getConfig();

export const emitter = new events.EventEmitter();

config.extraParams = {
extra: null,
extra2(ctx) {
if (ctx.oidc.params.login_hint) {
ctx.oidc.params.extra2 ||= 'defaulted';
}
},
};
config.features.encryption = {
enabled: true,
};
Expand Down
5 changes: 4 additions & 1 deletion test/ciba/ciba.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ describe('features.ciba', () => {
scope: 'openid',
login_hint: 'accountId',
client_id: 'client',
extra: 'provided',
unrecognized: true,
})
.type('form')
Expand All @@ -157,7 +158,9 @@ describe('features.ciba', () => {
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' });
expect(request.params).to.deep.eql({
client_id: 'client', login_hint: 'accountId', scope: 'openid', extra2: 'defaulted', extra: 'provided',
});
});

it('minimal w/ login_hint_token', async function () {
Expand Down
16 changes: 14 additions & 2 deletions test/configuration/constructor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,24 @@ describe('Provider configuration', () => {
});

describe('extraParams', () => {
it('only accepts arrays and sets', () => {
it('accepts arrays, sets, or plain objects with validators', () => {
new Provider('http://localhost:3000', { extraParams: ['foo', 'bar'] });
new Provider('http://localhost:3000', { extraParams: new Set(['foo', 'bar']) });
new Provider('http://localhost:3000', { extraParams: { foo: null } });
new Provider('http://localhost:3000', { extraParams: { foo: undefined } });
new Provider('http://localhost:3000', { extraParams: { foo() {} } });
// eslint-disable-next-line no-empty-function
new Provider('http://localhost:3000', { extraParams: { async foo() {} } });
expect(() => {
new Provider('http://localhost:3000', { extraParams: { foo: true } });
new Provider('http://localhost:3000', { extraParams: Boolean });
}).to.throw('extraParams must be an Array or Set');
expect(() => {
new Provider('http://localhost:3000', { extraParams: { foo: true } });
}).to.throw('invalid extraParams.foo type, it must be a function, null, or undefined');
expect(() => {
// eslint-disable-next-line no-empty-function
new Provider('http://localhost:3000', { extraParams: { * foo() {} } });
}).to.throw('invalid extraParams.foo type, it must be a function, null, or undefined');
});
});

Expand Down
8 changes: 7 additions & 1 deletion test/core/basic/basic.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import getConfig from '../../default.config.js';

const config = getConfig();

config.extraParams = ['triggerCustomFail'];
config.extraParams = {
triggerCustomFail: null,
extra: null,
extra2(ctx) {
ctx.oidc.params.extra2 ||= 'defaulted';
},
};
merge(config.features, {
pushedAuthorizationRequests: { enabled: false },
requestObjects: { requestUri: false, request: false },
Expand Down
5 changes: 5 additions & 0 deletions test/core/basic/code.authorization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ describe('BASIC code', () => {
it('populates ctx.oidc.entities', function (done) {
this.provider.use(this.assertOnce((ctx) => {
expect(ctx.oidc.entities).to.have.keys('AuthorizationCode', 'Grant', 'Client', 'Account', 'Session');
expect(ctx.oidc.params).to.include({
extra: 'provided',
extra2: 'defaulted',
});
}, done));

const auth = new this.AuthorizationRequest({
response_type,
scope,
extra: 'provided',
});

this.wrap({ route, verb, auth }).end(() => {});
Expand Down
1 change: 1 addition & 0 deletions test/device_code/device_authorization_endpoint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('device_authorization_endpoint', () => {
client_id: 'client',
scope: 'openid',
extra: 'included',
extra2: 'defaulted',
claims: JSON.stringify({ userinfo: { email: null } }),
redirect_uri: 'https://rp.example.com/cb/not/included',
response_mode: 'not included',
Expand Down
9 changes: 6 additions & 3 deletions test/device_code/device_code.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ merge(config.features, {
pushedAuthorizationRequests: { enabled: false },
});

config.extraParams = [
'extra',
];
config.extraParams = {
extra: null,
extra2(ctx) {
ctx.oidc.params.extra2 ||= 'defaulted';
},
};

export default {
config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import getConfig from '../default.config.js';

const config = getConfig();

config.extraParams = {
extra: null,
extra2(ctx) {
ctx.oidc.params.extra2 ||= 'defaulted';
},
};

merge(config.features, {
pushedAuthorizationRequests: {
requirePushedAuthorizationRequests: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('Pushed Request Object', () => {
response_type: 'code',
client_id: clientId,
iss: clientId,
extra: 'provided',
aud: this.provider.issuer,
})
.expect(201)
Expand All @@ -103,6 +104,10 @@ describe('Pushed Request Object', () => {
});

expect(spy).to.have.property('calledOnce', true);
expect(spy.args[0][0].oidc.params).to.include({
extra: 'provided',
extra2: 'defaulted',
});
expect(spy2).to.have.property('calledOnce', true);
const stored = spy2.args[0][0];
expect(stored).to.have.property('trusted', true);
Expand Down Expand Up @@ -313,6 +318,7 @@ describe('Pushed Request Object', () => {
jti: randomBytes(16).toString('base64url'),
response_type: 'code',
client_id: clientId,
extra: 'provided',
iss: clientId,
aud: this.provider.issuer,
}, this.key, 'HS256', {
Expand All @@ -327,6 +333,10 @@ describe('Pushed Request Object', () => {
});

expect(spy).to.have.property('calledOnce', true);
expect(spy.args[0][0].oidc.params).to.include({
extra: 'provided',
extra2: 'defaulted',
});
});

it('defaults to MAX_TTL when no expires_in is present', async function () {
Expand Down

0 comments on commit b7d3322

Please sign in to comment.