├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── MAINTAINERS.md ├── README.md ├── examples ├── advancedAuth.js ├── authMiddlewares.js ├── basic.js ├── config │ ├── docker-compose.yml │ ├── keycloak.json │ └── realm-export.json ├── lib │ └── common.js ├── resourceBasedAuth.js ├── subscriptions.js ├── subscriptionsAdvanced.js ├── subscriptionsResourceBasedAuth.js └── ts │ ├── basic.ts │ └── tsconfig.json ├── package.json ├── renovate.json ├── scripts ├── getToken.js ├── initKeycloak.js ├── prepareRelease.sh ├── publishRelease.sh └── validateRelease.sh ├── src ├── KeycloakContext.ts ├── KeycloakPermissionsHandler.ts ├── KeycloakSubscriptionHandler.ts ├── KeycloakTypings.ts ├── api │ ├── AuthContextProvider.ts │ ├── KeycloakSubscriptionHandlerOptions.ts │ ├── index.ts │ └── typeDefs.ts ├── directives │ ├── directiveResolvers.ts │ ├── index.ts │ ├── schemaDirectiveVisitors.ts │ └── utils.ts └── index.ts ├── test ├── KeycloakContext.test.ts ├── KeycloakPermissionsHandler.test.ts ├── KeycloakSubscriptionHandler.test.ts ├── auth.test.ts ├── hasPermission.test.ts ├── hasRole.test.ts ├── utils.test.ts └── utils │ └── KeycloakToken.ts ├── tsconfig.json ├── tslint.json └── tslint_tests.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | working_directory: ~/aerogear 5 | docker: 6 | # Node 8 LTS 7 | - image: circleci/node:lts 8 | steps: 9 | - checkout 10 | - run: 11 | name: install dependencies 12 | command: npm install 13 | - run: 14 | name: compile 15 | command: npm run compile 16 | - run: 17 | name: test 18 | command: npm test 19 | - run: 20 | name: coverage 21 | command: npm run coverage 22 | - run: 23 | name: Lint all TypeScript code 24 | command: npm run lint 25 | # test_examples: 26 | # docker: 27 | # # Node 8 LTS 28 | # - image: circleci/node:lts 29 | # # keycloak 30 | # - image: jboss/keycloak:3.4.3.Final 31 | # name: keycloak_instance 32 | # environment: 33 | # KEYCLOAK_USER: admin 34 | # KEYCLOAK_PASSWORD: admin 35 | # DB_VENDOR: h2 36 | # steps: 37 | # - checkout 38 | # - run: 39 | # name: Wait for keycloak instance to start up 40 | # command: dockerize -wait tcp://keycloak_instance:8080 -timeout 120s 41 | # - run: 42 | # name: install dependencies 43 | # command: npm install 44 | # - run: 45 | # name: bootstrap project 46 | # command: npm run bootstrap 47 | # - run: 48 | # name: compile 49 | # command: npm run compile 50 | # - run: 51 | # command: cd examples && npm run test-examples 52 | # environment: 53 | # KEYCLOAK_HOST: 'keycloak_instance' 54 | # KEYCLOAK_PORT: '8080' 55 | npm_publish: 56 | working_directory: ~/aerogear 57 | docker: 58 | # Node 8 LTS 59 | - image: circleci/node:lts 60 | steps: 61 | - checkout 62 | # Allows us to authenticate with the npm registry 63 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 64 | - run: CI=true npm run release:prep 65 | - run: TAG=$CIRCLE_TAG npm run release:validate 66 | - run: TAG=$CIRCLE_TAG npm run release:publish 67 | workflows: 68 | version: 2 69 | build_and_release: 70 | jobs: 71 | - build: 72 | filters: 73 | tags: 74 | only: /.*/ 75 | # - test_examples: 76 | # filters: 77 | # tags: 78 | # only: /.*/ 79 | - npm_publish: 80 | requires: 81 | - build 82 | filters: 83 | tags: 84 | only: /.*/ # allow anything because tag syntax is validated as part of validate-release.sh 85 | branches: 86 | ignore: /.*/ 87 | # - publish_example_containers: 88 | # requires: 89 | # - npm_publish 90 | # filters: 91 | # tags: 92 | # only: /.*/ 93 | # branches: 94 | # ignore: /.*/ 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js stuff 2 | node_modules 3 | package-lock.json 4 | 5 | # Typescript Stuff 6 | dist 7 | *.tsbuildinfo 8 | 9 | # Build stuff 10 | coverage 11 | .nyc_output 12 | .vscode 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci/ 2 | .nyc_output/ 3 | .vscode/ 4 | # were not publishing source so we don't need source maps either 5 | dist/*.js.map 6 | examples/ 7 | scripts/ 8 | src/ 9 | test/ 10 | .gitignore 11 | renovate.json 12 | tsconfig.* 13 | tslint* 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | * [Dara Hayes](https://github.com/darahayes) (lead maintainer) 4 | * [Wojciech Trocki](https://github.com/wtrocki) 5 | * [Enda Phelan](https://github.com/craicoverflow) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keycloak-connect-graphql 2 | 3 | [![CircleCI](https://img.shields.io/circleci/build/github/aerogear/keycloak-connect-graphql.svg)](https://circleci.com/gh/aerogear/keycloak-connect-graphql) 4 | [![Coverage Status](https://coveralls.io/repos/github/aerogear/keycloak-connect-graphql/badge.svg)](https://coveralls.io/github/aerogear/keycloak-connect-graphql) 5 | ![npm](https://img.shields.io/npm/v/keycloak-connect-graphql.svg) 6 | ![GitHub](https://img.shields.io/github/license/aerogear/keycloak-connect-graphql.svg) 7 | 8 | A comprehensive solution for adding [keycloak](https://www.keycloak.org/) Authentication and Authorization to your Express based GraphQL server. 9 | 10 | Based on the [keycloak-connect](https://github.com/keycloak/keycloak-nodejs-connect) middleware for Express. Provides useful Authentication/Authorization features within your GraphQL application. 11 | 12 | ## Features 13 | 14 | 🔒 Auth at the **GraphQL layer**. Authentication and Role Based Access Control (RBAC) on individual Queries, Mutations and fields. 15 | 16 | ⚡️ Auth on Subscriptions. Authentication and RBAC on incoming websocket connections for subscriptions. 17 | 18 | 🔑 Access to token/user information in resolver context via `context.kauth` (for regular resolvers and subscriptions) 19 | 20 | 📝 Declarative `@auth`, `@hasRole` and `@hasPermission` directives that can be applied directly in your Schema. 21 | 22 | ⚙️ `auth`, `hasRole` and `hasPermission` middleware resolver functions that can be used directly in code. (Alternative to directives) 23 | 24 | ## Getting Started 25 | 26 | Install library 27 | ```bash 28 | npm install --save keycloak-connect-graphql 29 | ``` 30 | 31 | Install required dependencies: 32 | ```bash 33 | npm install --save graphql keycloak-connect 34 | ``` 35 | 36 | Install one of the Apollo Server libraries 37 | ```bash 38 | npm install --save apollo-server-express 39 | ``` 40 | 41 | There are 3 steps to set up `keycloak-connect-graphql` in your application. 42 | 43 | 1. Add the `KeycloakTypeDefs` along with your own type defs. 44 | 2. Add the `KeycloakSchemaDirectives` (Apollo Server) 45 | 3. Add the `KeycloakContext` to `context.kauth` 46 | 47 | The example below shows a typical setup with comments beside each of the 3 steps mentioned. 48 | 49 | ```javascript 50 | const { ApolloServer, gql } = require('apollo-server-express') 51 | const Keycloak = require('keycloak-connect') 52 | 53 | const { KeycloakContext, KeycloakTypeDefs, KeycloakSchemaDirectives } = require('keycloak-connect-graphql') 54 | 55 | const { typeDefs, resolvers } = require('./schema') 56 | 57 | const app = express() 58 | const keycloak = new Keycloak() 59 | 60 | app.use(graphqlPath, keycloak.middleware()) 61 | 62 | const server = new ApolloServer({ 63 | typeDefs: [KeycloakTypeDefs, typeDefs], // 1. Add the Keycloak Type Defs 64 | schemaDirectives: KeycloakSchemaDirectives, // 2. Add the KeycloakSchemaDirectives 65 | resolvers, 66 | context: ({ req }) => { 67 | return { 68 | kauth: new KeycloakContext({ req }, keycloak) // 3. add the KeycloakContext to `kauth` 69 | } 70 | } 71 | }) 72 | 73 | server.applyMiddleware({ app }) 74 | 75 | app.listen({ 4000 }, () => 76 | console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) 77 | ) 78 | ``` 79 | 80 | In this example `keycloak.middleware()` is used on the GraphQL endpoint. This allows for **Authentication and Authorization at the GraphQL Layer**. `keycloak.middleware` parses user token information if found, but will not block unauthenticated requests. This approach gives us the flexibility to implement authentication on individual Queries, Mutations and Fields. 81 | 82 | ## Using @auth, @hasRole and @hasPermission directives (Apollo Server only) 83 | 84 | In Apollo Server, the `@auth`, `@hasRole` and `@hasPermission` directives can be used directly on the schema. 85 | This declarative approach means auth logic is never mixed with business logic. 86 | 87 | ```js 88 | const Keycloak = require('keycloak-connect') 89 | const { KeycloakContext, KeycloakTypeDefs, KeycloakSchemaDirectives } = require('keycloak-connect-graphql') 90 | 91 | const typeDefs = gql` 92 | type Article { 93 | id: ID! 94 | title: String! 95 | content: String! 96 | } 97 | 98 | type Query { 99 | listArticles: [Article]! @auth 100 | } 101 | 102 | type Mutation { 103 | publishArticle(title: String!, content: String!): Article! @hasRole(role: "editor") 104 | unpublishArticle(title: String!):Boolean @hasPermission(resources: ["Article:publish","Article:delete"]) 105 | } 106 | ` 107 | 108 | const resolvers = { 109 | Query: { 110 | listArticles: (obj, args, context, info) => { 111 | return Database.listArticles() 112 | } 113 | }, 114 | mutation: { 115 | publishArticle: (object, args, context, info) => { 116 | const user = context.kauth.accessToken.content // get the user details from the access token 117 | return Database.createArticle(args.title, args.content, user) 118 | }, 119 | unpublishArticle: (object, args, context, info) => { 120 | const user = context.kauth.accessToken.content 121 | return Database.deleteArticle(args.title, user) 122 | } 123 | } 124 | } 125 | 126 | const keycloak = new Keycloak() 127 | 128 | const server = new ApolloServer({ 129 | typeDefs: [KeycloakTypeDefs, typeDefs], // 1. Add the Keycloak Type Defs 130 | schemaDirectives: KeycloakSchemaDirectives, // 2. Add the KeycloakSchemaDirectives 131 | resolvers, 132 | context: ({ req }) => { 133 | return { 134 | kauth: new KeycloakContext({ req }, keycloak) // 3. add the KeycloakContext to `kauth` 135 | } 136 | } 137 | }) 138 | ``` 139 | 140 | In this example a number of things are happening: 141 | 142 | 1. `@auth` is applied to the `listArticles` Query. This means a user must be authenticated for this Query. 143 | 2. `@hasRole(role: "editor")` is applied to the `publishArticle` Mutation. This means the keycloak user must have the editor *client role* in keycloak 144 | 3. `@hasPermission(resources: ["Article:publish","Article:delete"])` is applied to `unpublishArticle` Mutation. This means keycloak user must have all permissions given in resources array. 145 | 4. The `publishArticle` resolver demonstrates how `context.kauth` can be used to get the keycloak user details 146 | 147 | ### `auth`,`hasRole` and `hasPermission` middlewares. 148 | 149 | `keycloak-connect-graphql` also exports the `auth` ,`hasRole` and `hasPermission` logic directly. They can be thought of as middlewares that wrap your business logic resolvers. This is useful if you don't have a clear way to use schema directives (e.g. when using `graphql-express`). 150 | 151 | ```js 152 | const { auth, hasRole } = require('keycloak-connect-graphql') 153 | 154 | const resolvers = { 155 | Query: { 156 | listArticles: auth(listArticlesResolver) 157 | }, 158 | mutation: { 159 | publishArticle: hasRole('editor')(publishArticleResolver) 160 | unpublishArticle: hasPermission(['Article:publish','Article:delete'])(unpublishArticleResolver) 161 | } 162 | } 163 | ``` 164 | 165 | ### hasRole Usage and Options 166 | 167 | **`@hasRole` directive** 168 | 169 | The syntax for the `@hasRole` schema directive is `@hasRole(role: "rolename")` or `@hasRole(role: ["array", "of", "roles"])` 170 | 171 | **`hasRole`** 172 | 173 | * The usage for the exported `hasRole` function is `hasRole('rolename')` or `hasRole(['array', 'of', 'roles'])` 174 | 175 | Both the `@hasRole` schema directive and the exported `hasRole` function work exactly the same. 176 | 177 | * If a single string is provided, it returns true if the keycloak user has a **client role** with that name. 178 | * If an array of strings is provided, it returns true if the keycloak user has **at least one** client role that matches. 179 | 180 | By default, hasRole checks for keycloak client roles. 181 | 182 | * Example: `hasRole('admin')` will check the logged in user has the client role named admin. 183 | 184 | It also is possible to check for realm roles and application roles. 185 | * `hasRole('realm:admin')` will check the logged in user has the admin realm role 186 | * `hasRole('some-other-app:admin')` will check the loged in user has the admin realm role in a different application 187 | 188 | ### hasPermission Usage and Options 189 | 190 | **`@hasPermission` directive** 191 | 192 | The syntax for the `@hasPermission` schema directive is `@hasPermission(resources: "resource:scope")` or `@hasPermission(resources: "resource")` because a scope is optional or for multiple resources `@hasPermission(resources: ["array", "of", "resources"])`, use colon to separate name of the resource and optionally its scope. 193 | 194 | **`hasPermission`** 195 | 196 | * The usage for the exported `hasPermission` function is `hasPremission('resource:scope')` or `hasPermission(['array', 'of', 'resources'])`, use colon to separate name of the resource and optionally its scope. 197 | 198 | Both the `@hasPermission` schema directive and the exported `hasPermission` function work exactly the same. 199 | 200 | * If a single string is provided, it returns true if the keycloak user has a permission for requested resource and its scope, if the scope is provided. 201 | * If an array of strings is provided, it returns true if the keycloak user has **all** requested permissions. 202 | 203 | ## Apollo Server Express 3+ Support 204 | 205 | `apollo-server-express@^3.x` no longer supports the `SchemaDirectiveVisitor` class and therefor prevents 206 | you from using the visitors of this library. They have adopted schema 207 | [transformers functions](https://www.apollographql.com/docs/apollo-server/schema/creating-directives/) that define behavior 208 | on the schema fields with the directives. 209 | 210 | Remediating this is actually rather simple and gives you the option of adding a bit more authentication logic if needed, 211 | but will require some understanding of the inner workings of this library. 212 | 213 | To make things easy, this is an example implementation of what the transformers may look like. (Note the validation of roles and permissions 214 | given to their respective directives): 215 | 216 | ```typescript 217 | import { defaultFieldResolver, GraphQLSchema } from 'graphql'; 218 | import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; 219 | import { auth, hasPermission, hasRole } from 'keycloak-connect-graphql'; 220 | 221 | const authDirectiveTransformer = (schema: GraphQLSchema, directiveName: string = 'auth') => { 222 | return mapSchema(schema, { 223 | [MapperKind.OBJECT_FIELD]: (fieldConfig) => { 224 | const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; 225 | if (authDirective) { 226 | const { resolve = defaultFieldResolver } = fieldConfig; 227 | fieldConfig.resolve = auth(resolve); 228 | } 229 | return fieldConfig; 230 | } 231 | }); 232 | }; 233 | 234 | export const permissionDirectiveTransformer = (schema: GraphQLSchema, directiveName: string = 'hasPermission') => { 235 | return mapSchema(schema, { 236 | [MapperKind.OBJECT_FIELD]: (fieldConfig) => { 237 | const permissionDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; 238 | if (permissionDirective) { 239 | const { resolve = defaultFieldResolver } = fieldConfig; 240 | const keys = Object.keys(permissionDirective); 241 | let resources; 242 | if (keys.length === 1 && keys[0] === 'resources') { 243 | resources = permissionDirective[keys[0]]; 244 | if (typeof resources === 'string') resources = [resources]; 245 | if (Array.isArray(resources)) { 246 | resources = resources.map((val: any) => String(val)); 247 | } else { 248 | throw new Error('invalid hasRole args. role must be a String or an Array of Strings'); 249 | } 250 | } else { 251 | throw Error("invalid hasRole args. must contain only a 'role argument"); 252 | } 253 | fieldConfig.resolve = hasPermission(resources)(resolve); 254 | } 255 | return fieldConfig; 256 | } 257 | }); 258 | }; 259 | 260 | export const roleDirectiveTransformer = (schema: GraphQLSchema, directiveName: string = 'hasRole') => { 261 | return mapSchema(schema, { 262 | [MapperKind.OBJECT_FIELD]: (fieldConfig) => { 263 | const roleDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; 264 | if (roleDirective) { 265 | const { resolve = defaultFieldResolver } = fieldConfig; 266 | const keys = Object.keys(roleDirective); 267 | let role; 268 | if (keys.length === 1 && keys[0] === 'role') { 269 | role = roleDirective[keys[0]]; 270 | if (typeof role === 'string') role = [role]; 271 | if (Array.isArray(role)) { 272 | role = role.map((val: any) => String(val)); 273 | } else { 274 | throw new Error('invalid hasRole args. role must be a String or an Array of Strings'); 275 | } 276 | } else { 277 | throw Error("invalid hasRole args. must contain only a 'role argument"); 278 | } 279 | fieldConfig.resolve = hasRole(role)(resolve); 280 | } 281 | return fieldConfig; 282 | } 283 | }); 284 | }; 285 | 286 | export const applyDirectiveTransformers = (schema: GraphQLSchema) => { 287 | return authDirectiveTransformer(roleDirectiveTransformer(permissionDirectiveTransformer(schema))); 288 | }; 289 | ``` 290 | 291 | With your transformers defined, apply them on the schema and continue configuring your server instance: 292 | ```typescript 293 | ... 294 | let schema = makeExecutableSchema({ 295 | typeDefs, 296 | resolvers 297 | }); 298 | 299 | schema = applyDirectiveTransformers(schema); 300 | 301 | // Now just passing the schema in the options, configurting the context with Keycloak as before. 302 | const server = new ApolloServer({ 303 | schema, 304 | context: ({ req }) => { 305 | return { 306 | kauth: new KeycloakContext({ req }, keycloak) 307 | }; 308 | } 309 | }); 310 | ... 311 | ``` 312 | 313 | ### Error Codes 314 | 315 | Library will return specific GraphQL errors to the client that can 316 | be differenciated by using error codes. 317 | 318 | Example response from GraphQL Server could look as follows: 319 | 320 | ```json 321 | { 322 | "errors":[ 323 | { 324 | "message":"User is not authorized. Must have one of the following roles: [admin]", 325 | "code": "FORBIDDEN" 326 | } 327 | ] 328 | } 329 | ``` 330 | 331 | Possible error codes: 332 | 333 | - `UNAUTHENTICATED`: returned when user is not authenticated to access API because it requires login 334 | - `FORBIDDEN`: returned when user do not have permission to perform operation 335 | 336 | ## Authentication and Authorization on Subscriptions 337 | 338 | The `KeycloakSubscriptionHandler` provides a way to validate incoming websocket connections to `SubscriptionServer` from [`subscriptions-transport-ws`](https://www.npmjs.com/package/subscriptions-transport-ws) for subscriptions and add the keycloak user token to the `context` in subscription resolvers. 339 | 340 | Using `onSubscriptionConnect` inside the `onConnect` function, we can parse and validate the keycloak user token from the `connectionParams`. The example below shows the typical setup that will **ensure all subscriptions must be authenticated**. 341 | 342 | ```js 343 | const { KeycloakSubscriptionHandler } = require('keycloak-connect-graphql') 344 | 345 | // Apollo Server Setup Goes Here. (See Getting Started Section) 346 | 347 | const httpServer = app.listen({ port }, () => { 348 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 349 | 350 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak }) 351 | new SubscriptionServer({ 352 | execute, 353 | subscribe, 354 | schema: server.schema, 355 | onConnect: async (connectionParams, websocket, connectionContext) => { 356 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 357 | return { 358 | kauth: new KeycloakSubscriptionContext(token) 359 | } 360 | } 361 | }, { 362 | server: httpServer, 363 | path: '/graphql' 364 | }) 365 | }) 366 | ``` 367 | 368 | In this example, `keycloakSubscriptionHandler.onSubscriptionConnect` parses the connectionParams into a Keycloak Access Token. The value returned from `onConnect` becomes the `context` in subscription resolvers. By returning `{ kauth: new KeycloakSubscriptionContext }` we will have access to the keycloak user token in our subscription resolvers. 369 | 370 | By default, `onSubscriptionConnect` throws an Authentication `Error` and the subscription is cancelled if invalid `connectionParams` or an expired/invalid keycloak token is supplied. This is an easy way to force authentication on all subscriptions. 371 | 372 | For more information, please read the generic apollo documentation on [Authentication Over Websockets.](https://www.apollographql.com/docs/apollo-server/features/subscriptions/#authentication-over-websocket) 373 | 374 | ### Advanced Authentication and Authorization on Subscriptions 375 | 376 | The `auth` and `hasRole` middlewares can be used on individual subscriptions. Use the same code to from the [Authentication and Authorization on Subscriptions](#authentication-and-authorization-on-subscriptions) example but intialise the `KeycloakSubscriptionHandler` with `protect:false`. 377 | 378 | ```js 379 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak, protect: false }) 380 | ``` 381 | 382 | When `protect` is false, an error will not be thrown during the initial websocket connection attempt if the client is not authenticated. Instead, the `auth`,`hasRole` and `hasPermission` middlewares can be used on the individual subscription resolvers. 383 | 384 | ```js 385 | const { auth, hasRole } = require('keycloak-connect-graphql') 386 | 387 | const typeDefs = gql` 388 | type Message { 389 | content: String! 390 | author: String 391 | } 392 | 393 | type Comment { 394 | content: String! 395 | author: String 396 | } 397 | 398 | type Subscription { 399 | commentAdded: Comment! 400 | messageAdded: Message! @auth 401 | alertAdded: String @hasRole(role: "admin") 402 | } 403 | ` 404 | 405 | const resolvers = { 406 | Subscription: { 407 | commentAdded: { 408 | subscribe: () => pubsub.asyncIterator(COMMENT_ADDED) 409 | }, 410 | messageAdded: { 411 | subscribe: auth(() => pubsub.asyncIterator(COMMENT_ADDED)) 412 | }, 413 | alertAdded: hasRole('admin')(() => pubsub.asyncIterator(ALERT_ADDED)), 414 | alertRemoved: hasPermission('alert:remove')(() => pubsub.asyncIterator(ALERT_REMOVED)) 415 | } 416 | } 417 | ``` 418 | 419 | In this hypothetical application we have three subscription type that have varying levels of Authentication/Authorization 420 | 421 | * commentAdded - Unauthenticated users can subscribe. 422 | * messageAdded - Only authenticated users can subscribe. 423 | * alertAdded - Only authenticated user with the `admin` client role can subscribe 424 | * alertRemoved - Only authenticated user with the permission on resource `alert` and scope `remove` can subscribe 425 | 426 | ### Client Authentication over Websocket 427 | 428 | The GraphQL client should provide the following `connectionParams` when attempting a websocket connection. 429 | 430 | ```json 431 | { 432 | "Authorization": "Bearer " 433 | } 434 | ``` 435 | 436 | The example code shows how it could be done on the client side using Apollo Client. 437 | 438 | ```js 439 | import Keycloak from 'keycloak-js' 440 | import { WebSocketLink } from 'apollo-link-ws' 441 | 442 | 443 | var keycloak = Keycloak({ 444 | url: 'http://keycloak-server/auth', 445 | realm: 'myrealm', 446 | clientId: 'myapp' 447 | }) 448 | 449 | const wsLink = new WebSocketLink({ 450 | uri: 'ws://localhost:5000/', 451 | options: { 452 | reconnect: true, 453 | connectionParams: { 454 | Authorization: `Bearer ${keycloak.token}` 455 | } 456 | }) 457 | ``` 458 | 459 | See the Apollo Client documentation for [Authentication Params Over Websocket](https://www.apollographql.com/docs/react/advanced/subscriptions/#authentication-over-websocket). 460 | 461 | See the Keycloak Documentation for the [Keycloak JavaScript Adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) 462 | 463 | ## Usage with Apollo Federation 464 | `keycloak-connect-graphql` can be used with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) for your distributed GraphQL service. 465 | 466 | There are 4 steps to set up `keycloak-connect-graphql` in your distributed application using [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/). The **first 3 steps** you should do in **every service** and for the step 3 you should also do to `gateway` service, then the step 4 just in a `gateway` service. 467 | 1. Add the `KeycloakTypeDefs` along with your own type defs. 468 | 2. Add the `KeycloakSchemaDirectives` (Apollo Server) 469 | 3. Add the `KeycloakContext` to context.kauth 470 | 4. Setup the gateway to pass `Authorization` token to all services. 471 | 472 | For the first 3 steps, you could see example at [Getting Started](#getting-started) section. So, The example below shows how to setup the `gateway` service. 473 | 474 | ```javascript 475 | const { ApolloGateway, RemoteGraphQLDataSource } = require("@apollo/gateway"); 476 | 477 | const gateway = new ApolloGateway({ 478 | serviceList: [ 479 | { name: "accounts", url: "http://localhost:4001/graphql" }, 480 | { name: "reviews", url: "http://localhost:4002/graphql" }, 481 | { name: "products", url: "http://localhost:4003/graphql" }, 482 | { name: "inventory", url: "http://localhost:4004/graphql" } 483 | // other services might be entry 484 | ], 485 | buildService({ name, url }) { 486 | return new RemoteGraphQLDataSource({ 487 | url, 488 | willSendRequest({ request, context }) { 489 | // 4. Setup the gateway to pass `Authorization` token to all services. 490 | // Passing Keycloak Access Token to services. 491 | if (context.kauth && context.kauth.accessToken) { 492 | request.http.headers.set('Authorization', 'bearer '+ context.kauth.accessToken.token); 493 | } 494 | } 495 | }) 496 | }, 497 | 498 | // Experimental: Enabling this enables the query plan view in Playground. 499 | __exposeQueryPlanExperimental: false, 500 | }); 501 | ``` 502 | 503 | See the example project for [Apollo Federation with Keycloak](https://github.com/ilmimris/apollofederation-keycloak-demo). 504 | 505 | > Apollo Federation does not currently support GraphQL subscription operations. 506 | 507 | ## Examples 508 | 509 | The `examples` folder contains runnable examples that demonstrate the various ways to use this library. 510 | 511 | * `examples/basic.js` - Shows the basic setup needed to use the library. Uses `keycloak.connect()` to require authentication on the entire GraphQL API. 512 | * `examples/advancedAuth` - Shows how to use the `@auth` and `@hasRole` schema directives to apply auth at the GraphQL layer. 513 | * `examples/authMiddlewares` - Shows usage of the `auth` and `hasRole` middlewares. 514 | * `examples/resourceBasedAuht` - Shows how to use `@hasPermission` middleware. 515 | * `subscriptions` - Shows basic subscriptions setup, requiring all subscriptions to be authenticated. 516 | * `subscriptionsAdvanced` - Shows subscriptions that use the `auth` and `hasRole` middlewares directly on subscription resolvers 517 | * `subscriptionsResourceBasedAuth.js` - Shows subscriptions that use the `auth` and `hasPermission` middlewares directly on subscription resolvers 518 | 519 | > NOTE: Examples using unrelased code that needs to be compiled before use. 520 | Please run `npm run compile` to compile source code before running examples. 521 | 522 | ## Setting up the Examples 523 | 524 | Prerequisites: 525 | 526 | * Docker and docker-compose installed 527 | * Node.js and NPM installed 528 | 529 | Start by cloning this repo. 530 | 531 | ``` 532 | git clone https://github.com/aerogear/keycloak-connect-graphql/ 533 | ``` 534 | 535 | Then start a Keycloak server using `docker-compose`. 536 | 537 | ``` 538 | cd examples/config && docker-compose up 539 | ``` 540 | 541 | Now in a separate terminal, seed the keycloak server with a sample configuration. 542 | 543 | ``` 544 | $ npm run examples:seed 545 | 546 | creating role admin 547 | creating role developer 548 | creating client role admin for client keycloak-connect-graphql-bearer 549 | creating client role developer for client keycloak-connect-graphql-bearer 550 | creating client role admin for client keycloak-connect-graphql-public 551 | creating client role developer for client keycloak-connect-graphql-public 552 | creating user developer with password developer 553 | assigning client and realm roles called "developer" to user developer 554 | creating user admin with password admin 555 | assigning client and realm roles called "admin" to user admin 556 | done 557 | ``` 558 | 559 | This creates a sample realm called `keycloak-connect-graphql` with some clients, roles and users that we can use in the examples. 560 | Now we are ready to start and explore the examples. 561 | 562 | The Keycloak console is accessible at [localhost:8080](http://localhost:8080) and the admin login is `admin/admin`. You can make any configuration changes you wish and `npm run examples:seed` will always recreate the example realm from scratch. 563 | 564 | ## Running the Basic Example 565 | 566 | The basic example shows: 567 | 568 | * The setup of the keycloak express middleware 569 | * How to add **Role Based Access Control** using the `@hasRole` schema directive. 570 | 571 | In `examples/basic.js` the GraphQL schema for the server is defined: 572 | 573 | ```js 574 | const typeDefs = gql` 575 | type Query { 576 | hello: String @hasRole(role: "developer") 577 | } 578 | ` 579 | ``` 580 | 581 | The `@hasRole` directive means only users with the `developer` role are authorized to perform the `hello` query. Start the server to try it out. 582 | 583 | ``` 584 | $ node examples/basic.js 585 | 🚀 Server ready at http://localhost:4000/graphql 586 | ``` 587 | 588 | Open the URL and you will see the Keycloak login screen. First login with `developer/developer` as the username/password. 589 | 590 | Now you should see the GraphQL Playground. 591 | 592 | NOTE: The login page is shown because the Keycloak middleware is enforcing authentication on the `/graphql` endpoint using a `public` client configuration. A public client is being used so we can access the GraphQL Playground in the browser. In production, your GraphQL API would use a `bearer` client configuration and instead you would receive an `Access Denied` message. 593 | 594 | On the right side of the GraphQL Playground you will see a message: 595 | 596 | ``` 597 | { 598 | "error": "Failed to fetch. Please check your connection" 599 | } 600 | ``` 601 | 602 | Although the browser has authenticated with the Keycloak server, the GraphQL playround isn't sending the keycloak `Authorization` header along with its requests to the GraphQL server. In the bottom left corner of the playground there is a field called **HTTP Headers** which will be added to requests sent by the playground. 603 | 604 | Use `scripts/getToken.js` to get a valid header for the `developer` user. 605 | 606 | ``` 607 | node scripts/getToken.js developer developer # username password 608 | 609 | {"Authorization":"Bearer "} 610 | ``` 611 | 612 | Copy the entire JSON object, then paste it into the HTTP Headers field in the playground. The error message should disappear. 613 | 614 | Now try the following query: 615 | 616 | ``` 617 | query { 618 | hello 619 | } 620 | ``` 621 | 622 | You should see the result. 623 | 624 | ``` 625 | { 626 | "data": { 627 | "hello": "Hello developer" 628 | } 629 | } 630 | ``` 631 | 632 | The `hasRole` directive checked that the user had the appropriate role and then the GraphQL resolver successfully executed. Let's change the role. Change the code in `examples/basic.js` to the code below and then restart the server. 633 | 634 | ```js 635 | const typeDefs = gql` 636 | type Query { 637 | hello: String @hasRole(role: "admin") 638 | } 639 | ` 640 | ``` 641 | 642 | Now run the query in the playground again. You should see an error. 643 | 644 | ``` 645 | { 646 | "errors": [ 647 | { 648 | "message": "User is not authorized. Must have one of the following roles: [admin]", 649 | "locations": [ 650 | { 651 | "line": 2, 652 | "column": 3 653 | } 654 | ], 655 | "path": [ 656 | "hello" 657 | ], 658 | "extensions": 659 | } 660 | ], 661 | "data": { 662 | "hello": null 663 | } 664 | } 665 | ``` 666 | 667 | This time an error comes back saying the user does not have the right role. That's the full example! The process of running and trying the other examples is very similar. Feel free to try them or to look at the code! 668 | -------------------------------------------------------------------------------- /examples/advancedAuth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { ApolloServer, gql } = require('apollo-server-express') 3 | const { configureKeycloak } = require('./lib/common') 4 | 5 | const { 6 | KeycloakContext, 7 | KeycloakTypeDefs, 8 | KeycloakSchemaDirectives 9 | } = require('../') 10 | 11 | const app = express() 12 | 13 | const graphqlPath = '/graphql' 14 | 15 | // perform the standard keycloak-connect middleware setup on our app 16 | configureKeycloak(app, graphqlPath) 17 | 18 | const typeDefs = gql` 19 | type Query { 20 | greetings: [String]! 21 | } 22 | 23 | type Mutation { 24 | addGreeting(greeting: String!): String! @auth 25 | } 26 | ` 27 | 28 | const greetings = [ 29 | 'hello world!' 30 | ] 31 | 32 | const resolvers = { 33 | Query: { 34 | greetings: () => greetings 35 | }, 36 | Mutation: { 37 | addGreeting: (obj, { greeting }, context, info) => { 38 | greetings.push(greeting) 39 | return greeting 40 | } 41 | } 42 | } 43 | 44 | const options ={ 45 | typeDefs: [KeycloakTypeDefs, typeDefs], 46 | schemaDirectives: KeycloakSchemaDirectives, 47 | resolvers, 48 | context: ({ req }) => { 49 | return { 50 | kauth: new KeycloakContext({ req }) 51 | } 52 | } 53 | } 54 | 55 | const server = new ApolloServer(options) 56 | 57 | server.applyMiddleware({ app }) 58 | 59 | const port = 4000 60 | 61 | app.listen({ port }, () => 62 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 63 | ) -------------------------------------------------------------------------------- /examples/authMiddlewares.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { ApolloServer, gql } = require('apollo-server-express') 3 | const { configureKeycloak } = require('./lib/common') 4 | 5 | const { 6 | KeycloakContext, 7 | KeycloakTypeDefs, 8 | auth, 9 | hasRole 10 | } = require('../') 11 | 12 | const app = express() 13 | 14 | const graphqlPath = '/graphql' 15 | 16 | // perform the standard keycloak-connect middleware setup on our app 17 | configureKeycloak(app, graphqlPath) 18 | 19 | const typeDefs = gql` 20 | type Query { 21 | greetings: [String]! 22 | } 23 | 24 | type Mutation { 25 | addGreeting(greeting: String!): String! 26 | } 27 | ` 28 | 29 | const greetings = [ 30 | 'hello world!' 31 | ] 32 | 33 | const resolvers = { 34 | Query: { 35 | greetings: () => greetings 36 | }, 37 | Mutation: { 38 | addGreeting: auth(hasRole('developer')((obj, { greeting }, context, info) => { 39 | greetings.push(greeting) 40 | return greeting 41 | })) 42 | } 43 | } 44 | 45 | const options ={ 46 | typeDefs: [KeycloakTypeDefs, typeDefs], 47 | resolvers, 48 | context: ({ req }) => { 49 | return { 50 | kauth: new KeycloakContext({ req }) 51 | } 52 | } 53 | } 54 | 55 | const server = new ApolloServer(options) 56 | 57 | server.applyMiddleware({ app }) 58 | 59 | const port = 4000 60 | 61 | app.listen({ port }, () => 62 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 63 | ) -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { ApolloServer, gql } = require('apollo-server-express') 3 | const { configureKeycloak } = require('./lib/common') 4 | const cors = require("cors"); 5 | const { 6 | KeycloakContext, 7 | KeycloakTypeDefs, 8 | KeycloakSchemaDirectives 9 | } = require('../') 10 | 11 | const app = express() 12 | 13 | const graphqlPath = '/graphql' 14 | 15 | // perform the standard keycloak-connect middleware setup on our app 16 | const { keycloak } = configureKeycloak(app, graphqlPath) 17 | 18 | // Ensure entire GraphQL Api can only be accessed by authenticated users 19 | app.use(graphqlPath, keycloak.protect()) 20 | app.use(cors()); 21 | const typeDefs = gql` 22 | type Query { 23 | hello: String @hasRole(role: "developer") 24 | } 25 | ` 26 | 27 | const resolvers = { 28 | Query: { 29 | hello: (obj, args, context, info) => { 30 | // log some of the auth related info added to the context 31 | console.log(context.kauth.isAuthenticated()) 32 | console.log(context.kauth.accessToken.content.preferred_username) 33 | 34 | const name = context.kauth.accessToken.content.preferred_username || 'world' 35 | return `Hello ${name}` 36 | } 37 | } 38 | } 39 | 40 | const server = new ApolloServer({ 41 | typeDefs: [KeycloakTypeDefs, typeDefs], 42 | schemaDirectives: KeycloakSchemaDirectives, 43 | resolvers, 44 | context: ({ req }) => { 45 | return { 46 | kauth: new KeycloakContext({ req }) 47 | } 48 | } 49 | }) 50 | 51 | server.applyMiddleware({ app }) 52 | 53 | const port = 4000 54 | 55 | app.listen({ port }, () => 56 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 57 | ) 58 | -------------------------------------------------------------------------------- /examples/config/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | keycloak: 5 | image: jboss/keycloak:11.0.1 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | DB_VENDOR: h2 10 | KEYCLOAK_USER: admin 11 | KEYCLOAK_PASSWORD: admin -------------------------------------------------------------------------------- /examples/config/keycloak.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "keycloak-connect-graphql", 3 | "auth-server-url": "http://localhost:8080/auth", 4 | "ssl-required": "none", 5 | "resource": "keycloak-connect-graphql-public", 6 | "public-client": true, 7 | "use-resource-role-mappings": true, 8 | "confidential-port": 0 9 | } -------------------------------------------------------------------------------- /examples/lib/common.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const session = require('express-session') 4 | const Keycloak = require('keycloak-connect') 5 | 6 | function configureKeycloak(app, graphqlPath) { 7 | 8 | const keycloakConfig = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../config/keycloak.json'))) 9 | 10 | const memoryStore = new session.MemoryStore() 11 | 12 | app.use(session({ 13 | secret: process.env.SESSION_SECRET_STRING || 'this should be a long secret', 14 | resave: false, 15 | saveUninitialized: true, 16 | store: memoryStore 17 | })) 18 | 19 | const keycloak = new Keycloak({ 20 | store: memoryStore 21 | }, keycloakConfig) 22 | 23 | // Install general keycloak middleware 24 | app.use(keycloak.middleware({ 25 | admin: graphqlPath 26 | })) 27 | 28 | // Protect the main route for all graphql services 29 | // Disable unauthenticated access 30 | app.use(graphqlPath, keycloak.middleware()) 31 | 32 | return { keycloak } 33 | } 34 | 35 | module.exports = { 36 | configureKeycloak 37 | } -------------------------------------------------------------------------------- /examples/resourceBasedAuth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { ApolloServer, gql } = require('apollo-server-express') 3 | const { configureKeycloak } = require('./lib/common') 4 | 5 | const { 6 | KeycloakContext, 7 | KeycloakTypeDefs, 8 | KeycloakSchemaDirectives, 9 | hasPermission 10 | } = require('../') 11 | 12 | const app = express() 13 | 14 | const graphqlPath = '/graphql' 15 | 16 | // perform the standard keycloak-connect middleware setup on our app 17 | const { keycloak } = configureKeycloak(app, graphqlPath) 18 | 19 | 20 | const typeDefs = gql` 21 | type Article { 22 | id: ID! 23 | title: String! 24 | content: String! 25 | } 26 | 27 | type Query { 28 | listArticles: [Article]! @hasPermission(resources: "Article:view") 29 | } 30 | 31 | type Mutation { 32 | publishArticle(title: String!, content: String!): Article! @hasPermission(resources: ["Article:publish"]) 33 | unpublishArticle(title: String!):Boolean @hasPermission(resources: ["Article:publish","Article:delete"]) 34 | deleteArticle(title: String!):Boolean @hasPermission(resources: ["Article:delete"]) 35 | } 36 | ` 37 | 38 | const resolvers = { 39 | Query: { 40 | listArticles: (obj, args, context, info) => { 41 | return [{ id: 1, title: 'About authorization', content: 'A short text about authorization.' }, 42 | { id: 2, title: 'About authentication', content: 'A short text about authentication.' }, 43 | { id: 3, title: 'GraphQL', content: 'A short text about GraphQL' }] 44 | } 45 | }, 46 | Mutation: { 47 | publishArticle: (object, args, context, info) => { 48 | const user = context.kauth.accessToken.content 49 | return { id: Math.floor(Math.random() * 100) + 10, title: args.title, content: args.content } 50 | }, 51 | unpublishArticle: () => { 52 | return true 53 | }, 54 | deleteArticle: () => { 55 | return true 56 | } 57 | } 58 | } 59 | 60 | const server = new ApolloServer({ 61 | typeDefs: [KeycloakTypeDefs, typeDefs], 62 | schemaDirectives: KeycloakSchemaDirectives, 63 | resolvers, 64 | context: ({ req }) => { 65 | return { 66 | kauth: new KeycloakContext({ req }, keycloak, { resource_server_id: 'keycloak-connect-graphql-resource-server'}) 67 | } 68 | } 69 | }) 70 | 71 | server.applyMiddleware({ app }) 72 | 73 | const port = 4000 74 | 75 | app.listen({ port }, () => 76 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 77 | ) -------------------------------------------------------------------------------- /examples/subscriptions.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const http = require('http') 3 | const { PubSub } = require('graphql-subscriptions') 4 | 5 | 6 | const { ApolloServer, gql } = require('apollo-server-express') 7 | 8 | const { configureKeycloak } = require('./lib/common') 9 | 10 | const { 11 | KeycloakContext, 12 | KeycloakSubscriptionContext, 13 | KeycloakTypeDefs, 14 | KeycloakSchemaDirectives, 15 | KeycloakSubscriptionHandler 16 | } = require('../') 17 | 18 | 19 | const app = express() 20 | 21 | const graphqlPath = '/graphql' 22 | 23 | // perform the standard keycloak-connect middleware setup on our app 24 | // return the initialized keycloak object 25 | const { keycloak } = configureKeycloak(app, graphqlPath) 26 | 27 | const pubsub = new PubSub() 28 | 29 | // set up the pubsub to publish a message every 2 seconds 30 | const TOPIC = 'HELLO' 31 | setInterval(() => { 32 | pubsub.publish(TOPIC, { testSubscription: `tesing... ${Date.now()}`}) 33 | }, 2000) 34 | 35 | const typeDefs = gql` 36 | type Query { 37 | hello: String! 38 | } 39 | 40 | type Subscription { 41 | testSubscription: String! 42 | } 43 | ` 44 | 45 | const resolvers = { 46 | Query: { 47 | hello: (obj, args, context, info) => { 48 | return `Hello world` 49 | } 50 | }, 51 | Subscription: { 52 | testSubscription: { 53 | subscribe: () => pubsub.asyncIterator(TOPIC) 54 | } 55 | } 56 | } 57 | 58 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak }) 59 | 60 | const server = new ApolloServer({ 61 | typeDefs: [KeycloakTypeDefs, typeDefs], 62 | schemaDirectives: KeycloakSchemaDirectives, 63 | subscriptions: { 64 | onConnect: async (connectionParams, websocket, connectionContext) => { 65 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 66 | return { 67 | kauth: new KeycloakSubscriptionContext(token) 68 | } 69 | } 70 | }, 71 | resolvers, 72 | context: ({ req, connection }) => { 73 | const kauth = connection ? connection.context.kauth : new KeycloakContext({ req }) 74 | return { 75 | kauth 76 | } 77 | } 78 | }) 79 | 80 | const port = 4000 81 | 82 | server.applyMiddleware({ app }) 83 | const httpServer = http.createServer(app) 84 | server.installSubscriptionHandlers(httpServer) 85 | 86 | httpServer.listen(port, () => { 87 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 88 | }) 89 | -------------------------------------------------------------------------------- /examples/subscriptionsAdvanced.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const http = require('http') 3 | const { PubSub } = require('graphql-subscriptions') 4 | 5 | const { ApolloServer, gql } = require('apollo-server-express') 6 | 7 | const { configureKeycloak } = require('./lib/common') 8 | 9 | const { 10 | KeycloakContext, 11 | KeycloakSubscriptionContext, 12 | KeycloakTypeDefs, 13 | KeycloakSchemaDirectives, 14 | KeycloakSubscriptionHandler, 15 | auth, 16 | hasRole 17 | } = require('../') 18 | 19 | 20 | const app = express() 21 | 22 | const graphqlPath = '/graphql' 23 | 24 | // perform the standard keycloak-connect middleware setup on our app 25 | // return the initialized keycloak object 26 | const { keycloak } = configureKeycloak(app, graphqlPath) 27 | 28 | const pubsub = new PubSub() 29 | 30 | // set up the pubsub to publish a message every 2 seconds 31 | const TOPIC = 'HELLO' 32 | setInterval(() => { 33 | pubsub.publish(TOPIC, { 34 | testSubscription: `tesing... ${Date.now()}`, 35 | testSubscriptionProtected: `tesing... ${Date.now()}` 36 | }) 37 | }, 2000) 38 | 39 | 40 | const typeDefs = gql` 41 | type Query { 42 | hello: String! 43 | } 44 | 45 | type Subscription { 46 | testSubscription: String! 47 | testSubscriptionProtected: String! 48 | } 49 | ` 50 | 51 | const resolvers = { 52 | Query: { 53 | hello: (obj, args, context, info) => { 54 | return `Hello world` 55 | } 56 | }, 57 | Subscription: { 58 | testSubscription: { 59 | subscribe: () => pubsub.asyncIterator(TOPIC) 60 | }, 61 | testSubscriptionProtected: { 62 | subscribe: auth(hasRole('developer')(() => pubsub.asyncIterator(TOPIC))) 63 | } 64 | } 65 | } 66 | 67 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak, protect: false }) 68 | 69 | const server = new ApolloServer({ 70 | typeDefs: [KeycloakTypeDefs, typeDefs], 71 | schemaDirectives: KeycloakSchemaDirectives, 72 | resolvers, 73 | subscriptions: { 74 | onConnect: async (connectionParams, websocket, connectionContext) => { 75 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 76 | return { 77 | kauth: new KeycloakSubscriptionContext(token) 78 | } 79 | } 80 | }, 81 | context: ({ req, connection }) => { 82 | const kauth = connection ? connection.context.kauth : new KeycloakContext({ req }) 83 | return { 84 | kauth 85 | } 86 | } 87 | }) 88 | 89 | const port = 4000 90 | 91 | server.applyMiddleware({ app }) 92 | const httpServer = http.createServer(app) 93 | server.installSubscriptionHandlers(httpServer) 94 | 95 | httpServer.listen(port, () => { 96 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 97 | }) 98 | 99 | -------------------------------------------------------------------------------- /examples/subscriptionsResourceBasedAuth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const http = require('http') 3 | const { PubSub } = require('graphql-subscriptions') 4 | 5 | const { ApolloServer, gql } = require('apollo-server-express') 6 | 7 | const { configureKeycloak } = require('./lib/common') 8 | 9 | const { 10 | KeycloakContext, 11 | KeycloakSubscriptionContext, 12 | KeycloakTypeDefs, 13 | KeycloakSchemaDirectives, 14 | KeycloakSubscriptionHandler, 15 | auth, 16 | hasPermission 17 | } = require('../dist') 18 | 19 | 20 | const app = express() 21 | 22 | const graphqlPath = '/graphql' 23 | 24 | // perform the standard keycloak-connect middleware setup on our app 25 | // return the initialized keycloak object 26 | const { keycloak } = configureKeycloak(app, graphqlPath) 27 | 28 | const pubsub = new PubSub() 29 | 30 | // set up the pubsub to publish a message every 2 seconds 31 | const TOPIC = 'HELLO' 32 | setInterval(() => { 33 | pubsub.publish(TOPIC, { 34 | testSubscription: `testing... ${Date.now()}`, 35 | testSubscriptionProtected: `testing... ${Date.now()}` 36 | }) 37 | }, 2000) 38 | 39 | 40 | const typeDefs = gql` 41 | type Query { 42 | hello: String! 43 | } 44 | 45 | type Subscription { 46 | testSubscription: String! 47 | testSubscriptionProtected: String! 48 | } 49 | ` 50 | 51 | const resolvers = { 52 | Query: { 53 | hello: (obj, args, context, info) => { 54 | return `Hello world` 55 | } 56 | }, 57 | Subscription: { 58 | testSubscription: { 59 | subscribe: () => pubsub.asyncIterator(TOPIC) 60 | }, 61 | testSubscriptionProtected: { 62 | subscribe: auth(hasPermission('Article:view')(() => pubsub.asyncIterator(TOPIC))) 63 | } 64 | } 65 | } 66 | 67 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak, protect: false }) 68 | 69 | const server = new ApolloServer({ 70 | typeDefs: [KeycloakTypeDefs, typeDefs], 71 | schemaDirectives: KeycloakSchemaDirectives, 72 | resolvers, 73 | subscriptions: { 74 | onConnect: async (connectionParams, websocket, connectionContext) => { 75 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 76 | return { 77 | kauth: new KeycloakSubscriptionContext(token, keycloak, { resource_server_id: 'keycloak-connect-graphql-resource-server'}) 78 | } 79 | } 80 | }, 81 | context: ({ req, connection }) => { 82 | const kauth = connection ? connection.context.kauth : new KeycloakContext({ req }, keycloak, { resource_server_id: 'keycloak-connect-graphql-resource-server'}) 83 | return { 84 | kauth 85 | } 86 | } 87 | }) 88 | 89 | const port = 4000 90 | 91 | server.applyMiddleware({ app }) 92 | const httpServer = http.createServer(app) 93 | server.installSubscriptionHandlers(httpServer) 94 | 95 | httpServer.listen(port, () => { 96 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 97 | }) 98 | 99 | -------------------------------------------------------------------------------- /examples/ts/basic.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { ApolloServer, gql } from 'apollo-server-express' 3 | import { configureKeycloak } from '../lib/common' 4 | import cors from "cors" 5 | import { 6 | KeycloakContext, 7 | KeycloakTypeDefs, 8 | KeycloakSchemaDirectives 9 | } from '../../dist/index' 10 | 11 | const app = express() 12 | 13 | const graphqlPath = '/graphql' 14 | 15 | // perform the standard keycloak-connect middleware setup on our app 16 | const { keycloak } = configureKeycloak(app, graphqlPath) 17 | 18 | // Ensure entire GraphQL Api can only be accessed by authenticated users 19 | app.use(graphqlPath, keycloak.protect()) 20 | app.use(cors()); 21 | const typeDefs = ` 22 | type Query { 23 | hello: String @hasRole(role: "developer") 24 | } 25 | ` 26 | 27 | const resolvers = { 28 | Query: { 29 | hello: (obj, args, context, info) => { 30 | // log some of the auth related info added to the context 31 | console.log(context.kauth.isAuthenticated()) 32 | console.log(context.kauth.accessToken.content.preferred_username) 33 | 34 | const name = context.kauth.accessToken.content.preferred_username || 'world' 35 | return `Hello ${name}` 36 | } 37 | } 38 | } 39 | 40 | const server = new ApolloServer({ 41 | typeDefs: [KeycloakTypeDefs, typeDefs], 42 | // See https://github.com/ardatan/graphql-tools/issues/1581 43 | schemaDirectives: KeycloakSchemaDirectives, 44 | resolvers, 45 | context: ({ req }) => { 46 | return { 47 | kauth: new KeycloakContext({ req : req as any }) 48 | } 49 | } 50 | }) 51 | 52 | server.applyMiddleware({ app }) 53 | 54 | const port = 4000 55 | 56 | app.listen({ port }, () => 57 | console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 58 | ) 59 | -------------------------------------------------------------------------------- /examples/ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "strict": true, /* Enable all strict type-checking options. */ 7 | "noImplicitAny": false, 8 | "allowJs": true, 9 | "types": ["node"], /* Type declaration files to be included in compilation. */ 10 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 11 | }, 12 | "include": ["./*.ts"], 13 | "exclude": [] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keycloak-connect-graphql", 3 | "version": "0.7.0", 4 | "description": "Add Keycloak authentication and authorization to your GraphQL server.", 5 | "keywords": [ 6 | "graphql", 7 | "apollo", 8 | "keycloak", 9 | "authentication", 10 | "express" 11 | ], 12 | "author": "AeroGear Team", 13 | "homepage": "http://aerogear.org", 14 | "license": "Apache-2.0", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/aerogear/keycloak-connect-graphql" 18 | }, 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "compile": "tsc --build", 26 | "watch": "tsc --build tsconfig.json --watch", 27 | "compile:clean": "tsc --build tsconfig.json --clean", 28 | "test": "nyc ava -v", 29 | "coverage": "nyc report --reporter=text-lcov | coveralls", 30 | "lint": "tslint '*/*/src/**/*.ts' --exclude 'src/**/*.test.ts' && tslint -c tslint_tests.json 'src/**/*.test.ts'", 31 | "release:prep": "./scripts/prepareRelease.sh", 32 | "release:validate": "./scripts/validateRelease.sh", 33 | "release:publish": "./scripts/publishRelease.sh", 34 | "examples:seed": "node scripts/initKeycloak.js" 35 | }, 36 | "dependencies": { 37 | "@graphql-tools/utils": "7.2.3" 38 | }, 39 | "devDependencies": { 40 | "@types/express-session": "1.17.3", 41 | "@types/graphql": "14.2.3", 42 | "@types/keycloak-connect": "4.5.4", 43 | "@types/node": "10.17.50", 44 | "@types/sinon": "9.0.10", 45 | "ava": "2.4.0", 46 | "cors": "2.8.5", 47 | "coveralls": "3.1.0", 48 | "express": "4.17.1", 49 | "express-session": "1.17.1", 50 | "graphql": "15.4.0", 51 | "graphql-subscriptions": "1.1.0", 52 | "keycloak-request-token": "0.1.0", 53 | "keycloak-admin": "1.14.4", 54 | "nyc": "15.1.0", 55 | "sinon": "9.2.2", 56 | "subscriptions-transport-ws": "0.9.18", 57 | "ts-node": "9.1.1", 58 | "tslint": "5.20.1", 59 | "typescript": "4.1.3", 60 | "apollo-server-express": "2.19.1", 61 | "keycloak-connect": "12.0.1" 62 | }, 63 | "peerDependencies": { 64 | "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0", 65 | "keycloak-connect": ">=9.0.0" 66 | }, 67 | "nyc": { 68 | "extension": [ 69 | ".ts" 70 | ], 71 | "include": [ 72 | "src/**/*.ts" 73 | ] 74 | }, 75 | "ava": { 76 | "compileEnhancements": false, 77 | "extensions": [ 78 | "ts" 79 | ], 80 | "files": [ 81 | "**/*.test.ts" 82 | ], 83 | "require": [ 84 | "ts-node/register" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", ":pinVersions", ":pinAllExceptPeerDependencies"], 3 | "groupName": "all", 4 | "ignoreDeps": ["ava"] 5 | } 6 | -------------------------------------------------------------------------------- /scripts/getToken.js: -------------------------------------------------------------------------------- 1 | const tokenRequester = require('keycloak-request-token') 2 | 3 | const username = process.argv[2] 4 | const password = process.argv[3] 5 | 6 | const baseUrl = 'http://localhost:8080/auth'; 7 | const settings = { 8 | username: username || 'developer', 9 | password: password || 'developer', 10 | grant_type: 'password', 11 | client_id: 'keycloak-connect-graphql-public', 12 | realmName: 'keycloak-connect-graphql' 13 | } 14 | 15 | tokenRequester(baseUrl, settings) 16 | .then((token) => { 17 | const headers = { 18 | Authorization: `Bearer ${token}` 19 | } 20 | console.log(JSON.stringify(headers)) 21 | }).catch((err) => { 22 | console.log('err', err) 23 | }) -------------------------------------------------------------------------------- /scripts/initKeycloak.js: -------------------------------------------------------------------------------- 1 | const KeycloakAdmin = require('keycloak-admin').default 2 | 3 | const realmExport = require('../examples/config/realm-export.json') 4 | 5 | // The public and bearer clients that will be set up 6 | const PUBLIC_CLIENT_NAME = `${realmExport.realm}-public` 7 | const BEARER_CLIENT_NAME = `${realmExport.realm}-bearer` 8 | const RESOURCE_SERVER_NAME = `${realmExport.realm}-resource-server` 9 | 10 | // The client roles you want created for the bearer and public clients 11 | const clientRoleNames = [ 12 | 'admin', 13 | 'developer', 14 | ] 15 | 16 | // The realm roles we want for the realm 17 | const realmRoleNames = [ 18 | 'admin', 19 | 'developer' 20 | ] 21 | 22 | // The users to be created 23 | // roles specified here will be added 24 | // as realm roles and as client roles 25 | // for both the public and bearer clients 26 | const users = [ 27 | { 28 | username: 'developer', 29 | password: 'developer', 30 | roles: [ 31 | 'developer' 32 | ] 33 | }, 34 | { 35 | username: 'adminuser', 36 | password: 'admin', 37 | roles: [ 38 | 'admin' 39 | ] 40 | } 41 | ] 42 | 43 | async function initKeycloak() { 44 | const kc = new KeycloakAdmin() 45 | 46 | await kc.auth({ 47 | username: 'admin', 48 | password: 'admin', 49 | grantType: 'password', 50 | clientId: 'admin-cli', 51 | }) 52 | 53 | await kc.realms.del({ realm: realmExport.realm }).catch((err) => { 54 | if (err.response.status !== 404) { 55 | throw err // if we get a 404 that's fine, if we get something else, throw it. 56 | } 57 | }) 58 | 59 | let result = await kc.realms.create(realmExport) 60 | 61 | kc.setConfig({ 62 | realmName: realmExport.realm, 63 | }) 64 | 65 | const clients = await kc.clients.find() 66 | 67 | const bearerClient = clients.find(c => c.clientId === BEARER_CLIENT_NAME) 68 | const publicClient = clients.find(c => c.clientId === PUBLIC_CLIENT_NAME) 69 | 70 | const ourClients = [bearerClient, publicClient] 71 | const resourceServer = clients.find(c => c.clientId === RESOURCE_SERVER_NAME) 72 | 73 | for (let realmRole of realmRoleNames) { 74 | console.log(`creating role ${realmRole}`) 75 | const role = await kc.roles.create({ 76 | name: realmRole, 77 | clientRole: false, 78 | realm: realmExport.realm 79 | }).catch((err) => { 80 | if (err.response.status !== 409) { 81 | throw err // if we get a 409 that's fine, if we get something else, throw it. 82 | } 83 | }) 84 | } 85 | 86 | for (let client of ourClients) { 87 | for (let clientRole of clientRoleNames) { 88 | console.log(`creating client role ${clientRole} for client ${client.clientId}`) 89 | const role = await kc.clients.createRole({ 90 | id: client.id, 91 | name: clientRole, 92 | clientRole: false, 93 | realm: realmExport.realm 94 | }).catch((err) => { 95 | if (err.response.status !== 409) { 96 | throw err // if we get a 409 that's fine, if we get something else, throw it. 97 | } 98 | }) 99 | } 100 | } 101 | 102 | realmRoles = await kc.roles.find() 103 | 104 | const bearerClientRoles = await kc.clients.listRoles({ id: bearerClient.id }) 105 | const publicClientRoles = await kc.clients.listRoles({ id: publicClient.id }) 106 | 107 | for (let user of users) { 108 | console.log(`creating user ${user.username} with password ${user.password}`) 109 | const u = await kc.users.create({ 110 | realm: realmExport.realm, 111 | username: user.username, 112 | enabled: true, 113 | emailVerified: true, 114 | credentials: [ 115 | { 116 | type: 'password', 117 | value: user.password, 118 | temporary: false 119 | } 120 | ] 121 | }) 122 | 123 | for (userRoleName of user.roles) { 124 | const publicRoleMapping = publicClientRoles.find((role) => { return role.name === userRoleName }) 125 | const bearerRoleMapping = bearerClientRoles.find((role) => { return role.name === userRoleName }) 126 | const realmRoleMapping = realmRoles.find((role) => { return role.name === userRoleName }) 127 | 128 | console.log(`assigning client and realm roles called "${userRoleName}" to user ${user.username}`) 129 | if (publicRoleMapping) { 130 | await kc.users.addClientRoleMappings({ 131 | id: u.id, 132 | clientUniqueId: publicRoleMapping.containerId, 133 | roles: [ 134 | publicRoleMapping 135 | ] 136 | }) 137 | } 138 | 139 | if (bearerRoleMapping) { 140 | await kc.users.addClientRoleMappings({ 141 | id: u.id, 142 | clientUniqueId: bearerRoleMapping.containerId, 143 | roles: [ 144 | bearerRoleMapping 145 | ] 146 | }) 147 | } 148 | 149 | if (realmRoleMapping) { 150 | await kc.users.addRealmRoleMappings({ 151 | id: u.id, 152 | roles: [ 153 | realmRoleMapping 154 | ] 155 | }) 156 | } 157 | } 158 | } 159 | 160 | // creating policy for realm roles 161 | let policies = {} 162 | for (let realmRole of realmRoleNames) { 163 | const roleFromKc = await kc.roles.findOneByName({ name: realmRole, realm: `${realmExport.realm}` }) 164 | 165 | const policyName = `realm-${roleFromKc.name}s policy` 166 | 167 | console.log(`creating policy: ${policyName}`) 168 | let policy = await kc.clients.createPolicy({ 169 | id: resourceServer.id, 170 | type: 'role' 171 | }, { 172 | logic: 'POSITIVE', 173 | decisionStrategy: 'UNANIMOUS', 174 | name: policyName, 175 | description: policyName, 176 | roles: [{ 177 | id: roleFromKc.id 178 | }] 179 | }) 180 | policies[realmRole] = policy 181 | } 182 | 183 | // creating permissions 184 | 185 | // listResources is still not released, once it's in the official npm 186 | // const articleResource = kc.clients.listResources()[0] 187 | 188 | // TODO: replace this constant with result from listResource 189 | const articleResourceId = '642e8197-8885-420d-ac2b-2573e6142876' 190 | const scopesWithRoles = { 191 | delete: ['admin'], 192 | publish: ['admin', 'developer'], 193 | view: ['admin', 'developer'], 194 | write: ['admin', 'developer'], 195 | } 196 | 197 | for (let authorizationScope in scopesWithRoles) { 198 | console.log(`creating permission on resource Article and scope ${authorizationScope}`) 199 | const policyIds = scopesWithRoles[authorizationScope].map(r => policies[r].id) 200 | await kc.clients.createPermission({ 201 | id: resourceServer.id, 202 | name: `${authorizationScope} article`, 203 | type: 'scope', 204 | logic: 'POSITIVE', 205 | decisionStrategy: 'AFFIRMATIVE', 206 | resources: [articleResourceId], 207 | scopes: [authorizationScope], 208 | policies: policyIds 209 | }) 210 | } 211 | } 212 | 213 | initKeycloak().catch(console.log).then(() => console.log('done')) -------------------------------------------------------------------------------- /scripts/prepareRelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "Preparing release" 6 | 7 | npm install 8 | npm run test 9 | npm run compile 10 | npm run lint 11 | 12 | echo "Repository is ready for release." -------------------------------------------------------------------------------- /scripts/publishRelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # explicit declaration that this script needs a $TAG variable passed in e.g TAG=1.2.3 ./script.sh 4 | TAG=$TAG 5 | 6 | RELEASE_SYNTAX='^[0-9]+\.[0-9]+\.[0-9]+$' 7 | PRERELEASE_SYNTAX='^[0-9]+\.[0-9]+\.[0-9]+(-.+)+$' 8 | 9 | if [ ! "$CI" = true ]; then 10 | echo "Warning: this script should not be run outside of the CI" 11 | echo "If you really need to run this script, you can use" 12 | echo "CI=true ./scripts/publishRelease.sh" 13 | exit 1 14 | fi 15 | 16 | if [[ "$(echo $TAG | grep -E $RELEASE_SYNTAX)" == "$TAG" ]]; then 17 | echo "publishing a new release: $TAG" 18 | npm publish 19 | elif [[ "$(echo $TAG | grep -E $PRERELEASE_SYNTAX)" == "$TAG" ]]; then 20 | echo "publishing a new pre release: $TAG" 21 | npm publish --tag next 22 | else 23 | echo "Error: the tag $TAG is not valid. exiting..." 24 | exit 1 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/validateRelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # explicit declaration that this script needs a $TAG variable passed in e.g TAG=1.2.3 ./script.sh 4 | TAG=$TAG 5 | TAG_SYNTAX='^[0-9]+\.[0-9]+\.[0-9]+(-.+)*$' 6 | 7 | # get version found in package.json 8 | PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') 9 | 10 | # validate tag has format x.y.z 11 | if [[ "$(echo $TAG | grep -E $TAG_SYNTAX)" == "" ]]; then 12 | echo "tag $TAG is invalid. Must be in the format x.y.z or x.y.z-SOME_TEXT" 13 | exit 1 14 | fi 15 | 16 | # validate that TAG == version found in package.json 17 | if [[ $TAG != $PACKAGE_VERSION ]]; then 18 | echo "tag $TAG is not the same as package version found in package.json $PACKAGE_VERSION" 19 | exit 1 20 | fi 21 | 22 | echo "TAG and PACKAGE_VERSION are valid" -------------------------------------------------------------------------------- /src/KeycloakContext.ts: -------------------------------------------------------------------------------- 1 | import { AuthContextProvider } from './api' 2 | import Keycloak from 'keycloak-connect' 3 | import { Grant } from 'keycloak-connect' 4 | import { Request } from 'express' 5 | import { AuthorizationConfiguration, KeycloakPermissionsHandler } from './KeycloakPermissionsHandler' 6 | 7 | export interface GrantedRequest extends Request { 8 | kauth: { grant?: Grant }; 9 | } 10 | 11 | export const CONTEXT_KEY = 'kauth' 12 | 13 | export class KeycloakContextBase implements AuthContextProvider { 14 | private readonly permissionsHandler: KeycloakPermissionsHandler | undefined 15 | 16 | public readonly accessToken: Keycloak.Token | undefined 17 | public static contextKey = CONTEXT_KEY 18 | 19 | constructor (token?: Keycloak.Token, keycloak: Keycloak.Keycloak | undefined = undefined, authorizationConfiguration: AuthorizationConfiguration | undefined = undefined) { 20 | this.accessToken = token 21 | if (keycloak) { 22 | this.permissionsHandler = new KeycloakPermissionsHandler(keycloak, token, authorizationConfiguration) 23 | } 24 | } 25 | 26 | /** 27 | * returns true if a valid, non expired access token is present 28 | */ 29 | public isAuthenticated (): boolean { 30 | return (this.accessToken && !this.accessToken.isExpired()) ? true : false 31 | } 32 | 33 | /** 34 | * 35 | * Checks that the authenticated keycloak user has the role. 36 | * If the user has the role, the next resolver is called. 37 | * If the user does not have the role, an error is thrown. 38 | * 39 | * If an array of roles is passed, it checks that the user has at least one of the roles 40 | * 41 | * By default, hasRole checks for keycloak client roles. 42 | * Example: `hasRole('admin')` will check the logged in user has the client role named admin. 43 | * 44 | * It also is possible to check for realm roles and application roles. 45 | * * `hasRole('realm:admin')` will check the logged in user has the admin realm role 46 | * * `hasRole('some-other-app:admin')` will check the loged in user has the admin realm role in a different application 47 | * 48 | * 49 | * @param role the role or array of roles to check 50 | */ 51 | public hasRole (role: string): boolean { 52 | //@ts-ignore 53 | return this.isAuthenticated() && this.accessToken.hasRole(role) 54 | } 55 | 56 | /** 57 | * 58 | * Checks that the authenticated keycloak user has permissions for requested resources. 59 | * If the user has the requested permissions, the next resolver is called. 60 | * If the user does not have the requested permissions, an error is thrown. 61 | * 62 | * If an array of expected permissions is passed, it checks that the user has at all of the permissions 63 | * 64 | * @param expectedPermissions the expected permission or array of permissions to check 65 | */ 66 | 67 | async hasPermission(expectedPermissions: string | string[]): Promise { 68 | if (!this.permissionsHandler) { 69 | return false 70 | } 71 | 72 | return await this.permissionsHandler.hasPermission(expectedPermissions); 73 | } 74 | } 75 | 76 | /** 77 | * Context builder class that adds the Keycloak token from `req.kauth` into the GraphQL context. 78 | * This class *must* be added to the context under `context.kauth`. 79 | * 80 | * 81 | * Example usage in Apollo Server: 82 | * 83 | * ```javascript 84 | * const server = new ApolloServer({ 85 | * typeDefs, 86 | * resolvers, 87 | * context: ({ req }) => { 88 | * return { 89 | * kauth: new KeycloakContext({ req }) 90 | * // your other things you want in your context 91 | * } 92 | * } 93 | * }) 94 | * ``` 95 | * Note: This class gets the token details from `req.kauth` so you must ensure that the keycloak middleware 96 | * is installed on the graphql endpoint 97 | * 98 | * In order to use hasPermission function keycloak and optionally authorizationConfiguration parameters are needed: 99 | * 100 | * ```javascript 101 | * 102 | * // perform the standard keycloak-connect middleware setup on our app 103 | * const { keycloak } = configureKeycloak(app, graphqlPath) 104 | * 105 | * const server = new ApolloServer({ 106 | * typeDefs, 107 | * resolvers, 108 | * context: ({ req }) => { 109 | * return { 110 | * kauth: new KeycloakContext({ req }, keycloak) 111 | * // your other things you want in your context 112 | * } 113 | * } 114 | * }) 115 | * ``` 116 | * 117 | */ 118 | export class KeycloakContext extends KeycloakContextBase implements AuthContextProvider { 119 | public readonly request: GrantedRequest 120 | public readonly accessToken: Keycloak.Token | undefined 121 | 122 | constructor ({ req }: { req: GrantedRequest }, keycloak: Keycloak.Keycloak | undefined = undefined, authorizationConfiguration: AuthorizationConfiguration | undefined = undefined) { 123 | const token = (req && req.kauth && req.kauth.grant) ? req.kauth.grant.access_token : undefined 124 | super(token,keycloak, authorizationConfiguration) 125 | this.request = req 126 | } 127 | } 128 | 129 | /** 130 | * Context builder class that extends the original KeycloakContext object 131 | * but is used for building the context for subscriptions 132 | * 133 | * example usage: 134 | * 135 | * ```javascript 136 | * const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak }) 137 | * new SubscriptionServer({ 138 | * execute, 139 | * subscribe, 140 | * schema: server.schema, 141 | * onConnect: async (connectionParams, websocket, connectionContext) => { 142 | * const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 143 | * return { 144 | * kauth: new KeycloakSubscriptionContext(token) 145 | * } 146 | * } 147 | * }, { 148 | * server, 149 | * path: '/graphql' 150 | * }) 151 | * ``` 152 | */ 153 | export class KeycloakSubscriptionContext extends KeycloakContextBase { 154 | /** 155 | * 156 | * @param token a keycloak token object 157 | */ 158 | constructor(token: Keycloak.Token, keycloak: Keycloak.Keycloak | undefined = undefined, authorizationConfiguration: AuthorizationConfiguration | undefined = undefined) { 159 | super(token, keycloak, authorizationConfiguration) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/KeycloakPermissionsHandler.ts: -------------------------------------------------------------------------------- 1 | import { Keycloak, Token, AuthZRequest } from 'keycloak-connect' 2 | import { GrantedRequest } from './KeycloakContext' 3 | 4 | /** 5 | * Provides hasPermission function to check if user has requested permissions. 6 | * 7 | * Requests uma-ticket, retrieves user's permissions and checks if user has all requested permissions. 8 | */ 9 | 10 | export interface AuthorizationConfiguration { 11 | /** 12 | * Resource server, if not defined 'resource' from Keycloak configuration is taken 13 | */ 14 | resource_server_id: string | undefined, 15 | /** 16 | * Additional claims for policy evaluation 17 | */ 18 | claims: ((request: GrantedRequest)=>any) | undefined 19 | } 20 | 21 | interface PermissionsToken extends Token { 22 | token: string, 23 | hasPermission(resource: string, scope: string | undefined): boolean 24 | } 25 | 26 | export class KeycloakPermissionsHandler { 27 | private permissionsToken: PermissionsToken | undefined 28 | private req: GrantedRequest 29 | 30 | constructor(private keycloak: Keycloak, token: Token | undefined, private config: AuthorizationConfiguration | undefined) { 31 | this.permissionsToken = token as PermissionsToken 32 | this.req = { 33 | headers: { 34 | authorization: "Bearer " + this.permissionsToken?.token 35 | }, 36 | kauth: { 37 | grant: { 38 | access_token: this.permissionsToken 39 | } 40 | } 41 | } as unknown as GrantedRequest 42 | } 43 | 44 | private handlePermissions(permissions: string[], handler: (r: string, s: string | undefined ) => boolean) { 45 | for (let i = 0; i < permissions.length; i++) { 46 | const expected = permissions[i].split(':') 47 | let resource = expected[0] 48 | let scope: string | undefined = undefined 49 | 50 | if (expected.length > 1) { 51 | resource = expected.slice(0, expected.length - 1).join(':') 52 | scope = expected[expected.length - 1] 53 | } 54 | 55 | if (!handler(resource, scope)) { 56 | return false 57 | } 58 | } 59 | 60 | return true 61 | } 62 | 63 | async hasPermission(resources: string | string[]): Promise { 64 | if (!this.permissionsToken) { 65 | return false 66 | } 67 | 68 | let expectedPermissions: string[]; 69 | if (typeof resources === 'string') { 70 | expectedPermissions = [resources] 71 | } else { 72 | expectedPermissions = resources; 73 | } 74 | 75 | if (expectedPermissions.length === 0) { 76 | return true 77 | } 78 | 79 | // try with cached permissions 80 | if (this.handlePermissions(expectedPermissions, (resource, scope) => { 81 | if (this.permissionsToken && this.permissionsToken.hasPermission(resource, scope)) { 82 | return true 83 | } 84 | return false 85 | })) { 86 | return true 87 | } 88 | 89 | // make request 90 | let authzRequest: AuthZRequest = { 91 | audience: this.config?.resource_server_id ?? this.keycloak.getConfig().resource, 92 | permissions: new Array<{ id: string, scopes: string[] }>(), 93 | } 94 | 95 | this.handlePermissions(expectedPermissions, (resource, scope) => { 96 | const permissions = { id: resource, scopes: new Array() } 97 | if (scope) { 98 | permissions.scopes = [scope] 99 | } 100 | 101 | authzRequest['permissions'].push(permissions) 102 | 103 | return true 104 | }) 105 | 106 | if (this.config?.claims) { 107 | const claims = this.config.claims(this.req) 108 | 109 | if (claims) { 110 | authzRequest.claim_token = Buffer.from(JSON.stringify(claims)).toString('base64') 111 | authzRequest.claim_token_format = 'urn:ietf:params:oauth:token-type:jwt' 112 | } 113 | } 114 | 115 | try { 116 | authzRequest.response_mode = undefined 117 | const grant = await this.keycloak.checkPermissions(authzRequest, this.req) 118 | const token = grant.access_token as PermissionsToken 119 | if (token && this.handlePermissions(expectedPermissions, (resource, scope) => { 120 | if (!token.hasPermission(resource, scope)) { 121 | return false 122 | } 123 | return true 124 | })) { 125 | return true 126 | } 127 | 128 | return false 129 | } catch (err) { 130 | return false 131 | } 132 | } 133 | } -------------------------------------------------------------------------------- /src/KeycloakSubscriptionHandler.ts: -------------------------------------------------------------------------------- 1 | import Keycloak from './KeycloakTypings' 2 | import { KeycloakSubscriptionHandlerOptions } from './api' 3 | 4 | /** 5 | * Provides the onSubscriptionConnect function that is used to validate incoming 6 | * websocket connections for subscriptions. 7 | * 8 | * Parses and validates the keycloak token sent by the client in the connectionParams 9 | * 10 | * Example usage: 11 | * 12 | * ```javascript 13 | * const server = app.listen({ port }, () => { 14 | * console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) 15 | * 16 | * const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak }) 17 | * new SubscriptionServer({ 18 | * execute, 19 | * subscribe, 20 | * schema: server.schema, 21 | * onConnect: async (connectionParams, websocket, connectionContext) => { 22 | * const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams) 23 | * return { 24 | * kauth: new KeycloakSubscriptionContext(token) 25 | * } 26 | * } 27 | * }, { 28 | * server, 29 | * path: '/graphql' 30 | * }) 31 | *}) 32 | *``` 33 | */ 34 | export class KeycloakSubscriptionHandler { 35 | 36 | public keycloak: Keycloak.Keycloak 37 | public protect?: boolean 38 | 39 | /** 40 | * 41 | * @param options 42 | */ 43 | constructor(options: KeycloakSubscriptionHandlerOptions) { 44 | if (!options || !options.keycloak) { 45 | throw new Error('missing keycloak instance in options') 46 | } 47 | this.keycloak = options.keycloak 48 | this.protect = (options.protect !== null && options.protect !== undefined) ? options.protect : true 49 | } 50 | 51 | /** 52 | * 53 | * @param connectionParams 54 | * @param webSocket 55 | * @param context 56 | */ 57 | public async onSubscriptionConnect(connectionParams: any, webSocket?: any, context?: any): Promise { 58 | if (!connectionParams || typeof connectionParams !== 'object') { 59 | if (this.protect === true) { 60 | const error: any = new Error(`Access Denied - missing connection parameters for Authentication`); 61 | error.code = "UNAUTHENTICATED" 62 | throw error 63 | } 64 | return 65 | } 66 | const header = connectionParams.Authorization 67 | || connectionParams.authorization 68 | || connectionParams.Auth 69 | || connectionParams.auth 70 | if (!header) { 71 | if (this.protect === true) { 72 | throw new Error(`Access Denied - missing Authorization field in connection parameters`); 73 | } 74 | return 75 | } 76 | try { 77 | // we don't use this naming style but 78 | // createGrant expects it 79 | const grant = await this.keycloak.grantManager.createGrant({ 80 | access_token: this.getAccessTokenFromHeader(header) 81 | }) 82 | 83 | return grant.access_token as unknown as Keycloak.Token 84 | } catch (e) { 85 | throw new Error(`Access Denied - ${e}`); 86 | } 87 | } 88 | 89 | private getAccessTokenFromHeader(header: any): string { 90 | if (header && typeof header === 'string' && (header.indexOf('bearer ') === 0 || header.indexOf('Bearer ') === 0)) { 91 | const tokenString = header.substring(7) 92 | return tokenString 93 | } else { 94 | throw new Error('Invalid Authorization field in connection params. Must be in the format "Authorization": "Bearer "') 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/KeycloakTypings.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | 3 | /** 4 | * The JavaScript module is exported as a single function, but for TypeScript we 5 | * need to export the function and a set of interfaces so developers can assign 6 | * types such as Grant, Token, etc. to variables in their own code. 7 | * 8 | * To achieve this we export "KeycloakConnect" that references a namespace 9 | * containing our typings, and a static instance exposing the constructor 10 | */ 11 | declare const KeycloakConnect: KeycloakConnectStatic 12 | export = KeycloakConnect 13 | 14 | interface KeycloakConnectStatic { 15 | new (options: KeycloakConnect.KeycloakOptions, config: KeycloakConnect.KeycloakConfig): KeycloakConnect.Keycloak 16 | } 17 | 18 | declare namespace KeycloakConnect { 19 | 20 | interface KeycloakConfig { 21 | 'confidential-port': string|number 22 | 'auth-server-url': string 23 | 'resource': string 24 | 'ssl-required': string 25 | 'bearer-only'?: boolean 26 | realm: string 27 | } 28 | 29 | interface KeycloakOptions { 30 | scope?: string 31 | store?: any 32 | cookies?: boolean 33 | } 34 | 35 | interface GrantProperties { 36 | access_token?: string 37 | refresh_token?: string 38 | id_token?: string 39 | expires_in?: string 40 | token_type?: string 41 | } 42 | 43 | interface Token { 44 | isExpired(): boolean 45 | hasRole(roleName: string): boolean 46 | hasApplicationRole(appName: string, roleName: string): boolean 47 | hasRealmRole(roleName: string): boolean 48 | } 49 | 50 | interface GrantManager { 51 | /** 52 | * Use the direct grant API to obtain a grant from Keycloak. 53 | * 54 | * The direct grant API must be enabled for the configured realm 55 | * for this method to work. This function ostensibly provides a 56 | * non-interactive, programatic way to login to a Keycloak realm. 57 | * 58 | * @param {String} username The username. 59 | * @param {String} password The cleartext password. 60 | */ 61 | obtainDirectly(username: string, password: string): Promise 62 | 63 | /** 64 | * Obtain a grant from a previous interactive login which results in a code. 65 | * 66 | * This is typically used by servers which receive the code through a 67 | * redirect_uri when sending a user to Keycloak for an interactive login. 68 | * 69 | * An optional session ID and host may be provided if there is desire for 70 | * Keycloak to be aware of this information. They may be used by Keycloak 71 | * when session invalidation is triggered from the Keycloak console itself 72 | * during its postbacks to `/k_logout` on the server. 73 | * 74 | * @param {String} code The code from a successful login redirected from Keycloak. 75 | * @param {String} sessionId Optional opaque session-id. 76 | * @param {String} sessionHost Optional session host for targetted Keycloak console post-backs. 77 | */ 78 | obtainFromCode(code: string, sessionid?: string, sessionHost?: string, callback?: (err: Error, grant: Grant) => void): Promise 79 | 80 | 81 | /** 82 | * Obtain a service account grant. 83 | * Client option 'Service Accounts Enabled' needs to be on. 84 | * 85 | * This method returns or promise or may optionally take a callback function. 86 | * 87 | * @param {Function} callback Optional callback, if not using promises. 88 | */ 89 | obtainFromClientCredentials (callback?: (err: Error, grant: Grant) => void, scopeParam?: string): Promise 90 | 91 | /** 92 | * Ensure that a grant is *fresh*, refreshing if required & possible. 93 | * 94 | * If the access_token is not expired, the grant is left untouched. 95 | * 96 | * If the access_token is expired, and a refresh_token is available, 97 | * the grant is refreshed, in place (no new object is created), 98 | * and returned. 99 | * 100 | * If the access_token is expired and no refresh_token is available, 101 | * an error is provided. 102 | * 103 | * @param {Grant} grant The grant object to ensure freshness of 104 | */ 105 | ensureFreshness (grant: Grant): Promise 106 | 107 | /** 108 | * Perform live validation of an `access_token` against the Keycloak server. 109 | * 110 | * @param {Token|String} token The token to validate. 111 | * @param {Function} callback Callback function if not using promises. 112 | * 113 | * @return {boolean} `false` if the token is invalid, or the same token if valid. 114 | */ 115 | validateAccessToken(token: T): Promise 116 | 117 | /** 118 | * Create a `Grant` object from a string of JSON data. 119 | * 120 | * This method creates the `Grant` object, including 121 | * the `access_token`, `refresh_token` and `id_token` 122 | * if available, and validates each for expiration and 123 | * against the known public-key of the server. 124 | * 125 | * @param {String|GrantProperties} rawData The raw JSON string received from the Keycloak server or from a client. 126 | * @return {Promise} A promise reoslving a grant. 127 | */ 128 | createGrant(data: string|GrantProperties): Promise 129 | 130 | /** 131 | * Validate the grant and all tokens contained therein. 132 | * 133 | * This method examines a grant (in place) and rejects 134 | * if any of the tokens are invalid. After this method 135 | * resolves, the passed grant is guaranteed to have 136 | * valid tokens. 137 | * 138 | * @param {Grant} grant The grant to validate. 139 | * 140 | * @return {Promise} That resolves to a validated grant or 141 | * rejects with an error if any of the tokens are invalid. 142 | */ 143 | validateGrant(grant: Grant): Promise 144 | 145 | /** 146 | * Validate a token. 147 | * 148 | * This method accepts a token, and returns a promise 149 | * 150 | * If the token is valid the promise will be resolved with the token 151 | * 152 | * If the token is undefined or fails validation an applicable error is returned 153 | * 154 | * @return {Promise} That resolve a token 155 | */ 156 | validateToken(token?: Token, expectedType?: string): Promise 157 | } 158 | 159 | interface Grant extends GrantProperties { 160 | /** 161 | * Update this grant in-place given data in another grant. 162 | * 163 | * This is used to avoid making client perform extra-bookkeeping 164 | * to maintain the up-to-date/refreshed grant-set. 165 | */ 166 | update(grant: Grant): void 167 | 168 | /** 169 | * Returns the raw String of the grant, if available. 170 | * 171 | * If the raw string is unavailable (due to programatic construction) 172 | * then `undefined` is returned. 173 | */ 174 | toString(): string|undefined 175 | 176 | /** 177 | * Determine if this grant is expired/out-of-date. 178 | * 179 | * Determination is made based upon the expiration status of the `access_token`. 180 | * 181 | * An expired grant *may* be possible to refresh, if a valid 182 | * `refresh_token` is available. 183 | * 184 | * @return {boolean} `true` if expired, otherwise `false`. 185 | */ 186 | isExpired(): boolean 187 | } 188 | 189 | type GaurdFn = (accessToken: string, req: express.Request, res: express.Response) => boolean 190 | 191 | 192 | interface Keycloak { 193 | config: {[key: string]: any} 194 | 195 | grantManager: GrantManager 196 | 197 | /** 198 | * Obtain an array of middleware for use in your application. 199 | * 200 | * Generally this should be installed at the root of your application, 201 | * as it provides general wiring for Keycloak interaction, without actually 202 | * causing Keycloak to get involved with any particular URL until asked 203 | * by using `protect(...)`. 204 | * 205 | * Example: 206 | * 207 | * var app = express(); 208 | * var keycloak = new Keycloak(); 209 | * app.use( keycloak.middleware() ); 210 | * 211 | * Options: 212 | * 213 | * - `logout` URL for logging a user out. Defaults to `/logout`. 214 | * - `admin` Root URL for Keycloak admin callbacks. Defaults to `/`. 215 | * 216 | * @param {Object} options Optional options for specifying details. 217 | */ 218 | middleware(options?: { admin?: string, logout?: string }): express.RequestHandler[] 219 | 220 | /** 221 | * Apply protection middleware to an application or specific URL. 222 | * 223 | * If no `spec` parameter is provided, the subsequent handlers will 224 | * be invoked if the user is authenticated, regardless of what roles 225 | * he or she may or may not have. 226 | * 227 | * If a user is not currently authenticated, the middleware will cause 228 | * the authentication workflow to begin by redirecting the user to the 229 | * Keycloak installation to login. Upon successful login, the user will 230 | * be redirected back to the originally-requested URL, fully-authenticated. 231 | * 232 | * If a `spec` is provided, the same flow as above will occur to ensure that 233 | * a user it authenticated. Once authenticated, the spec will then be evaluated 234 | * to determine if the user may or may not access the following resource. 235 | * 236 | * The `spec` may be either a `String`, specifying a single required role, 237 | * or a function to make more fine-grained determination about access-control 238 | * 239 | * If the `spec` is a `String`, then the string will be interpreted as a 240 | * role-specification according to the following rules: 241 | * 242 | * - If the string starts with `realm:`, the suffix is treated as the name 243 | * of a realm-level role that is required for the user to have access. 244 | * - If the string contains a colon, the portion before the colon is treated 245 | * as the name of an application within the realm, and the portion after the 246 | * colon is treated as a role within that application. The user then must have 247 | * the named role within the named application to proceed. 248 | * - If the string contains no colon, the entire string is interpreted as 249 | * as the name of a role within the current application (defined through 250 | * the installed `keycloak.json` as provisioned within Keycloak) that the 251 | * user must have in order to proceed. 252 | * 253 | * Example 254 | * 255 | * // Users must have the `special-people` role within this application 256 | * app.get( '/special/:page', keycloak.protect( 'special-people' ), mySpecialHandler ); 257 | * 258 | * If the `spec` is a function, it may take up to two parameters in order to 259 | * assist it in making an authorization decision: the access token, and the 260 | * current HTTP request. It should return `true` if access is allowed, otherwise 261 | * `false`. 262 | * 263 | * The `token` object has a method `hasRole(...)` which follows the same rules 264 | * as above for `String`-based specs. 265 | * 266 | * // Ensure that users have either `nicepants` realm-level role, or `mr-fancypants` app-level role. 267 | * function pants(token, request) { 268 | * return token.hasRole( 'realm:nicepants') || token.hasRole( 'mr-fancypants'); 269 | * } 270 | * 271 | * app.get( '/fancy/:page', keycloak.protect( pants ), myPantsHandler ); 272 | * 273 | * With no spec, simple authentication is all that is required: 274 | * 275 | * app.get( '/complain', keycloak.protect(), complaintHandler ); 276 | * 277 | * @param {String} spec The protection spec (optional) 278 | */ 279 | protect(spec: GaurdFn|string): express.RequestHandler 280 | 281 | /** 282 | * Callback made upon successful authentication of a user. 283 | * 284 | * By default, this a no-op, but may assigned to another 285 | * function for application-specific login which may be useful 286 | * for linking authentication information from Keycloak to 287 | * application-maintained user information. 288 | * 289 | * The `request.kauth.grant` object contains the relevant tokens 290 | * which may be inspected. 291 | * 292 | * For instance, to obtain the unique subject ID: 293 | * 294 | * request.kauth.grant.id_token.sub => bf2056df-3803-4e49-b3ba-ff2b07d86995 295 | * 296 | * @param {Object} request The HTTP request. 297 | */ 298 | authenticated(req: express.Request): void 299 | 300 | /** 301 | * Callback made upon successful de-authentication of a user. 302 | * 303 | * By default, this is a no-op, but may be used by the application 304 | * in the case it needs to remove information from the user's session 305 | * or otherwise perform additional logic once a user is logged out. 306 | * 307 | * @param {Object} request The HTTP request. 308 | */ 309 | deauthenticated(req: express.Request): void 310 | 311 | /** 312 | * Replaceable function to handle access-denied responses. 313 | * 314 | * In the event the Keycloak middleware decides a user may 315 | * not access a resource, or has failed to authenticate at all, 316 | * this function will be called. 317 | * 318 | * By default, a simple string of "Access denied" along with 319 | * an HTTP status code for 403 is returned. Chances are an 320 | * application would prefer to render a fancy template. 321 | * @param {Object} request The HTTP request. 322 | * @param {Object} response The HTTP response. 323 | */ 324 | accessDenied(req: express.Request, res: express.Response): void 325 | 326 | 327 | getGrant(req: express.Request, res: express.Response): Promise 328 | 329 | storeGrant(grant: Grant, req: express.Request, res: express.Response): Grant 330 | 331 | unstoreGrant(sessionId: string): void 332 | 333 | getGrantFromCode(code: string, req: express.Request, res: express.Response): Promise 334 | 335 | loginUrl(uuid: string, redirectUrl: string): string 336 | 337 | logoutUrl(redirectUrl: string): string 338 | 339 | accountUrl(): string 340 | 341 | // Uses deprecated method 342 | // getAccount 343 | 344 | redirectToLogin(req: express.Request): boolean 345 | } 346 | 347 | } -------------------------------------------------------------------------------- /src/api/AuthContextProvider.ts: -------------------------------------------------------------------------------- 1 | export type AuthContextProviderClass = new({ req }: { req: any }) => AuthContextProvider 2 | export interface AuthContextProvider { 3 | isAuthenticated (): boolean 4 | hasRole (role: string): boolean 5 | } 6 | -------------------------------------------------------------------------------- /src/api/KeycloakSubscriptionHandlerOptions.ts: -------------------------------------------------------------------------------- 1 | import Keycloak from '../KeycloakTypings' 2 | 3 | export interface KeycloakSubscriptionHandlerOptions { 4 | 5 | /** 6 | * The initialized keycloak object from keycloak-connect 7 | */ 8 | keycloak: Keycloak.Keycloak, 9 | 10 | /** 11 | * If true, then all subscriptions must be authenticated. 12 | * Clients that do not supply the correct connectionParams will be blocked 13 | * 14 | * If false, then the connectionParams will still be parsed and validated 15 | * but unauthenticated clients (i.e. no connectionParams) will not be immediately blocked. 16 | * This means it can be decided on an individual subscription level. 17 | * Allowing for publicly and non publicly accessible subscriptions 18 | * 19 | */ 20 | protect?: boolean 21 | } 22 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthContextProvider' 2 | export * from './KeycloakSubscriptionHandlerOptions' 3 | export * from './typeDefs' 4 | -------------------------------------------------------------------------------- /src/api/typeDefs.ts: -------------------------------------------------------------------------------- 1 | export const KeycloakTypeDefs = `directive @hasRole(role: [String]) on FIELD | FIELD_DEFINITION 2 | directive @auth on FIELD | FIELD_DEFINITION 3 | directive @hasPermission(resources: [String]) on FIELD | FIELD_DEFINITION` -------------------------------------------------------------------------------- /src/directives/directiveResolvers.ts: -------------------------------------------------------------------------------- 1 | import { CONTEXT_KEY } from '../KeycloakContext' 2 | import { isAuthorizedByRole } from './utils' 3 | 4 | /** 5 | * 6 | * @param next - The resolver function you want to wrap with the auth resolver 7 | * 8 | * Checks if the incoming request to the GraphQL server is authenticated. 9 | * Does this by checking that `context.kauth` is present and that the token is valid. 10 | * The keycloak middleware must be set up on your GraphQL endpoint. 11 | * 12 | * Example usage: 13 | * 14 | * ```javascript 15 | * const { auth } = require('keycloak-connect-graphql') 16 | * 17 | * const typeDefs = gql` 18 | * type Query { 19 | * hello: String 20 | * } 21 | * ` 22 | * 23 | * const hello = (root, args, context, info) => 'Hello World' 24 | * 25 | * const resolvers = { 26 | * hello: auth(hello) 27 | * } 28 | * 29 | * const server = new ApolloServer({ 30 | * typeDefs, 31 | * resolvers, 32 | * schemaDirectives: [KeycloakSchemaDirectives], 33 | * context: ({ req }) => { 34 | * return { 35 | * kauth: new KeycloakContext({ req }) 36 | * } 37 | * } 38 | * }) 39 | * ``` 40 | * 41 | */ 42 | export const auth = (next: Function) => (...params: any[]) => { 43 | let context = params[2] 44 | if (!context[CONTEXT_KEY] || !context[CONTEXT_KEY].isAuthenticated()) { 45 | const error: any = new Error(`User not Authenticated`); 46 | error.code = "UNAUTHENTICATED" 47 | throw error 48 | } 49 | return next.apply( null, params ) 50 | } 51 | 52 | /** 53 | * 54 | * @param roles - The role or array of roles you want to authorize the user against. 55 | * 56 | * Checks if the authenticated keycloak user has the role. 57 | * If the user has the role, the next resolver is called. 58 | * If the user does not have the role, an error is thrown. 59 | * 60 | * If an array of roles is passed, it checks that the user has at least one of the roles 61 | * 62 | * By default, hasRole checks for keycloak client roles. 63 | * Example: `hasRole('admin')` will check the logged in user has the client role named admin. 64 | * 65 | * It also is possible to check for realm roles and application roles. 66 | * * `hasRole('realm:admin')` will check the logged in user has the admin realm role 67 | * * `hasRole('some-other-app:admin')` will check the loged in user has the admin realm role in a different application 68 | * 69 | * 70 | * Example usage: 71 | * 72 | * ```javascript 73 | * const { hasRole } = require('keycloak-connect-graphql') 74 | * 75 | * const typeDefs = gql` 76 | * type Query { 77 | * hello: String 78 | * } 79 | * ` 80 | * 81 | * const hello = (root, args, context, info) => 'Hello World' 82 | * 83 | * const resolvers = { 84 | * hello: hasRole('admin')(hello) 85 | * } 86 | * 87 | * const server = new ApolloServer({ 88 | * typeDefs, 89 | * resolvers, 90 | * schemaDirectives: [KeycloakSchemaDirectives], 91 | * context: ({ req }) => { 92 | * return { 93 | * kauth: new KeycloakContext({ req }) 94 | * } 95 | * } 96 | * }) 97 | * ``` 98 | */ 99 | export const hasRole = (roles: Array) => (next: Function) => (...params: any[]) => { 100 | let context = params[2] 101 | if (!context[CONTEXT_KEY] || !context[CONTEXT_KEY].isAuthenticated()) { 102 | const error: any = new Error(`User not Authenticated`); 103 | error.code = "UNAUTHENTICATED" 104 | throw error 105 | } 106 | 107 | if (typeof roles === 'string') { 108 | roles = [roles] 109 | } 110 | 111 | if (!isAuthorizedByRole(roles, context)) { 112 | const error: any = new Error(`User is not authorized. Must have one of the following roles: [${roles}]`); 113 | error.code = "FORBIDDEN" 114 | throw error 115 | } 116 | 117 | return next.apply( null, params ) 118 | } 119 | 120 | /** 121 | * 122 | * @param permissions - The permission or array of permissions you want to authorize the user against. 123 | * 124 | * Checks if the authenticated keycloak user has the requested permissions. 125 | * If the user has all requested permissions, the next resolver is called. 126 | * If the user does not have all requested permissions, an error is thrown. 127 | * 128 | * Example usage: 129 | * 130 | * ```javascript 131 | * // perform the standard keycloak-connect middleware setup on our app 132 | * const { keycloak } = configureKeycloak(app, graphqlPath) 133 | * const { hasPermission } = require('keycloak-connect-graphql') 134 | * 135 | * const typeDefs = gql` 136 | * type Query { 137 | * hello: String 138 | * } 139 | * ` 140 | * 141 | * const hello = (root, args, context, info) => 'Hello World' 142 | * 143 | * const resolvers = { 144 | * hello: hasPermission('Article:view')(hello) 145 | * } 146 | * 147 | * const server = new ApolloServer({ 148 | * typeDefs, 149 | * resolvers, 150 | * schemaDirectives: [KeycloakSchemaDirectives], 151 | * context: ({ req }) => { 152 | * return { 153 | * kauth: new KeycloakContext({ req }, keycloak) 154 | * } 155 | * } 156 | * }) 157 | * ``` 158 | */ 159 | export const hasPermission = (permissions: Array) => (next: Function) => async (...params: any[]) => { 160 | let context = params[2] 161 | if (!context[CONTEXT_KEY] || !context[CONTEXT_KEY].isAuthenticated()) { 162 | const error: any = new Error(`User not Authenticated`); 163 | error.code = "UNAUTHENTICATED" 164 | throw error 165 | } 166 | 167 | if (!await context.kauth.hasPermission(permissions)) { 168 | const error: any = new Error(`User is not authorized. Must have the following permissions: [${permissions}]`); 169 | error.code = "FORBIDDEN" 170 | throw error 171 | } 172 | 173 | return next.apply( null, params ) 174 | } -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { HasRoleDirective, AuthDirective, HasPermissionDirective } from './schemaDirectiveVisitors' 2 | 3 | // Using any as there is only theoretical incompatiblity between graphql-tools and apollo library 4 | export type SchemaDirectiveMap = Record 5 | 6 | /** 7 | * Object that contains directive implementations for Apollo Server. Pass this into Apollo Server 8 | * to enable schemaDirectives such as `@auth` and `@hasRole` 9 | * 10 | * Example usage: 11 | * 12 | * ```javascript 13 | * const typeDefs = gql` 14 | * type Query { 15 | * hello: String! @auth 16 | * } 17 | * 18 | * type mutation { 19 | * changeSomething(arg: String!): String! @hasRole(role: "admin") 20 | * } 21 | * ` 22 | * const server = new ApolloServer({ 23 | * typeDefs, 24 | * resolvers, 25 | * schemaDirectives: [KeycloakSchemaDirectives], 26 | * context: ({ req }) => { 27 | * return { 28 | * kauth: new KeycloakContext({ req }) 29 | * } 30 | * } 31 | * }) 32 | * ``` 33 | */ 34 | export const KeycloakSchemaDirectives: SchemaDirectiveMap = { 35 | auth: AuthDirective, 36 | hasRole: HasRoleDirective, 37 | hasPermission: HasPermissionDirective 38 | } 39 | 40 | 41 | export * from './directiveResolvers' -------------------------------------------------------------------------------- /src/directives/schemaDirectiveVisitors.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLSchema } from 'graphql' 2 | import { SchemaDirectiveVisitor } from '@graphql-tools/utils' 3 | import { auth, hasPermission, hasRole } from './directiveResolvers' 4 | import { VisitableSchemaType } from '@graphql-tools/utils' 5 | 6 | export class AuthDirective extends SchemaDirectiveVisitor { 7 | 8 | constructor(config: { 9 | name: string 10 | visitedType: VisitableSchemaType 11 | schema: GraphQLSchema 12 | args: {}, 13 | context: { [key: string]: any } 14 | }) { 15 | super(config) 16 | } 17 | 18 | public visitFieldDefinition(field: any) { 19 | const { resolve = defaultFieldResolver } = field 20 | field.resolve = auth(resolve) 21 | } 22 | } 23 | 24 | export class HasRoleDirective extends SchemaDirectiveVisitor { 25 | 26 | constructor(config: { 27 | name: string 28 | args: { [name: string]: any } 29 | visitedType: VisitableSchemaType 30 | schema: GraphQLSchema 31 | context: { [key: string]: any } 32 | }) { 33 | super(config) 34 | } 35 | 36 | public visitFieldDefinition(field: any) { 37 | const { resolve = defaultFieldResolver } = field 38 | const roles = this.parseAndValidateArgs(this.args) 39 | field.resolve = hasRole(roles)(resolve) 40 | } 41 | 42 | /** 43 | * 44 | * validate a potential string or array of values 45 | * if an array is provided, cast all values to strings 46 | */ 47 | public parseAndValidateArgs(args: { [name: string]: any }): Array { 48 | const keys = Object.keys(args) 49 | 50 | if (keys.length === 1 && keys[0] === 'role') { 51 | const role = args[keys[0]] 52 | if (typeof role == 'string') { 53 | return [role] 54 | } else if (Array.isArray(role)) { 55 | return role.map(val => String(val)) 56 | } else { 57 | throw new Error(`invalid hasRole args. role must be a String or an Array of Strings`) 58 | } 59 | } 60 | throw Error('invalid hasRole args. must contain only a \'role\ argument') 61 | } 62 | } 63 | 64 | export class HasPermissionDirective extends SchemaDirectiveVisitor { 65 | 66 | constructor(config: { 67 | name: string 68 | args: { [name: string]: any } 69 | visitedType: VisitableSchemaType 70 | schema: GraphQLSchema 71 | context: { [key: string]: any } 72 | }) { 73 | super(config) 74 | } 75 | 76 | public visitFieldDefinition(field: any) { 77 | const { resolve = defaultFieldResolver } = field 78 | const resources = this.parseAndValidateArgs(this.args) 79 | field.resolve = hasPermission(resources)(resolve) 80 | } 81 | 82 | /** 83 | * 84 | * validate a potential string or array of values 85 | * if an array is provided, cast all values to strings 86 | */ 87 | public parseAndValidateArgs(args: { [name: string]: any }): Array { 88 | const keys = Object.keys(args) 89 | 90 | if (keys.length === 1 && keys[0] === 'resources') { 91 | const resources = args[keys[0]] 92 | if (typeof resources == 'string') { 93 | return [resources] 94 | } else if (Array.isArray(resources)) { 95 | return resources.map(val => String(val)) 96 | } else { 97 | throw new Error(`invalid hasPermission args. resources must be a String or an Array of Strings`) 98 | } 99 | } 100 | throw Error('invalid hasPermission args. must contain only a \'resources\ argument') 101 | } 102 | } -------------------------------------------------------------------------------- /src/directives/utils.ts: -------------------------------------------------------------------------------- 1 | import { CONTEXT_KEY } from '../KeycloakContext' 2 | 3 | /** 4 | * 5 | * @param roles the list of roles the user should match against 6 | * @param context the graphql context that contains the user info 7 | */ 8 | export function isAuthorizedByRole(roles: string[], context?: any) { 9 | if (!(context && context[CONTEXT_KEY])) { 10 | console.error(`context.${CONTEXT_KEY} is missing. Keycloak integration is probably misconfigured`) 11 | return false 12 | } 13 | 14 | for (const role of roles) { 15 | if (context[CONTEXT_KEY].hasRole(role)) { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './KeycloakSubscriptionHandler' 2 | export * from './KeycloakContext' 3 | export * from './directives' 4 | export * from './api' 5 | export { isAuthorizedByRole } from './directives/utils' 6 | -------------------------------------------------------------------------------- /test/KeycloakContext.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import Keycloak, { AuthZRequest, Grant } from 'keycloak-connect' 3 | import * as express from 'express' 4 | 5 | import { KeycloakContext, KeycloakContextBase, KeycloakSubscriptionContext, GrantedRequest } from '../src/KeycloakContext' 6 | import { AuthorizationConfiguration } from '../src/KeycloakPermissionsHandler' 7 | 8 | test('KeycloakContextBase accessToken is the access_token in req.kauth', (t) => { 9 | const token = { 10 | hasRole: (role: string) => { 11 | return true 12 | }, 13 | isExpired: () => { 14 | return false 15 | } 16 | } as Keycloak.Token 17 | 18 | const provider = new KeycloakContextBase(token) 19 | t.deepEqual(provider.accessToken, token) 20 | }) 21 | 22 | test('KeycloakSubscriptionContext accessToken is the access_token in req.kauth', (t) => { 23 | 24 | const token = { 25 | hasRole: (role: string) => { 26 | return true 27 | }, 28 | isExpired: () => { 29 | return false 30 | } 31 | } as Keycloak.Token 32 | 33 | const provider = new KeycloakSubscriptionContext(token) 34 | t.deepEqual(provider.accessToken, token) 35 | }) 36 | 37 | test('KeycloakContext accessToken is the access_token in req.kauth', (t) => { 38 | 39 | const req = { 40 | kauth: { 41 | grant: { 42 | access_token: { 43 | hasRole: (role: string) => { 44 | return true 45 | }, 46 | isExpired: () => { 47 | return false 48 | } 49 | } 50 | } 51 | } 52 | } as GrantedRequest 53 | 54 | const provider = new KeycloakContext({ req }) 55 | const token = req.kauth.grant && req.kauth.grant.access_token ? req.kauth.grant.access_token : undefined 56 | t.deepEqual(provider.accessToken, token) 57 | }) 58 | 59 | test('KeycloakContext hasRole calls hasRole in the access_token', (t) => { 60 | t.plan(2) 61 | const req = { 62 | kauth: { 63 | grant: { 64 | access_token: { 65 | hasRole: (role: string) => { 66 | t.pass() 67 | return true 68 | }, 69 | isExpired: () => { 70 | return false 71 | } 72 | } 73 | } 74 | } 75 | } as GrantedRequest 76 | 77 | const provider = new KeycloakContext({ req }) 78 | t.truthy(provider.hasRole('')) 79 | }) 80 | 81 | test('KeycloakContext.isAuthenticated is true when token is defined and isExpired returns false', (t) => { 82 | const req = { 83 | kauth: { 84 | grant: { 85 | access_token: { 86 | hasRole: (role: string) => { 87 | return true 88 | }, 89 | isExpired: () => { 90 | return false 91 | } 92 | } 93 | } 94 | } 95 | } as GrantedRequest 96 | 97 | const provider = new KeycloakContext({ req }) 98 | t.truthy(provider.isAuthenticated()) 99 | }) 100 | 101 | test('KeycloakContext.isAuthenticated is false when token is defined but isExpired returns true', (t) => { 102 | const req = { 103 | kauth: { 104 | grant: { 105 | access_token: { 106 | hasRole: (role: string) => { 107 | return true 108 | }, 109 | isExpired: () => { 110 | return true 111 | } 112 | } 113 | } 114 | } 115 | } as GrantedRequest 116 | 117 | const provider = new KeycloakContext({ req }) 118 | t.false(provider.isAuthenticated()) 119 | }) 120 | 121 | test('KeycloakContext.hasRole is false if token is expired', (t) => { 122 | const req = { 123 | kauth: { 124 | grant: { 125 | access_token: { 126 | hasRole: (role: string) => { 127 | return true 128 | }, 129 | isExpired: () => { 130 | return true 131 | } 132 | } 133 | } 134 | } 135 | } as GrantedRequest 136 | 137 | const provider = new KeycloakContext({ req }) 138 | t.false(provider.hasRole('')) 139 | }) 140 | 141 | test('KeycloakContext.hasPermission is false when keycloak and authorization objects are undefined', async (t) => { 142 | const req = { 143 | kauth: { 144 | grant: { 145 | } 146 | } 147 | } as GrantedRequest 148 | 149 | const provider = new KeycloakContext({ req }) 150 | t.false(await provider.hasPermission('')) 151 | }) 152 | 153 | test('KeycloakContext.hasPermission is true when keycloak and authorization objects are defined and access_token returns hasPermission true', async (t) => { 154 | const keycloak = { 155 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 156 | return new Promise((resolve, reject) => { 157 | const result = { 158 | access_token: { 159 | hasPermission: (r: string, s: string | undefined): boolean => { 160 | return true 161 | } 162 | } 163 | } as unknown as Grant 164 | return resolve(result) 165 | }) 166 | } 167 | } as Keycloak.Keycloak 168 | const req = { 169 | kauth: { 170 | grant: { 171 | access_token: { 172 | hasPermission: (r: string, s: string | undefined): boolean => { 173 | return false 174 | } 175 | } 176 | } 177 | } 178 | } as unknown as GrantedRequest 179 | const config = { 180 | resource_server_id: 'resource-server' 181 | } as AuthorizationConfiguration 182 | 183 | const provider = new KeycloakContext({ req }, keycloak, config) 184 | t.true(await provider.hasPermission('Article:view')) 185 | }) -------------------------------------------------------------------------------- /test/KeycloakPermissionsHandler.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import Keycloak, { AuthZRequest, Grant, KeycloakConfig, Token } from 'keycloak-connect' 3 | import * as express from 'express' 4 | 5 | import { AuthorizationConfiguration, KeycloakPermissionsHandler } from '../src/KeycloakPermissionsHandler' 6 | 7 | test('KeycloakPermissionsHandler.hasPermissions returns false when there is no token', async (t) => { 8 | const keycloak = { 9 | getConfig: (): string => { 10 | return 'resource-server' 11 | } 12 | } as unknown as Keycloak.Keycloak 13 | const token = undefined 14 | const config = {} as AuthorizationConfiguration 15 | 16 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 17 | t.deepEqual(await handler.hasPermission('Article:view'), false) 18 | }) 19 | 20 | test('KeycloakPermissionsHandler.hasPermissions returns true when resources is an empty array', async (t) => { 21 | const keycloak = { 22 | getConfig: (): string => { 23 | return 'resource-server' 24 | } 25 | } as unknown as Keycloak.Keycloak 26 | const token = { 27 | } as Token 28 | const config = {} as AuthorizationConfiguration 29 | 30 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 31 | t.deepEqual(await handler.hasPermission([]), true) 32 | }) 33 | 34 | test('KeycloakPermissionsHandler.hasPermissions returns true when expected resource and scope were matched', async (t) => { 35 | const keycloak = { 36 | getConfig: (): string => { 37 | return 'resource-server' 38 | } 39 | } as unknown as Keycloak.Keycloak 40 | const token = { 41 | hasPermission: (r: string, s: string | undefined): boolean => 42 | { 43 | return r === 'Article' && s === 'view' 44 | } 45 | } as unknown as Token 46 | const config = {} as AuthorizationConfiguration 47 | 48 | const handler = new KeycloakPermissionsHandler(keycloak, token , config) 49 | t.deepEqual(await handler.hasPermission('Article:view'), true) 50 | }) 51 | 52 | test('KeycloakPermissionsHandler.hasPermissions returns true when expected resource and scope were found in array', async (t) => { 53 | const keycloak = { 54 | getConfig: (): string => { 55 | return 'resource-server' 56 | } 57 | } as unknown as Keycloak.Keycloak 58 | const token = { 59 | hasPermission: (r: string, s: string | undefined): boolean => 60 | { 61 | return r === 'Article' && s === 'view' 62 | } 63 | } as unknown as Token 64 | const config = {} as AuthorizationConfiguration 65 | 66 | const handler = new KeycloakPermissionsHandler(keycloak, token , config) 67 | t.deepEqual(await handler.hasPermission(['Article:view']), true) 68 | }) 69 | 70 | test('KeycloakPermissionsHandler.hasPermissions returns false when expected resource was not found', async (t) => { 71 | const keycloak = {} as Keycloak.Keycloak 72 | const token = { 73 | hasPermission: (r: string, s: string | undefined): boolean => { 74 | return r === 'Article' && s === 'view' 75 | } 76 | } as unknown as Token 77 | 78 | const config = { 79 | resource_server_id: 'resource-server' 80 | } as AuthorizationConfiguration 81 | 82 | const handler = new KeycloakPermissionsHandler(keycloak, token , config) 83 | t.deepEqual(await handler.hasPermission(['Article1:view']), false) 84 | }) 85 | 86 | 87 | test('KeycloakPermissionsHandler.hasPermissions returns false when expected scope was not found', async (t) => { 88 | const keycloak = { 89 | getConfig: (): KeycloakConfig => { 90 | return { 91 | resource: 'resource-server' 92 | } as KeycloakConfig 93 | } 94 | } as Keycloak.Keycloak 95 | const token = { 96 | hasPermission: (r: string, s: string | undefined): boolean => { 97 | return r === 'Article' && s === 'view' 98 | } 99 | } as unknown as Token 100 | 101 | const handler = new KeycloakPermissionsHandler(keycloak, token, undefined) 102 | t.deepEqual(await handler.hasPermission(['Article:view1']), false) 103 | }) 104 | 105 | test('KeycloakPermissionsHandler.hasPermissions returns false when at least one resource and scope were not found', async (t) => { 106 | const keycloak = {} as Keycloak.Keycloak 107 | const token = { 108 | hasPermission: (r: string, s: string | undefined): boolean => { 109 | return r === 'Article' && s === 'view' 110 | } 111 | } as unknown as Token 112 | const config = { 113 | resource_server_id: 'resource-server', 114 | claims: ()=> undefined 115 | } as AuthorizationConfiguration 116 | 117 | const handler = new KeycloakPermissionsHandler(keycloak, token , config) 118 | t.deepEqual(await handler.hasPermission(['Article:view', 'Article:delete']), false) 119 | }) 120 | 121 | test('KeycloakPermissionsHandler.hasPermissions returns true when all resources and scopes were found', async (t) => { 122 | const keycloak = { 123 | getConfig: (): string => { 124 | return 'resource-server' 125 | } 126 | } as unknown as Keycloak.Keycloak 127 | const token = { 128 | hasPermission: (r: string, s: string | undefined): boolean => { 129 | return r === 'Article' && (s === 'view' || s === 'delete') 130 | } 131 | } as unknown as Token 132 | const config = {} as AuthorizationConfiguration 133 | 134 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 135 | t.deepEqual(await handler.hasPermission(['Article:view', 'Article:delete']), true) 136 | }) 137 | 138 | test('KeycloakPermissionsHandler.hasPermissions returns true when resource without scopes found', async (t) => { 139 | const keycloak = { 140 | getConfig: (): string => { 141 | return 'resource-server' 142 | } 143 | } as unknown as Keycloak.Keycloak 144 | const token = { 145 | hasPermission: (r: string, s: string | undefined): boolean => { 146 | return r === 'Article' 147 | } 148 | } as unknown as Token 149 | const config = {} as AuthorizationConfiguration 150 | 151 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 152 | t.deepEqual(await handler.hasPermission(['Article']), true) 153 | }) 154 | 155 | test('KeycloakPermissionsHandler.hasPermissions returns true when resource name contains ":" and scope is found', async (t) => { 156 | const keycloak = { 157 | getConfig: (): string => { 158 | return 'resource-server' 159 | } 160 | } as unknown as Keycloak.Keycloak 161 | const token = { 162 | hasPermission: (r: string, s: string | undefined): boolean => { 163 | return r === 'Article:123456' && s === 'read' 164 | } 165 | } as unknown as Token 166 | const config = {} as AuthorizationConfiguration 167 | 168 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 169 | t.deepEqual(await handler.hasPermission(['Article:123456:read']), true) 170 | }) 171 | 172 | test('KeycloakPermissionsHandler.hasPermissions uses claims defined in configuration when it asks keycloak to checkPermissions', async (t) => { 173 | const keycloak = { 174 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 175 | return new Promise((resolve, reject) => { 176 | const actual = JSON.parse(Buffer.from(authzRequest.claim_token!, 'base64').toString()) 177 | const hasAllClaims = actual['claim1'] === 'claim1' && actual['claim2'] === 'claim2' 178 | 179 | const result = { 180 | access_token: { 181 | hasPermission: (r: string, s: string | undefined): boolean => { 182 | return true 183 | } 184 | } 185 | } as unknown as Grant 186 | hasAllClaims ? resolve(result) : reject(result) 187 | }) 188 | } 189 | } as Keycloak.Keycloak 190 | const token = { 191 | hasPermission: (r: string, s: string | undefined): boolean => { 192 | return false 193 | } 194 | } as unknown as Token 195 | const config = { 196 | resource_server_id: 'resource-server', 197 | claims: (request: express.Request) => { 198 | return { 199 | claim1: 'claim1', 200 | claim2: 'claim2' 201 | } 202 | } 203 | } as AuthorizationConfiguration 204 | 205 | const handler = new KeycloakPermissionsHandler(keycloak, token , config) 206 | t.deepEqual(await handler.hasPermission(['Article:view', 'Article:delete']), true) 207 | }) 208 | 209 | test('KeycloakPermissionsHandler.hasPermissions returns true when access token from authorization request returns true', async (t) => { 210 | const keycloak = { 211 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 212 | return new Promise((resolve, reject) => { 213 | const result = { 214 | access_token: { 215 | hasPermission: (r: string, s: string | undefined): boolean => { 216 | return true 217 | } 218 | } 219 | } as unknown as Grant 220 | return resolve(result) 221 | }) 222 | } 223 | } as Keycloak.Keycloak 224 | const token = { 225 | hasPermission: (r: string, s: string | undefined): boolean => { 226 | return false 227 | } 228 | } as unknown as Token 229 | const config = { 230 | resource_server_id: 'resource-server' 231 | } as AuthorizationConfiguration 232 | 233 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 234 | t.deepEqual(await handler.hasPermission(['Article:view', 'Article:delete']), true) 235 | }) 236 | 237 | 238 | test('KeycloakPermissionsHandler.hasPermissions returns false when access token from authorization request returns false', async (t) => { 239 | const keycloak = { 240 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 241 | return new Promise((resolve, reject) => { 242 | const result = { 243 | access_token: { 244 | hasPermission: (r: string, s: string | undefined): boolean => { 245 | return false 246 | } 247 | } 248 | } as unknown as Grant 249 | return resolve(result) 250 | }) 251 | } 252 | } as Keycloak.Keycloak 253 | const token = { 254 | hasPermission: (r: string, s: string | undefined): boolean => { 255 | return false 256 | } 257 | } as unknown as Token 258 | const config = { 259 | resource_server_id: 'resource-server' 260 | } as AuthorizationConfiguration 261 | 262 | const handler = new KeycloakPermissionsHandler(keycloak, token, config) 263 | t.deepEqual(await handler.hasPermission(['Article:view', 'Article:delete']), false) 264 | }) -------------------------------------------------------------------------------- /test/KeycloakSubscriptionHandler.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import Keycloak from '../src/KeycloakTypings' 3 | 4 | import { KeycloakSubscriptionHandler } from '../src/KeycloakSubscriptionHandler' 5 | import { Token } from './utils/KeycloakToken'; 6 | 7 | const TEST_CLIENT_ID = 'voyager-testing' 8 | 9 | test('onSubscriptionConnect throws if no keycloak provided', async t => { 10 | t.throws(() => { 11 | //@ts-ignore 12 | new KeycloakSubscriptionHandler() 13 | }, 'missing keycloak instance in options') 14 | }) 15 | 16 | test('onSubscriptionConnect throws if no connectionParams Provided', async t => { 17 | const stubKeycloak = { 18 | grantManager: { 19 | createGrant: (token: any) => { 20 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 21 | } 22 | } 23 | } as unknown as Keycloak.Keycloak 24 | 25 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 26 | 27 | await t.throwsAsync(async () => { 28 | await subscriptionHandler.onSubscriptionConnect(null, {}, {}) 29 | }, 'Access Denied - missing connection parameters for Authentication') 30 | }) 31 | 32 | test('onSubscriptionConnect throws if no connectionParams is not an object', async t => { 33 | const stubKeycloak = { 34 | grantManager: { 35 | createGrant: (token: any) => { 36 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 37 | } 38 | } 39 | } as unknown as Keycloak.Keycloak 40 | 41 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 42 | const connectionParams = 'not an object' 43 | 44 | await t.throwsAsync(async () => { 45 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 46 | }, 'Access Denied - missing connection parameters for Authentication') 47 | }) 48 | 49 | test('onSubscriptionConnect throws if no Auth provided', async t => { 50 | const stubKeycloak = { 51 | grantManager: { 52 | createGrant: (token: any) => { 53 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 54 | } 55 | } 56 | } as unknown as Keycloak.Keycloak 57 | 58 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 59 | const connectionParams = { Authorization: undefined } 60 | 61 | await t.throwsAsync(async () => { 62 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 63 | }, 'Access Denied - missing Authorization field in connection parameters') 64 | }) 65 | 66 | test('onSubscriptionConnect throws if "Authorization" field is not formed correctly', async t => { 67 | const stubKeycloak = { 68 | grantManager: { 69 | createGrant: (token: any) => { 70 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 71 | } 72 | } 73 | } as unknown as Keycloak.Keycloak 74 | 75 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 76 | const connectionParams = { Authorization: '1234' } 77 | 78 | await t.throwsAsync(async () => { 79 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 80 | }, 'Access Denied - Error: Invalid Authorization field in connection params. Must be in the format "Authorization": "Bearer "') 81 | }) 82 | 83 | test('onSubscriptionConnect returns a token Object if the keycloak library considers it valid', async t => { 84 | const stubKeycloak = { 85 | grantManager: { 86 | createGrant: (token: any) => { 87 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 88 | } 89 | } 90 | } as unknown as Keycloak.Keycloak 91 | 92 | const tokenString = 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJfa29BTUtBcW1xQjcxazNGeDdBQ0xvNXZqMXNoWVZwSkdJM2FScFl4allZIn0.eyJqdGkiOiJjN2UyMzA0NS00NGVmLTQ1ZDItOGY0Yy1jODA4OTlhYzljYzIiLCJleHAiOjE1NTc5NjcxMjQsIm5iZiI6MCwiaWF0IjoxNTU3OTMxMTI0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvdm95YWdlci10ZXN0aW5nIiwiYXVkIjoidm95YWdlci10ZXN0aW5nIiwic3ViIjoiM2Y4MDRiNWEtM2U3Ni00YzI2LTk4ZTYtNDU1ZDNlMzUzZmY3IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidm95YWdlci10ZXN0aW5nIiwiYXV0aF90aW1lIjoxNTU3OTMxMTI0LCJzZXNzaW9uX3N0YXRlIjoiOThiNTM2ODAtODU5MC00MzFmLWFiNzctMDY0MDFmODgzYTY5IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImRldmVsb3BlciJ9.iF3WdY6hwlZIX2bq40fs0GhxG991TqtBEuKbX7A8DMfgOj2QFDyNHGLVzEiJqMal44pmhlWhtOSoVp77ZZ57HdatEYqYaTnc8C8ajA8A1yxOX81D0lFu2jmC3WpKS2H0prrjdPPZyf82YpbYuwYAyiKJMpJSiRC2fGk1Owsg9O6CSj8cFbKfrS4msE1Y90S84qwrDfRYFSFFdsmeTvC71qyj4ZhNqNfPWbIwymlnYJ6xYbmTrZBv2GktXBLd0BnSu5QFoHgjiCxG3cyFV4tCIBpvWjebI6rCUehD6TTIXiW4uVOp9YPWvyZH8WznFdtq36CDb51abWJ8EUquog7M1w' 93 | 94 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 95 | const connectionParams = { Authorization: tokenString } 96 | 97 | const token = await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 98 | t.truthy(token) 99 | //@ts-ignore 100 | t.truthy(token.content) 101 | }) 102 | 103 | test('onSubscriptionConnect can also parse the token with lowercase \'bearer\'', async t => { 104 | const stubKeycloak = { 105 | grantManager: { 106 | createGrant: (token: any) => { 107 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 108 | } 109 | } 110 | } as unknown as Keycloak.Keycloak 111 | 112 | const tokenString = 'bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJfa29BTUtBcW1xQjcxazNGeDdBQ0xvNXZqMXNoWVZwSkdJM2FScFl4allZIn0.eyJqdGkiOiJjN2UyMzA0NS00NGVmLTQ1ZDItOGY0Yy1jODA4OTlhYzljYzIiLCJleHAiOjE1NTc5NjcxMjQsIm5iZiI6MCwiaWF0IjoxNTU3OTMxMTI0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvdm95YWdlci10ZXN0aW5nIiwiYXVkIjoidm95YWdlci10ZXN0aW5nIiwic3ViIjoiM2Y4MDRiNWEtM2U3Ni00YzI2LTk4ZTYtNDU1ZDNlMzUzZmY3IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidm95YWdlci10ZXN0aW5nIiwiYXV0aF90aW1lIjoxNTU3OTMxMTI0LCJzZXNzaW9uX3N0YXRlIjoiOThiNTM2ODAtODU5MC00MzFmLWFiNzctMDY0MDFmODgzYTY5IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImRldmVsb3BlciJ9.iF3WdY6hwlZIX2bq40fs0GhxG991TqtBEuKbX7A8DMfgOj2QFDyNHGLVzEiJqMal44pmhlWhtOSoVp77ZZ57HdatEYqYaTnc8C8ajA8A1yxOX81D0lFu2jmC3WpKS2H0prrjdPPZyf82YpbYuwYAyiKJMpJSiRC2fGk1Owsg9O6CSj8cFbKfrS4msE1Y90S84qwrDfRYFSFFdsmeTvC71qyj4ZhNqNfPWbIwymlnYJ6xYbmTrZBv2GktXBLd0BnSu5QFoHgjiCxG3cyFV4tCIBpvWjebI6rCUehD6TTIXiW4uVOp9YPWvyZH8WznFdtq36CDb51abWJ8EUquog7M1w' 113 | 114 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 115 | const connectionParams = { Authorization: tokenString } 116 | 117 | const token = await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 118 | 119 | t.truthy(token) 120 | //@ts-ignore 121 | t.truthy(token.content) 122 | }) 123 | 124 | test('the token object will have hasRole function if grant is successfully created', async t => { 125 | const stubKeycloak = { 126 | grantManager: { 127 | createGrant: (token: any) => { 128 | console.log(`createGant called with token String ${tokenString}`) 129 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 130 | } 131 | } 132 | } as unknown as Keycloak.Keycloak 133 | 134 | // hardcoded token object that can be used for quick unit testing 135 | // works with a clientId called 'voyager-testing' and has a client role 'tester' 136 | const tokenString = 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJfa29BTUtBcW1xQjcxazNGeDdBQ0xvNXZqMXNoWVZwSkdJM2FScFl4allZIn0.eyJqdGkiOiJmMWZjZDdmNS1mMWM0LTQyYWQtYjFmOC00ZWVhNzNiZWU2N2MiLCJleHAiOjE1NTc5Njc4MzksIm5iZiI6MCwiaWF0IjoxNTU3OTMxODM5LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvdm95YWdlci10ZXN0aW5nIiwiYXVkIjoidm95YWdlci10ZXN0aW5nIiwic3ViIjoiM2Y4MDRiNWEtM2U3Ni00YzI2LTk4ZTYtNDU1ZDNlMzUzZmY3IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidm95YWdlci10ZXN0aW5nIiwiYXV0aF90aW1lIjoxNTU3OTMxODM5LCJzZXNzaW9uX3N0YXRlIjoiMDQ2YTk4N2QtNmI4NS00Njk5LTllNmUtNGIyYmVlYzBhYzNhIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7InZveWFnZXItdGVzdGluZyI6eyJyb2xlcyI6WyJ0ZXN0ZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImRldmVsb3BlciJ9.YjmImGZbs5-s0K1KEYnIedW3peIUz4rORoOUTNFgE2sEKHe2hvvDg48NNybVsJDZc29Al-6OiUw8En5GpschqHHb79GqStEtuJ5T2UZb5sC2B7sX1jAvZAafkxCcOMajEbgS5qVPGoFhDTTej06sGfQwI8h0Igwle86O8IDMbEK-uN_oVa1xKTrFtvsFKekS3Yz3_qSVlmAhOKyYejEg8hkZOvJzHXK9_zsi3Ze6MLq2VCSJE-13UnZuSvdD36FydJQXkZ7elKYqj_HcyPIMAkBuKPhYAXZ9laMo2X4wM6gSIFZXKPeG44eUAGH7estqeG2oXNsdbPaixoNFHHuMqA' 137 | 138 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 139 | const connectionParams = { Authorization: tokenString, clientId: 'voyager-testing' } 140 | 141 | const token = await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 142 | t.truthy(token) 143 | //@ts-ignore 144 | t.truthy(token.hasRole('tester')) 145 | }) 146 | 147 | test('If grant creation fails then onSubscriptionConnect will throw', async t => { 148 | const errorMsg = 'token is invalid' 149 | const stubKeycloak = { 150 | grantManager: { 151 | createGrant: (token: any) => { 152 | return new Promise((resolve, reject) => { 153 | reject(new Error(errorMsg)) 154 | }) 155 | } 156 | } 157 | } as unknown as Keycloak.Keycloak 158 | 159 | // hardcoded token object that can be used for quick unit testing 160 | // works with a clientId called 'voyager-testing' and has a client role 'tester' 161 | const tokenString = 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJfa29BTUtBcW1xQjcxazNGeDdBQ0xvNXZqMXNoWVZwSkdJM2FScFl4allZIn0.eyJqdGkiOiJmMWZjZDdmNS1mMWM0LTQyYWQtYjFmOC00ZWVhNzNiZWU2N2MiLCJleHAiOjE1NTc5Njc4MzksIm5iZiI6MCwiaWF0IjoxNTU3OTMxODM5LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvdm95YWdlci10ZXN0aW5nIiwiYXVkIjoidm95YWdlci10ZXN0aW5nIiwic3ViIjoiM2Y4MDRiNWEtM2U3Ni00YzI2LTk4ZTYtNDU1ZDNlMzUzZmY3IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidm95YWdlci10ZXN0aW5nIiwiYXV0aF90aW1lIjoxNTU3OTMxODM5LCJzZXNzaW9uX3N0YXRlIjoiMDQ2YTk4N2QtNmI4NS00Njk5LTllNmUtNGIyYmVlYzBhYzNhIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7InZveWFnZXItdGVzdGluZyI6eyJyb2xlcyI6WyJ0ZXN0ZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImRldmVsb3BlciJ9.YjmImGZbs5-s0K1KEYnIedW3peIUz4rORoOUTNFgE2sEKHe2hvvDg48NNybVsJDZc29Al-6OiUw8En5GpschqHHb79GqStEtuJ5T2UZb5sC2B7sX1jAvZAafkxCcOMajEbgS5qVPGoFhDTTej06sGfQwI8h0Igwle86O8IDMbEK-uN_oVa1xKTrFtvsFKekS3Yz3_qSVlmAhOKyYejEg8hkZOvJzHXK9_zsi3Ze6MLq2VCSJE-13UnZuSvdD36FydJQXkZ7elKYqj_HcyPIMAkBuKPhYAXZ9laMo2X4wM6gSIFZXKPeG44eUAGH7estqeG2oXNsdbPaixoNFHHuMqA' 162 | 163 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak }) 164 | const connectionParams = { Authorization: tokenString, clientId: 'voyager-testing' } 165 | 166 | await t.throwsAsync(async () => { 167 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 168 | }, `Access Denied - ${new Error(errorMsg)}`) 169 | }) 170 | 171 | test('onSubscriptionConnect with {protect: false} does not throw if no connectionParams Provided', async t => { 172 | const stubKeycloak = { 173 | grantManager: { 174 | createGrant: (token: any) => { 175 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 176 | } 177 | } 178 | } as unknown as Keycloak.Keycloak 179 | 180 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak, protect: false }) 181 | 182 | await t.notThrowsAsync(async () => { 183 | await subscriptionHandler.onSubscriptionConnect(null, {}, {}) 184 | }) 185 | }) 186 | 187 | test('onSubscriptionConnect with {protect: false} does not throw if connectionParams is not an object', async t => { 188 | const stubKeycloak = { 189 | grantManager: { 190 | createGrant: (token: any) => { 191 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 192 | } 193 | } 194 | } as unknown as Keycloak.Keycloak 195 | 196 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak, protect: false }) 197 | const connectionParams = 'not an object' 198 | 199 | await t.notThrowsAsync(async () => { 200 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 201 | }) 202 | }) 203 | 204 | test('onSubscriptionConnect with {protect: false} does not throw if no Auth provided', async t => { 205 | const stubKeycloak = { 206 | grantManager: { 207 | createGrant: (token: any) => { 208 | return { access_token: new Token(token.access_token, TEST_CLIENT_ID)} 209 | } 210 | } 211 | } as unknown as Keycloak.Keycloak 212 | 213 | const subscriptionHandler = new KeycloakSubscriptionHandler({ keycloak: stubKeycloak, protect: false }) 214 | const connectionParams = { Authorization: undefined } 215 | 216 | await t.notThrowsAsync(async () => { 217 | await subscriptionHandler.onSubscriptionConnect(connectionParams, {}, {}) 218 | }) 219 | }) -------------------------------------------------------------------------------- /test/auth.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | 4 | import { GraphQLSchema } from 'graphql' 5 | import { VisitableSchemaType } from '@graphql-tools/utils' 6 | import { AuthDirective } from '../src/directives/schemaDirectiveVisitors' 7 | 8 | import { KeycloakContext, GrantedRequest } from '../src/KeycloakContext' 9 | 10 | const createHasRoleDirective = () => { 11 | return new AuthDirective({ 12 | name: 'testAuthDirective', 13 | args: {}, 14 | visitedType: ({} as VisitableSchemaType), 15 | schema: ({} as GraphQLSchema), 16 | context: [] 17 | }) 18 | } 19 | 20 | test('happy path: context.kauth.isAuthenticated() is called, then original resolver is called', async (t) => { 21 | const directive = createHasRoleDirective() 22 | 23 | const field = { 24 | resolve: (root: any, args: any, context: any, info: any) => { 25 | t.pass() 26 | }, 27 | name: 'testField' 28 | } 29 | 30 | const resolverSpy = sinon.spy(field, 'resolve') 31 | 32 | directive.visitFieldDefinition(field) 33 | 34 | const root = {} 35 | const args = {} 36 | const req = { 37 | kauth: { 38 | grant: { 39 | access_token: { 40 | isExpired: () => { 41 | return false 42 | } 43 | } 44 | } 45 | } 46 | } as GrantedRequest 47 | 48 | const context = { 49 | request: req, 50 | kauth: new KeycloakContext({ req }) 51 | } 52 | 53 | const isAuthenticatedSpy = sinon.spy(context.kauth, 'isAuthenticated') 54 | 55 | const info = { 56 | parentType: { 57 | name: 'testParent' 58 | } 59 | } 60 | 61 | await field.resolve(root, args, context, info) 62 | 63 | t.truthy(isAuthenticatedSpy.called) 64 | t.truthy(resolverSpy.called) 65 | }) 66 | 67 | test('context.kauth.isAuthenticated() is called, even if field has no resolver', async (t) => { 68 | const directive = createHasRoleDirective() 69 | 70 | const field = { 71 | name: 'testField' 72 | } 73 | 74 | directive.visitFieldDefinition(field) 75 | 76 | const root = {} 77 | const args = {} 78 | const req = { 79 | kauth: { 80 | grant: { 81 | access_token: { 82 | isExpired: () => { 83 | return false 84 | } 85 | } 86 | } 87 | } 88 | } as GrantedRequest 89 | 90 | const context = { 91 | request: req, 92 | kauth: new KeycloakContext({ req }) 93 | } 94 | 95 | const isAuthenticatedSpy = sinon.spy(context.kauth, 'isAuthenticated') 96 | 97 | const info = { 98 | parentType: { 99 | name: 'testParent' 100 | } 101 | } 102 | 103 | //@ts-ignore 104 | await field.resolve(root, args, context, info) 105 | 106 | t.truthy(isAuthenticatedSpy.called) 107 | }) 108 | 109 | test('resolver will throw if context.kauth is not present', async (t) => { 110 | const directive = createHasRoleDirective() 111 | 112 | const field = { 113 | resolve: (root: any, args: any, context: any, info: any) => { 114 | t.fail() 115 | }, 116 | name: 'testField' 117 | } 118 | 119 | directive.visitFieldDefinition(field) 120 | 121 | const root = {} 122 | const args = {} 123 | const req = { 124 | kauth: { 125 | grant: { 126 | access_token: { 127 | isExpired: () => { 128 | return false 129 | } 130 | } 131 | } 132 | } 133 | } as GrantedRequest 134 | 135 | const context = { 136 | request: req 137 | } 138 | 139 | const info = { 140 | parentType: { 141 | name: 'testParent' 142 | } 143 | } 144 | 145 | await t.throwsAsync(async () => { 146 | await field.resolve(root, args, context, info) 147 | }, 'User not Authenticated') 148 | }) 149 | 150 | test('resolver will throw if context.kauth present but context.kauth.isAuthenticated returns false', async (t) => { 151 | const directive = createHasRoleDirective() 152 | 153 | const field = { 154 | resolve: (root: any, args: any, context: any, info: any) => { 155 | t.fail() 156 | }, 157 | name: 'testField' 158 | } 159 | 160 | directive.visitFieldDefinition(field) 161 | 162 | const root = {} 163 | const args = {} 164 | const req = {} as GrantedRequest 165 | 166 | const context = { 167 | request: req, 168 | kauth: { 169 | isAuthenticated: () => false 170 | } 171 | } 172 | 173 | const info = { 174 | parentType: { 175 | name: 'testParent' 176 | } 177 | } 178 | 179 | await t.throwsAsync(async () => { 180 | await field.resolve(root, args, context, info) 181 | }, 'User not Authenticated') 182 | }) -------------------------------------------------------------------------------- /test/hasPermission.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | 4 | import { GraphQLSchema } from 'graphql' 5 | import { VisitableSchemaType } from '@graphql-tools/utils' 6 | import { HasPermissionDirective } from '../src/directives/schemaDirectiveVisitors' 7 | import { KeycloakContext, GrantedRequest } from '../src/KeycloakContext' 8 | import Keycloak, { AuthZRequest, Grant } from 'keycloak-connect' 9 | 10 | import * as express from 'express' 11 | import { AuthorizationConfiguration } from '../src/KeycloakPermissionsHandler' 12 | 13 | const createHasPermissionDirective = (directiveArgs: any) => { 14 | return new HasPermissionDirective({ 15 | name: 'testHasPermissionDirective', 16 | args: directiveArgs, 17 | visitedType: ({} as VisitableSchemaType), 18 | schema: ({} as GraphQLSchema), 19 | context: [] 20 | }) 21 | } 22 | 23 | test('context.auth.hasPermission() is called', async (t) => { 24 | t.plan(1) 25 | const directiveArgs = { 26 | resources: 'Artical' 27 | } 28 | 29 | const directive = createHasPermissionDirective(directiveArgs) 30 | 31 | const field = { 32 | resolve: (root: any, args: any, context: any, info: any) => { 33 | t.pass() 34 | }, 35 | name: 'testField' 36 | } 37 | 38 | directive.visitFieldDefinition(field) 39 | 40 | const root = {} 41 | const args = {} 42 | const keycloak = { 43 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 44 | return new Promise((resolve, reject) => { 45 | const result = { 46 | access_token: { 47 | hasPermission: (r: string, s: string | undefined): boolean => { 48 | return true 49 | } 50 | } 51 | } as unknown as Grant 52 | return resolve(result) 53 | }) 54 | } 55 | } as Keycloak.Keycloak 56 | const req = { 57 | kauth: { 58 | grant: { 59 | access_token: { 60 | hasPermission: (r: string, s: string | undefined): boolean => { 61 | return false 62 | }, 63 | isExpired: () => { 64 | return false 65 | } 66 | } 67 | } 68 | } 69 | } as unknown as GrantedRequest 70 | 71 | const config = { 72 | resource_server_id: 'resource-server' 73 | } as AuthorizationConfiguration 74 | 75 | const context = { 76 | request: req, 77 | kauth: new KeycloakContext({ req }, keycloak, config) 78 | } 79 | 80 | const info = { 81 | parentType: { 82 | name: 'testParent' 83 | } 84 | } 85 | 86 | await field.resolve(root, args, context, info) 87 | }) 88 | 89 | test('hasPermission works on fields that have no resolvers. context.auth.hasPermission() is called', async (t) => { 90 | t.plan(1) 91 | const directiveArgs = { 92 | resources: 'Article:view' 93 | } 94 | 95 | const directive = createHasPermissionDirective(directiveArgs) 96 | const field = { 97 | name: 'testField' 98 | } 99 | 100 | directive.visitFieldDefinition(field) 101 | 102 | const root = {} 103 | const args = {} 104 | const keycloak = { 105 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 106 | return new Promise((resolve, reject) => { 107 | const result = { 108 | access_token: { 109 | hasPermission: (r: string, s: string | undefined): boolean => { 110 | return true 111 | } 112 | } 113 | } as unknown as Grant 114 | return resolve(result) 115 | }) 116 | } 117 | } as Keycloak.Keycloak 118 | 119 | const config = { 120 | resource_server_id: 'resource-server' 121 | } as AuthorizationConfiguration 122 | 123 | const req = { 124 | kauth: { 125 | grant: { 126 | access_token: { 127 | hasPermission: (r: string, s: string | undefined): boolean => { 128 | t.pass() 129 | return true 130 | }, 131 | isExpired: () => { 132 | return false 133 | } 134 | } 135 | } 136 | } 137 | } as unknown as GrantedRequest 138 | 139 | const context = { 140 | request: req, 141 | kauth: new KeycloakContext({ req }, keycloak, config) 142 | } 143 | 144 | const info = { 145 | parentType: { 146 | name: 'testParent' 147 | } 148 | } 149 | 150 | //@ts-ignore 151 | await field.resolve(root, args, context, info) 152 | }) 153 | 154 | test('visitFieldDefinition accepts an array of permissions', async (t) => { 155 | t.plan(2) 156 | const directiveArgs = { 157 | resources: ['Article:view', 'Article:write', 'Article:delete'] 158 | } 159 | 160 | const directive = createHasPermissionDirective(directiveArgs) 161 | 162 | const field = { 163 | resolve: (root: any, args: any, context: any, info: any) => { 164 | t.pass() 165 | }, 166 | name: 'testField' 167 | } 168 | 169 | directive.visitFieldDefinition(field) 170 | 171 | const root = {} 172 | const args = {} 173 | const keycloak = { 174 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 175 | return new Promise((resolve, reject) => { 176 | const result = { 177 | access_token: { 178 | hasPermission: (r: string, s: string | undefined): boolean => { 179 | return true 180 | } 181 | } 182 | } as unknown as Grant 183 | return resolve(result) 184 | }) 185 | } 186 | } as Keycloak.Keycloak 187 | 188 | const config = { 189 | resource_server_id: 'resource-server' 190 | } as AuthorizationConfiguration 191 | 192 | const req = { 193 | kauth: { 194 | grant: { 195 | access_token: { 196 | hasPermission: (r: string, s: string | undefined): boolean => { 197 | t.pass() 198 | return r === 'Article' && s === 'write' 199 | }, 200 | isExpired: () => { 201 | return false 202 | } 203 | } 204 | } 205 | } 206 | } as unknown as GrantedRequest 207 | 208 | const context = { 209 | request: req, 210 | kauth: new KeycloakContext({ req }, keycloak, config) 211 | } 212 | 213 | const info = { 214 | parentType: { 215 | name: 'testParent' 216 | } 217 | } 218 | 219 | await field.resolve(root, args, context, info) 220 | }) 221 | 222 | test('if there is no authentication, then an error is returned and the original resolver will not execute', async (t) => { 223 | const directiveArgs = { 224 | resources: 'Article' 225 | } 226 | 227 | const directive = createHasPermissionDirective(directiveArgs) 228 | 229 | const field = { 230 | resolve: (root: any, args: any, context: any, info: any) => { 231 | return new Promise((resolve, reject) => { 232 | t.fail('the original resolver should never be called when an auth error is thrown') 233 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 234 | }) 235 | }, 236 | name: 'testField' 237 | } 238 | 239 | directive.visitFieldDefinition(field) 240 | 241 | const root = {} 242 | const args = {} 243 | const keycloak = { 244 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 245 | return new Promise((resolve, reject) => { 246 | const result = { 247 | access_token: { 248 | hasPermission: (r: string, s: string | undefined): boolean => { 249 | return true 250 | } 251 | } 252 | } as unknown as Grant 253 | return resolve(result) 254 | }) 255 | } 256 | } as Keycloak.Keycloak 257 | 258 | const config = { 259 | resource_server_id: 'resource-server' 260 | } as AuthorizationConfiguration 261 | 262 | const req = {} as GrantedRequest 263 | const context = { 264 | request: req, 265 | kauth: new KeycloakContext({ req }, keycloak, config) 266 | } 267 | 268 | const info = { 269 | parentType: { 270 | name: 'testParent' 271 | } 272 | } 273 | 274 | await t.throwsAsync(async () => { 275 | await field.resolve(root, args, context, info) 276 | }, `User not Authenticated`) 277 | }) 278 | 279 | test('if token does not have the required permission, then an error is returned and the original resolver will not execute', async (t) => { 280 | const directiveArgs = { 281 | resources: 'Article:view' 282 | } 283 | 284 | const directive = createHasPermissionDirective(directiveArgs) 285 | 286 | const field = { 287 | resolve: (root: any, args: any, context: any, info: any) => { 288 | return new Promise((resolve, reject) => { 289 | t.fail('the original resolver should never be called when an auth error is thrown') 290 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 291 | }) 292 | } 293 | } 294 | 295 | directive.visitFieldDefinition(field) 296 | 297 | const root = {} 298 | const args = {} 299 | 300 | const keycloak = { 301 | checkPermissions(authzRequest: AuthZRequest, request: express.Request, callback?: (json: any) => any): Promise { 302 | return new Promise((resolve, reject) => { 303 | const result = { 304 | access_token: { 305 | hasPermission: (r: string, s: string | undefined): boolean => { 306 | return true 307 | } 308 | } 309 | } as unknown as Grant 310 | return resolve(result) 311 | }) 312 | } 313 | } as Keycloak.Keycloak 314 | 315 | const config = { 316 | resource_server_id: 'resource-server' 317 | } as AuthorizationConfiguration 318 | 319 | const req = { 320 | kauth: { 321 | grant: { 322 | access_token: { 323 | hasPermission: (resources: string) => { 324 | t.deepEqual(resources, directiveArgs.resources) 325 | return false 326 | }, 327 | isExpired: () => { 328 | return false 329 | } 330 | } 331 | } 332 | } 333 | } as unknown as GrantedRequest 334 | 335 | const context = { 336 | request: req, 337 | kauth: new KeycloakContext({ req }) 338 | } 339 | 340 | const info = { 341 | fieldName: 'testField', 342 | parentType: { 343 | name: 'testParent' 344 | } 345 | } 346 | 347 | await t.throwsAsync(async () => { 348 | await field.resolve(root, args, context, info) 349 | }, `User is not authorized. Must have the following permissions: [${directiveArgs.resources}]`) 350 | }) 351 | 352 | test('hasPermission does not allow unknown arguments, visitFieldDefinition will throw', async (t) => { 353 | const directiveArgs = { 354 | resources: 'Article:view', 355 | some: 'unknown arg' 356 | } 357 | 358 | const directive = createHasPermissionDirective(directiveArgs) 359 | 360 | const field = { 361 | resolve: (root: any, args: any, context: any, info: any) => { 362 | return new Promise((resolve, reject) => { 363 | t.fail('the original resolver should never be called') 364 | }) 365 | }, 366 | name: 'testField' 367 | } 368 | 369 | t.throws(() => { 370 | directive.visitFieldDefinition(field) 371 | }) 372 | }) 373 | 374 | test('hasPermission does not allow a non string value for resources, visitFieldDefinition will throw', async (t) => { 375 | const directiveArgs = { 376 | resources: 123 377 | } 378 | 379 | const directive = createHasPermissionDirective(directiveArgs) 380 | 381 | const field = { 382 | resolve: (root: any, args: any, context: any, info: any) => { 383 | return new Promise((resolve, reject) => { 384 | t.fail('the original resolver should never be called') 385 | }) 386 | }, 387 | name: 'testField' 388 | } 389 | 390 | t.throws(() => { 391 | directive.visitFieldDefinition(field) 392 | }) 393 | }) 394 | 395 | test('hasPermission must contain resources arg, visitFieldDefinition will throw', async (t) => { 396 | const directiveArgs = {} 397 | 398 | const directive = createHasPermissionDirective(directiveArgs) 399 | 400 | const field = { 401 | resolve: (root: any, args: any, context: any, info: any) => { 402 | return new Promise((resolve, reject) => { 403 | t.fail('the original resolver should never be called') 404 | }) 405 | }, 406 | name: 'testField' 407 | } 408 | 409 | t.throws(() => { 410 | directive.visitFieldDefinition(field) 411 | }) 412 | }) 413 | 414 | test('hasPermission resources arg can be an array, visitFieldDefinition will not throw', async (t) => { 415 | const directiveArgs = { 416 | resources: ['Article:view', 'Blog'] 417 | } 418 | 419 | const directive = createHasPermissionDirective(directiveArgs) 420 | 421 | const field = { 422 | resolve: (root: any, args: any, context: any, info: any) => { 423 | return new Promise((resolve, reject) => { 424 | t.fail('the original resolver should never be called') 425 | }) 426 | }, 427 | name: 'testField' 428 | } 429 | 430 | t.notThrows(() => { 431 | directive.visitFieldDefinition(field) 432 | }) 433 | }) -------------------------------------------------------------------------------- /test/hasRole.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | 4 | import { GraphQLSchema } from 'graphql' 5 | import { VisitableSchemaType } from '@graphql-tools/utils' 6 | import { HasRoleDirective } from '../src/directives/schemaDirectiveVisitors' 7 | import { KeycloakContext, GrantedRequest } from '../src/KeycloakContext' 8 | 9 | const createHasRoleDirective = (directiveArgs: any) => { 10 | return new HasRoleDirective({ 11 | name: 'testHasRoleDirective', 12 | args: directiveArgs, 13 | visitedType: ({} as VisitableSchemaType), 14 | schema: ({} as GraphQLSchema), 15 | context: [] 16 | }) 17 | } 18 | 19 | test('context.auth.hasRole() is called', async (t) => { 20 | t.plan(3) 21 | const directiveArgs = { 22 | role: 'admin' 23 | } 24 | 25 | const directive = createHasRoleDirective(directiveArgs) 26 | 27 | const field = { 28 | resolve: (root: any, args: any, context: any, info: any) => { 29 | t.pass() 30 | }, 31 | name: 'testField' 32 | } 33 | 34 | directive.visitFieldDefinition(field) 35 | 36 | const root = {} 37 | const args = {} 38 | const req = { 39 | kauth: { 40 | grant: { 41 | access_token: { 42 | hasRole: (role: string) => { 43 | t.pass() 44 | t.deepEqual(role, directiveArgs.role) 45 | return true 46 | }, 47 | isExpired: () => { 48 | return false 49 | } 50 | } 51 | } 52 | } 53 | } as GrantedRequest 54 | 55 | const context = { 56 | request: req, 57 | kauth: new KeycloakContext({ req }) 58 | } 59 | 60 | const info = { 61 | parentType: { 62 | name: 'testParent' 63 | } 64 | } 65 | 66 | await field.resolve(root, args, context, info) 67 | }) 68 | 69 | test('hasRole works on fields that have no resolvers. context.auth.hasRole() is called', async (t) => { 70 | t.plan(2) 71 | const directiveArgs = { 72 | role: 'admin' 73 | } 74 | 75 | const directive = createHasRoleDirective(directiveArgs) 76 | 77 | const field = { 78 | name: 'testField' 79 | } 80 | 81 | directive.visitFieldDefinition(field) 82 | 83 | const root = {} 84 | const args = {} 85 | const req = { 86 | kauth: { 87 | grant: { 88 | access_token: { 89 | hasRole: (role: string) => { 90 | t.pass() 91 | t.deepEqual(role, directiveArgs.role) 92 | return true 93 | }, 94 | isExpired: () => { 95 | return false 96 | } 97 | } 98 | } 99 | } 100 | } as GrantedRequest 101 | 102 | const context = { 103 | request: req, 104 | kauth: new KeycloakContext({ req }) 105 | } 106 | 107 | const info = { 108 | parentType: { 109 | name: 'testParent' 110 | } 111 | } 112 | 113 | //@ts-ignore 114 | await field.resolve(root, args, context, info) 115 | }) 116 | 117 | test('visitFieldDefinition accepts an array of roles', async (t) => { 118 | t.plan(4) 119 | const directiveArgs = { 120 | role: ['foo', 'bar', 'baz'] 121 | } 122 | 123 | const directive = createHasRoleDirective(directiveArgs) 124 | 125 | const field = { 126 | resolve: (root: any, args: any, context: any, info: any) => { 127 | t.pass() 128 | }, 129 | name: 'testField' 130 | } 131 | 132 | directive.visitFieldDefinition(field) 133 | 134 | const root = {} 135 | const args = {} 136 | const req = { 137 | kauth: { 138 | grant: { 139 | access_token: { 140 | hasRole: (role: string) => { 141 | t.pass() 142 | return (role === 'baz') // this makes sure it doesn't return true instantly 143 | }, 144 | isExpired: () => { 145 | return false 146 | } 147 | } 148 | } 149 | } 150 | } as GrantedRequest 151 | 152 | const context = { 153 | request: req, 154 | kauth: new KeycloakContext({ req }) 155 | } 156 | 157 | const info = { 158 | parentType: { 159 | name: 'testParent' 160 | } 161 | } 162 | 163 | await field.resolve(root, args, context, info) 164 | }) 165 | 166 | test('if there is no authentication, then an error is returned and the original resolver will not execute', async (t) => { 167 | const directiveArgs = { 168 | role: 'admin' 169 | } 170 | 171 | const directive = createHasRoleDirective(directiveArgs) 172 | 173 | const field = { 174 | resolve: (root: any, args: any, context: any, info: any) => { 175 | return new Promise((resolve, reject) => { 176 | t.fail('the original resolver should never be called when an auth error is thrown') 177 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 178 | }) 179 | }, 180 | name: 'testField' 181 | } 182 | 183 | directive.visitFieldDefinition(field) 184 | 185 | const root = {} 186 | const args = {} 187 | const req = {} as GrantedRequest 188 | const context = { 189 | request: req, 190 | kauth: new KeycloakContext({ req }) 191 | } 192 | 193 | const info = { 194 | parentType: { 195 | name: 'testParent' 196 | } 197 | } 198 | 199 | await t.throwsAsync(async () => { 200 | await field.resolve(root, args, context, info) 201 | }, `User not Authenticated`) 202 | }) 203 | 204 | test('if token does not have the required role, then an error is returned and the original resolver will not execute', async (t) => { 205 | const directiveArgs = { 206 | role: 'admin' 207 | } 208 | 209 | const directive = createHasRoleDirective(directiveArgs) 210 | 211 | const field = { 212 | resolve: (root: any, args: any, context: any, info: any) => { 213 | return new Promise((resolve, reject) => { 214 | t.fail('the original resolver should never be called when an auth error is thrown') 215 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 216 | }) 217 | } 218 | } 219 | 220 | directive.visitFieldDefinition(field) 221 | 222 | const root = {} 223 | const args = {} 224 | const req = { 225 | kauth: { 226 | grant: { 227 | access_token: { 228 | hasRole: (role: string) => { 229 | t.deepEqual(role, directiveArgs.role) 230 | return false 231 | }, 232 | isExpired: () => { 233 | return false 234 | } 235 | } 236 | } 237 | } 238 | } as GrantedRequest 239 | 240 | const context = { 241 | request: req, 242 | kauth: new KeycloakContext({ req }) 243 | } 244 | 245 | const info = { 246 | fieldName: 'testField', 247 | parentType: { 248 | name: 'testParent' 249 | } 250 | } 251 | 252 | await t.throwsAsync(async () => { 253 | await field.resolve(root, args, context, info) 254 | }, `User is not authorized. Must have one of the following roles: [${directiveArgs.role}]`) 255 | }) 256 | 257 | test('hasRole does not allow unkown arguments, visitFieldDefinition will throw', async (t) => { 258 | const directiveArgs = { 259 | role: 'admin', 260 | some: 'unknown arg' 261 | } 262 | 263 | const directive = createHasRoleDirective(directiveArgs) 264 | 265 | const field = { 266 | resolve: (root: any, args: any, context: any, info: any) => { 267 | return new Promise((resolve, reject) => { 268 | t.fail('the original resolver should never be called') 269 | }) 270 | }, 271 | name: 'testField' 272 | } 273 | 274 | t.throws(() => { 275 | directive.visitFieldDefinition(field) 276 | }) 277 | }) 278 | 279 | test('hasRole does not allow a non string value for role, visitFieldDefinition will throw', async (t) => { 280 | const directiveArgs = { 281 | role: 123 282 | } 283 | 284 | const directive = createHasRoleDirective(directiveArgs) 285 | 286 | const field = { 287 | resolve: (root: any, args: any, context: any, info: any) => { 288 | return new Promise((resolve, reject) => { 289 | t.fail('the original resolver should never be called') 290 | }) 291 | }, 292 | name: 'testField' 293 | } 294 | 295 | t.throws(() => { 296 | directive.visitFieldDefinition(field) 297 | }) 298 | }) 299 | 300 | test('hasRole must contain role arg, visitFieldDefinition will throw', async (t) => { 301 | const directiveArgs = {} 302 | 303 | const directive = createHasRoleDirective(directiveArgs) 304 | 305 | const field = { 306 | resolve: (root: any, args: any, context: any, info: any) => { 307 | return new Promise((resolve, reject) => { 308 | t.fail('the original resolver should never be called') 309 | }) 310 | }, 311 | name: 'testField' 312 | } 313 | 314 | t.throws(() => { 315 | directive.visitFieldDefinition(field) 316 | }) 317 | }) 318 | 319 | test('hasRole role arg can be an array, visitFieldDefinition will not throw', async (t) => { 320 | const directiveArgs = { 321 | role: ['admin', 'developer'] 322 | } 323 | 324 | const directive = createHasRoleDirective(directiveArgs) 325 | 326 | const field = { 327 | resolve: (root: any, args: any, context: any, info: any) => { 328 | return new Promise((resolve, reject) => { 329 | t.fail('the original resolver should never be called') 330 | }) 331 | }, 332 | name: 'testField' 333 | } 334 | 335 | t.notThrows(() => { 336 | directive.visitFieldDefinition(field) 337 | }) 338 | }) 339 | 340 | test('hasRole role arg can be an array, non string values will be converted, visitFieldDefinition will not throw', async (t) => { 341 | t.plan(1) 342 | const directiveArgs = { 343 | role: ['admin', 1, 1.234] 344 | } 345 | 346 | const expectedValue = ['admin', '1', '1.234'] 347 | 348 | 349 | const directive = createHasRoleDirective(directiveArgs) 350 | 351 | const field = { 352 | resolve: (root: any, args: any, context: any, info: any) => {}, 353 | name: 'testField' 354 | } 355 | 356 | const validateSpy = sinon.spy(directive, 'parseAndValidateArgs') 357 | 358 | directive.visitFieldDefinition(field) 359 | 360 | t.deepEqual(expectedValue, validateSpy.returnValues[0]) 361 | }) 362 | 363 | test('context.auth.hasRole() works even if request is not supplied in context', async (t) => { 364 | t.plan(3) 365 | const directiveArgs = { 366 | role: 'admin' 367 | } 368 | 369 | const directive = createHasRoleDirective(directiveArgs) 370 | 371 | const field = { 372 | resolve: (root: any, args: any, context: any, info: any) => { 373 | t.pass() 374 | }, 375 | name: 'testField' 376 | } 377 | 378 | directive.visitFieldDefinition(field) 379 | 380 | const root = {} 381 | const args = {} 382 | const req = { 383 | kauth: { 384 | grant: { 385 | access_token: { 386 | hasRole: (role: string) => { 387 | t.pass() 388 | t.deepEqual(role, directiveArgs.role) 389 | return true 390 | }, 391 | isExpired: () => { 392 | return false 393 | } 394 | } 395 | } 396 | } 397 | } as GrantedRequest 398 | 399 | const context = { 400 | kauth: new KeycloakContext({ req }) 401 | } 402 | 403 | const info = { 404 | parentType: { 405 | name: 'testParent' 406 | } 407 | } 408 | 409 | await field.resolve(root, args, context, info) 410 | }) 411 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { isAuthorizedByRole } from '../src/directives/utils' 3 | import Keycloak from 'keycloak-connect' 4 | import { KeycloakContextBase } from '../src/KeycloakContext' 5 | 6 | test('isAuthorizedByRole returns the result of token.hasRole', (t) => { 7 | t.plan(4) 8 | const token = { 9 | hasRole: (role: string) => { 10 | t.pass() 11 | return role === 'c' 12 | }, 13 | isExpired: () => { 14 | return false 15 | } 16 | } as Keycloak.Token 17 | 18 | const context = { kauth: new KeycloakContextBase(token) } 19 | 20 | t.truthy(isAuthorizedByRole(['a', 'b', 'c'], context)) 21 | }) 22 | 23 | test('isAuthorizedByRole returns false if hasRole returns false', (t) => { 24 | t.plan(4) 25 | const token = { 26 | hasRole: (role: string) => { 27 | t.pass() 28 | return false 29 | }, 30 | isExpired: () => { 31 | return false 32 | } 33 | } as Keycloak.Token 34 | 35 | const context = { kauth: new KeycloakContextBase(token) } 36 | 37 | t.falsy(isAuthorizedByRole(['a', 'b', 'c'], context)) 38 | }) 39 | 40 | test('isAuthorizedByRole returns false if context is empty', (t) => { 41 | const context = { } 42 | t.falsy(isAuthorizedByRole(['a', 'b', 'c'], context)) 43 | }) 44 | 45 | test('isAuthorizedByRole returns false if context undefined', (t) => { 46 | const context = { } 47 | t.falsy(isAuthorizedByRole(['a', 'b', 'c'], undefined)) 48 | }) -------------------------------------------------------------------------------- /test/utils/KeycloakToken.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Construct a token. 3 | * 4 | * Based on a JSON Web Token string, construct a token object. Optionally 5 | * if a `clientId` is provided, the token may be tested for roles with 6 | * `hasRole()`. 7 | * 8 | * @constructor 9 | * 10 | * @param {String} token The JSON Web Token formatted token string. 11 | * @param {String} clientId Optional clientId if this is an `access_token`. 12 | */ 13 | 14 | export class Token { 15 | 16 | public token: string 17 | public clientId?: string 18 | public header?: any 19 | public content?: any 20 | public signature?: Buffer 21 | public signed?: string 22 | 23 | constructor(token: string, clientId?: string) { 24 | this.token = token 25 | this.clientId = clientId 26 | 27 | if (token) { 28 | try { 29 | const parts = token.split('.') 30 | this.header = JSON.parse(Buffer.from(parts[0], 'base64').toString()) 31 | this.content = JSON.parse(Buffer.from(parts[1], 'base64').toString()) 32 | this.signature = Buffer.from(parts[2], 'base64') 33 | this.signed = parts[0] + '.' + parts[1] 34 | } catch (err) { 35 | this.content = { 36 | exp: 0 37 | } 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * Determine if this token is expired. 44 | * 45 | * @return {boolean} `true` if it is expired, otherwise `false`. 46 | */ 47 | public isExpired () { 48 | return ((this.content.exp * 1000) < Date.now()) 49 | } 50 | 51 | /** 52 | * Determine if this token has an associated role. 53 | * 54 | * This method is only functional if the token is constructed 55 | * with a `clientId` parameter. 56 | * 57 | * The parameter matches a role specification using the following rules: 58 | * 59 | * - If the name contains no colons, then the name is taken as the entire 60 | * name of a role within the current application, as specified via 61 | * `clientId`. 62 | * - If the name starts with the literal `realm:`, the subsequent portion 63 | * is taken as the name of a _realm-level_ role. 64 | * - Otherwise, the name is split at the colon, with the first portion being 65 | * taken as the name of an arbitrary application, and the subsequent portion 66 | * as the name of a role with that app. 67 | * 68 | * @param {String} name The role name specifier. 69 | * 70 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 71 | */ 72 | public hasRole (name: string): boolean { 73 | if (!this.clientId) { 74 | return false 75 | } 76 | 77 | const parts = name.split(':') 78 | if (parts.length === 1) { 79 | return this.hasApplicationRole(this.clientId, parts[0]) 80 | } 81 | 82 | if (parts[0] === 'realm') { 83 | return this.hasRealmRole(parts[1]) 84 | } 85 | 86 | return this.hasApplicationRole(parts[0], parts[1]) 87 | } 88 | 89 | /** 90 | * Determine if this token has an associated specific application role. 91 | * 92 | * Even if `clientId` is not set, this method may be used to explicitly test 93 | * roles for any given application. 94 | * 95 | * @param {String} appName The identifier of the application to test. 96 | * @param {String} roleName The name of the role within that application to test. 97 | * 98 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 99 | */ 100 | public hasApplicationRole (appName: string, roleName: string): boolean { 101 | const appRoles = this.content.resource_access[appName] 102 | 103 | if (!appRoles) { 104 | return false 105 | } 106 | 107 | return (appRoles.roles.indexOf(roleName) >= 0) 108 | } 109 | 110 | /** 111 | * Determine if this token has an associated specific realm-level role. 112 | * 113 | * Even if `clientId` is not set, this method may be used to explicitly test 114 | * roles for the realm. 115 | * 116 | * @param {String} appName The identifier of the application to test. 117 | * @param {String} roleName The name of the role within that application to test. 118 | * 119 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 120 | */ 121 | public hasRealmRole (roleName: string): boolean { 122 | // Make sure we have these properties before we check for a certain realm level role! 123 | // Without this we attempt to access an undefined property on token 124 | // for a user with no realm level roles. 125 | if (!this.content.realm_access || !this.content.realm_access.roles) { 126 | return false 127 | } 128 | 129 | return (this.content.realm_access.roles.indexOf(roleName) >= 0) 130 | } 131 | 132 | /** 133 | * Determine if this token has an associated role. 134 | * 135 | * This method is only functional if the token is constructed 136 | * with a `clientId` parameter. 137 | * 138 | * The parameter matches a role specification using the following rules: 139 | * 140 | * - If the name contains no colons, then the name is taken as the entire 141 | * name of a role within the current application, as specified via 142 | * `clientId`. 143 | * - If the name starts with the literal `realm:`, the subsequent portion 144 | * is taken as the name of a _realm-level_ role. 145 | * - Otherwise, the name is split at the colon, with the first portion being 146 | * taken as the name of an arbitrary application, and the subsequent portion 147 | * as the name of a role with that app. 148 | * 149 | * @param {String} permission The role name specifier. 150 | * 151 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 152 | */ 153 | public hasPermission (resource: string, scope: string): boolean { 154 | const permissions = this.content.authorization ? this.content.authorization.permissions : undefined 155 | 156 | if (!permissions) { 157 | return false 158 | } 159 | 160 | for (const permission of permissions) { 161 | 162 | if (permission.rsid === resource || permission.rsname === resource) { 163 | if (scope) { 164 | if (permission.scopes && permission.scopes.length > 0) { 165 | if (!permission.scopes.includes(scope)) { 166 | return false 167 | } 168 | } 169 | } 170 | return true 171 | } 172 | } 173 | 174 | return false 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "./dist", 5 | /* Basic Options */ 6 | "target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 8 | "lib": ["es2017", "esnext.asynciterable", "dom"], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | "composite": true, /* Enable project compilation */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | "types": ["node"], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "include": ["src/**/*.ts"], 62 | "exclude": [] 63 | } 64 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "linterOptions": { 4 | "exclude": [ 5 | "node_modules/**" 6 | ] 7 | }, 8 | "rules": { 9 | "semicolon": [ 10 | true, 11 | "never" 12 | ], 13 | "quotemark": [ 14 | true, 15 | "single" 16 | ], 17 | "max-line-length": [false], 18 | "space-before-function-paren": [false, "always"], 19 | "only-arrow-functions": [ 20 | false 21 | ], 22 | "trailing-comma": [ 23 | true, 24 | { 25 | "multiline": "never", 26 | "singleline": "never" 27 | } 28 | ], 29 | "arrow-parens": false, 30 | "object-literal-sort-keys": false, 31 | "object-literal-key-quotes": [ 32 | false 33 | ], 34 | "max-classes-per-file": [ 35 | false 36 | ], 37 | "ban-types": [ 38 | false 39 | ], 40 | "no-console": [ 41 | true, 42 | "log" 43 | ], 44 | "variable-name": [ 45 | true, 46 | "ban-keywords", 47 | "check-format", 48 | "allow-pascal-case", 49 | "allow-leading-underscore" 50 | ], 51 | "interface-name": [ 52 | false 53 | ], 54 | "ordered-imports": false 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tslint_tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tslint.json", 3 | "rules": { 4 | "no-shadowed-variable": [ 5 | true, 6 | { 7 | "temporalDeadZone": false 8 | } 9 | ] 10 | } 11 | } 12 | --------------------------------------------------------------------------------