Skip to content

Commit

Permalink
feat: initial and registration access token policies
Browse files Browse the repository at this point in the history
Policies are sync/async functions that are assigned to an Initial Access
Token that run before the regular client property validations are run.

Multiple policies may be assigned to an Initial Access Token and by
default the same policies will transfer over to the Registration Access
Token. A policy may throw / reject and it may modify the properties
object.

Resolves #394
  • Loading branch information
panva committed Nov 22, 2018
1 parent b0fc1c1 commit 452000c
Show file tree
Hide file tree
Showing 13 changed files with 614 additions and 21 deletions.
44 changes: 44 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,50 @@ Configure `features.registration` to be an object like so:
new (provider.InitialAccessToken)({}).save().then(console.log);
```
</details>
<details>
<summary>(Click to expand) To define registration and registration management policies</summary>
<br>


Policies are sync/async functions that are assigned to an Initial Access Token that run before the regular client property validations are run. Multiple policies may be assigned to an Initial Access Token and by default the same policies will transfer over to the Registration Access Token. A policy may throw / reject and it may modify the properties object. To define policy functions configure `features.registration` to be an object like so:


```js
{
initialAccessToken: true, // to enable adapter-backed initial access tokens
policies: {
'my-policy': function (ctx, properties) {
// @param ctx - koa request context
// @param properties - the client properties which are about to be validated
// example of setting a default
if (!('client_name' in properties)) {
properties.client_name = generateRandomClientName();
}
// example of forcing a value
properties.userinfo_signed_response_alg = 'RS256';
// example of throwing a validation error
if (someCondition(ctx, properties)) {
throw new Provider.errors.InvalidClientMetadata('validation error message');
}
},
'my-policy-2': async function (ctx, properties) {},
},
}
```
An Initial Access Token with those policies being executed (one by one in that order) is created like so


```js
new (provider.InitialAccessToken)({ policies: ['my-policy', 'my-policy-2'] }).save().then(console.log);
```
Note: referenced policies must always be present when encountered on a token, an AssertionError will be thrown inside the request context if it's not, resulting in a 500 Server Error. Note: the same policies will be assigned to the Registration Access Token after a successful validation. If you wish to assign different policies to the Registration Access Token


```js
// inside your final ran policy
ctx.oidc.entities.RegistrationAccessToken.policies = ['update-policy'];
```
</details>

### features.registrationManagement

Expand Down
31 changes: 28 additions & 3 deletions lib/actions/registration.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ module.exports = function registrationAction(provider) {
const clientId = String(idFactory());

const rat = new provider.RegistrationAccessToken({ clientId });
ctx.oidc.entity('RegistrationAccessToken', rat);

Object.assign(properties, ctx.oidc.body, {
client_id: clientId,
Expand All @@ -122,6 +123,21 @@ module.exports = function registrationAction(provider) {
});
}

if (
ctx.oidc.entities.InitialAccessToken
&& ctx.oidc.entities.InitialAccessToken.policies
) {
const { policies } = ctx.oidc.entities.InitialAccessToken;
const implementations = instance(provider).configuration('features.registration.policies');
for (const policy of policies) { // eslint-disable-line no-restricted-syntax
await implementations[policy](ctx, properties); // eslint-disable-line no-await-in-loop
}

if (!('policies' in rat)) {
rat.policies = policies;
}
}

const client = await instance(provider).clientAdd(properties, true);
ctx.oidc.entity('Client', client);

Expand All @@ -134,8 +150,6 @@ module.exports = function registrationAction(provider) {
registration_access_token: await rat.save(),
});

ctx.oidc.entity('RegistrationAccessToken', rat);

ctx.status = 201;

provider.emit('registration_create.success', client, ctx);
Expand Down Expand Up @@ -226,6 +240,14 @@ module.exports = function registrationAction(provider) {
});
}

if (ctx.oidc.entities.RegistrationAccessToken.policies) {
const { policies } = ctx.oidc.entities.RegistrationAccessToken;
const implementations = instance(provider).configuration('features.registration.policies');
for (const policy of policies) { // eslint-disable-line no-restricted-syntax
await implementations[policy](ctx, properties); // eslint-disable-line no-await-in-loop
}
}

const client = await instance(provider).clientAdd(properties, true);

ctx.body = client.metadata();
Expand All @@ -240,7 +262,10 @@ module.exports = function registrationAction(provider) {
const management = instance(provider).configuration('features.registrationManagement');
if (management.rotateRegistrationAccessToken) {
ctx.oidc.entity('RotatedRegistrationAccessToken', ctx.oidc.entities.RegistrationAccessToken);
const rat = new provider.RegistrationAccessToken({ client: ctx.oidc.client });
const rat = new provider.RegistrationAccessToken({
client: ctx.oidc.client,
policies: ctx.oidc.entities.RegistrationAccessToken.policies,
});

await ctx.oidc.registrationAccessToken.destroy();

Expand Down
7 changes: 7 additions & 0 deletions lib/helpers/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ class Configuration {
throw new Error('registrationManagement is only available in conjuction with registration');
}

if (
(this.features.registration && this.features.registration.policies)
&& !this.features.registration.initialAccessToken
) {
throw new Error('registration policies are only available in conjuction with adapter-backed initial access tokens');
}

if (this.features.deviceFlow) {
if (this.features.deviceFlow.charset !== undefined) {
if (!['base-20', 'digits'].includes(this.features.deviceFlow.charset)) {
Expand Down
51 changes: 51 additions & 0 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,57 @@ const DEFAULTS = {
* ```js
* new (provider.InitialAccessToken)({}).save().then(console.log);
* ```
*
* example: To define registration and registration management policies
* Policies are sync/async functions that are assigned to an Initial Access Token that run
* before the regular client property validations are run. Multiple policies may be assigned
* to an Initial Access Token and by default the same policies will transfer over to the
* Registration Access Token.
*
* A policy may throw / reject and it may modify the properties object.
*
* To define policy functions configure `features.registration` to be an object like so:
* ```js
* {
* initialAccessToken: true, // to enable adapter-backed initial access tokens
* policies: {
* 'my-policy': function (ctx, properties) {
* // @param ctx - koa request context
* // @param properties - the client properties which are about to be validated
*
* // example of setting a default
* if (!('client_name' in properties)) {
* properties.client_name = generateRandomClientName();
* }
*
* // example of forcing a value
* properties.userinfo_signed_response_alg = 'RS256';
*
* // example of throwing a validation error
* if (someCondition(ctx, properties)) {
* throw new Provider.errors.InvalidClientMetadata('validation error message');
* }
* },
* 'my-policy-2': async function (ctx, properties) {},
* },
* }
* ```
*
* An Initial Access Token with those policies being executed (one by one in that order) is
* created like so
* ```js
* new (provider.InitialAccessToken)({ policies: ['my-policy', 'my-policy-2'] }).save().then(console.log);
* ```
*
* Note: referenced policies must always be present when encountered on a token, an AssertionError
* will be thrown inside the request context if it's not, resulting in a 500 Server Error.
*
* Note: the same policies will be assigned to the Registration Access Token after a successful
* validation. If you wish to assign different policies to the Registration Access Token
* ```js
* // inside your final ran policy
* ctx.oidc.entities.RegistrationAccessToken.policies = ['update-policy'];
* ```
*/
registration: false,

Expand Down
16 changes: 7 additions & 9 deletions lib/models/initial_access_token.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
const apply = require('./mixins/apply');
const hasFormat = require('./mixins/has_format');
const hasPolicies = require('./mixins/has_policies');

module.exports = provider => class InitialAccessToken extends hasFormat(
provider,
'InitialAccessToken',
provider.BaseToken,
) {
module.exports = provider => class InitialAccessToken extends apply([
hasPolicies(provider),
hasFormat(provider, 'InitialAccessToken', provider.BaseToken),
]) {
static get IN_PAYLOAD() {
return [
'jti',
'kind',
];
return super.IN_PAYLOAD.filter(v => v !== 'clientId');
}
};
32 changes: 32 additions & 0 deletions lib/models/mixins/has_policies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const assert = require('assert');

const instance = require('../../helpers/weak_cache');

function validate(provider, policies) {
assert(Array.isArray(policies), 'policies must be an array');
assert(policies.length, 'policies must not be empty');
policies.forEach((policy) => {
assert(typeof policy === 'string', 'policies must be strings');
assert(instance(provider).configuration(`features.registration.policies.${policy}`), `policy ${policy} not configured`);
});
}

module.exports = provider => superclass => class extends superclass {
async save() {
if (typeof this.policies !== 'undefined') validate(provider, this.policies);
return super.save();
}

static async find(...args) {
const result = await super.find(...args);
if (result && typeof result.policies !== 'undefined') validate(provider, result.policies);
return result;
}

static get IN_PAYLOAD() {
return [
...super.IN_PAYLOAD,
'policies',
];
}
};
11 changes: 6 additions & 5 deletions lib/models/registration_access_token.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const apply = require('./mixins/apply');
const hasFormat = require('./mixins/has_format');
const hasPolicies = require('./mixins/has_policies');

module.exports = provider => class RegistrationAccessToken extends hasFormat(
provider,
'RegistrationAccessToken',
provider.BaseToken,
) {};
module.exports = provider => class RegistrationAccessToken extends apply([
hasPolicies(provider),
hasFormat(provider, 'RegistrationAccessToken', provider.BaseToken),
]) {};
17 changes: 17 additions & 0 deletions test/registration_policies/registration_policies.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { clone } = require('lodash');

const config = clone(require('../default.config'));

config.features = {
registrationManagement: true,
registration: {
initialAccessToken: true,
policies: {
'empty-policy': () => {},
},
},
};

module.exports = {
config,
};
Loading

0 comments on commit 452000c

Please sign in to comment.