├── .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 | [](https://npmjs.org/package/bunjil)
4 | [](https://npmjs.org/package/bunjil)
5 | [](https://david-dm.org/ojkelly/bunjil)
6 | [](https://travis-ci.org/ojkelly/bunjil)
7 | [](https://codecov.io/gh/ojkelly/bunjil)
8 | [](https://nodesecurity.io/orgs/ojkelly/projects/7f441bdb-76ab-4155-aec9-00777b5adc9a)[](https://snyk.io/test/npm/bunjil)
9 | [](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 | [](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 |
--------------------------------------------------------------------------------