diff --git a/FAQ.md b/FAQ.md index 7378fa4c..f2d34b7b 100644 --- a/FAQ.md +++ b/FAQ.md @@ -30,9 +30,6 @@ You can configure which version you want to support here. The values should neve - **logging** - Place to put logging configurations. Currently only level is allowed, but we intend to add more in the future. You can also customize the logger in `src/lib/logger` to add support for whatever you need. - **auth** - Various configurations for authentication. - **name** - Name of the strategy to use for passport - - **clientId** - Client Id needed for an introspection query. This will be populated by a CLIENT_ID environment variable. - - **clientSecret** - Client Secret needed for an introspection query. This will be populated by a CLIENT_SECRET environment variable. - - **introspectionUrl** - Introspection url - **strategy** - Path to your strategy - **passportOptions** - Options object passed directly into passport. @@ -51,9 +48,16 @@ Authentication is implemented based on the [SMART App Authorization Guide](http: The way it works is our server will parse the bearer token from headers and then send the token back to the introspection url along with the client id and client secret to validate the token. If the token is valid, the scopes will be used to validate access to all resources. For example, let's say Joe logs in to some app that uses this server as a backend. He authenticates with the application and then tries to load up a dashboard full of data. When the request comes to this server, we will take the token provided by the app, validate it, then check the scopes associated with it before returning any data. If Joe has enough scope to view the resources requested, they will be returned, otherwise, an insufficient scope error will be returned. -To set all this up in our server, you only need to define a few environment variables. However, you will need a valid auth server with an introspection endpoint available. This must be used to prevent someone from resigning a token after modifying it or some other man in the middle type attacks. We already wrote our own bearer strategy (`src/strategies/bearer.strategy`) that will utilize the introspection endpoint. You can use it, customize it, or add your own passport strategies (writing your own requires more customization). The three environment variables you need to define are `CLIENT_ID`, `CLIENT_SECRET`, and `INTROSPECTION_URL`. +To set all this up in our server, you only need to define a few environment variables. However, you will need a valid auth server with an introspection endpoint available. This must be used to prevent someone from resigning a token after modifying it or some other man in the middle type attacks. We already wrote our own bearer strategy, [https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-strategy](https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-strategy), that will utilize the introspection endpoint. You can use it, customize it, or add your own passport strategies (writing your own requires more customization). The three environment variables you need to define are `CLIENT_ID`, `CLIENT_SECRET`, and `INTROSPECTION_URL`. -That's it. Remember authentication is enabled by default and will throw an error if you attempt to query the graphql endpoint without setting those three environment variables. If you want to disable auth completely, just remove the name or strategy from `src/config.js` under the `SERVER_CONFIG.auth` property or remove the `configurePassport` call in `src/index.js`. +That's it. Remember authentication is enabled by default and will throw an error if you attempt to query the graphql endpoint without setting those three environment variables. If you want to disable auth completely, see below. + +### Disabling Authentication +There are two things you will need to do to if you want to disable authentication. We are using [https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-strategy](https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-strategy) and [https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-graphql-invariant](https://github.com/Asymmetrik/phx-tools/tree/master/packages/sof-graphql-invariant). + +To disable the strategy, just remove the name or strategy from `src/config.js` under the `SERVER_CONFIG.auth` property or remove the `configurePassport` call in `src/index.js`. This enables the authentication piece. + +To disable the scope invariant, or the authorization piece, change the feature flags in `src/environment.js` for the environment you want to disable it in from true to false. For example, change `process.env.SOF_AUTHENTICATION = true;` to `process.env.SOF_AUTHENTICATION = false;`. This tells the scopeInvariantResolver to not do any scope checking, which only works if SMART is enabled. ## Resolvers You can read a little bit about GraphQL resolvers on [graphql.org](https://graphql.org/learn/execution/#root-fields-resolvers). Resolvers are where you return data back for the API and it needs to be in the correct format. GraphQL will attempt to coerce data types being resolved when possible, but if you return an invalid property, it will throw an error. You can return an object, array of objects, promise, or an array or promises and GraphQL will just handle it. All of the resolvers are located in `src/resources/{version}/profiles/{profile_name}/resolver.js`. @@ -62,7 +66,7 @@ All of the resolver's contain stubs that just need to be filled in, so all you n ```javascript // In src/resources/3_0_1/profiles/patient/resolver.js -module.exports.patientResolver = function patientResolver (root, args, ctx, info) { +module.exports.getPatient = function getPatient (root, args, ctx, info) { // This is not very realistic, but just giving you a simple idea of how // this works let id = args._id; @@ -148,7 +152,7 @@ Finally, grab the connnection or client in your resolvers so you can start worki // In src/resources/3_0_1/profiles/patient/resolver.js const errorUtils = require('../../../../utils/error.utils'); -module.exports.patientResolver = function patientResolver (root, args, ctx, info) { +module.exports.getPatient = function getPatient (root, args, ctx, info) { let db = ctx.server.db; let version = ctx.version; let logger = ctx.server.logger; diff --git a/package.json b/package.json index a93ec948..482238ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-fhir", - "version": "1.0.1", + "version": "1.1.0", "description": "A Javascript based GraphQL FHIR server", "main": "index.js", "author": "Robert-W >", @@ -21,6 +21,7 @@ "dependencies": { "@asymmetrik/fhir-gql-schema-utils": "^1.0.1", "@asymmetrik/sof-graphql-invariant": "^1.0.2", + "@asymmetrik/sof-strategy": "^1.0.2", "body-parser": "^1.18.3", "compression": "^1.7.2", "cross-env": "^5.2.0", diff --git a/src/config.js b/src/config.js index 6fd80a60..d551ee0e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,4 +1,4 @@ -const path = require('path'); +const smartBearerStrategy = require('@asymmetrik/sof-strategy'); /** * @name VERSION @@ -27,10 +27,11 @@ const SERVER_CONFIG = { // Auth configurations auth: { name: 'bearer', - clientId: process.env.CLIENT_ID, - clientSecret: process.env.CLIENT_SECRET, - introspectionUrl: process.env.INTROSPECTION_URL, - strategy: path.posix.resolve('src/strategies/bearer.strategy.js'), + strategy: smartBearerStrategy({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + introspectionUrl: process.env.INTROSPECTION_URL, + }), passportOptions: { session: false, }, diff --git a/src/lib/server.js b/src/lib/server.js index a49ddd22..a5cb5de8 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -101,7 +101,7 @@ class Server { // to make it easy to determine if the server is using authentication this.env.AUTHENTICATION = true; // Add the strategy to passport - passport.use(require(auth.strategy)); + passport.use(auth.strategy); } // return self for chaining return this; diff --git a/src/middleware/deprecated.auth.middleware.js b/src/middleware/deprecated.auth.middleware.js deleted file mode 100644 index 36fc1b83..00000000 --- a/src/middleware/deprecated.auth.middleware.js +++ /dev/null @@ -1,42 +0,0 @@ -// const errorUtils = require('../utils/error.utils'); -// const parser = require('graphql/language/parser'); -// -// /** -// * @name exports -// * @summary Middleware function for authentication -// * @example app.use('/some/router', authenticationMiddleware(server), expressGraphql({ ... })); -// * NOTE: This middleware allows for validation in a single point, it is up to the implementer to -// * decide whether or not they handle things here or they want to handle them in a resolver. Either -// * are a valid solution as long as errors are reported in the correct format. -// */ -// module.exports = function authenticationMiddleware (server, version) { -// // return a valid middleware function -// return (req, res, next) => { -// let args = req.method === 'GET' -// ? req.query.query -// : req.body.query; -// -// // Add a check for validation config from the server, is there is no auth -// // then we should just call next and skip all the rest -// // TODO: Check for auth config from the server -// -// // TODO: Validate the authenticated user can use access this resource -// // if a reqource_id is present -// let resource_id = req.params.id; -// -// // Parse the documentNode from the graphql request if we have args -// if (args) { -// let documentNode = parser.parse(args); -// // Grab the definitions from the document node -// let definitions = documentNode.definitions; -// // Iterate over each definition and validate the selection set -// definitions.forEach(definition => { -// // TODO: Inspect the AST to determine which fields they are requesting -// // and perform validation on those fields and the scopes the user has -// }); -// } -// // NOTE Example of throwing an error as an observation outcome -// // next(errorUtils.internal(version)); -// next(); -// }; -// }; diff --git a/src/strategies/bearer.strategy.js b/src/strategies/bearer.strategy.js deleted file mode 100644 index 973d1a46..00000000 --- a/src/strategies/bearer.strategy.js +++ /dev/null @@ -1,38 +0,0 @@ -const { Strategy } = require('passport-http-bearer'); -const { auth } = require('../config').SERVER_CONFIG; -const superagent = require('superagent'); - -module.exports = new Strategy(function bearer(token, done) { - // If we do not have a valid instrospection url, we cannot use this strategy - if (!auth.introspectionUrl) { - let errorMessage = 'No introspection endpoint provided. The server cannot'; - errorMessage += ' use the bearer strategy without an introspection url.'; - errorMessage += ' Please define a INTROSPECTION_URL environment variable.'; - return done(new Error(errorMessage)); - } - - if (!auth.clientId || !auth.clientSecret) { - let errorMessage = 'No clientId and clientSecret were provided. You cannot'; - errorMessage += ' use the bearer strategy without these. Please define a'; - errorMessage += ' CLIENT_ID and CLIENT_SECRET environment variable.'; - return done(new Error(errorMessage)); - } - - return superagent - .post(auth.introspectionUrl) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - token: token, - client_id: auth.clientId, - client_secret: auth.clientSecret, - }) - .then(res => { - let decoded = res.body; - if (decoded.active) { - return done(null, decoded); - } else { - return done(new Error('Unable to retrieve valid token')); - } - }) - .catch(err => done(err)); -}); diff --git a/src/strategies/bearer.strategy.test.js b/src/strategies/bearer.strategy.test.js deleted file mode 100644 index f6d60a3f..00000000 --- a/src/strategies/bearer.strategy.test.js +++ /dev/null @@ -1,120 +0,0 @@ -const bearerStrategy = require('./bearer.strategy'); -const superagent = require('superagent'); - -describe('Bearer Strategy Test', () => { - describe('missing env variables', () => { - test('should throw an error if the introspectionUrl is missing', () => { - const { auth } = require('../config').SERVER_CONFIG; - auth.introspectionUrl = undefined; - // Setup the arguments for our strategy - let token = 'somegibberish'; - let done = jest.fn(); - // Invoke the verify function - bearerStrategy._verify(token, done); - let maybeErr = done.mock.calls[0][0]; - // We should see an error complaining aobut the inspection url - expect(maybeErr.message).toContain('INTROSPECTION_URL'); - }); - - test('should throw an error if the clientId or clientSecret is missing', () => { - const { auth } = require('../config').SERVER_CONFIG; - auth.clientId = undefined; - auth.clientSecret = undefined; - auth.introspectionUrl = 'https://www.foo.com/introspection'; - // Setup the arguments for our strategy - let token = 'somegibberish'; - let done = jest.fn(); - // Invoke the verify function - bearerStrategy._verify(token, done); - let maybeErr = done.mock.calls[0][0]; - // We should see an error complaining aobut the inspection url - expect(maybeErr.message).toContain('CLIENT_ID'); - expect(maybeErr.message).toContain('CLIENT_SECRET'); - }); - }); - - describe('valid env setup', () => { - beforeEach(() => { - const { auth } = require('../config').SERVER_CONFIG; - auth.clientId = 'client'; - auth.clientSecret = 'secret'; - auth.introspectionUrl = 'https://www.foo.com/introspection'; - // Reset necessary mocks from superagent - superagent.__reset(); - superagent.set.mockClear(); - superagent.post.mockClear(); - superagent.send.mockClear(); - }); - - test('should send a post request to the introspectionUrl with correct headers and body', () => { - // Setup the arguments for our strategy - let token = 'somegibberish'; - let done = jest.fn(); - // Invoke the verify function - bearerStrategy._verify(token, done); - // Check the calls - expect(superagent.set.mock.calls.length).toBe(1); - expect(superagent.post.mock.calls.length).toBe(1); - expect(superagent.send.mock.calls.length).toBe(1); - expect(superagent.set.mock.calls[0][0]).toEqual('content-type'); - expect(superagent.set.mock.calls[0][1]).toEqual( - 'application/x-www-form-urlencoded', - ); - expect(superagent.post.mock.calls[0][0]).toEqual( - 'https://www.foo.com/introspection', - ); - expect(superagent.send.mock.calls[0][0]).toEqual({ - token: token, - client_id: 'client', - client_secret: 'secret', - }); - }); - - test('should pass an active token to the done callback', async () => { - // Setup the arguments for our strategy - let decoded_token = { active: true, value: 'foo' }; - let encryptedToken = 'somegibberish'; - let done = jest.fn(); - // Set some mock results - superagent.__setResults({ body: decoded_token }); - // Invoke the verify function - await bearerStrategy._verify(encryptedToken, done); - // Check that done has been called with null and a decoded token - expect(done.mock.calls.length).toBe(1); - expect(done.mock.calls[0][0]).toBeNull(); - expect(done.mock.calls[0][1]).toBe(decoded_token); - }); - - test('should pass an error to the done callback if the token is inactive', async () => { - // Setup the arguments for our strategy - let decoded_token = { active: false, value: 'foo' }; - let encryptedToken = 'somegibberish'; - let done = jest.fn(); - // Set some mock results - superagent.__setResults({ body: decoded_token }); - // Invoke the verify function - await bearerStrategy._verify(encryptedToken, done); - // Check that done has been called with null and a decoded token - expect(done.mock.calls.length).toBe(1); - expect(done.mock.calls[0][1]).toBeUndefined(); - expect(done.mock.calls[0][0].message).toBe( - 'Unable to retrieve valid token', - ); - }); - - test('should pass an unexpected error to the done callback', async () => { - // Setup the arguments for our strategy - let errorMessage = 'Danger Will Robinson'; - let encryptedToken = 'somegibberish'; - let done = jest.fn(); - // Set some mock results - superagent.__setError(errorMessage); - // Invoke the verify function - await bearerStrategy._verify(encryptedToken, done); - // Check that done has been called with null and a decoded token - expect(done.mock.calls.length).toBe(1); - expect(done.mock.calls[0][1]).toBeUndefined(); - expect(done.mock.calls[0][0].message).toBe(errorMessage); - }); - }); -}); diff --git a/yarn.lock b/yarn.lock index 41de8d4f..8ca4db07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,6 +20,14 @@ resolved "https://registry.yarnpkg.com/@asymmetrik/sof-scope-checker/-/sof-scope-checker-1.0.1.tgz#dd5a10088d015546c11c3160e786ed65ba9381d3" integrity sha512-T6A24Llmrtpd46F6tfwU8Myd1ulUPKFCL3Tj6DZYLm+skQzrQ6/Bs9q3CeV7J3JX7IGw3pwh699Z78vuyDbUJQ== +"@asymmetrik/sof-strategy@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@asymmetrik/sof-strategy/-/sof-strategy-1.0.2.tgz#76de8d5bbd27becf50ccd4b75e417561907555a2" + integrity sha512-VWLvR/8FfdprqEdaH7gychQIfShom8GLtV9KixaX6SMp3iNxjDfOQ2CO7vFfJNHDTx4gPJcVaaN3iCY/nC4lGg== + dependencies: + passport-http-bearer "^1.0.1" + superagent "^4.1.0" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.0.0-beta.35": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -1076,7 +1084,7 @@ cookie@0.3.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= -cookiejar@^2.1.0: +cookiejar@^2.1.0, cookiejar@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== @@ -1219,7 +1227,7 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.0.1: +debug@^4.0.1, debug@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -1927,7 +1935,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -form-data@^2.3.1, form-data@~2.3.2: +form-data@^2.3.1, form-data@^2.3.3, form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== @@ -3703,6 +3711,11 @@ mime@^1.4.1: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.0.tgz#e051fd881358585f3279df333fe694da0bcffdd6" + integrity sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -4428,7 +4441,7 @@ qs@6.5.2, qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -qs@^6.5.1: +qs@^6.5.1, qs@^6.6.0: version "6.6.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.6.0.tgz#a99c0f69a8d26bf7ef012f871cdabb0aee4424c2" integrity sha512-KIJqT9jQJDQx5h5uAVPimw6yVg2SekOKu959OCtktD3FjzbpvaPr8i4zzg07DOMz+igA4W/aNM7OV8H37pFYfA== @@ -5207,6 +5220,21 @@ superagent@^3.8.3: qs "^6.5.1" readable-stream "^2.3.5" +superagent@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-4.1.0.tgz#c465c2de41df2b8d05c165cbe403e280790cdfd5" + integrity sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.2" + debug "^4.1.0" + form-data "^2.3.3" + formidable "^1.2.0" + methods "^1.1.1" + mime "^2.4.0" + qs "^6.6.0" + readable-stream "^3.0.6" + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"