├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── coverage.lcov ├── examples └── auth0 │ ├── .gitignore │ ├── environment.yml │ ├── graphql │ └── schema.graphql │ ├── package-lock.json │ ├── package.json │ ├── readme.md │ ├── src │ ├── auth0 │ │ └── index.ts │ ├── authentication │ │ └── authenticationMiddleware.ts │ ├── config.ts │ ├── errors.ts │ ├── index.ts │ ├── policies │ │ └── index.ts │ ├── resolvers │ │ ├── index.ts │ │ └── mutation │ │ │ └── authenticateUser.ts │ └── server.ts │ ├── tsconfig.json │ ├── typings.d.ts │ └── yarn.lock ├── package-lock.json ├── package.json ├── readme.md ├── renovate.json ├── src ├── bunjil.ts ├── cache.ts ├── directives.ts ├── errors.ts ├── index.ts ├── middleware │ ├── graphqlMiddleware.ts │ ├── persistedQueriesMiddleware.ts │ └── sanitiseMiddleware.ts ├── types.ts ├── utils.ts └── validationRules │ └── noIntrospection.ts ├── tests └── integration │ ├── authorization.spec.ts │ ├── cacheControl.spec.ts │ ├── noIntrospection.spec.ts │ ├── schemaMerging.spec.ts │ └── simpleServer.spec.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | quote_type = double 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.ts] 18 | curly_bracket_next_line = false 19 | continuation_indent_size = 4 20 | indent_brace_style = BSD KNF 21 | indent_size = 2 22 | max_line_length = 140 23 | spaces_around_brackets = outside 24 | spaces_around_operators = true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/** 2 | 3 | .DS_Store 4 | 5 | # Logs 6 | logs 7 | *.log 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | ./.tmp 30 | 31 | # Dependency directory 32 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 33 | node_modules 34 | node_modules/* 35 | 36 | # Artifacts 37 | build/** 38 | typings/** 39 | 40 | # Compiled graphql Cache and typings 41 | .graphql/** 42 | 43 | yarn-error.log 44 | 45 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | tests/** 3 | lib/tests 4 | lib/tests/** 5 | .vscode/** 6 | examples/** 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 80, 4 | "trailingComma": "all", 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 9.8.0 5 | 6 | before_install: 7 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.5.1 8 | - export PATH=$HOME/.yarn/bin:$PATH 9 | 10 | script: 11 | - yarn build 12 | - yarn test 13 | 14 | after_success: 15 | - yarn send-coverage 16 | 17 | 18 | cache: 19 | yarn: false 20 | directories: 21 | - node_modules 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Run server", 8 | "runtimeExecutable": "yarn", 9 | "runtimeArgs": ["run", "example-simple-debug"], 10 | "port": 9229, 11 | "sourceMaps": true, 12 | "protocol": "inspector", 13 | "cwd": "${workspaceRoot}", 14 | "skipFiles": ["**/node_modules/**"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/.DS_Store": true, 7 | "**/node_modules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [1.0.9](https://github.com/ojkelly/bunjil/compare/v1.0.8...v1.0.9) (2018-03-31) 7 | 8 | 9 | 10 | 11 | ## [1.0.8](https://github.com/ojkelly/bunjil/compare/v1.0.7...v1.0.8) (2018-03-31) 12 | 13 | 14 | 15 | 16 | ## [1.0.7](https://github.com/ojkelly/bunjil/compare/v1.0.6...v1.0.7) (2018-03-31) 17 | 18 | 19 | 20 | 21 | ## [1.0.6](https://github.com/ojkelly/bunjil/compare/v1.0.5...v1.0.6) (2018-03-29) 22 | 23 | 24 | 25 | 26 | ## [1.0.5](https://github.com/ojkelly/bunjil/compare/v1.0.4...v1.0.5) (2018-03-26) 27 | 28 | 29 | 30 | 31 | ## [1.0.4](https://github.com/ojkelly/bunjil/compare/v1.0.3...v1.0.4) (2018-03-22) 32 | 33 | 34 | 35 | 36 | ## [1.0.3](https://github.com/ojkelly/bunjil/compare/v1.0.2...v1.0.3) (2018-03-13) 37 | 38 | 39 | 40 | 41 | ## [1.0.2](https://github.com/ojkelly/bunjil/compare/v1.0.1...v1.0.2) (2018-03-12) 42 | 43 | 44 | 45 | 46 | ## [1.0.1](https://github.com/ojkelly/bunjil/compare/v0.9.1...v1.0.1) (2018-03-04) 47 | 48 | 49 | 50 | 51 | ## 0.9.1 (2018-03-04) 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Owen Kelly 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /coverage.lcov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ojkelly/bunjil/09c026971d88013ba585fccb16b4fe404152e560/coverage.lcov -------------------------------------------------------------------------------- /examples/auth0/.gitignore: -------------------------------------------------------------------------------- 1 | .auth0/** 2 | .build/** 3 | -------------------------------------------------------------------------------- /examples/auth0/environment.yml: -------------------------------------------------------------------------------- 1 | # If you make any changes here, you need to restart webpack for them to 2 | # have an effect 3 | 4 | # Dev environment 5 | development: 6 | domain: localhost 7 | graphQLEndpoint: 'http://localhost:4444/graphql' 8 | jwt: 9 | secret: 'ChangeThisToASecret' 10 | issuer: 'DemoIssuer' 11 | audience: 'DemoAudience' 12 | sentry: 13 | enable: false 14 | url: '' 15 | auth0: 16 | domain: # Get this from Auth0 17 | clientID: # Get this from Auth0 18 | redirectUri: 'http://localhost:8080/callback/auth0' 19 | logoutReturnTo: 'http://localhost:8080/' 20 | audience: # Get this from Auth0 21 | responseType: 'id_token' 22 | scope: 'openid profile email' 23 | alg: 'RS256' 24 | connections: 25 | - 'google-oauth2' 26 | socialBigButtons: false 27 | 28 | # Prod 29 | production: 30 | domain: example.com 31 | auth0: 32 | domain: # Get this from Auth0 33 | clientID: # Get this from Auth0 34 | redirectUri: 'http://localhost:8080/callback/auth0' 35 | logoutReturnTo: 'http://localhost:8080/' 36 | audience: # Get this from Auth0 37 | responseType: 'id_token' 38 | scope: 'openid profile email' 39 | alg: 'RS256' 40 | connections: 41 | - 'google-oauth2' 42 | socialBigButtons: false 43 | -------------------------------------------------------------------------------- /examples/auth0/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | viewer: User 3 | topPosts(limit: Int): [Post] 4 | } 5 | 6 | type User { 7 | id: String 8 | email: String 9 | givenName: String 10 | name: String 11 | } 12 | 13 | type Post { 14 | id: ID 15 | title: String 16 | views: Int 17 | author: User 18 | } 19 | 20 | type Mutation { 21 | authenticateUser(idToken: String): AuthenticationResponse 22 | } 23 | 24 | type AuthenticationResponse { 25 | token: String 26 | user: User 27 | } 28 | -------------------------------------------------------------------------------- /examples/auth0/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/body-parser": { 8 | "version": "1.16.8", 9 | "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.16.8.tgz", 10 | "integrity": "sha512-BdN2PXxOFnTXFcyONPW6t0fHjz2fvRZHVMFpaS0wYr+Y8fWEaNOs4V8LEu/fpzQlMx+ahdndgTaGTwPC+J/EeA==", 11 | "dev": true, 12 | "requires": { 13 | "@types/express": "4.11.1", 14 | "@types/node": "9.4.7" 15 | } 16 | }, 17 | "@types/events": { 18 | "version": "1.2.0", 19 | "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", 20 | "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", 21 | "dev": true 22 | }, 23 | "@types/express": { 24 | "version": "4.11.1", 25 | "resolved": "https://registry.npmjs.org/@types/express/-/express-4.11.1.tgz", 26 | "integrity": "sha512-ttWle8cnPA5rAelauSWeWJimtY2RsUf2aspYZs7xPHiWgOlPn6nnUfBMtrkcnjFJuIHJF4gNOdVvpLK2Zmvh6g==", 27 | "dev": true, 28 | "requires": { 29 | "@types/body-parser": "1.16.8", 30 | "@types/express-serve-static-core": "4.11.1", 31 | "@types/serve-static": "1.13.1" 32 | } 33 | }, 34 | "@types/express-jwt": { 35 | "version": "0.0.38", 36 | "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.38.tgz", 37 | "integrity": "sha512-z+yOUVqcfIcQklTyYIzDHWyOLNfX1EMsnuNjIm8l/liE+62ZEjRtBuDfkPDGxSSoq9MSd2W3uMoDawOtdg7Sng==", 38 | "dev": true, 39 | "requires": { 40 | "@types/express": "4.11.1", 41 | "@types/express-unless": "0.0.32" 42 | } 43 | }, 44 | "@types/express-serve-static-core": { 45 | "version": "4.11.1", 46 | "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.11.1.tgz", 47 | "integrity": "sha512-EehCl3tpuqiM8RUb+0255M8PhhSwTtLfmO7zBBdv0ay/VTd/zmrqDfQdZFsa5z/PVMbH2yCMZPXsnrImpATyIw==", 48 | "dev": true, 49 | "requires": { 50 | "@types/events": "1.2.0", 51 | "@types/node": "9.4.7" 52 | } 53 | }, 54 | "@types/express-unless": { 55 | "version": "0.0.32", 56 | "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.0.32.tgz", 57 | "integrity": "sha512-6YpJyFNlDDnPnRjMOvJCoDYlSDDmG/OEEUsPk7yhNkL4G9hUYtgab6vi1CcWsGSSSM0CsvNlWTG+ywAGnvF03g==", 58 | "dev": true, 59 | "requires": { 60 | "@types/express": "4.11.1" 61 | } 62 | }, 63 | "@types/mime": { 64 | "version": "2.0.0", 65 | "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", 66 | "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==", 67 | "dev": true 68 | }, 69 | "@types/node": { 70 | "version": "9.4.7", 71 | "resolved": "https://registry.npmjs.org/@types/node/-/node-9.4.7.tgz", 72 | "integrity": "sha512-4Ba90mWNx8ddbafuyGGwjkZMigi+AWfYLSDCpovwsE63ia8w93r3oJ8PIAQc3y8U+XHcnMOHPIzNe3o438Ywcw==", 73 | "dev": true 74 | }, 75 | "@types/serve-static": { 76 | "version": "1.13.1", 77 | "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.1.tgz", 78 | "integrity": "sha512-jDMH+3BQPtvqZVIcsH700Dfi8Q3MIcEx16g/VdxjoqiGR/NntekB10xdBpirMKnPe9z2C5cBmL0vte0YttOr3Q==", 79 | "dev": true, 80 | "requires": { 81 | "@types/express-serve-static-core": "4.11.1", 82 | "@types/mime": "2.0.0" 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/auth0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bunjil-auth0", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "description": "Server", 6 | "license": "MIT", 7 | "author": "Owen Kelly { 17 | return new Promise((resolve: any, reject: any) => { 18 | // ensure our temp folder exists 19 | mkdirp.sync("./.auth0/", { mode: "0777" }); 20 | 21 | const jwksFile: string = path.resolve("./.auth0/jwks.json"); 22 | 23 | const url: string = `https://${ 24 | config.auth0.domain 25 | }/.well-known/jwks.json`; 26 | 27 | // Create the file stream 28 | const file = fs.createWriteStream(jwksFile); 29 | 30 | // Download the file 31 | const request = https 32 | .get(url, function(response) { 33 | response.pipe(file); 34 | file.on("finish", function() { 35 | file.close(); // close() is async, call cb after close completes. 36 | resolve(); 37 | }); 38 | }) 39 | .on("error", function(err) { 40 | // Handle errors 41 | fs.unlinkSync(jwksFile); // Delete the file async. (But we don't check the result) 42 | console.error(err.message); 43 | reject(err.message); 44 | }); 45 | }); 46 | } 47 | 48 | const auth0 = new auth0Lib.Authentication({ 49 | domain: config.auth0.domain, 50 | clientID: config.auth0.clientID, 51 | }); 52 | 53 | export { downloadAuth0JKS, auth0 }; 54 | -------------------------------------------------------------------------------- /examples/auth0/src/authentication/authenticationMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from "koa"; 2 | import * as jwt from "jsonwebtoken"; 3 | import { config } from "../config"; 4 | import { AccessDeniedError } from "../errors"; 5 | 6 | async function authenticationMiddleware( 7 | ctx: Koa.Context, 8 | next: () => Promise, 9 | ): Promise { 10 | // Check the Authentication header for a Bearer token 11 | try { 12 | if ( 13 | ctx && 14 | ctx.request && 15 | ctx.request.header && 16 | ctx.request.header.authorization && 17 | // Make sure it's a bearer token 18 | ctx.request.header.authorization.startsWith("Bearer: ") 19 | ) { 20 | // If there is a token, decode it 21 | const decoded: any = jwt.verify( 22 | ctx.request.header.authorization.replace("Bearer: ", ""), 23 | config.jwt.secret, 24 | { 25 | issuer: config.jwt.issuer, 26 | audience: config.jwt.audience, 27 | }, 28 | ); 29 | // Add that to ctx.user 30 | ctx.user = { 31 | ...decoded, 32 | // In this example the subject field of the jwt contains the id 33 | // So we need to explictly set it to the id field for Bunjil 34 | id: decoded.subject, 35 | }; 36 | } 37 | } catch (error) { 38 | console.error(error.message); 39 | // If authentication fails, pass an anonymous user to Bunjil, and let 40 | // the Authorization policies deal with errors. 41 | // This way, we get a clean access denied error back via GraphQL 42 | ctx.user = { 43 | id: null, 44 | roles: ["anonymous user", "authentication failed"], 45 | }; 46 | } 47 | // hand off to the next middleware 48 | await next(); 49 | } 50 | 51 | export { authenticationMiddleware }; 52 | -------------------------------------------------------------------------------- /examples/auth0/src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as yaml from "js-yaml"; 3 | import * as fs from "fs"; 4 | 5 | // Load the per env non secret config 6 | const envConfigFile = path.join(__dirname, "../../../environment.yml"); 7 | 8 | const envConfigAll = yaml.safeLoad(fs.readFileSync(envConfigFile)); 9 | const config = { 10 | ...envConfigAll[process.env.NODE_ENV], 11 | auth0: { 12 | ...envConfigAll[process.env.NODE_ENV].auth0, 13 | jwksFile: "./.auth0/jwks.json", 14 | }, 15 | }; 16 | 17 | type Config = { 18 | auth0: auth0ConfigType; 19 | domain: string; 20 | graphQLEndpoint: string; 21 | jwt: { 22 | secret: string; 23 | }; 24 | [name: string]: any; 25 | }; 26 | type auth0ConfigType = { 27 | domain: string; 28 | clientID: string; 29 | redirectUri: string; 30 | audience: string; 31 | responseType: string; 32 | logoutReturnTo: string; 33 | scope: string; 34 | connections: [string]; 35 | socialBigButtons: boolean; 36 | jwksFile: string; 37 | alg: string; 38 | }; 39 | 40 | export { config }; 41 | -------------------------------------------------------------------------------- /examples/auth0/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { createError } from "apollo-errors"; 2 | 3 | const AccessDeniedError = createError("AccessDeniedError", { 4 | message: "Access Denied", 5 | }); 6 | 7 | export { AccessDeniedError }; 8 | -------------------------------------------------------------------------------- /examples/auth0/src/index.ts: -------------------------------------------------------------------------------- 1 | import { downloadAuth0JKS } from "./auth0"; 2 | 3 | (async () => { 4 | // Download the auth0 keys 5 | await downloadAuth0JKS(); 6 | 7 | // Start the server 8 | const { server } = await import("./server"); 9 | server(); 10 | })(); 11 | -------------------------------------------------------------------------------- /examples/auth0/src/policies/index.ts: -------------------------------------------------------------------------------- 1 | import { Policy, PolicyCondition, PolicyEffect } from "bunjil"; 2 | 3 | const policies: Policy[] = [ 4 | { 5 | id: "Allow Authenticated Access to User", 6 | resources: ["Query::*", "User::*", "viewer:*"], 7 | actions: ["query"], 8 | effect: PolicyEffect.Allow, 9 | roles: ["authenticated user"], 10 | }, 11 | // For authentication to work, you must allow both the mutation for login, 12 | // and the login response to be accessible by anonymous users. 13 | { 14 | id: "Allow Anonymous Login", 15 | resources: [ 16 | "Mutation::authenticateUser", 17 | "AuthenticationResponse::*", 18 | "User::*", 19 | ], 20 | actions: ["mutation"], 21 | effect: PolicyEffect.Allow, 22 | roles: ["*"], 23 | }, 24 | ]; 25 | 26 | export { policies }; 27 | -------------------------------------------------------------------------------- /examples/auth0/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import { authenticateUserResolver } from "./mutation/authenticateUser"; 2 | 3 | const resolvers: any = { 4 | Query: {}, 5 | Mutation: { 6 | authenticateUser: authenticateUserResolver, 7 | }, 8 | }; 9 | 10 | export { resolvers }; 11 | -------------------------------------------------------------------------------- /examples/auth0/src/resolvers/mutation/authenticateUser.ts: -------------------------------------------------------------------------------- 1 | import * as expressJwt from "express-jwt"; 2 | import * as jwt from "jsonwebtoken"; 3 | import * as jwksRsa from "jwks-rsa"; 4 | import { config } from "../../config"; 5 | import * as faker from "faker"; 6 | import * as fs from "fs"; 7 | 8 | import { AccessDeniedError } from "../../errors"; 9 | 10 | import { auth0 } from "../../auth0"; 11 | 12 | type auth0KeyCollection = { 13 | keys: auth0KeySet[]; 14 | }; 15 | 16 | type auth0KeySet = { 17 | alg: string; 18 | kty: string; 19 | use: string; 20 | x5c: string[]; 21 | n: string; 22 | e: string; 23 | kid: string; 24 | x5t: string; 25 | }; 26 | 27 | const auth0Jwks: auth0KeyCollection = JSON.parse( 28 | fs.readFileSync(config.auth0.jwksFile, "utf8"), 29 | ); 30 | 31 | // From https://github.com/sgmeyer/auth0-node-jwks-rs256/blob/master/src/lib/utils.js 32 | function certToPEM(cert) { 33 | cert = cert.match(/.{1,64}/g).join("\n"); 34 | cert = `-----BEGIN CERTIFICATE-----\n${cert}\n-----END CERTIFICATE-----\n`; 35 | return cert; 36 | } 37 | 38 | async function authenticateUserResolver( 39 | root: any, 40 | args: any, 41 | context: any, 42 | info: any, 43 | ) { 44 | try { 45 | // The incoming JWT 46 | const idToken: string = args.idToken; 47 | 48 | // Defensively set verified to false 49 | let verified: any | boolean = false; 50 | 51 | // Decode the jwt 52 | const decoded: any = jwt.decode(idToken, { 53 | json: true, 54 | complete: true, 55 | }); 56 | 57 | // Grab the alg from the config, and falback to RS256 58 | const alg: string = config.auth0.alg ? config.auth0.alg : "RS256"; 59 | 60 | // Attempt to decode and verify the token 61 | if ( 62 | decoded && 63 | decoded.header && 64 | decoded.header.alg === alg && 65 | decoded.header.kid 66 | ) { 67 | // We're decoding the token, so we can get the key id (kid) from the header field 68 | // We'll then use this kid to find a key in our auth0 jwks, and attempt to verify it 69 | // against that key 70 | const keySet: auth0KeySet | undefined = auth0Jwks.keys.find( 71 | (keySet: auth0KeySet) => (keySet.kid = decoded.header.kid), 72 | ); 73 | 74 | if (keySet) { 75 | // convert the x509 cert into a pem format 76 | const key: string = certToPEM(keySet.x5c[0]); 77 | // Attempt to verify the signature of the jwt with our auth0 key 78 | verified = jwt.verify(idToken, key, { 79 | issuer: `https://${config.auth0.domain}/`, 80 | audience: config.auth0.clientId, 81 | }); 82 | } 83 | } 84 | 85 | // If verification worked we can return a jwt 86 | // You will likely want to extend this to create a user in your databse 87 | if (verified !== false && verified.email_verified) { 88 | return { 89 | token: jwt.sign( 90 | { 91 | iss: config.jwt.issuer, 92 | aud: config.jwt.audience, 93 | sub: verified.sub, 94 | email: verified.email, 95 | name: verified.name, 96 | givenName: verified.given_name, 97 | roles: ["authenticated user"], 98 | }, 99 | config.jwt.secret, 100 | ), 101 | user: { 102 | id: verified.sub, 103 | email: verified.email, 104 | name: verified.name, 105 | givenName: verified.given_name, 106 | roles: ["authenticated user"], 107 | }, 108 | }; 109 | } 110 | // If none of the above works, we throw a denied error. 111 | throw new AccessDeniedError(); 112 | } catch (err) { 113 | console.log(err.message); 114 | } 115 | } 116 | 117 | export { authenticateUserResolver }; 118 | -------------------------------------------------------------------------------- /examples/auth0/src/server.ts: -------------------------------------------------------------------------------- 1 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "bunjil"; 2 | import { 3 | mockServer, 4 | MockList, 5 | makeExecutableSchema, 6 | addMockFunctionsToSchema, 7 | } from "graphql-tools"; 8 | import * as faker from "faker"; 9 | import { GraphQLSchema } from "graphql"; 10 | import * as url from "url"; 11 | import * as Cors from "koa-cors"; 12 | import { 13 | getGraphQLProjectConfig, 14 | GraphQLProjectConfig, 15 | GraphQLEndpoint, 16 | } from "graphql-config"; 17 | 18 | import { authenticationMiddleware } from "./authentication/authenticationMiddleware"; 19 | import { resolvers } from "./resolvers/"; 20 | import { config } from "./config"; 21 | import { policies } from "./policies"; 22 | 23 | // --[ Init ]-------------------------------------------------------------------------------------- 24 | 25 | /** 26 | * Our main function, this sets up the Bunjil instance, and then starts it 27 | */ 28 | async function server() { 29 | try { 30 | const env: string = process.env.NODE_ENV 31 | ? process.env.NODE_ENV 32 | : "production"; 33 | const config: GraphQLProjectConfig = getGraphQLProjectConfig( 34 | "../../.graphqlconfig.yml", 35 | "server", 36 | ); 37 | const typeDefs: string = config.getSchemaSDL(); 38 | 39 | if ( 40 | typeof config.extensions.endpoints === "undefined" || 41 | typeof config.extensions.endpoints[env] === "undefined" 42 | ) { 43 | throw new Error( 44 | "FATAL: Cannot start, you need to configure environment.yml to had config.extensions.endpoints", 45 | ); 46 | } 47 | const configEndpoints: any = config.extensions.endpoints[env]; 48 | 49 | const graphqlEndpoint = url.parse(configEndpoints.url); 50 | 51 | const schema = makeExecutableSchema({ 52 | typeDefs, 53 | resolvers, 54 | }); 55 | 56 | const endpoints = { 57 | graphQL: "/graphql", 58 | subscriptions: "/graphql/subscriptions", 59 | playground: "/playground", 60 | }; 61 | 62 | const bunjil: Bunjil = new Bunjil({ 63 | server: { 64 | port: Number(graphqlEndpoint.port), 65 | tracing: false, 66 | cacheControl: false, 67 | }, 68 | debug: true, 69 | playgroundOptions: { 70 | enabled: env === "development" ? true : false, 71 | }, 72 | endpoints: { 73 | graphQL: graphqlEndpoint.pathname, 74 | subscriptions: "/graphql/subscriptions", 75 | playground: "/playground", 76 | }, 77 | // Policies imported from ./policies 78 | policies, 79 | hooks: { 80 | authentication: authenticationMiddleware, 81 | }, 82 | }); 83 | 84 | bunjil.addSchema({ schemas: [schema] }); 85 | 86 | if (process.env.NODE_ENV === "development") { 87 | // In the dev server set CORS to localhost 88 | bunjil.koa.use( 89 | Cors({ 90 | origin: "*", 91 | }), 92 | ); 93 | } 94 | 95 | // Run the bunjil start, but dont bind the server to a port 96 | bunjil.start(); 97 | } catch (error) { 98 | console.error(error.message); 99 | console.error(error.stack); 100 | } 101 | } 102 | 103 | export { server }; 104 | -------------------------------------------------------------------------------- /examples/auth0/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "umd", 5 | "outDir": "./.build", 6 | "declaration": false, 7 | "noImplicitAny": false, 8 | "removeComments": true, 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "sourceMap": true, 12 | "inlineSources": false, 13 | "strictNullChecks": true, 14 | "watch": true, 15 | "allowSyntheticDefaultImports": false 16 | }, 17 | "filesGlob": [ 18 | "typings.d.ts", 19 | "./src/**/*.ts", 20 | "./test/**/*.ts", 21 | "**/*.css" 22 | ], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/auth0/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json" { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bunjil", 3 | "version": "1.0.9", 4 | "description": "A GraphQL bastion server", 5 | "license": "MIT", 6 | "author": "Owen Kelly ", 7 | "main": "./lib/src/", 8 | "typings": "./lib/src/", 9 | "scripts": { 10 | "prisma": "cd examples/simple && dotenv -e ../../.env -- prisma info", 11 | "example-simple-debug": "dotenv -- ts-node --inspect-brk examples/simple/src/index.ts", 12 | "example-simple-clean": "rimraf examples/simple/src/.graphql/**", 13 | "example-simple-init": "yarn example-simple-clean && cd examples/simple && dotenv -e ../../.env -- prisma deploy", 14 | "dev": "ava-ts ./tests/**/*.spec.ts --watch", 15 | "debug": "DEBUG='wahn:*,bunjil:*' yarn dev", 16 | "clean": "rimraf ./lib ./.nyc_output ./coverage", 17 | "build": "tsc -p tsconfig.json", 18 | "unit": "nyc ava", 19 | "check-coverage": "nyc check-coverage --lines 10 --functions 10 --branches 10", 20 | "test": "yarn unit && yarn check-coverage", 21 | "cov": "yarn unit && yarn html-coverage", 22 | "html-coverage": "nyc report --reporter=html", 23 | "send-coverage": "nyc report --reporter=lcov > coverage.lcov && codecov", 24 | "docs": "typedoc src/index.ts --excludePrivate --mode file --theme minimal --out lib/docs && opn lib/docs/index.html", 25 | "docs:json": "typedoc --mode file --json lib/docs/typedoc.json src/index.ts", 26 | "release-minor": "yarn run clean && yarn run build && standard-version --release-as minor", 27 | "release-patch": "yarn run clean && yarn run build && standard-version --release-as patch", 28 | "release-major": "yarn run clean && yarn run build && standard-version --sign --release-as major" 29 | }, 30 | "repository": { 31 | "type": "github", 32 | "url": "https://github.com/ojkelly/bunjil" 33 | }, 34 | "nyc": { 35 | "exclude": [ 36 | "**/*.spec.js", 37 | "examples" 38 | ] 39 | }, 40 | "ava": { 41 | "files": [ 42 | "lib/**/*.spec.js" 43 | ], 44 | "source": [ 45 | "lib/**/*" 46 | ] 47 | }, 48 | "husky": { 49 | "hooks": { 50 | "pre-commit": "pretty-quick --staged" 51 | } 52 | }, 53 | "dependencies": { 54 | "@types/debug": "0.0.30", 55 | "@types/graphql": "^0.12.7", 56 | "@types/koa": "^2.0.44", 57 | "@types/node": "^9.6.1", 58 | "@types/node-cache": "^4.1.1", 59 | "@types/winston": "^2.3.8", 60 | "apollo-cache-control": "0.1.0", 61 | "apollo-engine": "^1.0.4", 62 | "apollo-errors": "^1.7.1", 63 | "apollo-server-koa": "^1.3.4", 64 | "debug": "^3.1.0", 65 | "graphql": "0.13.2", 66 | "graphql-add-middleware": "^0.1.5", 67 | "graphql-binding": "^1.2.5", 68 | "graphql-playground-middleware-koa": "^1.4.3", 69 | "graphql-tools": "^2.23.1", 70 | "jsonwebtoken": "^8.2.0", 71 | "koa": "^2.5.0", 72 | "koa-bodyparser": "^4.2.0", 73 | "koa-compose": "^4.0.0", 74 | "koa-compress": "^2.0.0", 75 | "koa-router": "^7.4.0", 76 | "node-cache": "^4.2.0", 77 | "object-hash": "^1.3.0", 78 | "prisma-binding": "^1.5.16", 79 | "wahn": "^0.10.0", 80 | "winston": "^2.4.1" 81 | }, 82 | "devDependencies": { 83 | "@types/faker": "4.1.2", 84 | "@types/supertest": "2.0.4", 85 | "ava": "0.25.0", 86 | "ava-ts": "0.24.2", 87 | "codecov": "3.0.0", 88 | "dotenv-cli": "1.4.0", 89 | "faker": "4.1.0", 90 | "graphql-cli": "2.15.8", 91 | "husky": "0.14.3", 92 | "nyc": "11.6.0", 93 | "prisma": "1.5.1", 94 | "rimraf": "2.6.2", 95 | "standard-version": "4.3.0", 96 | "supertest": "3.0.0", 97 | "ts-node": "5.0.1", 98 | "typescript": "2.8.1", 99 | "wedgetail": "1.0.0" 100 | }, 101 | "resolutions": { 102 | "graphql": "0.13.2", 103 | "ts-node": "5.0.1" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Bunjil 2 | 3 | [![View on npm](https://img.shields.io/npm/v/bunjil.svg)](https://npmjs.org/package/bunjil) 4 | [![npm downloads](https://img.shields.io/npm/dm/bunjil.svg)](https://npmjs.org/package/bunjil) 5 | [![Dependencies](https://img.shields.io/david/ojkelly/bunjil.svg)](https://david-dm.org/ojkelly/bunjil) 6 | [![Build Status](https://travis-ci.org/ojkelly/bunjil.svg?branch=master)](https://travis-ci.org/ojkelly/bunjil) 7 | [![codecov](https://codecov.io/gh/ojkelly/bunjil/branch/master/graph/badge.svg)](https://codecov.io/gh/ojkelly/bunjil) 8 | [![NSP Status](https://nodesecurity.io/orgs/ojkelly/projects/7f441bdb-76ab-4155-aec9-00777b5adc9a/badge)](https://nodesecurity.io/orgs/ojkelly/projects/7f441bdb-76ab-4155-aec9-00777b5adc9a)[![Known Vulnerabilities](https://snyk.io/test/npm/bunjil/badge.svg)](https://snyk.io/test/npm/bunjil) 9 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fojkelly%2Fbunjil.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fojkelly%2Fbunjil?ref=badge_shield) 10 | 11 | [bunjil.js.org](https://bunjil.js.org) | [Getting Started](https://bunjil.js.org/docs/getting-started) 12 | 13 | Bunjil is a public facing GraphQL server. 14 | 15 | It comes with Policy Based authorization, and hook for your own authentication (Passport.js, Auth0, database). 16 | 17 | It’s purpose is to allow the stitching of one or more private GraphQL Schemas into a public one. 18 | 19 | 20 | # Roadmap 21 | 22 | * [x] Documentation 23 | * [x] Merge multiple GraphQL schemas into one public schema 24 | * [ ] Ability to hide Types 25 | * [ ] Ability to hide fields (masking) 26 | * [x] Policy based authorization down to the field/edge level 27 | * [x] Ability to deny access to fields based on roles with a policy 28 | * [x] Caching, and caching policies down to the field level 29 | * [x] Authentication hook 30 | * [x] Authorization hook 31 | 32 | ## Getting Started 33 | 34 | `yarn add bunjil` 35 | 36 | `npm install bunjil` 37 | 38 | ```typescript 39 | // Import Bunjil and the Policy Types 40 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "bunjil"; 41 | 42 | // Create a schema 43 | 44 | const typeDefs: string = ` 45 | type User { 46 | id: ID 47 | name: String 48 | password: String 49 | posts(limit: Int): [Post] 50 | } 51 | 52 | type Post { 53 | id: ID 54 | title: String 55 | views: Int 56 | author: User 57 | } 58 | 59 | type Query { 60 | author(id: ID): User 61 | topPosts(limit: Int): [Post] 62 | } 63 | `; 64 | 65 | // Resolvers are not shown in this example. 66 | const schema = makeExecutableSchema({ 67 | typeDefs, 68 | resolvers, 69 | }); 70 | 71 | // Create a simple policy allowing public access to the data 72 | const policies: Policy[] = [ 73 | { 74 | id: "public:read-all", 75 | resources: ["Query::topPosts", "Post::*", "User::*"], 76 | actions: ["query"], 77 | effect: PolicyEffect.Allow, 78 | roles: ["*"], 79 | }, 80 | { 81 | // Explicitly deny access to the password field. 82 | // This will superseed any other policy 83 | id: "deny:user::password", 84 | resources: ["User::password"], 85 | actions: ["query"], 86 | effect: PolicyEffect.Deny, 87 | roles: ["*"], 88 | }, 89 | ]; 90 | 91 | // Create our bunjil server 92 | const bunjil: Bunjil = new Bunjil({ 93 | // Server config 94 | server: { 95 | port: 3000, 96 | tracing: true, 97 | cacheControl: true, 98 | }, 99 | // Optionally in DEV you can enable the GraphQL playground 100 | playgroundOptions: { 101 | enabled: false, 102 | }, 103 | // Set the endpoints where GraphQL is available at 104 | endpoints: { 105 | graphQL: "/graphql", 106 | subscriptions: "/graphql/subscriptions", 107 | playground: "/playground", 108 | }, 109 | policies, 110 | }); 111 | 112 | // Add our schema to the Bunjil instance 113 | bunjil.addSchema({ schemas: [schema] }); 114 | 115 | // Now start Bunjil 116 | await bunjil.start(); 117 | ``` 118 | 119 | ### Usage 120 | 121 | ## Running the tests 122 | 123 | Use `yarn test` or `npm run test`. 124 | 125 | Tests are written with `ava`, and we would strongly like tests with any new functionality. 126 | 127 | ## Contributing 128 | 129 | Please read [CONTRIBUTING.md](https://github.com/ojkelly/bunjil/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 130 | 131 | ## Versioning 132 | 133 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/ojkelly/bunjil/tags). 134 | 135 | ## Authors 136 | 137 | * **Owen Kelly** - [ojkelly](https://github.com/ojkelly) 138 | 139 | ## License 140 | 141 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fojkelly%2Fbunjil.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fojkelly%2Fbunjil?ref=badge_large) 142 | 143 | This project is licensed under the MIT License - see the [LICENSE.md](https://github.com/ojkelly/bunjil/LICENSE.md) file for details 144 | 145 | ## Acknowledgments 146 | 147 | * [Behind the name](https://en.wikipedia.org/wiki/Bunjil) 148 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/bunjil.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema, GraphQLNamedType, GraphQLError } from "graphql"; 2 | import { GraphQLConfigData } from "graphql-config"; 3 | import { Prisma, extractFragmentReplacements, forwardTo } from "prisma-binding"; 4 | import { FragmentReplacements } from "graphql-binding/dist/types"; 5 | import { GraphQLOptions } from "apollo-server-core"; 6 | import * as koaCompress from "koa-compress"; 7 | import { addMiddleware } from "graphql-add-middleware"; 8 | import * as debug from "debug"; 9 | import * as hash from "object-hash"; 10 | import { mergeSchemas, makeExecutableSchema } from "graphql-tools"; 11 | import { 12 | IResolvers, 13 | MergeInfo, 14 | UnitOrList, 15 | } from "graphql-tools/dist/Interfaces"; 16 | import * as Koa from "koa"; 17 | import * as KoaRouter from "koa-router"; 18 | import * as KoaBody from "koa-bodyparser"; 19 | import KoaPlaygroundMiddleware from "graphql-playground-middleware-koa"; 20 | import * as winston from "winston"; 21 | import { Wahn } from "wahn"; 22 | 23 | import { ResolverError, AuthorizationError } from "./errors"; 24 | import { graphqlKoaMiddleware } from "./middleware/graphqlMiddleware"; 25 | import { sanitiseMiddleware } from "./middleware/sanitiseMiddleware"; 26 | import { persistedQueriesMiddleware } from "./middleware/persistedQueriesMiddleware"; 27 | 28 | import { noIntrospection } from "./validationRules/noIntrospection"; 29 | 30 | import { Cache } from "./cache"; 31 | 32 | import { isType } from "./utils"; 33 | import { 34 | BunjilOptions, 35 | AuthenticationMiddleware, 36 | AuthorizationCallback, 37 | AuthorizationCallbackOptions, 38 | KoaMiddleware, 39 | playgroundOptions, 40 | playgroundTheme, 41 | PlaygroundSettings, 42 | Policy, 43 | PolicyEffect, 44 | PolicyCondition, 45 | OnTypeConflictCallback, 46 | } from "./types"; 47 | 48 | // Setup debugging 49 | const info: debug.IDebugger = debug("bunjil:info"); 50 | const log: debug.IDebugger = debug("bunjil:log"); 51 | const warn: debug.IDebugger = debug("bunjil:warn"); 52 | 53 | /** 54 | * Bunjil 55 | * 56 | * A public facing GraphQL server 57 | * 58 | * TODO: Add/Test subscriptions 59 | */ 60 | class Bunjil { 61 | // [ Properties ]-------------------------------------------------------------------------- 62 | 63 | // Meta properties 64 | private debug: boolean = false; 65 | private logger: winston.LoggerInstance; 66 | 67 | public playgroundOptions: playgroundOptions = { 68 | enabled: false, 69 | }; 70 | 71 | // Koa Server properties 72 | public koa: Koa; 73 | private router: KoaRouter; 74 | public serverConfig: { 75 | port?: number; 76 | tracing: boolean; 77 | cacheControl: boolean; 78 | disableBunjilCache: boolean; 79 | useApolloCache: boolean; 80 | useApolloTracing: boolean; 81 | disableIntrospection: boolean; 82 | usePersistedQueries: boolean; 83 | }; 84 | 85 | public endpoints: { 86 | graphQL: string; 87 | subscriptions: string | undefined; 88 | playground: string | undefined; 89 | }; 90 | 91 | // GraphQL properties 92 | 93 | private graphQL: { 94 | context: any; 95 | typeDefs: string | undefined; 96 | schema: GraphQLSchema | undefined; 97 | resolvers: { 98 | Query: any; 99 | Mutation: any; 100 | Subscription: any; 101 | }; 102 | validationRules?: any; 103 | persistedQueries?: any; 104 | }; 105 | 106 | // Authorization 107 | private wahn: Wahn | undefined; 108 | 109 | // Caching 110 | private cache: Cache | undefined; 111 | 112 | private sanitiseMiddleware: KoaMiddleware; 113 | private persistedQueriesMiddleware: KoaMiddleware; 114 | 115 | // [ Constructor ]-------------------------------------------------------------------------- 116 | 117 | /** 118 | * Create a new Bunjil instance 119 | * @param options 120 | */ 121 | constructor(options: BunjilOptions) { 122 | if (options.debug) { 123 | this.debug = options.debug; 124 | } 125 | // Setup Winston as the logger, and only log when Debug enabled 126 | this.logger = new winston.Logger({ 127 | level: "info", 128 | transports: 129 | this.debug === true ? [new winston.transports.Console()] : [], 130 | }); 131 | 132 | // Add any plargoundOptions 133 | this.playgroundOptions = options.playgroundOptions; 134 | 135 | // Add the GraphQL Endpoints 136 | if ( 137 | typeof options.endpoints !== "undefined" && 138 | typeof options.endpoints.graphQL === "string" 139 | ) { 140 | this.endpoints = { 141 | ...options.endpoints, 142 | graphQL: options.endpoints.graphQL, 143 | }; 144 | } else { 145 | throw new Error("options.endpoints.graphQL is required"); 146 | } 147 | 148 | // Setup the serverConfig 149 | this.serverConfig = { 150 | ...options.server, 151 | tracing: 152 | typeof options.server.tracing === "boolean" 153 | ? options.server.tracing 154 | : false, 155 | cacheControl: 156 | typeof options.server.cacheControl === "boolean" 157 | ? options.server.cacheControl 158 | : false, 159 | disableBunjilCache: 160 | typeof options.server.disableBunjilCache === "boolean" 161 | ? options.server.disableBunjilCache 162 | : false, 163 | useApolloCache: 164 | typeof options.server.useApolloCache === "boolean" 165 | ? options.server.useApolloCache 166 | : false, 167 | useApolloTracing: 168 | typeof options.server.useApolloTracing === "boolean" 169 | ? options.server.useApolloTracing 170 | : false, 171 | disableIntrospection: 172 | typeof options.server.disableIntrospection === "boolean" 173 | ? options.server.disableIntrospection 174 | : false, 175 | usePersistedQueries: 176 | typeof options.server.usePersistedQueries === "boolean" 177 | ? options.server.usePersistedQueries 178 | : false, 179 | }; 180 | 181 | if ( 182 | this.serverConfig.disableIntrospection === true && 183 | this.playgroundOptions.enabled === true 184 | ) { 185 | throw new Error( 186 | "Can't start playground with disableIntrospection: true.", 187 | ); 188 | } 189 | 190 | if (options.server.port) { 191 | this.serverConfig.port = Number(options.server.port); 192 | } 193 | 194 | // Init the graphQL props 195 | this.graphQL = { 196 | context: {}, 197 | typeDefs: undefined, 198 | schema: undefined, 199 | resolvers: { 200 | Query: {}, 201 | Mutation: {}, 202 | Subscription: {}, 203 | }, 204 | }; 205 | 206 | // Check to see if there are any auth* hooks to monkey patch the defaults with 207 | if (typeof options.hooks !== "undefined") { 208 | if (typeof options.hooks.authentication === "function") { 209 | this.authenticationMiddleware = options.hooks.authentication; 210 | } 211 | if (typeof options.hooks.authorization === "function") { 212 | this.authorizationCallback = options.hooks.authorization; 213 | } 214 | } 215 | 216 | // Access Control 217 | if (Array.isArray(options.policies)) { 218 | this.wahn = new Wahn({ 219 | policies: options.policies, 220 | }); 221 | } 222 | 223 | // If cacheControl is on, setup a cache. 224 | if ( 225 | this.serverConfig.cacheControl === true && 226 | this.serverConfig.disableBunjilCache === false 227 | ) { 228 | this.cache = new Cache(); 229 | } 230 | 231 | // Initialise Koa and its router 232 | this.koa = new Koa(); 233 | this.router = new KoaRouter(); 234 | 235 | this.sanitiseMiddleware = sanitiseMiddleware.bind(this); 236 | this.persistedQueriesMiddleware = persistedQueriesMiddleware.bind(this); 237 | } 238 | 239 | /** 240 | * Every resolver added to Bunjil is wrapped by this hook. 241 | * This allows up to inject an authorization callback beforehand. 242 | * The authentication callback is processed at the start of the request, 243 | * bbut not here. 244 | * 245 | * @param root 246 | * @param args 247 | * @param context 248 | * @param info 249 | * @param next 250 | */ 251 | private async resolverHook( 252 | root: any, 253 | args: any, 254 | context: any, 255 | info: any, 256 | next: any, 257 | ): Promise { 258 | // construct an Resource name 259 | let resource: string = `${info.parentType.name}:`; 260 | resource = `${resource}:${info.fieldName}`; 261 | 262 | // Get the action name 263 | const action: string = info.operation.operation; 264 | 265 | try { 266 | // Attemp to authorize this resolver 267 | const authorization: boolean = this.authorizationCallback({ 268 | action, 269 | resource, 270 | context: { 271 | ...context, 272 | root, 273 | args, 274 | }, 275 | }); 276 | 277 | if (authorization === true) { 278 | let cacheKey: string | undefined = undefined; 279 | let cacheTTL: number | undefined = undefined; 280 | if ( 281 | action === "query" && 282 | this.cache && 283 | info && 284 | info.cacheControl && 285 | info.cacheControl.cacheHint && 286 | info.cacheControl.cacheHint.maxAge 287 | ) { 288 | cacheKey = `${hash(resource)}:${hash(args)}`; 289 | 290 | // If scope is private, scope the cacheKey to this user 291 | // only 292 | if ( 293 | info.cacheControl.cacheHint.scope && 294 | info.cacheControl.cacheHint.scope === "PRIVATE" && 295 | context.user.id !== null 296 | ) { 297 | cacheKey = `${cacheKey}:${context.user.id}`; 298 | } 299 | 300 | cacheTTL = info.cacheControl.cacheHint.maxAge; 301 | 302 | try { 303 | const cachedResult: any | undefined = this.cache.get( 304 | cacheKey, 305 | ); 306 | 307 | if (typeof cachedResult !== "undefined") { 308 | // this is a cache hit 309 | return cachedResult; 310 | } 311 | } catch (cacheErr) { 312 | debug(cacheErr); 313 | } 314 | } 315 | // Hand off to the graphql resolvers 316 | const result: Promise = await next(); 317 | 318 | // If the cache is enabled, cache the result 319 | if ( 320 | action === "query" && 321 | this.cache && 322 | typeof cacheKey === "string" && 323 | typeof cacheTTL === "number" 324 | ) { 325 | this.cache.set(cacheKey, result, cacheTTL); 326 | } 327 | 328 | // And return the result of the query 329 | return result; 330 | } 331 | throw new AuthorizationError("access-denied", "Access Denied"); 332 | } catch (err) { 333 | if (this.debug) { 334 | debug(`bunjil::resolverHook: ${err.message}, ${err.stack}`); 335 | } 336 | throw new AuthorizationError( 337 | err.denyType ? err.denyType : "access-denied", 338 | "Access Denied", 339 | ); 340 | } 341 | } 342 | 343 | // [ Instatition ]-------------------------------------------------------------------------- 344 | 345 | private finaliseResolvers(): void { 346 | if (typeof this.graphQL.schema === "undefined") { 347 | throw new Error("Cannot start GraphQL server, schema is undefined"); 348 | } 349 | 350 | // Add our resolverHook to every resolver 351 | addMiddleware(this.graphQL.schema, this.resolverHook.bind(this)); 352 | } 353 | 354 | /** 355 | * Add our graphQL endpoints to Koa 356 | */ 357 | private finaliseGraphqlRoutes(): void { 358 | if (typeof this.graphQL.schema === "undefined") { 359 | throw new Error("Cannot start GraphQL server, schema is undefined"); 360 | } 361 | 362 | this.finaliseResolvers(); 363 | 364 | if (this.serverConfig.disableIntrospection === true) { 365 | this.graphQL.validationRules = [noIntrospection]; 366 | } 367 | 368 | // Add the graphql POST route 369 | this.router.post( 370 | this.endpoints.graphQL, 371 | 372 | this.sanitiseMiddleware.bind(this), 373 | // Set the default anonymous user 374 | // Before we run any authentication middleware we need to set the default user 375 | // to anonymous. This lets you set a policy with the role `anonymous` to access 376 | // things like your login mutation, or public resources. 377 | async (ctx: Koa.Context, next: Function) => { 378 | ctx.user = { id: null, roles: ["anonymous"] }; 379 | await next(); 380 | }, 381 | 382 | // Now we run the authentication middleware 383 | // This should check for something like an Authentication header, and 384 | // if it can populate ctx.user with at least an id and an array of roles 385 | this.authenticationMiddleware.bind(this), 386 | 387 | // Now we run the persistedQueriesMiddleware 388 | this.persistedQueriesMiddleware.bind(this), 389 | 390 | // And now we run the actual graphQL query 391 | // In each resolver we run the authorization callback, against the data we just 392 | // added to ctx.user 393 | graphqlKoaMiddleware({ 394 | schema: this.graphQL.schema, 395 | debug: this.debug, 396 | // tracing: this.serverConfig.tracing, 397 | cacheControl: this.serverConfig.cacheControl, 398 | context: { 399 | ...this.graphQL.context, 400 | }, 401 | validationRules: this.graphQL.validationRules, 402 | }), 403 | ); 404 | 405 | // Add the graphql GET route 406 | this.router.get( 407 | this.endpoints.graphQL, 408 | graphqlKoaMiddleware({ 409 | schema: this.graphQL.schema, 410 | debug: this.debug, 411 | tracing: true, 412 | }), 413 | ); 414 | 415 | // Optionally add the playground 416 | if ( 417 | this.playgroundOptions.enabled && 418 | typeof this.endpoints.playground === "string" 419 | ) { 420 | const playgroundOptions: playgroundOptions = { 421 | ...this.playgroundOptions, 422 | endpoint: this.endpoints.graphQL, 423 | }; 424 | this.router.get( 425 | this.endpoints.playground, 426 | KoaPlaygroundMiddleware(playgroundOptions), 427 | ); 428 | } 429 | } 430 | 431 | /** 432 | * Prepare the GraphQL routes, and star the Koa server 433 | */ 434 | public async start(): Promise { 435 | this.koa.on("log", this.logger.info); 436 | 437 | // Add the graphQL routes 438 | this.logger.debug("Finalising GraphQL routes"); 439 | this.finaliseGraphqlRoutes(); 440 | 441 | this.koa.use(KoaBody()); 442 | this.koa.use(koaCompress()); 443 | 444 | // Finalise the routes for Koa 445 | this.koa.use(this.router.routes()); 446 | // Finalise the methods for Koa 447 | this.koa.use(this.router.allowedMethods()); 448 | 449 | this.logger.debug("Starting Koa"); 450 | // Start Koa 451 | this.koa.listen(this.serverConfig.port); 452 | this.logger.debug(`Bunjil running at port ${this.serverConfig.port}`); 453 | } 454 | 455 | /** 456 | * Add a resolver that use forwardsTo, and 457 | * point it to the location in context where 458 | * it is being forwarded to. 459 | */ 460 | private addForwardedResolver(resolver: any, forwardToKey: string): any { 461 | return forwardTo(forwardToKey); 462 | } 463 | 464 | // [ Setters ]---------------------------------------------------------------------------------- 465 | 466 | /** 467 | * A special handler to add a schema from Prisma 468 | * 469 | * This handler automatically adds prisma to the context, and 470 | * sets up the resolvers to forward to it 471 | */ 472 | public addPrismaSchema({ 473 | typeDefs, 474 | prisma, 475 | contextKey, 476 | }: { 477 | typeDefs: string; 478 | prisma: Prisma | any; 479 | contextKey?: string | undefined; 480 | }): void { 481 | // Defensively setup the context key 482 | let prismaContextKey: string = "prisma"; 483 | if (typeof contextKey === "string") { 484 | prismaContextKey = contextKey; 485 | } 486 | 487 | // Loop through the resolvers, and wrap them with our forwarding 488 | // call 489 | const queryResolvers: any = Object.keys(prisma.query).reduce( 490 | (accumulator: any, current: any) => { 491 | accumulator[current] = this.addForwardedResolver( 492 | current, 493 | prismaContextKey, 494 | ); 495 | return accumulator; 496 | }, 497 | {}, 498 | ); 499 | const mutationResolvers: any = Object.keys(prisma.mutation).reduce( 500 | (accumulator: any, current: any) => { 501 | accumulator[current] = this.addForwardedResolver( 502 | current, 503 | prismaContextKey, 504 | ); 505 | return accumulator; 506 | }, 507 | {}, 508 | ); 509 | const subscriptionResolvers: any = Object.keys( 510 | prisma.subscription, 511 | ).reduce((accumulator: any, current: any) => { 512 | accumulator[current] = this.addForwardedResolver( 513 | current, 514 | prismaContextKey, 515 | ); 516 | return accumulator; 517 | }, {}); 518 | 519 | // Spread the resolvers out to a new object 520 | const resolvers: any = { 521 | Query: { 522 | ...queryResolvers, 523 | }, 524 | Mutation: { 525 | ...mutationResolvers, 526 | }, 527 | Subscription: { 528 | ...subscriptionResolvers, 529 | }, 530 | }; 531 | 532 | // Create an executable schema 533 | const schema: GraphQLSchema = makeExecutableSchema({ 534 | typeDefs: [typeDefs], 535 | resolvers, 536 | }); 537 | 538 | // Add the new schema 539 | this.addSchema({ schemas: [schema] }); 540 | this.logger.debug("Added Prisma schema"); 541 | 542 | // Add Prisma to the context 543 | this.addContext(prismaContextKey, prisma); 544 | } 545 | 546 | /** 547 | * Merge new schemas with one already existing on the Bunjil instance. 548 | */ 549 | public addSchema({ 550 | schemas, 551 | onTypeConflict, 552 | resolvers, 553 | }: { 554 | schemas: (GraphQLSchema | string)[]; 555 | onTypeConflict?: OnTypeConflictCallback | undefined; 556 | resolvers?: UnitOrList< 557 | IResolvers | ((mergeInfo: MergeInfo) => IResolvers) 558 | >; 559 | }): void { 560 | // The default conflict handler will always favour the incoming type over the incumbent. 561 | // This ensures we always overwrite the existing type with the new one 562 | let onTypeConflictCallback: OnTypeConflictCallback = ( 563 | left: GraphQLNamedType, 564 | right: GraphQLNamedType, 565 | ): GraphQLNamedType => { 566 | return right; 567 | }; 568 | 569 | // However, sometimes the server many need a different conflict handler, and we allow it 570 | if (typeof onTypeConflict !== "undefined") { 571 | onTypeConflictCallback = onTypeConflictCallback; 572 | } 573 | 574 | let schemasToMerge: (GraphQLSchema | string)[]; 575 | if (typeof this.graphQL.schema === "undefined") { 576 | this.logger.debug("Added initial schema."); 577 | schemasToMerge = schemas; 578 | } else { 579 | this.logger.debug("Merging additional schema."); 580 | schemasToMerge = [this.graphQL.schema, ...schemas]; 581 | } 582 | 583 | // Merge the incumbent schema, with the newly passed schemas, and add them back onto the 584 | // Bunjil instance 585 | this.graphQL.schema = mergeSchemas({ 586 | schemas: schemasToMerge, 587 | onTypeConflict: onTypeConflictCallback, 588 | }); 589 | } 590 | 591 | /** 592 | * Add a value to the context object passed into the 593 | * GraphQL query, at the location of key 594 | */ 595 | public addContext(key: string, value: any): void { 596 | this.logger.debug(`Added '${key}' to GraphQL context.`); 597 | this.graphQL.context = { 598 | ...this.graphQL.context, 599 | [key]: value, 600 | }; 601 | } 602 | 603 | // [ Hooks ]---------------------------------------------------------------------------------- 604 | 605 | /** 606 | * Default Authentication Middleware 607 | * 608 | * In normal use, this function will never be called, as you should provide your own 609 | * authentication callback, that integrates with your authentication provider. 610 | */ 611 | public async authenticationMiddleware( 612 | ctx: Koa.Context, 613 | next: () => Promise, 614 | ): Promise { 615 | await next(); 616 | } 617 | 618 | /** 619 | * Run before a resolver calls upstream, used to verify 620 | * the current user has access to the field and operation 621 | */ 622 | public authorizationCallback({ 623 | action, 624 | resource, 625 | context, 626 | }: AuthorizationCallbackOptions): boolean { 627 | log("authorizationCallback", { 628 | action, 629 | resource, 630 | context, 631 | }); 632 | 633 | try { 634 | if (this.wahn instanceof Wahn) { 635 | const authorization: boolean = this.wahn.evaluateAccess({ 636 | context, 637 | action, 638 | resource, 639 | }); 640 | if (this.debug) { 641 | debug( 642 | JSON.stringify( 643 | { 644 | type: "authorizationCallback", 645 | action, 646 | resource, 647 | authorization, 648 | user: context.user, 649 | context, 650 | }, 651 | null, 652 | 4, 653 | ), 654 | ); 655 | } 656 | return authorization; 657 | } 658 | throw Error("Error: no policies."); 659 | } catch (err) { 660 | if (this.debug) { 661 | warn(err.message, err.stack); 662 | } 663 | throw err; 664 | } 665 | } 666 | 667 | public addPersistedQueries(persistedQueries: any): void { 668 | if (this.serverConfig.usePersistedQueries) { 669 | this.graphQL.persistedQueries = persistedQueries; 670 | } else { 671 | throw Error( 672 | "You cannot add persisted queries as server.usePersistedQueries is false.", 673 | ); 674 | } 675 | } 676 | 677 | public getPersistedQueries(): any { 678 | if (this.serverConfig.usePersistedQueries) { 679 | return this.graphQL.persistedQueries; 680 | } else { 681 | throw Error( 682 | "You cannot get persisted queries as server.usePersistedQueries is false.", 683 | ); 684 | } 685 | } 686 | } 687 | 688 | export { Bunjil, BunjilOptions }; 689 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import * as NodeCache from "node-cache"; 2 | 3 | /** 4 | * This class standardises the cache implementation 5 | * in Bunjil, and implements a simple in memory cache. 6 | */ 7 | class Cache { 8 | private cache: any; 9 | 10 | constructor(maxTTL: number = 86000, checkperiod: number = 5) { 11 | this.cache = new NodeCache({ 12 | stdTTL: maxTTL, 13 | errorOnMissing: false, 14 | checkperiod, 15 | useClones: true, 16 | }); 17 | } 18 | 19 | public set(key: string, value: any, ttl: number): any { 20 | return this.cache.set(key, value, ttl); 21 | } 22 | 23 | // If return is undefined, cache missed 24 | public get(key: string): any | undefined { 25 | return this.cache.get(key); 26 | } 27 | } 28 | 29 | export { Cache }; 30 | -------------------------------------------------------------------------------- /src/directives.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumValue } from "graphql"; 2 | import { SchemaDirectiveVisitor } from "graphql-tools"; 3 | 4 | class CacheControlDirective extends SchemaDirectiveVisitor { 5 | public visitEnumValue(value: GraphQLEnumValue) { 6 | // value.cacheControl = true; 7 | // value.ttl = this.args.ttle; 8 | } 9 | } 10 | 11 | export { CacheControlDirective }; 12 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | // [ Errors ]--------------------------------------------------------------------------------------- 2 | 3 | class ExtendableError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = this.constructor.name; 7 | this.stack = new Error(message).stack; 8 | } 9 | } 10 | 11 | class AuthorizationError extends ExtendableError { 12 | public name: string = "AuthorizationError"; 13 | constructor( 14 | public denyType: string = "Denied", 15 | public reason: string = "Access Denied", 16 | ) { 17 | super(reason); 18 | this.reason = reason; 19 | this.denyType = denyType; 20 | } 21 | } 22 | 23 | class ResolverError extends ExtendableError {} 24 | 25 | export { AuthorizationError, ResolverError, ExtendableError }; 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Bunjil } from "./bunjil"; 2 | import { 3 | BunjilOptions, 4 | AuthenticationMiddleware, 5 | AuthorizationCallback, 6 | AuthorizationCallbackOptions, 7 | playgroundOptions, 8 | playgroundTheme, 9 | PlaygroundSettings, 10 | Policy, 11 | PolicyEffect, 12 | PolicyOperator, 13 | PolicyCondition, 14 | } from "./types"; 15 | 16 | export { 17 | Bunjil, 18 | BunjilOptions, 19 | AuthenticationMiddleware, 20 | AuthorizationCallback, 21 | AuthorizationCallbackOptions, 22 | playgroundOptions, 23 | playgroundTheme, 24 | PlaygroundSettings, 25 | Policy, 26 | PolicyEffect, 27 | PolicyOperator, 28 | PolicyCondition, 29 | }; 30 | -------------------------------------------------------------------------------- /src/middleware/graphqlMiddleware.ts: -------------------------------------------------------------------------------- 1 | // Forked from apollo-server-koa 2 | // https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-koa/src/koaApollo.ts 3 | // 4 | // This is similar, but has a few additions to make authentication and authorization work for 5 | // Bunjil 6 | 7 | import { 8 | GraphQLOptions, 9 | HttpQueryError, 10 | runHttpQuery, 11 | } from "apollo-server-core"; 12 | import * as Koa from "koa"; 13 | 14 | interface KoaHandler { 15 | (req: any, next): void; 16 | } 17 | 18 | function graphqlKoaMiddleware(options: GraphQLOptions): KoaHandler { 19 | if (!options) { 20 | throw new Error("Apollo Server requires options."); 21 | } 22 | 23 | if (arguments.length > 1) { 24 | throw new Error( 25 | `Apollo Server expects exactly one argument, got ${ 26 | arguments.length 27 | }`, 28 | ); 29 | } 30 | 31 | return (ctx: any): Promise => { 32 | let serverOptions: GraphQLOptions = { 33 | ...options, 34 | schema: options.schema, 35 | }; 36 | // Add any user information to the graphQL context 37 | if (ctx.user) { 38 | serverOptions.context = { 39 | ...serverOptions.context, 40 | user: { ...ctx.user }, 41 | request: ctx.request, 42 | }; 43 | } 44 | return runHttpQuery([ctx], { 45 | method: ctx.request.method, 46 | options: serverOptions, 47 | query: 48 | ctx.request.method === "POST" 49 | ? ctx.request.body 50 | : ctx.request.query, 51 | }).then( 52 | gqlResponse => { 53 | ctx.set("Content-Type", "application/json"); 54 | ctx.body = gqlResponse; 55 | }, 56 | (error: HttpQueryError) => { 57 | if ("HttpQueryError" !== error.name) { 58 | throw error; 59 | } 60 | 61 | if (error.headers) { 62 | Object.keys(error.headers).forEach(header => { 63 | ctx.set(header, error.headers[header]); 64 | }); 65 | } 66 | 67 | ctx.status = error.statusCode; 68 | ctx.body = error.message; 69 | }, 70 | ); 71 | }; 72 | } 73 | 74 | export { graphqlKoaMiddleware, KoaHandler }; 75 | -------------------------------------------------------------------------------- /src/middleware/persistedQueriesMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Bunjil } from "../bunjil"; 2 | import { invert } from "lodash"; 3 | 4 | async function persistedQueriesMiddleware( 5 | this: Bunjil, 6 | ctx: any, 7 | next: () => Promise, 8 | ): Promise { 9 | if (this.serverConfig.usePersistedQueries) { 10 | const invertedMap = invert(this.getPersistedQueries()); 11 | ctx.req.body.query = invertedMap[ctx.req.body.id]; 12 | } 13 | await next(); 14 | } 15 | export { persistedQueriesMiddleware }; 16 | -------------------------------------------------------------------------------- /src/middleware/sanitiseMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Bunjil } from "../bunjil"; 2 | /** 3 | * Sanitisation Middleware 4 | * 5 | * This is run as the last middleware before the response is returned. 6 | */ 7 | async function sanitiseMiddleware( 8 | this: Bunjil, 9 | ctx: any, 10 | next: () => Promise, 11 | ): Promise { 12 | // Wait for the GraphQL query to resolve 13 | await next(); 14 | 15 | // Remove caching and or tracing information if there isn't 16 | // going to be an Apollo Engine proxy in front 17 | if ( 18 | this.serverConfig.useApolloCache === false || 19 | this.serverConfig.useApolloTracing === false 20 | ) { 21 | const body: any = JSON.parse(ctx.response.body); 22 | const sanitisedBody = { 23 | data: body.data, 24 | errors: body.errors, 25 | extensions: { 26 | ...body.extensions, 27 | cacheControl: this.serverConfig.useApolloCache 28 | ? body.extensions.cacheControl 29 | : undefined, 30 | tracing: this.serverConfig.useApolloTracing 31 | ? body.extensions.tracing 32 | : undefined, 33 | }, 34 | }; 35 | ctx.body = JSON.stringify(sanitisedBody); 36 | } 37 | } 38 | 39 | export { sanitiseMiddleware }; 40 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from "koa"; 2 | import { Policy, PolicyEffect, PolicyCondition, PolicyOperator } from "wahn"; 3 | import { GraphQLSchema, GraphQLNamedType, GraphQLError } from "graphql"; 4 | import { GraphQLConfigData } from "graphql-config"; 5 | 6 | interface OnTypeConflictCallback { 7 | (left: GraphQLNamedType, right: GraphQLNamedType): GraphQLNamedType; 8 | } 9 | 10 | type playgroundOptions = { 11 | enabled: boolean; 12 | endpoint?: string; 13 | subscriptionsEndpoint?: string; 14 | htmlTitle?: string; 15 | workspaceName?: string; 16 | env?: any; 17 | config?: GraphQLConfigData; 18 | settings?: PlaygroundSettings; 19 | }; 20 | declare type playgroundTheme = "dark" | "light"; 21 | interface PlaygroundSettings { 22 | ["general.betaUpdates"]: boolean; 23 | ["editor.theme"]: playgroundTheme; 24 | ["editor.reuseHeaders"]: boolean; 25 | ["tracing.hideTracingResponse"]: boolean; 26 | } 27 | 28 | type BunjilOptions = { 29 | // Meta 30 | debug?: boolean; 31 | 32 | playgroundOptions: playgroundOptions; 33 | 34 | // Koa 35 | server: { 36 | port?: number; 37 | tracing?: boolean | undefined; 38 | 39 | // Enable the Apollo Cache Control directives, and the Bunjil cache 40 | cacheControl?: boolean | undefined; 41 | 42 | // Set to true to disable Bunjil's cache, useful when cacheControl is true, as it lets you 43 | // use Apollo Engine's caching. 44 | disableBunjilCache?: boolean | undefined; 45 | 46 | // Enable to print information regarding the cacheability for use 47 | // with Apollo Engine 48 | useApolloCache?: boolean | undefined; 49 | 50 | // Enable to print tracing data for use 51 | // with Apollo Engine 52 | useApolloTracing?: boolean | undefined; 53 | 54 | // Disable introspection queries 55 | // Useful when you want to hide the schema of your public api 56 | // This will prevent playground from working 57 | disableIntrospection?: boolean | undefined; 58 | 59 | // Persisted queries 60 | usePersistedQueries?: boolean | undefined; 61 | }; 62 | 63 | // GraphQL 64 | endpoints: { 65 | graphQL: string | undefined; 66 | subscriptions: string | undefined; 67 | playground: string | undefined; 68 | }; 69 | 70 | // Access control 71 | policies: Policy[]; 72 | 73 | hooks?: { 74 | authentication?: AuthenticationMiddleware; 75 | authorization?: AuthorizationCallback; 76 | }; 77 | }; 78 | 79 | interface AuthenticationMiddleware { 80 | (ctx: Koa.Context, next: () => Promise): Promise; 81 | } 82 | interface KoaMiddleware { 83 | (ctx: Koa.Context, next: () => Promise): Promise; 84 | } 85 | 86 | type AuthorizationCallbackOptions = { 87 | action: string; 88 | resource: string; 89 | context: any; 90 | }; 91 | interface AuthorizationCallback { 92 | (AuthorizationCallbackOptions): boolean; 93 | } 94 | 95 | export { 96 | BunjilOptions, 97 | AuthenticationMiddleware, 98 | AuthorizationCallback, 99 | AuthorizationCallbackOptions, 100 | KoaMiddleware, 101 | playgroundOptions, 102 | playgroundTheme, 103 | PlaygroundSettings, 104 | Policy, 105 | PolicyEffect, 106 | PolicyOperator, 107 | PolicyCondition, 108 | OnTypeConflictCallback, 109 | }; 110 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | function isType(type: string, name: string, value: any): boolean | TypeError { 2 | if (typeof value === type) { 3 | return true; 4 | } 5 | throw new TypeError(`${name} is not of type '${type}'`); 6 | } 7 | 8 | export { isType }; 9 | -------------------------------------------------------------------------------- /src/validationRules/noIntrospection.ts: -------------------------------------------------------------------------------- 1 | // From/inspired by https://github.com/helfer/graphql-disable-introspection/blob/master/index.js 2 | 3 | import * as graphql from "graphql"; 4 | 5 | function noIntrospection(context) { 6 | return { 7 | Field(node) { 8 | if ( 9 | node.name.value === "__schema" || 10 | node.name.value === "__type" 11 | ) { 12 | context.reportError( 13 | new graphql.GraphQLError( 14 | "GraphQL introspection is disabled.", 15 | [node], 16 | ), 17 | ); 18 | } 19 | }, 20 | }; 21 | } 22 | 23 | export { noIntrospection }; 24 | -------------------------------------------------------------------------------- /tests/integration/authorization.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as faker from "faker"; 3 | import * as Koa from "koa"; 4 | import { 5 | mockServer, 6 | MockList, 7 | makeExecutableSchema, 8 | addMockFunctionsToSchema, 9 | } from "graphql-tools"; 10 | 11 | import * as request from "supertest"; 12 | import * as jwt from "jsonwebtoken"; 13 | 14 | import { 15 | Bunjil, 16 | Policy, 17 | PolicyEffect, 18 | PolicyOperator, 19 | PolicyCondition, 20 | AuthenticationMiddleware, 21 | } from "../../src/index"; 22 | 23 | test("Can authenticate, and run authorized query", async t => { 24 | const topPostsLimit: number = 10; 25 | // This is a test, so security is not really an issue 26 | // But dont do this in production, use a real random secret 27 | const jwtSecret = faker.random.uuid(); 28 | 29 | const typeDefs: string = ` 30 | type User { 31 | id: ID 32 | name: String 33 | email: String 34 | password: String 35 | roles: [String] 36 | posts(limit: Int): [Post] 37 | } 38 | 39 | type Post { 40 | id: ID 41 | title: String 42 | views: Int 43 | author: User 44 | } 45 | 46 | type LoginResponse { 47 | token: String 48 | } 49 | 50 | type Query { 51 | author(id: ID): User 52 | topPosts(limit: Int): [Post] 53 | } 54 | type Mutation { 55 | login(email: String, password: String): LoginResponse 56 | } 57 | `; 58 | 59 | const userId: string = faker.random.uuid(); 60 | const name: string = faker.name.findName(); 61 | const email: string = faker.internet.email(); 62 | const password: string = faker.internet.password(); 63 | const roles: string[] = [faker.commerce.department()]; 64 | const issuer: string = "BunjilTest"; 65 | 66 | const schema = makeExecutableSchema({ typeDefs }); 67 | addMockFunctionsToSchema({ 68 | schema, 69 | mocks: { 70 | Query: () => ({ 71 | topPosts: () => new MockList(topPostsLimit), 72 | }), 73 | Mutation: () => ({ 74 | // This is a psuedo login function 75 | login: (root: any, args: any, context: any, info: any) => { 76 | if (args.email === email && args.password === password) { 77 | return { 78 | // return the jwt in the token field 79 | token: jwt.sign( 80 | { email, roles, name, userId }, 81 | jwtSecret, // sign the jwt with our jwt secret 82 | { 83 | issuer, 84 | }, 85 | ), 86 | }; 87 | } 88 | }, 89 | }), 90 | User: () => ({ 91 | id: userId, 92 | name, 93 | email, 94 | password, 95 | roles, 96 | }), 97 | }, 98 | }); 99 | 100 | // This is an example of a simple authentication callback that uses a server signed JWT 101 | // The important bit is extracting the `id` and an array of `roles` that we put on the 102 | // ctx.user object. 103 | const authenticationMiddleware: AuthenticationMiddleware = async ( 104 | ctx: Koa.Context, 105 | next: () => Promise, 106 | ): Promise => { 107 | // Check the Authentication header for a Bearer token 108 | if ( 109 | ctx && 110 | ctx.request && 111 | ctx.request.header && 112 | ctx.request.header.authorization 113 | ) { 114 | // If there is a token, decode it 115 | const decoded: any = jwt.verify( 116 | ctx.request.header.authorization.replace("Bearer: ", ""), 117 | jwtSecret, 118 | { 119 | issuer, 120 | }, 121 | ); 122 | // Add that to ctx.user 123 | ctx.user = { 124 | ...decoded, 125 | }; 126 | } 127 | 128 | // hand off to the next 129 | await next(); 130 | }; 131 | 132 | const policies: Policy[] = [ 133 | { 134 | // Allow access to fields only by authenticated users 135 | id: faker.random.uuid(), 136 | resources: [ 137 | "Query::topPosts", 138 | "Query::author", 139 | "Post::*", 140 | "User::*", 141 | ], 142 | actions: ["query"], 143 | effect: PolicyEffect.Allow, 144 | roles, 145 | }, 146 | { 147 | // Allow anyone to login 148 | id: faker.random.uuid(), 149 | resources: ["Mutation::login", "LoginResponse::token"], 150 | actions: ["mutation"], 151 | effect: PolicyEffect.Allow, 152 | roles: ["anonymous"], 153 | }, 154 | { 155 | // Add explicit deny for the password field 156 | id: faker.random.uuid(), 157 | resources: ["User::password"], 158 | actions: ["query"], 159 | effect: PolicyEffect.Deny, 160 | roles: ["*"], 161 | }, 162 | ]; 163 | 164 | const endpoints = { 165 | graphQL: "/graphql", 166 | subscriptions: "/graphql/subscriptions", 167 | playground: "/playground", 168 | }; 169 | 170 | const bunjil: Bunjil = new Bunjil({ 171 | server: { 172 | tracing: false, 173 | cacheControl: false, 174 | }, 175 | playgroundOptions: { 176 | enabled: false, 177 | }, 178 | debug: false, 179 | endpoints, 180 | policies, 181 | // Here is where we add the authentication callback 182 | hooks: { 183 | authentication: authenticationMiddleware, 184 | }, 185 | }); 186 | 187 | bunjil.addSchema({ schemas: [schema] }); 188 | 189 | // Run the bunjil start, but dont bind the server to a port 190 | await bunjil.start(); 191 | 192 | // Create the server 193 | const server: any = await request(bunjil.koa.callback()); 194 | 195 | // Send a login mutation 196 | const login = await server.post(endpoints.graphQL).send({ 197 | query: ` 198 | mutation login { 199 | login(email: "${email}", password: "${password}") { 200 | token 201 | } 202 | } 203 | `, 204 | }); 205 | 206 | // Test the response of the login request 207 | t.is(login.status, 200); 208 | t.notDeepEqual(login.body.data, { 209 | login: null, 210 | }); 211 | t.is(login.body.data.errors, undefined); 212 | if (login.body.data.login) { 213 | t.true(typeof login.body.data.login.token === "string"); 214 | } 215 | 216 | // Save the auth token 217 | const authorizationToken: string = login.body.data.login.token; 218 | 219 | // Try an authenticated request 220 | const topPosts = await server 221 | .post(endpoints.graphQL) 222 | .set("Authorization", `Bearer: ${authorizationToken}`) 223 | .send({ 224 | query: ` 225 | query topPosts { 226 | topPosts(limit: ${topPostsLimit}) { 227 | id 228 | title 229 | views 230 | author { 231 | id 232 | name 233 | } 234 | } 235 | } 236 | `, 237 | }); 238 | t.is(topPosts.status, 200); 239 | t.notDeepEqual(topPosts.body.data, { 240 | topPosts: null, 241 | }); 242 | t.is(topPosts.body.data.errors, undefined); 243 | if (topPosts.body.data.topPosts) { 244 | t.is(topPosts.body.data.topPosts.length, topPostsLimit); 245 | } 246 | 247 | // Try a request with a field that has been explicitly denied 248 | const passwordRequest = await server 249 | .post(endpoints.graphQL) 250 | .set("Authorization", `Bearer: ${authorizationToken}`) 251 | .send({ 252 | query: ` 253 | query author { 254 | author(id: "${userId}") { 255 | id 256 | name 257 | email 258 | roles 259 | password 260 | } 261 | } 262 | `, 263 | }); 264 | 265 | // This query should succeed but password MUST be null 266 | t.is(passwordRequest.status, 200); 267 | t.deepEqual(passwordRequest.body.data, { 268 | author: { 269 | id: userId, 270 | name, 271 | email, 272 | roles, 273 | password: null, 274 | }, 275 | }); 276 | }); 277 | 278 | /** 279 | * Goal: We would like the capability to make a rule that can restrict access to a Type based on 280 | * a comparison of the context (ie userId) and the returned information from the resolver 281 | * (ie, userId on the Post type). 282 | */ 283 | test("Restrict access to a type based on userId", async t => { 284 | // This is a test, so security is not really an issue 285 | // But dont do this in production, use a real random secret 286 | const jwtSecret = faker.random.uuid(); 287 | 288 | const typeDefs: string = ` 289 | type User { 290 | id: ID 291 | name: String 292 | email: String 293 | password: String 294 | roles: [String] 295 | } 296 | 297 | type LoginResponse { 298 | token: String 299 | } 300 | 301 | type Query { 302 | User(id: ID): User 303 | } 304 | type Mutation { 305 | login(email: String, password: String): LoginResponse 306 | updatePassword(id: ID, password: String): User 307 | } 308 | `; 309 | 310 | // This is our logged in user 311 | const userId: string = faker.random.uuid(); 312 | const name: string = faker.name.findName(); 313 | const email: string = faker.internet.email(); 314 | const password: string = faker.internet.password(); 315 | const roles: string[] = [faker.commerce.department()]; 316 | const issuer: string = "BunjilTest"; 317 | 318 | const schema = makeExecutableSchema({ typeDefs }); 319 | 320 | addMockFunctionsToSchema({ 321 | schema, 322 | mocks: { 323 | Query: () => ({ 324 | employeeSalary: (employeeId: string) => {}, 325 | }), 326 | Mutation: () => ({ 327 | // This is a psuedo login function 328 | login: (root: any, args: any, context: any, info: any) => { 329 | if (args.email === email && args.password === password) { 330 | return { 331 | // return the jwt in the token field 332 | token: jwt.sign( 333 | { email, roles, name, userId }, 334 | jwtSecret, // sign the jwt with our jwt secret 335 | { 336 | issuer, 337 | }, 338 | ), 339 | }; 340 | } 341 | }, 342 | }), 343 | User: (root: any, args: any, context: any, info: any) => { 344 | // This is just mimicking a database lookup 345 | if (args.id === userId) { 346 | return { 347 | id: userId, 348 | name, 349 | email, 350 | password, 351 | roles, 352 | }; 353 | } else { 354 | // This just mimics returning the user that was requested. 355 | return { 356 | id: args.id, 357 | name: faker.name.findName(), 358 | email: faker.internet.email(), 359 | password: faker.internet.password(), 360 | roles: [faker.commerce.department()], 361 | }; 362 | } 363 | }, 364 | }, 365 | }); 366 | 367 | const authenticationMiddleware: AuthenticationMiddleware = async ( 368 | ctx: Koa.Context, 369 | next: () => Promise, 370 | ): Promise => { 371 | // Check the Authentication header for a Bearer token 372 | if ( 373 | ctx && 374 | ctx.request && 375 | ctx.request.header && 376 | ctx.request.header.authorization 377 | ) { 378 | // If there is a token, decode it 379 | const decoded: any = jwt.verify( 380 | ctx.request.header.authorization.replace("Bearer: ", ""), 381 | jwtSecret, 382 | { 383 | issuer, 384 | }, 385 | ); 386 | // Add that to ctx.user 387 | ctx.user = { 388 | ...decoded, 389 | }; 390 | } 391 | 392 | // hand off to the next 393 | await next(); 394 | }; 395 | 396 | const policies: Policy[] = [ 397 | { 398 | // Allow access to fields only by authenticated users 399 | id: faker.random.uuid(), 400 | resources: ["Query::*", "User::*"], 401 | actions: ["query", "mutation"], 402 | effect: PolicyEffect.Allow, 403 | roles, 404 | }, 405 | { 406 | // Allow anyone to login 407 | id: faker.random.uuid(), 408 | resources: ["Mutation::login", "LoginResponse::token"], 409 | actions: ["mutation"], 410 | effect: PolicyEffect.Allow, 411 | roles: ["anonymous"], 412 | }, 413 | { 414 | // Add explicit deny for the password field 415 | id: faker.random.uuid(), 416 | resources: ["User::password"], 417 | actions: ["*"], 418 | effect: PolicyEffect.Deny, 419 | roles: ["*"], 420 | }, 421 | { 422 | id: faker.random.uuid(), 423 | resources: ["User::*"], 424 | actions: ["*"], 425 | effect: PolicyEffect.Deny, 426 | roles: ["*"], 427 | conditions: [ 428 | { 429 | field: "user.userId", 430 | operator: PolicyOperator.notMatch, 431 | expectedOnContext: ["root.id"], 432 | } as PolicyCondition, 433 | ], 434 | }, 435 | { 436 | id: faker.random.uuid(), 437 | resources: ["Mutation::updatePassword"], 438 | actions: ["mutation"], 439 | effect: PolicyEffect.Allow, 440 | roles: ["*"], 441 | conditions: [ 442 | { 443 | field: "user.userId", 444 | operator: PolicyOperator.match, 445 | expectedOnContext: ["args.id"], 446 | } as PolicyCondition, 447 | ], 448 | }, 449 | ]; 450 | 451 | const endpoints = { 452 | graphQL: "/graphql", 453 | subscriptions: "/graphql/subscriptions", 454 | playground: "/playground", 455 | }; 456 | 457 | const bunjil: Bunjil = new Bunjil({ 458 | server: { 459 | tracing: false, 460 | cacheControl: false, 461 | }, 462 | playgroundOptions: { 463 | enabled: false, 464 | }, 465 | debug: false, 466 | endpoints, 467 | policies, 468 | // Here is where we add the authentication callback 469 | hooks: { 470 | authentication: authenticationMiddleware, 471 | }, 472 | }); 473 | 474 | bunjil.addSchema({ schemas: [schema] }); 475 | 476 | // Run the bunjil start, but dont bind the server to a port 477 | await bunjil.start(); 478 | 479 | // Create the server 480 | const server: any = await request(bunjil.koa.callback()); 481 | 482 | // Send a login mutation 483 | const login = await server.post(endpoints.graphQL).send({ 484 | query: ` 485 | mutation login { 486 | login(email: "${email}", password: "${password}") { 487 | token 488 | } 489 | } 490 | `, 491 | }); 492 | 493 | // Test the response of the login request 494 | t.is(login.status, 200); 495 | t.notDeepEqual(login.body.data, { 496 | login: null, 497 | }); 498 | t.is(login.body.data.errors, undefined); 499 | if (login.body.data.login) { 500 | t.true(typeof login.body.data.login.token === "string"); 501 | } 502 | 503 | // Save the auth token 504 | const authorizationToken: string = login.body.data.login.token; 505 | 506 | // Try an authenticated request 507 | const getCurrentUser = await server 508 | .post(endpoints.graphQL) 509 | .set("Authorization", `Bearer: ${authorizationToken}`) 510 | .send({ 511 | query: ` 512 | query user { 513 | User(id: "${userId}") { 514 | id 515 | name 516 | email 517 | } 518 | } 519 | `, 520 | }); 521 | 522 | t.is(getCurrentUser.status, 200); 523 | t.deepEqual(getCurrentUser.body.data, { 524 | User: { 525 | id: userId, 526 | name: name, 527 | email: email, 528 | }, 529 | }); 530 | 531 | // This should fail 532 | const getAnotherUser = await server 533 | .post(endpoints.graphQL) 534 | .set("Authorization", `Bearer: ${authorizationToken}`) 535 | .send({ 536 | query: ` 537 | query user { 538 | User(id: "${faker.random.uuid()}") { 539 | id 540 | name 541 | email 542 | } 543 | } 544 | `, 545 | }); 546 | 547 | t.is(getAnotherUser.status, 200); 548 | 549 | // Update own password 550 | const updateMyPassword = await server 551 | .post(endpoints.graphQL) 552 | .set("Authorization", `Bearer: ${authorizationToken}`) 553 | .send({ 554 | query: ` 555 | mutation updateMyPassword { 556 | updatePassword(id: "${userId}", password: "${password}") { 557 | id 558 | name 559 | email 560 | } 561 | } 562 | `, 563 | }); 564 | t.is(updateMyPassword.status, 200); 565 | 566 | const updateSomeoneElsesPassword = await server 567 | .post(endpoints.graphQL) 568 | .set("Authorization", `Bearer: ${authorizationToken}`) 569 | .send({ 570 | query: ` 571 | mutation updateMyPassword { 572 | updatePassword(id: "${faker.random.uuid()}", password: "${password}") { 573 | id 574 | name 575 | email 576 | } 577 | } 578 | `, 579 | }); 580 | t.is(updateSomeoneElsesPassword.status, 200); 581 | }); 582 | -------------------------------------------------------------------------------- /tests/integration/cacheControl.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as faker from "faker"; 3 | import * as Koa from "koa"; 4 | import * as util from "util"; 5 | import { 6 | mockServer, 7 | MockList, 8 | makeExecutableSchema, 9 | addMockFunctionsToSchema, 10 | } from "graphql-tools"; 11 | import "apollo-cache-control"; 12 | import * as request from "supertest"; 13 | import { timeExecution, TimedPerformance, Timings } from "wedgetail"; 14 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "../../src/index"; 15 | 16 | // https://github.com/lirown/graphql-custom-directive 17 | 18 | test("Can cache top level queries", async t => { 19 | const topPostsLimit: number = 10; 20 | 21 | const typeDefs: string = ` 22 | type User { 23 | id: ID 24 | name: String 25 | password: String 26 | posts(limit: Int): [Post] 27 | } 28 | 29 | type Post { 30 | id: ID 31 | title: String 32 | views: Int 33 | author: User 34 | } 35 | 36 | type Query { 37 | author(id: ID): User 38 | topPosts(limit: Int): [Post] @cacheControl(maxAge: 3000) 39 | } 40 | `; 41 | const schema = makeExecutableSchema({ typeDefs }); 42 | addMockFunctionsToSchema({ 43 | schema, 44 | mocks: { 45 | Query: () => ({ 46 | topPosts: () => new MockList(topPostsLimit), 47 | }), 48 | }, 49 | }); 50 | 51 | const policies: Policy[] = [ 52 | { 53 | id: faker.random.uuid(), 54 | resources: ["Query::topPosts", "Post::*", "User::*"], 55 | actions: ["query"], 56 | effect: PolicyEffect.Allow, 57 | roles: ["*"], 58 | }, 59 | ]; 60 | 61 | const endpoints = { 62 | graphQL: "/graphql", 63 | subscriptions: "/graphql/subscriptions", 64 | playground: "/playground", 65 | }; 66 | 67 | const bunjil: Bunjil = new Bunjil({ 68 | server: { 69 | tracing: false, 70 | cacheControl: true, 71 | }, 72 | playgroundOptions: { 73 | enabled: false, 74 | }, 75 | debug: true, 76 | endpoints, 77 | policies, 78 | }); 79 | 80 | bunjil.addSchema({ schemas: [schema] }); 81 | 82 | // Run the bunjil start, but dont bind the server to a port 83 | await bunjil.start(); 84 | 85 | // Create the server 86 | const server: any = await request(bunjil.koa.callback()); 87 | 88 | const res: any = await server.post(endpoints.graphQL).send({ 89 | query: ` 90 | query getTopPosts { 91 | topPosts(limit: ${topPostsLimit}) { 92 | id 93 | title 94 | views 95 | author { 96 | id 97 | name 98 | } 99 | } 100 | } 101 | `, 102 | }); 103 | 104 | // console.log(JSON.stringify(res.body, null, 4)); 105 | 106 | t.is(res.status, 200); 107 | t.notDeepEqual(res.body.data, { 108 | topPosts: null, 109 | }); 110 | t.is(res.body.data.errors, undefined); 111 | t.is(res.body.data.topPosts.length, topPostsLimit); 112 | 113 | const res2: any = await server.post(endpoints.graphQL).send({ 114 | query: ` 115 | query getTopPosts { 116 | topPosts(limit: ${topPostsLimit}) { 117 | id 118 | title 119 | views 120 | author { 121 | id 122 | name 123 | } 124 | } 125 | } 126 | `, 127 | }); 128 | t.deepEqual(res.body.data, res2.body.data); 129 | }); 130 | 131 | test("Can cache individual fields", async t => { 132 | const topPostsLimit: number = 1; 133 | 134 | const typeDefs: string = ` 135 | type User { 136 | id: ID @cacheControl(maxAge: 500) 137 | name: String @cacheControl(maxAge: 500) 138 | password: String 139 | posts(limit: Int): [Post] 140 | } 141 | 142 | type Post { 143 | id: ID @cacheControl(maxAge: 500) 144 | title: String @cacheControl(maxAge: 500) 145 | views: Int @cacheControl(maxAge: 500) 146 | author: User 147 | } 148 | 149 | type Query { 150 | author(id: ID): User 151 | topPosts(limit: Int): [Post] 152 | } 153 | `; 154 | const schema = makeExecutableSchema({ typeDefs }); 155 | addMockFunctionsToSchema({ 156 | schema, 157 | mocks: { 158 | Query: () => ({ 159 | topPosts: () => new MockList(topPostsLimit), 160 | }), 161 | }, 162 | }); 163 | 164 | const policies: Policy[] = [ 165 | { 166 | id: faker.random.uuid(), 167 | resources: ["Query::topPosts", "Post::*", "User::*"], 168 | actions: ["query"], 169 | effect: PolicyEffect.Allow, 170 | roles: ["*"], 171 | }, 172 | ]; 173 | 174 | const endpoints = { 175 | graphQL: "/graphql", 176 | subscriptions: "/graphql/subscriptions", 177 | playground: "/playground", 178 | }; 179 | 180 | const bunjil: Bunjil = new Bunjil({ 181 | server: { 182 | tracing: false, 183 | cacheControl: true, 184 | }, 185 | playgroundOptions: { 186 | enabled: false, 187 | }, 188 | debug: true, 189 | endpoints, 190 | policies, 191 | }); 192 | 193 | bunjil.addSchema({ schemas: [schema] }); 194 | 195 | // Run the bunjil start, but dont bind the server to a port 196 | await bunjil.start(); 197 | 198 | // Create the server 199 | const server: any = await request(bunjil.koa.callback()); 200 | 201 | const res: any = await server.post(endpoints.graphQL).send({ 202 | query: ` 203 | query getTopPosts { 204 | topPosts(limit: ${topPostsLimit}) { 205 | id 206 | title 207 | views 208 | author { 209 | id 210 | name 211 | } 212 | } 213 | } 214 | `, 215 | }); 216 | 217 | // console.log(JSON.stringify(res.body, null, 4)); 218 | 219 | t.is(res.status, 200); 220 | t.notDeepEqual(res.body.data, { 221 | topPosts: null, 222 | }); 223 | t.is(res.body.data.errors, undefined); 224 | t.is(res.body.data.topPosts.length, topPostsLimit); 225 | 226 | const res2: any = await server.post(endpoints.graphQL).send({ 227 | query: ` 228 | query getTopPosts { 229 | topPosts(limit: ${topPostsLimit}) { 230 | id 231 | title 232 | views 233 | author { 234 | id 235 | name 236 | } 237 | } 238 | } 239 | `, 240 | }); 241 | t.deepEqual(res.body.data, res2.body.data); 242 | }); 243 | -------------------------------------------------------------------------------- /tests/integration/noIntrospection.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as faker from "faker"; 3 | import * as Koa from "koa"; 4 | import * as util from "util"; 5 | import { 6 | mockServer, 7 | MockList, 8 | makeExecutableSchema, 9 | addMockFunctionsToSchema, 10 | } from "graphql-tools"; 11 | 12 | import * as request from "supertest"; 13 | import { timeExecution, TimedPerformance, Timings } from "wedgetail"; 14 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "../../src/index"; 15 | 16 | test("Can disable introspection", async t => { 17 | const topPostsLimit: number = 10; 18 | 19 | const typeDefs: string = ` 20 | type User { 21 | id: ID 22 | name: String 23 | password: String 24 | posts(limit: Int): [Post] 25 | } 26 | 27 | type Post { 28 | id: ID 29 | title: String 30 | views: Int 31 | author: User 32 | } 33 | 34 | type Query { 35 | author(id: ID): User 36 | topPosts(limit: Int): [Post] 37 | } 38 | `; 39 | const schema = makeExecutableSchema({ typeDefs }); 40 | addMockFunctionsToSchema({ 41 | schema, 42 | mocks: { 43 | Query: () => ({ 44 | topPosts: () => new MockList(topPostsLimit), 45 | }), 46 | }, 47 | }); 48 | 49 | const policies: Policy[] = [ 50 | { 51 | id: faker.random.uuid(), 52 | resources: ["Query::topPosts", "Post::*", "User::*"], 53 | actions: ["query"], 54 | effect: PolicyEffect.Allow, 55 | roles: ["*"], 56 | }, 57 | ]; 58 | 59 | const endpoints = { 60 | graphQL: "/graphql", 61 | subscriptions: "/graphql/subscriptions", 62 | playground: "/playground", 63 | }; 64 | 65 | const bunjil: Bunjil = new Bunjil({ 66 | server: { 67 | tracing: false, 68 | cacheControl: false, 69 | disableIntrospection: true, 70 | }, 71 | playgroundOptions: { 72 | enabled: false, 73 | }, 74 | endpoints, 75 | policies, 76 | }); 77 | 78 | bunjil.addSchema({ schemas: [schema] }); 79 | 80 | // Run the bunjil start, but dont bind the server to a port 81 | await bunjil.start(); 82 | const server: any = await request(bunjil.koa.callback()); 83 | 84 | // Send a login mutation 85 | const introspectionQuery = await server.post(endpoints.graphQL).send({ 86 | query: ` 87 | query { 88 | __schema { 89 | types { 90 | name 91 | kind 92 | description 93 | fields { 94 | name 95 | } 96 | } 97 | } 98 | } 99 | `, 100 | }); 101 | 102 | t.is(introspectionQuery.status, 400); 103 | t.is(introspectionQuery.body.data, undefined); 104 | 105 | t.is( 106 | introspectionQuery.body.errors[0].message, 107 | "GraphQL introspection is disabled.", 108 | ); 109 | }); 110 | -------------------------------------------------------------------------------- /tests/integration/schemaMerging.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as faker from "faker"; 3 | import * as Koa from "koa"; 4 | import * as util from "util"; 5 | import { 6 | mockServer, 7 | MockList, 8 | makeExecutableSchema, 9 | addMockFunctionsToSchema, 10 | } from "graphql-tools"; 11 | 12 | import * as request from "supertest"; 13 | import { timeExecution, TimedPerformance, Timings } from "wedgetail"; 14 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "../../src/index"; 15 | 16 | test("Can merge schemas, and mask a type", async t => { 17 | const topPostsLimit: number = 10; 18 | 19 | const typeDefs: string = ` 20 | type User { 21 | id: ID 22 | name: String 23 | email: String 24 | password: String 25 | } 26 | 27 | type Post { 28 | id: ID 29 | title: String 30 | views: Int 31 | author: User 32 | } 33 | 34 | type Query { 35 | User(id: ID): User 36 | topPosts(limit: Int): [Post] 37 | } 38 | `; 39 | const schema = makeExecutableSchema({ typeDefs }); 40 | 41 | addMockFunctionsToSchema({ 42 | schema, 43 | mocks: { 44 | Query: () => ({ 45 | topPosts: () => new MockList(topPostsLimit), 46 | User: () => ({ 47 | id: faker.random.uuid(), 48 | name: faker.name.findName(), 49 | email: faker.internet.email(), 50 | password: faker.internet.password(), 51 | }), 52 | }), 53 | }, 54 | }); 55 | 56 | const policies: Policy[] = [ 57 | { 58 | id: faker.random.uuid(), 59 | resources: ["Query::*", "User::*", "Posts:*"], 60 | actions: ["query"], 61 | effect: PolicyEffect.Allow, 62 | roles: ["*"], 63 | }, 64 | ]; 65 | 66 | const endpoints = { 67 | graphQL: "/graphql", 68 | subscriptions: "/graphql/subscriptions", 69 | playground: "/playground", 70 | }; 71 | 72 | const bunjil: Bunjil = new Bunjil({ 73 | server: { 74 | tracing: false, 75 | cacheControl: false, 76 | }, 77 | playgroundOptions: { 78 | enabled: false, 79 | }, 80 | endpoints, 81 | policies, 82 | }); 83 | 84 | bunjil.addSchema({ schemas: [schema] }); 85 | 86 | const maskingTypeDefs: string = ` 87 | type User { 88 | id: ID 89 | name: String 90 | email: String 91 | location: String 92 | } 93 | type Query { 94 | User(id: ID): User 95 | } 96 | 97 | `; 98 | 99 | const maskingSchema = makeExecutableSchema({ typeDefs: maskingTypeDefs }); 100 | addMockFunctionsToSchema({ 101 | schema: maskingSchema, 102 | mocks: { 103 | Query: () => ({ 104 | User: () => ({ 105 | id: faker.random.uuid(), 106 | name: faker.name.findName(), 107 | email: faker.internet.email(), 108 | }), 109 | }), 110 | }, 111 | }); 112 | bunjil.addSchema({ schemas: [maskingSchema] }); 113 | 114 | // Run the bunjil start, but dont bind the server to a port 115 | await bunjil.start(); 116 | 117 | // Create the server 118 | const server: any = await request(bunjil.koa.callback()); 119 | 120 | const res: any = await server.post(endpoints.graphQL).send({ 121 | query: ` 122 | query getUser { 123 | User { 124 | id 125 | name 126 | email 127 | location 128 | } 129 | } 130 | `, 131 | }); 132 | 133 | t.is(res.status, 200); 134 | t.false( 135 | typeof res !== "undefined" && 136 | typeof res.body !== "undefined" && 137 | typeof res.body.User !== "undefined" && 138 | typeof res.body.data.User.password === "string", 139 | "Masking failed, password field exists", 140 | ); 141 | 142 | // Try an authenticated request 143 | const topPosts = await server.post(endpoints.graphQL).send({ 144 | query: ` 145 | query topPosts { 146 | topPosts(limit: ${topPostsLimit}) { 147 | id 148 | title 149 | views 150 | author { 151 | id 152 | name 153 | } 154 | } 155 | } 156 | `, 157 | }); 158 | t.is(topPosts.status, 200); 159 | t.notDeepEqual(topPosts.body.data, { 160 | topPosts: null, 161 | }); 162 | t.is(topPosts.body.data.errors, undefined); 163 | if (topPosts.body.data.topPosts) { 164 | t.is(topPosts.body.data.topPosts.length, topPostsLimit); 165 | } 166 | }); 167 | -------------------------------------------------------------------------------- /tests/integration/simpleServer.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import * as faker from "faker"; 3 | import * as Koa from "koa"; 4 | import * as util from "util"; 5 | import { 6 | mockServer, 7 | MockList, 8 | makeExecutableSchema, 9 | addMockFunctionsToSchema, 10 | } from "graphql-tools"; 11 | 12 | import * as request from "supertest"; 13 | import { timeExecution, TimedPerformance, Timings } from "wedgetail"; 14 | import { Bunjil, Policy, PolicyCondition, PolicyEffect } from "../../src/index"; 15 | 16 | test("Can create server with a simple schema, and respond to query", async t => { 17 | const topPostsLimit: number = 10; 18 | 19 | const typeDefs: string = ` 20 | type User { 21 | id: ID 22 | name: String 23 | password: String 24 | posts(limit: Int): [Post] 25 | } 26 | 27 | type Post { 28 | id: ID 29 | title: String 30 | views: Int 31 | author: User 32 | } 33 | 34 | type Query { 35 | author(id: ID): User 36 | topPosts(limit: Int): [Post] 37 | } 38 | `; 39 | const schema = makeExecutableSchema({ typeDefs }); 40 | addMockFunctionsToSchema({ 41 | schema, 42 | mocks: { 43 | Query: () => ({ 44 | topPosts: () => new MockList(topPostsLimit), 45 | }), 46 | }, 47 | }); 48 | 49 | const policies: Policy[] = [ 50 | { 51 | id: faker.random.uuid(), 52 | resources: ["Query::topPosts", "Post::*", "User::*"], 53 | actions: ["query"], 54 | effect: PolicyEffect.Allow, 55 | roles: ["*"], 56 | }, 57 | ]; 58 | 59 | const endpoints = { 60 | graphQL: "/graphql", 61 | subscriptions: "/graphql/subscriptions", 62 | playground: "/playground", 63 | }; 64 | 65 | const bunjil: Bunjil = new Bunjil({ 66 | server: { 67 | tracing: false, 68 | cacheControl: false, 69 | }, 70 | playgroundOptions: { 71 | enabled: false, 72 | }, 73 | endpoints, 74 | policies, 75 | }); 76 | 77 | bunjil.addSchema({ schemas: [schema] }); 78 | 79 | // Run the bunjil start, but dont bind the server to a port 80 | await bunjil.start(); 81 | 82 | const res: any = await request(bunjil.koa.callback()) 83 | .post(endpoints.graphQL) 84 | .send({ 85 | query: ` 86 | query getTopPosts { 87 | topPosts(limit: ${topPostsLimit}) { 88 | id 89 | title 90 | views 91 | author { 92 | id 93 | name 94 | } 95 | } 96 | } 97 | `, 98 | }); 99 | 100 | t.is(res.status, 200); 101 | t.notDeepEqual(res.body.data, { 102 | topPosts: null, 103 | }); 104 | t.is(res.body.data.errors, undefined); 105 | t.is(res.body.data.topPosts.length, topPostsLimit); 106 | }); 107 | 108 | test("Performance of simple query with policy", async t => { 109 | const numOfTimedFunctionCalls: number = 50000; 110 | 111 | const allowedPerformance: Timings = { 112 | high: 20, 113 | low: 0.02, 114 | average: 0.5, 115 | percentiles: { 116 | ninetyNinth: 0.09, 117 | ninetyFifth: 0.05, 118 | ninetieth: 0.04, 119 | tenth: 0.02, 120 | }, 121 | }; 122 | const topPostsLimit: number = 10; 123 | 124 | const typeDefs: string = ` 125 | type User { 126 | id: ID 127 | name: String 128 | password: String 129 | posts(limit: Int): [Post] 130 | } 131 | 132 | type Post { 133 | id: ID 134 | title: String 135 | views: Int 136 | author: User 137 | } 138 | 139 | type Query { 140 | author(id: ID): User 141 | topPosts(limit: Int): [Post] 142 | } 143 | `; 144 | const schema = makeExecutableSchema({ typeDefs }); 145 | addMockFunctionsToSchema({ 146 | schema, 147 | mocks: { 148 | Query: () => ({ 149 | topPosts: () => new MockList(topPostsLimit), 150 | }), 151 | }, 152 | }); 153 | 154 | const policies: Policy[] = [ 155 | { 156 | id: faker.random.uuid(), 157 | resources: ["Query::topPosts", "Post::*", "User::*"], 158 | actions: ["query"], 159 | effect: PolicyEffect.Allow, 160 | roles: ["*"], 161 | }, 162 | ]; 163 | 164 | const endpoints = { 165 | graphQL: "/graphql", 166 | subscriptions: "/graphql/subscriptions", 167 | playground: "/playground", 168 | }; 169 | 170 | const bunjil: Bunjil = new Bunjil({ 171 | server: { 172 | tracing: false, 173 | cacheControl: false, 174 | }, 175 | playgroundOptions: { 176 | enabled: false, 177 | }, 178 | endpoints, 179 | policies, 180 | }); 181 | 182 | bunjil.addSchema({ schemas: [schema] }); 183 | 184 | // Run the bunjil start, but dont bind the server to a port 185 | await bunjil.start(); 186 | const server: any = await request(bunjil.koa.callback()); 187 | 188 | const timings: TimedPerformance = await timeExecution({ 189 | expectedTimings: allowedPerformance, 190 | numberOfExecutions: numOfTimedFunctionCalls, 191 | callback: () => { 192 | const res: any = server 193 | .post(endpoints.graphQL) 194 | .send({ 195 | query: ` 196 | query getTopPosts { 197 | topPosts(limit: ${topPostsLimit}) { 198 | id 199 | title 200 | views 201 | author { 202 | id 203 | name 204 | } 205 | } 206 | } 207 | `, 208 | }) 209 | .expect(res => t.is(res.status, 200)) 210 | .expect(res => 211 | t.notDeepEqual(res.body.data, { 212 | topPosts: null, 213 | }), 214 | ) 215 | .expect(res => t.is(res.body.data.errors, undefined)) 216 | .expect(res => { 217 | if (res.body.data.topPosts) { 218 | t.is(res.body.data.topPosts.length, topPostsLimit); 219 | } 220 | }); 221 | }, 222 | }); 223 | 224 | t.true(timings.results.passed, `Execution took too long.`); 225 | 226 | if (timings.results.passed === false) { 227 | console.log(JSON.stringify(timings, null, 4)); 228 | } 229 | }); 230 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "lib", 6 | "declaration": true, 7 | "noImplicitAny": false, 8 | "removeComments": true, 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "sourceMap": true, 12 | "inlineSources": false, 13 | "strictNullChecks": true, 14 | "watch": false, 15 | "jsx": "preserve", 16 | "allowSyntheticDefaultImports": true, 17 | "lib": ["esnext", "dom"] 18 | }, 19 | "filesGlob": [ 20 | "typings.d.ts", 21 | "./src/**/*.ts", 22 | "./test/**/*.ts", 23 | "./.graphql/*.ts", 24 | "./.graphql/*.graphql" 25 | ], 26 | "exclude": ["./examples/**"] 27 | } 28 | --------------------------------------------------------------------------------