Skip to content

Commit

Permalink
feat: add Resource Indicators for OAuth 2.0 - draft 00 implementation
Browse files Browse the repository at this point in the history
Based on https://tools.ietf.org/html/draft-ietf-oauth-resource-indicators-00
this feature enables the client and authorization server to more
explicitly to communicate about the protected resource(s) to be
accessed.

Enabling this feature adds the `resource` parameter to authorization
and token endpoint whitelists, validates the value(s) as per the draft
(only absolute uris, no query, no fragment).

Simply enabling the feature will not push these additional resources
as audiences to your tokens, to do that you must use the `audiences`
helper function. See the docs section for a complete example combining
the feature, audiences and dynamic access token ttl.
  • Loading branch information
panva committed Oct 3, 2018
1 parent 2b14161 commit 1bc2994
Show file tree
Hide file tree
Showing 14 changed files with 726 additions and 9 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ enabled by default, check the configuration section on how to enable them.
- [RFC8252 - OAuth 2.0 for Native Apps BCP][oauth-native-apps]

The following drafts/experimental specifications are implemented by oidc-provider.
- [OpenID Connect Session Management 1.0 - draft 28][session-management]
- [JWT Response for OAuth Token Introspection - draft 01][jwt-introspection]
- [OAuth 2.0 Device Flow for Browserless and Input Constrained Devices - draft 12][device-flow]
- [OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens - draft 11][mtls]
- [OAuth 2.0 Resource Indicators - draft 00][resource-indicators]
- [OAuth 2.0 Web Message Response Mode - draft 00][wmrm]
- [OpenID Connect Back-Channel Logout 1.0 - draft 04][backchannel-logout]
- [OpenID Connect Front-Channel Logout 1.0 - draft 02][frontchannel-logout]
- [OpenID Connect Session Management 1.0 - draft 28][session-management]
- [RFC7592 - OAuth 2.0 Dynamic Client Registration Management Protocol (Update and Delete)][registration-management]
- [OAuth 2.0 Web Message Response Mode - draft 00][wmrm]
- [OAuth 2.0 Device Flow for Browserless and Input Constrained Devices - draft 12][device-flow]
- [OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens - draft 11][mtls]
- [JWT Response for OAuth Token Introspection - draft 01][jwt-introspection]

Updates to draft and experimental specification versions are released as MINOR library versions,
if you utilize these specification implementations consider using the tilde `~` operator in your
Expand Down Expand Up @@ -176,3 +177,4 @@ See the list of available emitted [event names](/docs/events.md) and their descr
[suggest-feature]: https://github.com/panva/node-oidc-provider/issues/new?template=feature-request.md
[bug]: https://github.com/panva/node-oidc-provider/issues/new?template=bug-report.md
[mtls]: https://tools.ietf.org/html/draft-ietf-oauth-mtls-11
[resource-indicators]: https://tools.ietf.org/html/draft-ietf-oauth-resource-indicators-00
59 changes: 59 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ is a good starting point to get an idea of what you should provide.
- [features.registrationManagement](#featuresregistrationmanagement)
- [features.request](#featuresrequest)
- [features.requestUri](#featuresrequesturi)
- [features.resourceIndicators](#featuresresourceindicators)
- [features.revocation](#featuresrevocation)
- [features.sessionManagement](#featuressessionmanagement)
- [features.webMessageResponseMode](#featureswebmessageresponsemode)
Expand Down Expand Up @@ -743,6 +744,7 @@ Enable/disable features.
jwtResponseModes: false,
registration: false,
registrationManagement: false,
resourceIndicators: false,
request: false,
revocation: false,
sessionManagement: false,
Expand Down Expand Up @@ -1112,6 +1114,63 @@ Configure `features.requestUri` with an object like so instead of a Boolean valu
```
</details>

### features.resourceIndicators

[draft-ietf-oauth-resource-indicators-00](https://tools.ietf.org/html/draft-ietf-oauth-resource-indicators-00) - Resource Indicators for OAuth 2.0

Enables the use and validations of `resource` parameter for the authorization and token endpoints. In order for the feature to be any useful you must also use the `audiences` helper function to further validate/whitelist the resource(s) and push them down to issued access tokens.



_**default value**_:
```js
false
```
<details>
<summary>(Click to expand) Example use with audiences and dynamic AccessToken format</summary>
<br>


This example will
- throw when multiple resources are requested (per spec at the OPs discretion)
- throw based on an OP policy
- push resources down to the audience of access tokens


```js
// const { InvalidResource } = Provider.errors;
// resourceAllowedForClient is the custom OP policy
{
// ...
async audiences(ctx, sub, token, use) {
const { resource } = ctx.oidc.params;
if (resource && use === 'access_token') {
if (Array.isArray(resource)) {
throw new InvalidResource('multiple "resource" parameters are not allowed');
}
const { client } = ctx.oidc;
const allowed = await resourceAllowedForClient(resource, client.clientId);
if (!allowed) {
throw new InvalidResource('unauthorized "resource" requested');
}
return [resource];
}
return undefined;
},
formats: {
default: 'opaque',
AccessToken(token) {
if (Array.isArray(token.aud)) {
return 'jwt';
}
return 'opaque';
}
},
// ...
}
```
</details>

### features.revocation

[RFC7009](https://tools.ietf.org/html/rfc7009) - OAuth 2.0 Token Revocation
Expand Down
4 changes: 3 additions & 1 deletion lib/actions/authorization/decode_request.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { InvalidRequestObject } = require('../../helpers/errors');
*
* @throws: invalid_request_object
*/
module.exports = (provider, PARAM_LIST) => {
module.exports = (provider, PARAM_LIST, arrayResource) => {
const { keystore, configuration: conf } = instance(provider);

return async function decodeRequest(ctx, next) {
Expand Down Expand Up @@ -95,6 +95,8 @@ module.exports = (provider, PARAM_LIST) => {
if (PARAM_LIST.has(key)) {
if (key === 'claims' && isPlainObject(value)) {
acc[key] = JSON.stringify(value);
} else if (key === 'resource' && arrayResource && Array.isArray(value)) {
acc[key] = value;
} else if (typeof value !== 'string') {
acc[key] = String(value);
} else {
Expand Down
15 changes: 13 additions & 2 deletions lib/actions/authorization/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const paramsMiddleware = require('../../shared/assemble_params');
const sessionMiddleware = require('../../shared/session');
const instance = require('../../helpers/weak_cache');
const { PARAM_LIST } = require('../../consts');
const getCheckResource = require('../../shared/check_resource');

const checkClient = require('./check_client');
const checkResponseMode = require('./check_response_mode');
Expand Down Expand Up @@ -50,6 +51,7 @@ module.exports = function authorizationAction(provider, endpoint) {
const {
features: {
claimsParameter,
resourceIndicators,
pkce,
webMessageResponseMode,
},
Expand All @@ -72,6 +74,14 @@ module.exports = function authorizationAction(provider, endpoint) {
whitelist.add('claims');
}

let rejectDupesMiddleware = rejectDupes;
let resource = false;
if (endpoint === A && resourceIndicators) {
resource = true;
whitelist.add('resource');
rejectDupesMiddleware = rejectDupes.except.bind(undefined, new Set(['resource']));
}

extraParams.forEach(Set.prototype.add.bind(whitelist));
if ([DA, CV, DR].includes(endpoint)) {
whitelist.delete('response_type');
Expand Down Expand Up @@ -99,21 +109,22 @@ module.exports = function authorizationAction(provider, endpoint) {
use(() => rejectDupes.only(clientIdSet), A, DA, R, CV, DR);
use(() => checkClient(provider), A, DA, R, CV, DR);
use(() => oneRedirectUriClients, A );
use(() => rejectDupes, A, DA );
use(() => rejectDupesMiddleware, A, DA );
use(() => checkClientGrantType, DA );
use(() => checkResponseMode(provider), A );
use(() => throwNotSupported(provider), A, DA );
use(() => deviceCheckParams, DA );
use(() => oauthRequired, A );
use(() => checkOpenidPresent, A );
use(() => fetchRequestUri(provider), A, DA );
use(() => decodeRequest(provider, whitelist), A, DA );
use(() => decodeRequest(provider, whitelist, resource), A, DA );
use(() => oidcRequired, A );
use(() => checkPrompt(provider), A, DA );
use(() => checkResponseType(provider), A );
use(() => checkScope(provider, whitelist), A, DA );
use(() => checkRedirectUri, A );
use(() => checkWebMessageUri(provider), A );
use(() => getCheckResource(provider), A );
use(() => checkPixy(provider), A, DA );
use(() => assignDefaults, A, DA );
use(() => checkClaims(provider), A, DA );
Expand Down
3 changes: 3 additions & 0 deletions lib/actions/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const getTokenAuth = require('../shared/token_auth');
const bodyParser = require('../shared/selective_body');
const rejectDupes = require('../shared/reject_dupes');
const getParams = require('../shared/assemble_params');
const getCheckResource = require('../shared/check_resource');

const grantTypeSet = new Set(['grant_type']);

Expand Down Expand Up @@ -40,6 +41,8 @@ module.exports = function tokenAction(provider) {
await next();
},

getCheckResource(provider),

async function supportedGrantTypeCheck(ctx, next) {
presence(ctx, 'grant_type');

Expand Down
56 changes: 56 additions & 0 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,62 @@ const DEFAULTS = {
*/
registrationManagement: false,

/*
* features.resourceIndicators
*
* title: [draft-ietf-oauth-resource-indicators-00](https://tools.ietf.org/html/draft-ietf-oauth-resource-indicators-00) - Resource Indicators for OAuth 2.0
*
* description: Enables the use and validations of `resource` parameter for the authorization
* and token endpoints. In order for the feature to be any useful you must also use the
* `audiences` helper function to further validate/whitelist the resource(s) and push them
* down to issued access tokens.
*
* example: Example use with audiences and dynamic AccessToken format
* This example will
* - throw when multiple resources are requested (per spec at the OPs discretion)
* - throw based on an OP policy
* - push resources down to the audience of access tokens
*
* ```js
* // const { InvalidResource } = Provider.errors;
* // resourceAllowedForClient is the custom OP policy
*
* {
* // ...
* async audiences(ctx, sub, token, use) {
* const { resource } = ctx.oidc.params;
* if (resource && use === 'access_token') {
* if (Array.isArray(resource)) {
* throw new InvalidResource('multiple "resource" parameters are not allowed');
* }
*
* const { client } = ctx.oidc;
* const allowed = await resourceAllowedForClient(resource, client.clientId);
* if (!allowed) {
* throw new InvalidResource('unauthorized "resource" requested');
* }
*
* return [resource];
* }
*
* return undefined;
* },
* formats: {
* default: 'opaque',
* AccessToken(token) {
* if (Array.isArray(token.aud)) {
* return 'jwt';
* }
*
* return 'opaque';
* }
* },
* // ...
* }
* ```
*/
resourceIndicators: false,

/*
* features.request
*
Expand Down
1 change: 1 addition & 0 deletions lib/helpers/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const classes = [
['expired_token'],
['invalid_request_object'],
['invalid_request_uri'],
['invalid_resource'],
['redirect_uri_mismatch', 'redirect_uri did not match any client\'s registered redirect_uris'],
['registration_not_supported', 'registration parameter provided but not supported'],
['request_not_supported', 'request parameter provided but not supported'],
Expand Down
7 changes: 6 additions & 1 deletion lib/helpers/initialize_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,15 @@ module.exports = function initializeApp() {
Object.entries(grants).forEach(([grantType, { handler, parameters }]) => {
const { grantTypeHandlers } = instance(this);
if (configuration.grantTypes.has(grantType) && !grantTypeHandlers.has(grantType)) {
let dupes;
if (configuration.features.pkce && pkceGrants.has(grantType)) {
parameters.add('code_verifier');
}
this.registerGrantType(grantType, handler, parameters);
if (configuration.features.resourceIndicators) {
parameters.add('resource');
dupes = new Set(['resource']);
}
this.registerGrantType(grantType, handler, parameters, dupes);
}
});

Expand Down
38 changes: 38 additions & 0 deletions lib/shared/check_resource.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const { URL } = require('url');

const instance = require('../helpers/weak_cache');
const { InvalidResource } = require('../helpers/errors');

module.exports = function getCheckResource(provider) {
return function checkResource({ oidc: { params } }, next) {
if (!instance(provider).configuration('features.resourceIndicators') || params.resource === undefined) {
return next();
}

let requested = params.resource;
if (!Array.isArray(requested)) {
requested = [requested];
}

requested.forEach((resource) => {
let href;
try {
({ href } = new URL(resource)); // eslint-disable-line no-new
} catch (err) {
throw new InvalidResource('resource must be an absolute URI');
}

// NOTE: we don't check for new URL() => search of hash because of an edge case
// new URL('https://example.com?#') => they're empty, seems like an inconsistent validation
if (href.includes('#')) {
throw new InvalidResource('resource must not contain a fragment component');
}

if (href.includes('?')) {
throw new InvalidResource('resource must not contain a query component');
}
});

return next();
};
};
1 change: 1 addition & 0 deletions test/provider/provider_class.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('Provider', () => {
'InvalidRequest',
'InvalidRequestObject',
'InvalidRequestUri',
'InvalidResource',
'InvalidScope',
'InvalidToken',
'RedirectUriMismatch',
Expand Down
28 changes: 28 additions & 0 deletions test/request/jwt_request.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,34 @@ describe('request parameter features', () => {
});

if (route !== '/device/auth') {
it('can contain resource parameter as an Array', async function () {
const spy = sinon.spy();
this.provider.once(successEvt, spy);

await JWT.sign({
client_id: 'client',
response_type: 'code',
redirect_uri: 'https://client.example.com/cb',
resource: ['https://rp.example.com/api'],
}, null, 'none', { issuer: 'client', audience: this.provider.issuer }).then(request => this.wrap({
agent: this.agent,
route,
verb,
auth: {
request,
scope: 'openid',
client_id: 'client',
response_type: 'code',
},
})
.expect(successCode)
.expect(successFnCheck));

expect(
spy.calledWithMatch({ oidc: { params: { resource: ['https://rp.example.com/api'] } } }),
).to.be.true;
});

it('doesnt allow response_type to differ', function () {
const spy = sinon.spy();
this.provider.once(errorEvt, spy);
Expand Down
1 change: 1 addition & 0 deletions test/request/request.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ config.features = {
requestUri: { requireRequestUriRegistration: false },
claimsParameter: true,
deviceFlow: true,
resourceIndicators: true,
};

pull(config.whitelistedJWA.requestObjectSigningAlgValues, 'HS384');
Expand Down
Loading

0 comments on commit 1bc2994

Please sign in to comment.