From 34f19849a181ff4724f5a0dd1d946516667b5020 Mon Sep 17 00:00:00 2001 From: Anna Lesniak <43753023+annalesniak@users.noreply.github.com> Date: Wed, 27 Feb 2019 09:49:22 +0100 Subject: [PATCH] Introduce new middleware for oauth scopes verification (#15) --- README.md | 3 +- docs/docs.hbs | 3 +- errors/MissingRequiredScopes.js | 7 + lib/middlewares/oauth/oauth-scopes.js | 12 ++ middlewares/oauth/oauth-scopes.js | 36 ++++ test/middlewares/oauth/oauth-scopes.spec.js | 199 ++++++++++++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 errors/MissingRequiredScopes.js create mode 100644 lib/middlewares/oauth/oauth-scopes.js create mode 100644 middlewares/oauth/oauth-scopes.js create mode 100644 test/middlewares/oauth/oauth-scopes.spec.js diff --git a/README.md b/README.md index 4b2c5a9..7bf90aa 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ Currently following middlewares are available: - setting default query parameters values - removing unspecified query parameters, - content type validation, -- routing to appropriate controller. +- routing to appropriate controller, +- oauth scopes authorization. ### Validation middlewares diff --git a/docs/docs.hbs b/docs/docs.hbs index 4b2c5a9..7bf90aa 100644 --- a/docs/docs.hbs +++ b/docs/docs.hbs @@ -135,7 +135,8 @@ Currently following middlewares are available: - setting default query parameters values - removing unspecified query parameters, - content type validation, -- routing to appropriate controller. +- routing to appropriate controller, +- oauth scopes authorization. ### Validation middlewares diff --git a/errors/MissingRequiredScopes.js b/errors/MissingRequiredScopes.js new file mode 100644 index 0000000..d05044c --- /dev/null +++ b/errors/MissingRequiredScopes.js @@ -0,0 +1,7 @@ +module.exports = class MissingRequiredScopes extends Error { + constructor(requiredScopes) { + super() + this.name = this.constructor.name + this.requiredScopes = requiredScopes + } +} diff --git a/lib/middlewares/oauth/oauth-scopes.js b/lib/middlewares/oauth/oauth-scopes.js new file mode 100644 index 0000000..f35cc4a --- /dev/null +++ b/lib/middlewares/oauth/oauth-scopes.js @@ -0,0 +1,12 @@ +const {get} = require('lodash') +const MissingRequiredScopes = require('../../../errors/MissingRequiredScopes') + +module.exports = (requiredScopes, grantedScopesLocation) => (req, res, next) => { + const grantedScopes = get(req, grantedScopesLocation) + if (grantedScopes) { + return grantedScopes.some(scope => requiredScopes.includes(scope)) + ? next() + : next(new MissingRequiredScopes(requiredScopes)) + } + return next() +} diff --git a/middlewares/oauth/oauth-scopes.js b/middlewares/oauth/oauth-scopes.js new file mode 100644 index 0000000..1a821ee --- /dev/null +++ b/middlewares/oauth/oauth-scopes.js @@ -0,0 +1,36 @@ +const {pickBy, keys, values, flatten} = require('lodash') + +const oauthScopes = require('../../lib/middlewares/oauth/oauth-scopes') + +/** + * Verifies scope access to operation by comparing granted OAuth scopes and required scopes to perform that operation. + * + * Uses required OAuth scopes assigned to given operation or global security definition + * if the first one is not specified. + * Granted scopes are retrieved from `options.grantedScopesLocation`. + * + * In situation when there is more than one OAuth strategy assigned to operation the first one is taken for the check. + * + * To successfully pass through the middleware the request must have at least one of the required scopes. + * It is also possible to successfully pass through when request has undefined `options.grantedScopesLocation` property. + * On error `MissingRequiredScopes` error is created. + * + * @param {Object} options options + * @param {object} options.grantedScopesLocation request property to retrieve granted scopes + * @returns {Function} middleware + */ +module.exports = ({grantedScopesLocation = 'headers.x-oauth-scopes'} = {}) => (operation, {spec}) => { + const security = operation.security ? operation.security : spec.security + + if (!security) { + return + } + + const oauthSchemes = pickBy(spec.components.securitySchemes, scheme => scheme.type === 'oauth2') + const oauthNames = keys(oauthSchemes) + const oauthStrategy = security.find(strategy => oauthNames.includes(keys(strategy).toString())) + + if (oauthStrategy) { + return oauthScopes(flatten(values(oauthStrategy)), grantedScopesLocation) + } +} diff --git a/test/middlewares/oauth/oauth-scopes.spec.js b/test/middlewares/oauth/oauth-scopes.spec.js new file mode 100644 index 0000000..d9b1190 --- /dev/null +++ b/test/middlewares/oauth/oauth-scopes.spec.js @@ -0,0 +1,199 @@ +const oauthScopes = require('../../../middlewares/oauth/oauth-scopes') +const MissingRequiredScopes = require('../../../errors/MissingRequiredScopes') + +describe('oauth scopes middleware', () => { + + const spec = { + paths: { + '/hello': { + get: { + security: [ + { + oauth: [ + 'write', + 'execute' + ] + } + ] + } + } + }, + components: { + securitySchemes: { + oauth: { + type: 'oauth2' + }, + key: { + type: 'apiKey' + } + } + } + } + + const operation = spec.paths['/hello'].get + + it('should pass request containing required scopes in user', done => { + // given + const middleware = oauthScopes({grantedScopesLocation: 'user.grantedScopes'})(operation, {spec}) + const req = {user: {grantedScopes: ['write']}} + + // when + middleware(req, undefined, done) + }) + + it('should pass request containing required scopes in header', done => { + // given + const middleware = oauthScopes()(operation, {spec}) + const req = {headers: {'x-oauth-scopes': ['read', 'write']}} + + // when + middleware(req, undefined, done) + }) + + it('should indicate missing required scopes', () => { + //given + const middleware = oauthScopes({grantedScopesLocation: 'user.grantedScopes'})(operation, {spec}) + const req = {user: {grantedScopes: ['read']}} + const next = sinon.spy() + + // when + middleware(req, undefined, next) + + // then + sinon.assert.calledWithMatch(next, sinon.match.instanceOf(MissingRequiredScopes) + .and(sinon.match.has('requiredScopes', operation.security.find(strategy => strategy.oauth).oauth))) + }) + + it('should pass request when granted scopes not set', done => { + // given + const middleware = oauthScopes({grantedScopesLocation: 'user.grantedScopes'})(operation, {spec}) + const req = {user: {}} + + // when + middleware(req, undefined, done) + }) + + it('should pass request when security property not set anywhere', () => { + // given + const spec = { + paths: { + '/hello': { + get: {} + } + }, + components: { + securitySchemes: { + oauth: { + type: 'oauth2' + } + } + } + } + const operation = spec.paths['/hello'].get + const middleware = oauthScopes()(operation, {spec}) + + // when + expect(middleware).to.be.undefined + }) + + it('should pass request when security property does not contain oauth', () => { + // given + const operation = { + security: [ + { + key: [] + } + ] + } + const middleware = oauthScopes()(operation, {spec}) + + // when + expect(middleware).to.be.undefined + }) + + it('should use global security property', () => { + // given + const spec = { + security: [ + { + oauth: [ + 'write', + 'execute' + ] + } + ], + paths: { + '/hello': { + get: {} + } + }, + components: { + securitySchemes: { + oauth: { + type: 'oauth2' + } + } + } + } + const operation = spec.paths['/hello'].get + const middleware = oauthScopes({grantedScopesLocation: 'user.grantedScopes'})(operation, {spec}) + const req = {user: {grantedScopes: ['read']}} + const next = sinon.spy() + + // when + middleware(req, undefined, next) + + // then + sinon.assert.calledWithMatch(next, sinon.match.instanceOf(MissingRequiredScopes) + .and(sinon.match.has('requiredScopes', spec.security.find(strategy => strategy.oauth).oauth))) + }) + + it('should use first oauth security definition when more than one', () => { + // given + const spec = { + paths: { + '/hello': { + get: { + security: [ + { + oauth1: [ + 'write', + 'execute' + ] + }, + { + oauth2: [ + 'read' + ] + } + ] + } + } + }, + components: { + securitySchemes: { + oauth1: { + type: 'oauth2' + }, + oauth2: { + type: 'oauth2' + }, + key: { + type: 'apiKey' + } + } + } + } + const operation = spec.paths['/hello'].get + const middleware = oauthScopes({grantedScopesLocation: 'user.grantedScopes'})(operation, {spec}) + const req = {user: {grantedScopes: ['read']}} + const next = sinon.spy() + + // when + middleware(req, undefined, next) + + // then + sinon.assert.calledWithMatch(next, sinon.match.instanceOf(MissingRequiredScopes) + .and(sinon.match.has('requiredScopes', operation.security.find(strategy => strategy.oauth1).oauth1))) + }) +})