├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── kong-plugin-jwt-keycloak-1.1.0-1.rockspec ├── luarocks.Dockerfile ├── makefiles ├── keycloak.mk └── kong.mk ├── src ├── handler.lua ├── key_conversion.lua ├── keycloak_keys.lua ├── schema.lua └── validators │ ├── issuers.lua │ ├── roles.lua │ └── scope.lua └── tests ├── Makefile ├── integration_tests ├── Dockerfile └── tests │ ├── TestBasics.py │ ├── TestConsumerMapping.py │ ├── TestIssuers.py │ ├── TestKeyRotation.py │ ├── TestRoles.py │ ├── config.py │ └── utils.py └── unit_tests ├── Dockerfile ├── busted_bin └── tests ├── .busted ├── key_conversion_spec.lua ├── validators_client_roles_spec.lua ├── validators_issuers_spec.lua ├── validators_realm_roles_spec.lua ├── validators_roles_spec.lua └── validators_scope_spec.lua /.dockerignore: -------------------------------------------------------------------------------- 1 | ./idea 2 | ./makefiles 3 | .git 4 | Dockerfile 5 | Makefile 6 | README.md 7 | Dockerfile 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * linguist-vendored 2 | *.lua linguist-vendored=false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.rock 3 | venv 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## Build plugin 2 | ARG KONG_VERSION 3 | FROM kong:${KONG_VERSION} as builder 4 | 5 | # Root needed to install dependencies 6 | USER root 7 | 8 | RUN apk --no-cache add zip 9 | WORKDIR /tmp 10 | 11 | COPY ./*.rockspec /tmp 12 | COPY ./LICENSE /tmp/LICENSE 13 | COPY ./src /tmp/src 14 | ARG PLUGIN_VERSION 15 | RUN luarocks make && luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION} 16 | 17 | ## Create Image 18 | FROM kong:${KONG_VERSION} 19 | 20 | ENV KONG_PLUGINS="bundled,jwt-keycloak" 21 | 22 | COPY --from=builder /tmp/*.rock /tmp/ 23 | 24 | # Root needed for installing plugin 25 | USER root 26 | 27 | ARG PLUGIN_VERSION 28 | RUN luarocks install /tmp/kong-plugin-jwt-keycloak-${PLUGIN_VERSION}.all.rock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include makefiles/*.mk 2 | 3 | REPOSITORY?=gbbirkisson 4 | IMAGE?=kong-plugin-jwt-keycloak 5 | KONG_VERSION?=2.3.2 6 | FULL_IMAGE_NAME:=${REPOSITORY}/${IMAGE}:${KONG_VERSION} 7 | 8 | PLUGIN_VERSION?=1.1.0-1 9 | 10 | TEST_VERSIONS?=1.1.3 1.2.3 1.3.1 1.4.3 1.5.1 2.0.5 2.1.4 2.2.0 2.3.2 11 | 12 | ### Docker ### 13 | 14 | build: 15 | @echo "Building image ..." 16 | docker build --pull -q -t ${FULL_IMAGE_NAME} --build-arg KONG_VERSION=${KONG_VERSION} --build-arg PLUGIN_VERSION=${PLUGIN_VERSION} . 17 | 18 | run: build 19 | docker run -it --rm ${FULL_IMAGE_NAME} kong start --vv 20 | 21 | exec: build 22 | docker run -it --rm ${FULL_IMAGE_NAME} ash 23 | 24 | push: build test 25 | docker push ${FULL_IMAGE_NAME} 26 | 27 | ### LuaRocks ### 28 | 29 | upload: 30 | luarocks upload kong-plugin-jwt-keycloak-${PLUGIN_VERSION}.rockspec --api-key=${API_KEY} 31 | 32 | ### Testing ### 33 | 34 | start: kong-db-start kong-start 35 | restart: kong-stop kong-start 36 | restart-all: stop start 37 | stop: kong-stop kong-db-stop 38 | 39 | test-unit: keycloak-start 40 | @echo ====================================================================== 41 | @echo "Running unit tests with kong version ${KONG_VERSION}" 42 | @echo 43 | 44 | @cd tests && $(MAKE) --no-print-directory _tests-unit PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=${KONG_VERSION} 45 | 46 | @echo 47 | @echo "Unit tests passed with kong version ${KONG_VERSION}" 48 | @echo ====================================================================== 49 | 50 | test-integration: restart-all sleep keycloak-start 51 | @echo ====================================================================== 52 | @echo "Testing kong version ${KONG_VERSION} with ${KONG_DATABASE}" 53 | @echo 54 | 55 | @cd tests && $(MAKE) --no-print-directory _tests-integration PLUGIN_VERSION=${PLUGIN_VERSION} 56 | 57 | @echo 58 | @echo "Testing kong version ${KONG_VERSION} with ${KONG_DATABASE} was successful" 59 | @echo ====================================================================== 60 | 61 | test: test-unit test-integration 62 | 63 | test-all: keycloak-start 64 | @echo "Starting integration tests for multiple versions" 65 | @set -e; for t in $(TEST_VERSIONS); do \ 66 | $(MAKE) --no-print-directory test-unit PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t ; \ 67 | $(MAKE) --no-print-directory test-integration PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t KONG_DATABASE=postgres ; \ 68 | $(MAKE) --no-print-directory test-integration PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t KONG_DATABASE=cassandra ; \ 69 | done 70 | @echo "All test successful" 71 | 72 | sleep: 73 | @sleep 5 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Kong plugin jwt-keycloak

2 | 3 | > **:warning: No longer maintained!** 4 | > 5 | > I will no longer be maintaining this plugin. Thanks for all the positive feedback and interest in this project. Feel free to fork and keep it alive. Cheers! 6 | > 7 | > This repository has been archived, but you can find its successor here: [telekom-digioss/kong-plugin-jwt-keycloak](https://github.com/telekom-digioss/kong-plugin-jwt-keycloak) 8 | 9 | A plugin for the [Kong Microservice API Gateway](https://konghq.com/solutions/gateway/) to validate access tokens issued by [Keycloak](https://www.keycloak.org/). It uses the [Well-Known Uniform Resource Identifiers](https://tools.ietf.org/html/rfc5785) provided by [Keycloak](https://www.keycloak.org/) to load [JWK](https://tools.ietf.org/html/rfc7517) public keys from issuers that are specifically allowed for each endpoint. 10 | 11 | The biggest advantages of this plugin are that it supports: 12 | 13 | * Rotating public keys 14 | * Authorization based on token claims: 15 | * `scope` 16 | * `realm_access` 17 | * `resource_access` 18 | * Matching Keycloak users/clients to Kong consumers 19 | 20 | If you have any suggestion or comments, please feel free to open an issue on this GitHub page. 21 | 22 | ## Table of Contents 23 | 24 | - [Table of Contents](#table-of-contents) 25 | - [Tested and working for](#tested-and-working-for) 26 | - [Installation](#installation) 27 | - [Using luarocks](#using-luarocks) 28 | - [From source](#from-source) 29 | - [Packing the rock](#packing-the-rock) 30 | - [Installing the rock](#installing-the-rock) 31 | - [Enabling plugin](#enabling-plugin) 32 | - [Changing plugin priority](#changing-plugin-priority) 33 | - [Examples](#examples) 34 | - [Usage](#usage) 35 | - [Enabling on endpoints](#enabling-on-endpoints) 36 | - [Service](#service) 37 | - [Route](#route) 38 | - [Globally](#globally) 39 | - [Parameters](#parameters) 40 | - [Example](#example) 41 | - [Caveats](#caveats) 42 | - [Testing](#testing) 43 | - [Setup before tests](#setup-before-tests) 44 | - [Running tests](#running-tests) 45 | - [Useful debug commands](#useful-debug-commands) 46 | 47 | ## Tested and working for 48 | 49 | | Kong Version | Tests passing | 50 | | ------------ | :----------------: | 51 | | 0.13.x | :x: | 52 | | 0.14.x | :x: | 53 | | 1.0.x | :white_check_mark: | 54 | | 1.1.x | :white_check_mark: | 55 | | 1.2.x | :white_check_mark: | 56 | | 1.3.x | :white_check_mark: | 57 | | 1.4.x | :white_check_mark: | 58 | | 1.5.x | :white_check_mark: | 59 | | 2.0.x | :white_check_mark: | 60 | | 2.1.x | :white_check_mark: | 61 | | 2.2.x | :white_check_mark: | 62 | | 2.3.x | :white_check_mark: | 63 | 64 | | Keycloak Version | Tests passing | 65 | | ---------------- | :----------------: | 66 | | 3.X.X | :white_check_mark: | 67 | | 4.X.X | :white_check_mark: | 68 | | 5.X.X | :white_check_mark: | 69 | | 6.X.X | :white_check_mark: | 70 | | 7.X.X | :white_check_mark: | 71 | | 8.X.X | :white_check_mark: | 72 | | 9.X.X | :white_check_mark: | 73 | | 10.X.X | :white_check_mark: | 74 | | 11.X.X | :white_check_mark: | 75 | | 12.X.X | :white_check_mark: | 76 | 77 | ## Installation 78 | 79 | ### Using luarocks 80 | 81 | ```bash 82 | luarocks install kong-plugin-jwt-keycloak 83 | ``` 84 | 85 | ### From source 86 | 87 | #### Packing the rock 88 | 89 | ```bash 90 | export PLUGIN_VERSION=1.1.0-1 91 | luarocks make 92 | luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION} 93 | ``` 94 | 95 | #### Installing the rock 96 | 97 | ```bash 98 | export PLUGIN_VERSION=1.1.0-1 99 | luarocks install jwt-keycloak-${PLUGIN_VERSION}.all.rock 100 | ``` 101 | 102 | ### Enabling plugin 103 | 104 | Set enabled kong enabled plugins, i.e. with environmental variable: `KONG_PLUGINS="bundled,jwt-keycloak"` 105 | 106 | ### Changing plugin priority 107 | 108 | In some cases you might want to change the execution priority of the plugin. You can do that by setting an environmental variable: `JWT_KEYCLOAK_PRIORITY="900"` 109 | 110 | ### Examples 111 | 112 | See [Dockerfile](./Dockerfile) or [luarocks Dockerfile](./luarocks.Dockerfile) for more concrete examples. 113 | 114 | ## Usage 115 | 116 | ### Enabling on endpoints 117 | 118 | The same principle applies to this plugin as the [standard jwt plugin that comes with kong](https://docs.konghq.com/hub/kong-inc/jwt/). You can enable it on service, routes and globally. 119 | 120 | #### Service 121 | 122 | ```bash 123 | curl -X POST http://localhost:8001/services/{service}/plugins \ 124 | --data "name=jwt-keycloak" \ 125 | --data "config.allowed_iss=http://localhost:8080/auth/realms/master" 126 | ``` 127 | 128 | #### Route 129 | ```bash 130 | curl -X POST http://localhost:8001/routes/{route_id}/plugins \ 131 | --data "name=jwt-keycloak" \ 132 | --data "config.allowed_iss=http://localhost:8080/auth/realms/master" 133 | ``` 134 | 135 | #### Globally 136 | 137 | ```bash 138 | curl -X POST http://localhost:8001/plugins \ 139 | --data "name=jwt-keycloak" \ 140 | --data "config.allowed_iss=http://localhost:8080/auth/realms/master" 141 | ``` 142 | 143 | ### Parameters 144 | 145 | | Parameter | Requied | Default | Description | 146 | | -------------------------------------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 147 | | name | yes | | The name of the plugin to use, in this case `keycloak-jwt`. | 148 | | service_id | semi | | The id of the Service which this plugin will target. | 149 | | route_id | semi | | The id of the Route which this plugin will target. | 150 | | enabled | no | `true` | Whether this plugin will be applied. | 151 | | config.uri_param_names | no | `jwt` | A list of querystring parameters that Kong will inspect to retrieve JWTs. | 152 | | config.cookie_names | no | | A list of cookie names that Kong will inspect to retrieve JWTs. | 153 | | config.claims_to_verify | no | `exp` | A list of registered claims (according to [RFC 7519](https://tools.ietf.org/html/rfc7519)) that Kong can verify as well. Accepted values: `exp`, `nbf`. | 154 | | config.anonymous | no | | An optional string (consumer uuid) value to use as an “anonymous” consumer if authentication fails. If empty (default), the request will fail with an authentication failure `4xx`. Please note that this value must refer to the Consumer `id` attribute which is internal to Kong, and not its `custom_id`. | 155 | | config.run_on_preflight | no | `true` | A boolean value that indicates whether the plugin should run (and try to authenticate) on `OPTIONS` preflight requests, if set to false then `OPTIONS` requests will always be allowed. | 156 | | config.maximum_expiration | no | `0` | An integer limiting the lifetime of the JWT to `maximum_expiration` seconds in the future. Any JWT that has a longer lifetime will rejected (HTTP 403). If this value is specified, `exp` must be specified as well in the `claims_to_verify` property. The default value of `0` represents an indefinite period. Potential clock skew should be considered when configuring this value. | 157 | | config.algorithm | no | `RS256` | The algorithm used to verify the token’s signature. Can be `HS256`, `HS384`, `HS512`, `RS256`, or `ES256`. | 158 | | config.allowed_iss | yes | | A list of allowed issuers for this route/service/api. Can be specified as a `string` or as a [Pattern](http://lua-users.org/wiki/PatternsTutorial). | 159 | | config.iss_key_grace_period | no | `10` | An integer that sets the number of seconds until public keys for an issuer can be updated after writing new keys to the cache. This is a guard so that the Kong cache will not invalidate every time a token signed with an invalid public key is sent to the plugin. | 160 | | config.well_known_template | false | *see description* | A string template that the well known endpoint for keycloak is created from. String formatting is applied on the template and `%s` is replaced by the issuer of the token. Default value is `%s/.well-known/openid-configuration` | 161 | | config.scope | no | | A list of scopes the token must have to access the api, i.e. `["email"]`. The token only has to have one of the listed scopes to be authorized. | 162 | | config.roles | no | | A list of roles of current client the token must have to access the api, i.e. `["uma_protection"]`. The token only has to have one of the listed roles to be authorized. | 163 | | config.realm_roles | no | | A list of realm roles (`realm_access`) the token must have to access the api, i.e. `["offline_access"]`. The token only has to have one of the listed roles to be authorized. | 164 | | config.client_roles | no | | A list of roles of a different client (`resource_access`) the token must have to access the api, i.e. `["account:manage-account"]`. The format for each entry should be `:`. The token only has to have one of the listed roles to be authorized. | 165 | | config.consumer_match | no | `false` | A boolean value that indicates if the plugin should find a kong consumer with `id`/`custom_id` that equals the `consumer_match_claim` claim in the access token. | 166 | | config.consumer_match_claim | no | `azp` | The claim name in the token that the plugin will try to match the kong `id`/`custom_id` against. | 167 | | config.consumer_match_claim_custom_id | no | `false` | A boolean value that indicates if the plugin should match the `consumer_match_claim` claim against the consumers `id` or `custom_id`. By default it matches the consumer against the `id`. | 168 | | config.consumer_match_ignore_not_found | no | `false` | A boolean value that indicates if the request should be let through regardless if the plugin is able to match the request to a kong consumer or not. | 169 | 170 | ### Example 171 | 172 | Create service and add the plugin to it, and lastly create a route: 173 | 174 | ```bash 175 | curl -X POST http://localhost:8001/services \ 176 | --data "name=mockbin-echo" \ 177 | --data "url=http://mockbin.org/echo" 178 | 179 | curl -X POST http://localhost:8001/services/mockbin-echo/plugins \ 180 | --data "name=jwt-keycloak" \ 181 | --data "config.allowed_iss=http://localhost:8080/auth/realms/master" 182 | 183 | curl -X POST http://localhost:8001/services/mockbin-echo/routes \ 184 | --data "paths=/" 185 | ``` 186 | 187 | Then you can call the API: 188 | 189 | ```bash 190 | curl http://localhost:8000/ 191 | ``` 192 | 193 | This should give you a 401 unauthorized. But if we call the API with a token: 194 | 195 | ```bash 196 | export CLIENT_ID= 197 | export CLIENT_SECRET= 198 | 199 | export TOKENS=$(curl -s -X POST \ 200 | -H "Content-Type: application/x-www-form-urlencoded" \ 201 | -d "grant_type=client_credentials" \ 202 | -d "client_id=${CLIENT_ID}" \ 203 | -d "client_secret=${CLIENT_SECRET}" \ 204 | http://localhost:8080/auth/realms/master/protocol/openid-connect/token) 205 | 206 | export ACCESS_TOKEN=$(echo ${TOKENS} | jq -r ".access_token") 207 | 208 | curl -H "Authorization: Bearer ${ACCESS_TOKEN}" http://localhost:8000/ \ 209 | --data "plugin=working" 210 | ``` 211 | 212 | This should give you the response: `plugin=working` 213 | 214 | ### Caveats 215 | 216 | To verify token issuers, this plugin needs to be able to access the `/.well-known/openid-configuration` and `/protocol/openid-connect/certs` endpoints of keycloak. If you are getting the error `{ "message": "Unable to get public key for issuer" }` it is probably because for some reason the plugin is unable to access these endpoints. 217 | 218 | ## Testing 219 | 220 | Requires: 221 | * make 222 | * docker 223 | 224 | **Because testing uses docker host networking it does not work on MacOS** 225 | 226 | ### Setup before tests 227 | 228 | ```bash 229 | make keycloak-start 230 | ``` 231 | 232 | ### Running tests 233 | 234 | ```bash 235 | make test-unit # Unit tests 236 | make test-integration # Integration tests with postgres 237 | make test-integration KONG_DATABASE=cassandra # Integration tests with cassandra 238 | make test # All test with postgres 239 | make test KONG_DATABASE=cassandra # All test with cassandra 240 | make test-all # All test with cassandra and postgres and multiple versions of kong 241 | ``` 242 | 243 | ### Useful debug commands 244 | 245 | ```bash 246 | make kong-log # For proxy logs 247 | make kong-err-proxy # For proxy error logs 248 | make kong-err-admin # For admin error logs 249 | ``` 250 | -------------------------------------------------------------------------------- /kong-plugin-jwt-keycloak-1.1.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "kong-plugin-jwt-keycloak" 2 | 3 | version = "1.1.0-1" 4 | -- The version '0.1.0' is the source code version, the trailing '1' is the version of this rockspec. 5 | -- whenever the source version changes, the rockspec should be reset to 1. The rockspec version is only 6 | -- updated (incremented) when this file changes, but the source remains the same. 7 | 8 | local pluginName = package:match("^kong%-plugin%-(.+)$") -- "jwt-keycloak" 9 | supported_platforms = {"linux", "macosx"} 10 | 11 | source = { 12 | url = "git://github.com/gbbirkisson/kong-plugin-jwt-keycloak", 13 | tag = "v1.1.0", 14 | } 15 | description = { 16 | summary = "A Kong plugin that will validate tokens issued by keycloak", 17 | homepage = "https://github.com/gbbirkisson/kong-plugin-jwt-keycloak", 18 | license = "Apache 2.0" 19 | } 20 | dependencies = { 21 | "lua ~> 5" 22 | } 23 | build = { 24 | type = "builtin", 25 | modules = { 26 | ["kong.plugins.jwt-keycloak.validators.issuers"] = "src/validators/issuers.lua", 27 | ["kong.plugins.jwt-keycloak.validators.roles"] = "src/validators/roles.lua", 28 | ["kong.plugins.jwt-keycloak.validators.scope"] = "src/validators/scope.lua", 29 | ["kong.plugins.jwt-keycloak.handler"] = "src/handler.lua", 30 | ["kong.plugins.jwt-keycloak.key_conversion"] = "src/key_conversion.lua", 31 | ["kong.plugins.jwt-keycloak.keycloak_keys"] = "src/keycloak_keys.lua", 32 | ["kong.plugins.jwt-keycloak.schema"] = "src/schema.lua", 33 | } 34 | } -------------------------------------------------------------------------------- /luarocks.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM kong:2.3.2 as builder 2 | 3 | USER root 4 | 5 | ENV LUAROCKS_MODULE=kong-plugin-jwt-keycloak 6 | 7 | RUN apk add --no-cache git zip && \ 8 | git config --global url.https://github.com/.insteadOf git://github.com/ && \ 9 | luarocks install ${LUAROCKS_MODULE} && \ 10 | luarocks pack ${LUAROCKS_MODULE} 11 | 12 | FROM kong:2.3.2 13 | 14 | USER root 15 | 16 | ENV KONG_PLUGINS="bundled,jwt-keycloak" 17 | 18 | COPY --from=builder kong-plugin-jwt-keycloak* /tmp/ 19 | RUN luarocks install /tmp/kong-plugin-jwt-keycloak* 20 | 21 | USER kong 22 | -------------------------------------------------------------------------------- /makefiles/keycloak.mk: -------------------------------------------------------------------------------- 1 | KEYCLOAK_IMAGE:=jboss/keycloak:12.0.2 2 | KEYCLOAK_CONTAINER_NAME:=kc_local 3 | KEYCLOAK_PORT:=8080 4 | KEYCLOAK_ADMIN_USER:=admin 5 | KEYCLOAK_ADMIN_PASS:=admin 6 | 7 | keycloak-start: 8 | @echo "Running Keycloak..." 9 | -- @docker start ${KEYCLOAK_CONTAINER_NAME} || docker run -d \ 10 | --name ${KEYCLOAK_CONTAINER_NAME} \ 11 | -p ${KEYCLOAK_PORT}:8080 \ 12 | -e KEYCLOAK_USER=${KEYCLOAK_ADMIN_USER} \ 13 | -e KEYCLOAK_PASSWORD=${KEYCLOAK_ADMIN_PASS} \ 14 | ${KEYCLOAK_IMAGE} 15 | @bash -c 'while ! timeout 1 bash -c "echo > /dev/tcp/localhost/8080"; do sleep 1; done' 16 | 17 | keycloak-stop: 18 | @echo "Stopping Keycloak" 19 | - @docker stop ${KEYCLOAK_CONTAINER_NAME} 20 | 21 | keycloak-rm: keycloak-stop 22 | @echo "Removing Keycloak" 23 | - @docker rm ${KEYCLOAK_CONTAINER_NAME} 24 | -------------------------------------------------------------------------------- /makefiles/kong.mk: -------------------------------------------------------------------------------- 1 | KONG_CONTAINER_NAME:=kong 2 | KONG_PORT:=8000 3 | KONG_ADMIN_PORT:=8001 4 | 5 | KONG_DB_CONTAINER_NAME:=kongdb 6 | KONG_DB_PORT:=5432 7 | KONG_DB_USER:=kong 8 | KONG_DB_PASS:=kong 9 | KONG_DB_NAME:=kong 10 | KONG_DATABASE?=postgres 11 | 12 | POSTGRES_IMAGE:=postgres:11.2-alpine 13 | CASSANDRA_IMAGE:=cassandra:3.11 14 | 15 | wait-for-log: 16 | @while ! docker logs ${CONTAINER} | grep -q "${PATTERN}"; do sleep 5; done 17 | 18 | kong-db-create: 19 | @$(MAKE) --no-print-directory kong-db-create-${KONG_DATABASE} 20 | 21 | kong-db-create-postgres: 22 | @echo "Creating Kong DB" 23 | - @docker run --rm -d \ 24 | --name ${KONG_DB_CONTAINER_NAME} \ 25 | --net=host \ 26 | -e POSTGRES_USER=${KONG_DB_USER} \ 27 | -e POSTGRES_DB=${KONG_DB_PASS} \ 28 | -e POSTGRES_PASSWORD=${KONG_DB_NAME} \ 29 | ${POSTGRES_IMAGE} 30 | @$(MAKE) --no-print-directory CONTAINER=${KONG_DB_CONTAINER_NAME} PATTERN="database system is ready to accept connections" wait-for-log 31 | 32 | kong-db-create-cassandra: 33 | @echo "Creating Kong DB" 34 | - @docker run --rm -d \ 35 | --name ${KONG_DB_CONTAINER_NAME} \ 36 | --net=host \ 37 | ${CASSANDRA_IMAGE} 38 | @$(MAKE) --no-print-directory CONTAINER=${KONG_DB_CONTAINER_NAME} PATTERN="Starting listening for CQL clients" wait-for-log 39 | 40 | kong-db-migrate: build 41 | @echo "Migrating Kong DB" 42 | @docker run -it --rm \ 43 | --name ${KONG_CONTAINER_NAME} \ 44 | --net=host \ 45 | -e "KONG_DATABASE=${KONG_DATABASE}" \ 46 | -e "KONG_CASSANDRA_CONTACT_POINTS=localhost" \ 47 | -e "KONG_PG_HOST=localhost" \ 48 | -e "KONG_PG_USER=${KONG_DB_USER}" \ 49 | -e "KONG_PG_PASSWORD=${KONG_DB_PASS}" \ 50 | -e "KONG_PG_DATABASE=${KONG_DB_NAME}" \ 51 | ${FULL_IMAGE_NAME} kong migrations bootstrap --vv 52 | 53 | kong-db-start: kong-db-create kong-db-migrate 54 | 55 | kong-db-stop: 56 | @echo "Removing Kong DB..." 57 | - @docker stop ${KONG_DB_CONTAINER_NAME} 58 | 59 | kong-start: build 60 | @echo "Creating kong..." 61 | @docker run -d --rm \ 62 | --name ${KONG_CONTAINER_NAME} \ 63 | --net=host \ 64 | -e "KONG_LOG_LEVEL=debug" \ 65 | -e "KONG_PROXY_ACCESS_LOG=/proxy_access.log" \ 66 | -e "KONG_ADMIN_ACCESS_LOG=/admin_access.log" \ 67 | -e "KONG_PROXY_ERROR_LOG=/proxy_error.log" \ 68 | -e "KONG_ADMIN_ERROR_LOG=/admin_error.log" \ 69 | -e "KONG_DATABASE=${KONG_DATABASE}" \ 70 | -e "KONG_CASSANDRA_CONTACT_POINTS=localhost" \ 71 | -e "KONG_PG_HOST=localhost" \ 72 | -e "KONG_PG_USER=${KONG_DB_USER}" \ 73 | -e "KONG_PG_PASSWORD=${KONG_DB_PASS}" \ 74 | -e "KONG_PG_DATABASE=${KONG_DB_NAME}" \ 75 | -e "KONG_ADMIN_LISTEN=0.0.0.0:8001" \ 76 | ${FULL_IMAGE_NAME} kong start --vv 77 | 78 | kong-stop: 79 | @echo "Removing Kong..." 80 | - @docker stop ${KONG_CONTAINER_NAME} 81 | 82 | kong-log: 83 | - @docker logs -f ${KONG_CONTAINER_NAME} 84 | 85 | kong-err-proxy: 86 | - @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 /proxy_error.log 87 | 88 | kong-err-admin: 89 | - @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 /admin_error.log 90 | 91 | kong-restart: kong-stop kong-db-stop kong-create 92 | - @docker logs ${KONG_CONTAINER_NAME} -------------------------------------------------------------------------------- /src/handler.lua: -------------------------------------------------------------------------------- 1 | local BasePlugin = require "kong.plugins.base_plugin" 2 | local constants = require "kong.constants" 3 | local jwt_decoder = require "kong.plugins.jwt.jwt_parser" 4 | local socket = require "socket" 5 | local keycloak_keys = require("kong.plugins.jwt-keycloak.keycloak_keys") 6 | 7 | local validate_issuer = require("kong.plugins.jwt-keycloak.validators.issuers").validate_issuer 8 | local validate_scope = require("kong.plugins.jwt-keycloak.validators.scope").validate_scope 9 | local validate_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_roles 10 | local validate_realm_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_realm_roles 11 | local validate_client_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_client_roles 12 | 13 | local re_gmatch = ngx.re.gmatch 14 | 15 | local JwtKeycloakHandler = BasePlugin:extend() 16 | 17 | local priority_env_var = "JWT_KEYCLOAK_PRIORITY" 18 | local priority 19 | if os.getenv(priority_env_var) then 20 | priority = tonumber(os.getenv(priority_env_var)) 21 | else 22 | priority = 1005 23 | end 24 | kong.log.debug('JWT_KEYCLOAK_PRIORITY: ' .. priority) 25 | 26 | JwtKeycloakHandler.PRIORITY = priority 27 | JwtKeycloakHandler.VERSION = "1.1.0" 28 | 29 | function table_to_string(tbl) 30 | local result = "" 31 | for k, v in pairs(tbl) do 32 | -- Check the key type (ignore any numerical keys - assume its an array) 33 | if type(k) == "string" then 34 | result = result.."[\""..k.."\"]".."=" 35 | end 36 | 37 | -- Check the value type 38 | if type(v) == "table" then 39 | result = result..table_to_string(v) 40 | elseif type(v) == "boolean" then 41 | result = result..tostring(v) 42 | else 43 | result = result.."\""..v.."\"" 44 | end 45 | result = result.."," 46 | end 47 | -- Remove leading commas from the result 48 | if result ~= "" then 49 | result = result:sub(1, result:len()-1) 50 | end 51 | return result 52 | end 53 | 54 | --- Retrieve a JWT in a request. 55 | -- Checks for the JWT in URI parameters, then in cookies, and finally 56 | -- in the `Authorization` header. 57 | -- @param request ngx request object 58 | -- @param conf Plugin configuration 59 | -- @return token JWT token contained in request (can be a table) or nil 60 | -- @return err 61 | local function retrieve_token(conf) 62 | local args = kong.request.get_query() 63 | for _, v in ipairs(conf.uri_param_names) do 64 | if args[v] then 65 | return args[v] 66 | end 67 | end 68 | 69 | local var = ngx.var 70 | for _, v in ipairs(conf.cookie_names) do 71 | local cookie = var["cookie_" .. v] 72 | if cookie and cookie ~= "" then 73 | return cookie 74 | end 75 | end 76 | 77 | local authorization_header = kong.request.get_header("authorization") 78 | if authorization_header then 79 | local iterator, iter_err = re_gmatch(authorization_header, "\\s*[Bb]earer\\s+(.+)") 80 | if not iterator then 81 | return nil, iter_err 82 | end 83 | 84 | local m, err = iterator() 85 | if err then 86 | return nil, err 87 | end 88 | 89 | if m and #m > 0 then 90 | return m[1] 91 | end 92 | end 93 | end 94 | 95 | function JwtKeycloakHandler:new() 96 | JwtKeycloakHandler.super.new(self, "jwt-keycloak") 97 | end 98 | 99 | local function load_consumer(consumer_id, anonymous) 100 | local result, err = kong.db.consumers:select { id = consumer_id } 101 | if not result then 102 | if anonymous and not err then 103 | err = 'anonymous consumer "' .. consumer_id .. '" not found' 104 | end 105 | return nil, err 106 | end 107 | return result 108 | end 109 | 110 | local function load_consumer_by_custom_id(custom_id) 111 | local result, err = kong.db.consumers:select_by_custom_id(custom_id) 112 | if not result then 113 | return nil, err 114 | end 115 | return result 116 | end 117 | 118 | local function set_consumer(consumer, credential, token) 119 | local set_header = kong.service.request.set_header 120 | local clear_header = kong.service.request.clear_header 121 | 122 | if consumer and consumer.id then 123 | set_header(constants.HEADERS.CONSUMER_ID, consumer.id) 124 | else 125 | clear_header(constants.HEADERS.CONSUMER_ID) 126 | end 127 | 128 | if consumer and consumer.custom_id then 129 | set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id) 130 | else 131 | clear_header(constants.HEADERS.CONSUMER_CUSTOM_ID) 132 | end 133 | 134 | if consumer and consumer.username then 135 | set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username) 136 | else 137 | clear_header(constants.HEADERS.CONSUMER_USERNAME) 138 | end 139 | 140 | kong.client.authenticate(consumer, credential) 141 | 142 | if credential then 143 | kong.ctx.shared.authenticated_jwt_token = token -- TODO: wrap in a PDK function? 144 | ngx.ctx.authenticated_jwt_token = token -- backward compatibilty only 145 | 146 | if credential.username then 147 | set_header(constants.HEADERS.CREDENTIAL_USERNAME, credential.username) 148 | else 149 | clear_header(constants.HEADERS.CREDENTIAL_USERNAME) 150 | end 151 | 152 | clear_header(constants.HEADERS.ANONYMOUS) 153 | 154 | else 155 | clear_header(constants.HEADERS.CREDENTIAL_USERNAME) 156 | set_header(constants.HEADERS.ANONYMOUS, true) 157 | end 158 | end 159 | 160 | local function get_keys(well_known_endpoint) 161 | kong.log.debug('Getting public keys from keycloak') 162 | keys, err = keycloak_keys.get_issuer_keys(well_known_endpoint) 163 | if err then 164 | return nil, err 165 | end 166 | 167 | decoded_keys = {} 168 | for i, key in ipairs(keys) do 169 | decoded_keys[i] = jwt_decoder:base64_decode(key) 170 | end 171 | 172 | kong.log.debug('Number of keys retrieved: ' .. table.getn(decoded_keys)) 173 | return { 174 | keys = decoded_keys, 175 | updated_at = socket.gettime(), 176 | } 177 | end 178 | 179 | local function validate_signature(conf, jwt, second_call) 180 | local issuer_cache_key = 'issuer_keys_' .. jwt.claims.iss 181 | 182 | well_known_endpoint = keycloak_keys.get_wellknown_endpoint(conf.well_known_template, jwt.claims.iss) 183 | -- Retrieve public keys 184 | local public_keys, err = kong.cache:get(issuer_cache_key, nil, get_keys, well_known_endpoint, true) 185 | 186 | if not public_keys then 187 | if err then 188 | kong.log.err(err) 189 | end 190 | return kong.response.exit(403, { message = "Unable to get public key for issuer" }) 191 | end 192 | 193 | -- Verify signatures 194 | for _, k in ipairs(public_keys.keys) do 195 | if jwt:verify_signature(k) then 196 | kong.log.debug('JWT signature verified') 197 | return nil 198 | end 199 | end 200 | 201 | -- We could not validate signature, try to get a new keyset? 202 | since_last_update = socket.gettime() - public_keys.updated_at 203 | if not second_call and since_last_update > conf.iss_key_grace_period then 204 | kong.log.debug('Could not validate signature. Keys updated last ' .. since_last_update .. ' seconds ago') 205 | kong.cache:invalidate_local(issuer_cache_key) 206 | return validate_signature(conf, jwt, true) 207 | end 208 | 209 | return kong.response.exit(401, { message = "Invalid token signature" }) 210 | end 211 | 212 | local function match_consumer(conf, jwt) 213 | local consumer, err 214 | local consumer_id = jwt.claims[conf.consumer_match_claim] 215 | 216 | if conf.consumer_match_claim_custom_id then 217 | consumer_cache_key = "custom_id_key_" .. consumer_id 218 | consumer, err = kong.cache:get(consumer_cache_key, nil, load_consumer_by_custom_id, consumer_id, true) 219 | else 220 | consumer_cache_key = kong.db.consumers:cache_key(consumer_id) 221 | consumer, err = kong.cache:get(consumer_cache_key, nil, load_consumer, consumer_id, true) 222 | end 223 | 224 | if err then 225 | kong.log.err(err) 226 | end 227 | 228 | if not consumer and not conf.consumer_match_ignore_not_found then 229 | return false, { status = 401, message = "Unable to find consumer for token" } 230 | end 231 | 232 | if consumer then 233 | set_consumer(consumer, nil, nil) 234 | end 235 | 236 | return true 237 | end 238 | 239 | local function do_authentication(conf) 240 | -- Retrieve token 241 | local token, err = retrieve_token(conf) 242 | if err then 243 | kong.log.err(err) 244 | return kong.response.exit(500, { message = "An unexpected error occurred" }) 245 | end 246 | 247 | local token_type = type(token) 248 | if token_type ~= "string" then 249 | if token_type == "nil" then 250 | return false, { status = 401, message = "Unauthorized" } 251 | elseif token_type == "table" then 252 | return false, { status = 401, message = "Multiple tokens provided" } 253 | else 254 | return false, { status = 401, message = "Unrecognizable token" } 255 | end 256 | end 257 | 258 | -- Decode token 259 | local jwt, err = jwt_decoder:new(token) 260 | if err then 261 | return false, { status = 401, message = "Bad token; " .. tostring(err) } 262 | end 263 | 264 | -- Verify algorithim 265 | if jwt.header.alg ~= (conf.algorithm or "HS256") then 266 | return false, {status = 403, message = "Invalid algorithm"} 267 | end 268 | 269 | -- Verify the JWT registered claims 270 | local ok_claims, errors = jwt:verify_registered_claims(conf.claims_to_verify) 271 | if not ok_claims then 272 | return false, { status = 401, message = "Token claims invalid: " .. table_to_string(errors) } 273 | end 274 | 275 | -- Verify maximum expiration 276 | if conf.maximum_expiration ~= nil and conf.maximum_expiration > 0 then 277 | local ok, errors = jwt:check_maximum_expiration(conf.maximum_expiration) 278 | if not ok then 279 | return false, { status = 403, message = "Token claims invalid: " .. table_to_string(errors) } 280 | end 281 | end 282 | 283 | -- Verify that the issuer is allowed 284 | if not validate_issuer(conf.allowed_iss, jwt.claims) then 285 | return false, { status = 401, message = "Token issuer not allowed" } 286 | end 287 | 288 | err = validate_signature(conf, jwt) 289 | if err ~= nil then 290 | return false, err 291 | end 292 | 293 | -- Match consumer 294 | if conf.consumer_match then 295 | ok, err = match_consumer(conf, jwt) 296 | if not ok then 297 | return ok, err 298 | end 299 | end 300 | 301 | -- Verify roles or scopes 302 | local ok, err = validate_scope(conf.scope, jwt.claims) 303 | 304 | if ok then 305 | ok, err = validate_realm_roles(conf.realm_roles, jwt.claims) 306 | end 307 | 308 | if ok then 309 | ok, err = validate_roles(conf.roles, jwt.claims) 310 | end 311 | 312 | if ok then 313 | ok, err = validate_client_roles(conf.client_roles, jwt.claims) 314 | end 315 | 316 | if ok then 317 | kong.ctx.shared.jwt_keycloak_token = jwt 318 | return true 319 | end 320 | 321 | return false, { status = 403, message = "Access token does not have the required scope/role: " .. err } 322 | end 323 | 324 | 325 | function JwtKeycloakHandler:access(conf) 326 | JwtKeycloakHandler.super.access(self) 327 | 328 | -- check if preflight request and whether it should be authenticated 329 | if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then 330 | return 331 | end 332 | 333 | if conf.anonymous and kong.client.get_credential() then 334 | -- we're already authenticated, and we're configured for using anonymous, 335 | -- hence we're in a logical OR between auth methods and we're already done. 336 | return 337 | end 338 | 339 | local ok, err = do_authentication(conf) 340 | if not ok then 341 | if conf.anonymous then 342 | -- get anonymous user 343 | local consumer_cache_key = kong.db.consumers:cache_key(conf.anonymous) 344 | local consumer, err = kong.cache:get(consumer_cache_key, nil, 345 | load_consumer, 346 | conf.anonymous, true) 347 | if err then 348 | kong.log.err(err) 349 | return kong.response.exit(500, { message = "An unexpected error occurred" }) 350 | end 351 | 352 | set_consumer(consumer, nil, nil) 353 | else 354 | return kong.response.exit(err.status, err.errors or { message = err.message }) 355 | end 356 | end 357 | end 358 | 359 | return JwtKeycloakHandler 360 | -------------------------------------------------------------------------------- /src/key_conversion.lua: -------------------------------------------------------------------------------- 1 | -- Taken from https://github.com/zmartzone/lua-resty-openidc/blob/master/lib/resty/openidc.lua 2 | 3 | local string = string 4 | local b64 = ngx.encode_base64 5 | local unb64 = ngx.decode_base64 6 | 7 | local wrap = ('.'):rep(64) 8 | 9 | local function encode_length(length) 10 | if length < 0x80 then 11 | return string.char(length) 12 | elseif length < 0x100 then 13 | return string.char(0x81, length) 14 | elseif length < 0x10000 then 15 | return string.char(0x82, math.floor(length / 0x100), length % 0x100) 16 | end 17 | error("Can't encode lengths over 65535") 18 | end 19 | 20 | local function encode_bit_string(array) 21 | local s = "\0" .. array -- first octet holds the number of unused bits 22 | return "\3" .. encode_length(#s) .. s 23 | end 24 | 25 | local function encode_sequence(array, of) 26 | local encoded_array = array 27 | if of then 28 | encoded_array = {} 29 | for i = 1, #array do 30 | encoded_array[i] = of(array[i]) 31 | end 32 | end 33 | encoded_array = table.concat(encoded_array) 34 | return string.char(0x30) .. encode_length(#encoded_array) .. encoded_array 35 | end 36 | 37 | local function der2pem(data, typ) 38 | data = b64(data) 39 | return data:gsub(wrap, '%0\n', (#data - 1) / 64) 40 | end 41 | 42 | local function encode_binary_integer(bytes) 43 | if bytes:byte(1) > 127 then 44 | -- We currenly only use this for unsigned integers, 45 | -- however since the high bit is set here, it would look 46 | -- like a negative signed int, so prefix with zeroes 47 | bytes = "\0" .. bytes 48 | end 49 | return "\2" .. encode_length(#bytes) .. bytes 50 | end 51 | 52 | local function encode_sequence_of_integer(array) 53 | return encode_sequence(array, encode_binary_integer) 54 | end 55 | 56 | local function openidc_base64_url_decode(input) 57 | local reminder = #input % 4 58 | if reminder > 0 then 59 | local padlen = 4 - reminder 60 | input = input .. string.rep('=', padlen) 61 | end 62 | input = input:gsub('-', '+'):gsub('_', '/') 63 | return unb64(input) 64 | end 65 | 66 | local function openidc_pem_from_rsa_n_and_e(n, e) 67 | local der_key = { 68 | openidc_base64_url_decode(n), openidc_base64_url_decode(e) 69 | } 70 | 71 | local encoded_key = encode_sequence_of_integer(der_key) 72 | local pem = der2pem(encode_sequence({ 73 | encode_sequence({ 74 | "\6\9\42\134\72\134\247\13\1\1\1" -- OID :rsaEncryption 75 | .. "\5\0" -- ASN.1 NULL of length 0 76 | }), 77 | encode_bit_string(encoded_key) 78 | }), "PUBLIC KEY" 79 | ) 80 | 81 | return pem 82 | end 83 | 84 | local function convert_kc_key(key) 85 | return openidc_pem_from_rsa_n_and_e(key.n, key.e) 86 | end 87 | 88 | return { 89 | convert_kc_key = convert_kc_key 90 | } -------------------------------------------------------------------------------- /src/keycloak_keys.lua: -------------------------------------------------------------------------------- 1 | local url = require "socket.url" 2 | local http = require "socket.http" 3 | local https = require "ssl.https" 4 | local cjson_safe = require "cjson.safe" 5 | local convert = require "kong.plugins.jwt-keycloak.key_conversion" 6 | 7 | local function get_request(url, scheme, port) 8 | local req 9 | if scheme == "https" then 10 | req = https.request 11 | else 12 | req = http.request 13 | end 14 | 15 | local res 16 | local status 17 | local err 18 | 19 | local chunks = {} 20 | res, status = req{ 21 | url = url, 22 | port = port, 23 | sink = ltn12.sink.table(chunks) 24 | } 25 | 26 | if status ~= 200 then 27 | return nil, 'Failed calling url ' .. url .. ' response status ' .. status 28 | end 29 | 30 | res, err = cjson_safe.decode(table.concat(chunks)) 31 | if not res then 32 | return nil, 'Failed to parse json response' 33 | end 34 | 35 | return res, nil 36 | end 37 | 38 | local function get_wellknown_endpoint(well_known_template, issuer) 39 | return string.format(well_known_template, issuer) 40 | end 41 | 42 | local function get_issuer_keys(well_known_endpoint) 43 | -- Get port of the request: This is done because keycloak 3.X.X does not play well with lua socket.http 44 | local req = url.parse(well_known_endpoint) 45 | 46 | local res, err = get_request(well_known_endpoint, req.scheme, req.port) 47 | if err then 48 | return nil, err 49 | end 50 | 51 | local res, err = get_request(res['jwks_uri'], req.scheme, req.port) 52 | if err then 53 | return nil, err 54 | end 55 | 56 | local keys = {} 57 | for i, key in ipairs(res['keys']) do 58 | keys[i] = string.gsub( 59 | convert.convert_kc_key(key), 60 | "[\r\n]+", "" 61 | ) 62 | end 63 | return keys, nil 64 | end 65 | 66 | return { 67 | get_request = get_request, 68 | get_issuer_keys = get_issuer_keys, 69 | get_wellknown_endpoint = get_wellknown_endpoint, 70 | } -------------------------------------------------------------------------------- /src/schema.lua: -------------------------------------------------------------------------------- 1 | local typedefs = require "kong.db.schema.typedefs" 2 | 3 | return { 4 | name = "jwt-keycloak-endpoint", 5 | fields = { 6 | { consumer = typedefs.no_consumer }, 7 | { config = { 8 | type = "record", 9 | fields = { 10 | { uri_param_names = { type = "set", elements = { type = "string" }, default = { "jwt" }, }, }, 11 | { cookie_names = { type = "set", elements = { type = "string" }, default = {} }, }, 12 | { claims_to_verify = { type = "set", elements = { type = "string", one_of = { "exp", "nbf" }, }, default = { "exp" } }, }, 13 | { anonymous = { type = "string", uuid = true, legacy = true }, }, 14 | { run_on_preflight = { type = "boolean", default = true }, }, 15 | { maximum_expiration = { type = "number", default = 0, between = { 0, 31536000 }, }, }, 16 | { algorithm = { type = "string", default = "RS256" }, }, 17 | 18 | { allowed_iss = { type = "set", elements = { type = "string" }, required = true }, }, 19 | { iss_key_grace_period = { type = "number", default = 10, between = { 1, 60 }, }, }, 20 | { well_known_template = { type = "string", default = "%s/.well-known/openid-configuration" }, }, 21 | 22 | { scope = { type = "set", elements = { type = "string" }, default = nil }, }, 23 | { roles = { type = "set", elements = { type = "string" }, default = nil }, }, 24 | { realm_roles = { type = "set", elements = { type = "string" }, default = nil }, }, 25 | { client_roles = { type = "set", elements = { type = "string" }, default = nil }, }, 26 | 27 | { consumer_match = { type = "boolean", default = false }, }, 28 | { consumer_match_claim = { type = "string", default = "azp" }, }, 29 | { consumer_match_claim_custom_id = { type = "boolean", default = false }, }, 30 | { consumer_match_ignore_not_found = { type = "boolean", default = false }, }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | } -------------------------------------------------------------------------------- /src/validators/issuers.lua: -------------------------------------------------------------------------------- 1 | local function validate_issuer(allowed_issuers, jwt_claims) 2 | if allowed_issuers == nil or table.getn(allowed_issuers) == 0 then 3 | return nil, "Allowed issuers is empty" 4 | end 5 | if jwt_claims.iss == nil then 6 | return nil, "Missing issuer claim" 7 | end 8 | for _, curr_iss in pairs(allowed_issuers) do 9 | if curr_iss == jwt_claims.iss or string.match(jwt_claims.iss, curr_iss) ~= nil then 10 | return true 11 | end 12 | end 13 | return nil, "Token issuer not allowed" 14 | end 15 | 16 | return { 17 | validate_issuer = validate_issuer 18 | } -------------------------------------------------------------------------------- /src/validators/roles.lua: -------------------------------------------------------------------------------- 1 | local function validate_client_roles(allowed_client_roles, jwt_claims) 2 | if allowed_client_roles == nil or table.getn(allowed_client_roles) == 0 then 3 | return true 4 | end 5 | 6 | if jwt_claims == nil or jwt_claims.resource_access == nil then 7 | return nil, "Missing required resource_access claim" 8 | end 9 | 10 | for _, allowed_client_role in pairs(allowed_client_roles) do 11 | for curr_allowed_client, curr_allowed_role in string.gmatch(allowed_client_role, "(%S+):(%S+)") do 12 | for claim_client, claim_client_roles in pairs(jwt_claims.resource_access) do 13 | if curr_allowed_client == claim_client then 14 | for _, curr_claim_client_roles in pairs(claim_client_roles) do 15 | for _, curr_claim_client_role in pairs(curr_claim_client_roles) do 16 | if curr_claim_client_role == curr_allowed_role then 17 | return true 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | 26 | return nil, "Missing required role" 27 | end 28 | 29 | local function validate_roles(allowed_roles, jwt_claims) 30 | if allowed_roles == nil or table.getn(allowed_roles) == 0 then 31 | return true 32 | end 33 | 34 | if jwt_claims.azp == nil then 35 | return nil, "Missing required azp claim" 36 | end 37 | 38 | local tmp_allowed = {} 39 | for i, allowed in pairs(allowed_roles) do 40 | tmp_allowed[i] = jwt_claims.azp .. ":" .. allowed 41 | end 42 | 43 | return validate_client_roles(tmp_allowed, jwt_claims) 44 | end 45 | 46 | local function validate_realm_roles(allowed_realm_roles, jwt_claims) 47 | if allowed_realm_roles == nil or table.getn(allowed_realm_roles) == 0 then 48 | return true 49 | end 50 | 51 | if jwt_claims == nil or jwt_claims.realm_access == nil or jwt_claims.realm_access.roles == nil then 52 | return nil, "Missing required realm_access.roles claim" 53 | end 54 | 55 | for _, curr_claim_role in pairs(jwt_claims.realm_access.roles) do 56 | for _, curr_allowed_role in pairs(allowed_realm_roles) do 57 | if curr_claim_role == curr_allowed_role then 58 | return true 59 | end 60 | end 61 | end 62 | 63 | return nil, "Missing required realm role" 64 | end 65 | 66 | return { 67 | validate_client_roles = validate_client_roles, 68 | validate_realm_roles = validate_realm_roles, 69 | validate_roles = validate_roles 70 | } -------------------------------------------------------------------------------- /src/validators/scope.lua: -------------------------------------------------------------------------------- 1 | local function validate_scope(allowed_scopes, jwt_claims) 2 | if allowed_scopes == nil or table.getn(allowed_scopes) == 0 then 3 | return true 4 | end 5 | 6 | if jwt_claims == nil or jwt_claims.scope == nil then 7 | return nil, "Missing required scope claim" 8 | end 9 | 10 | for scope in string.gmatch(jwt_claims.scope, "%S+") do 11 | for _, curr_scope in pairs(allowed_scopes) do 12 | if scope == curr_scope then 13 | return true 14 | end 15 | end 16 | end 17 | return nil, "Missing required scope" 18 | end 19 | 20 | return { 21 | validate_scope = validate_scope 22 | } -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | UNIT_TEST_IMAGE:=jwt-keycloak-unit-test-image 2 | INTE_TEST_IMAGE:=jwt-keycloak-inte-test-image 3 | 4 | _build-unit-test-image: 5 | @echo "Building unit test image ..." 6 | @docker build -q -t ${UNIT_TEST_IMAGE} --build-arg PLUGIN_VERSION=${PLUGIN_VERSION} --build-arg KONG_VERSION=${KONG_VERSION} -f unit_tests/Dockerfile .. 7 | 8 | _tests-unit: _build-unit-test-image 9 | @docker run -it --rm --net=host -v ${PWD}:/jwt-keycloak:ro ${UNIT_TEST_IMAGE} 10 | 11 | 12 | _build-inte-test-image: 13 | @echo "Building integration test image ..." 14 | @docker build -q -t ${INTE_TEST_IMAGE} -f integration_tests/Dockerfile .. 15 | 16 | _tests-integration: _build-inte-test-image 17 | @docker run -it --rm --net=host -v ${PWD}/integration_tests/tests:/tests:ro ${INTE_TEST_IMAGE} python -m unittest discover -s /tests -t /tests -p *.py -v -------------------------------------------------------------------------------- /tests/integration_tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.2-alpine 2 | 3 | RUN pip install requests -------------------------------------------------------------------------------- /tests/integration_tests/tests/TestBasics.py: -------------------------------------------------------------------------------- 1 | from tests.utils import * 2 | 3 | STANDARD_JWT = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJObjlsNXctQ1lORHUwUGh6MTFoWUNqQ050MGJmb2ZMQjZMcGMtWk5hUkFFIn0.eyJqdGkiOiIwZDBlODEyMy1mNjIxLTQzZWQtOTBjZS0yNWNhZDZhOGQ0MGQiLCJleHAiOjE1MzY1NzgxOTQsIm5iZiI6MCwiaWF0IjoxNTM2NTc4MTM0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImIzY2RjZjcwLTljMDMtNDgwZi1hZGQwLTY4MWNkMzQyYWU1OCIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiIxMGMzZWFjNC1kNzlmLTQyOGYtYmVlMC1mNDk3MTEwNTY0NDgiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.cFOVC_tLfyTHXB0T8MMJHizVXhDfh36ZwA6BNA3Jhjm-s-_Kt4_acZtbC-jLoch2Q-A4LPGURpG48RgWfALNaRvv6R5rWwOJ3O94bsCVbsAcY7rw-UMEyWz8sO-VObJnHayybVsnfvLzKZaWCsWIRZaMsE9OtiFfRoWgqHOCqMxFl0YX_ugZGGKKfMDjO0-ie-zzRQeUKjKfNdeJSk7OcrlZp8rpP0J616AocWd_NZTiB6RIuP4zy6z28dYY4Pgw5o-_GyoGI7NyDZxTVQ17XzTl_MFV7pTD9pvYzSpGZevcSfMGh00NHdagq9qr7jF65NYuGmZuCn0jUs9TmtLezQ' 4 | NOT_FOUND_JWT = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLTFczaVNKQUNndkxvci1qMlAzWFdGVXZBR1dTakdGWlZ2TUNmcDhITHdnIn0.eyJqdGkiOiI1MjA5MDA1Yi02NjI2LTQ4Y2MtODg0Mi1mYzQ4MWNhZTI0MzkiLCJleHAiOjE2MjI5ODE5ODMsIm5iZiI6MCwiaWF0IjoxNTM2NTgxOTgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvTk9UX0ZPVU5EIiwiYXVkIjoidGVzdCIsInN1YiI6ImVlMTMwMWY0LTJlNmYtNGEwYi1hYjQ0LTgzNzExZGE3NWRkNyIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI4YWQ2MDNkMy1kYmYwLTQ4NmQtYWYyOC1hNmI2ZDJlODcxYzIiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.q5Sf3oL3i4dexGo8F7Qm4g4yzxApaAqbve1ijENg__08h_M6CBYf1b6J9bRrr4qriDWlyFW7N6nmyK5jyBMSVA_2oRoCxSFf4wUigjO1RcnMHC9w5Gd0DNfsA21kiNE2OiEEc-YNM5J7MXTOo5ueO79E8-8eVKjs25QipfZ_wF01MAF6bKKjVhOytBvRwUqPrxZ_37AhMcPEtcdSs0rvnKioZnNqQPTutNzvwfTwNO7neWI4RSJdSKJlx_yfxZbLHDtXRCPlqCfBFeiL1VfbJlpB-sRgAe7Lf8Mp28G_mAIzi2lm639QFjFZOLs8ykytmJSCdfsrwJZ6PO0kZBzAlw' 5 | BAD_SIGNATURE = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJObjlsNXctQ1lORHUwUGh6MTFoWUNqQ050MGJmb2ZMQjZMcGMtWk5hUkFFIn0.eyJqdGkiOiI0NTQwMGZiNi01MTE0LTRkNWUtOTNkOC1jYjgzYjM0MDFjMjMiLCJleHAiOjE2MjI5ODI4NjAsIm5iZiI6MCwiaWF0IjoxNTM2NTgyODYwLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImIzY2RjZjcwLTljMDMtNDgwZi1hZGQwLTY4MWNkMzQyYWU1OCIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiJiNTNjNmZhZC0xYWJjLTRmMjYtOGUzNi01MDhkOTdjMTI4NmEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.PtpAE8sCkSWuosm7chw_TH2qAQuRIugP-1688WtZ9ZpkrulZ1OxxfAtnJY1eCYk0C4LQd14eI5d-1srim96FGdgG0BKq4T0TknG5JgQsPignMy2JnJWz-ZozO8a6FMLfpGT0hUQyiDbLRs3VES8RV3N_2uxl0ihy_tJ_wvCU0GrBF5-e2z4R-99zWuOpPbDvnDlP6YfCxLsp77ng4HYB1rBSG9100mpkTBsL8Q48HBZk_qAVdHhGRxqTXDEMYPd3gsKNu184DAsE0I1Ea9D0QXijvH7SVoUJvmZwQ0hOtg1bzWxIeIW1sVDqshkaG58kkiomG7G-9RzKrWOxg3lyQ' 6 | LONG_LASTING = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvQ084ZEZ3RWxNdVNtU2d3bXlhN05iMlJNamZOQ3pxTTVrLVozMDFZUUZRIn0.eyJqdGkiOiJiN2QwZGIyOS00ZTY0LTRkNTgtOTM5Ni1hMTBjNmI2MDUyODYiLCJleHAiOjE2MjI5OTE3MjgsIm5iZiI6MCwiaWF0IjoxNTM2NTkxNzI4LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImQ5YWU5NzJiLTVmYzEtNDFjNC1iN2I0LWRmMDE4NDYyZmFiZiIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI0ODc1N2I5MS0wOTQ0LTRkNzEtOGFmZS0zZDQ3MGI5MDc0MjciLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SWQiOiJ0ZXN0IiwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.GH9Qof8bJk--mZW6SCXKRKeHV39Qhin9QqBAdDqNQmABDnbmGN9vDaceowHUs6M5Yr4In2lGAWVGjRH_k6eajvZCVjZHQVELLzjjwEDrL-syIImYYT2VG0TV4pJ3K8VD0-M_aAbmeYXA9kndk3bsM157nPu-cH0XvDTzJTra2IVJc9LSXxOD26XQDzOp0Hs5VObyDDnZJscnfcq_OmnfrNjN6h1TujUqtMIw_ZEYtG64R9aRG2QF2rwLuX6ED4OoX23AjqfjOH21AoIXYRwp_RtxDB57U-dX-vSY9MfdHAb7FnEKNE2ciPOZ_2zyj6RdAMY5IkDOFyqmS-nIqLQRTA' 7 | 8 | 9 | class TestBasics(unittest.TestCase): 10 | 11 | @create_api({ 12 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 13 | }) 14 | @call_api() 15 | def test_no_auth(self, status, body): 16 | self.assertEqual(UNAUTHORIZED, status) 17 | self.assertEqual('Unauthorized', body.get('message')) 18 | 19 | @create_api({ 20 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 21 | }) 22 | @call_api() 23 | def test_preflight_rainy(self, status, body): 24 | self.assertEqual(UNAUTHORIZED, status) 25 | self.assertEqual('Unauthorized', body.get('message')) 26 | 27 | @create_api({ 28 | 'run_on_preflight': False, 29 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 30 | }) 31 | @call_api(method='options') 32 | def test_preflight(self, status, body): 33 | self.assertEqual(OK, status) 34 | 35 | @create_api({ 36 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 37 | }) 38 | @call_api(params={"jwt": 1234}) 39 | def test_bad_token(self, status, body): 40 | self.assertEqual(UNAUTHORIZED, status) 41 | 42 | @create_api({ 43 | 'algorithm': 'HS256', 44 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 45 | }) 46 | @call_api(token=STANDARD_JWT) 47 | def test_invalid_algorithm(self, status, body): 48 | self.assertEqual(FORBIDDEN, status) 49 | self.assertEqual('Invalid algorithm', body.get('message')) 50 | 51 | @create_api({ 52 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 53 | }) 54 | @call_api(token=STANDARD_JWT) 55 | def test_invalid_exp(self, status, body): 56 | self.assertEqual(UNAUTHORIZED, status) 57 | self.assertEqual('Token claims invalid: ["exp"]="token expired"', body.get('message')) 58 | 59 | @create_api({ 60 | 'allowed_iss': ['http://localhost:8080/auth/realms/NOT_FOUND'] 61 | }) 62 | @call_api(token=NOT_FOUND_JWT) 63 | def test_invalid_iss(self, status, body): 64 | self.assertEqual(FORBIDDEN, status) 65 | self.assertEqual('Unable to get public key for issuer', body.get('message')) 66 | 67 | @create_api({ 68 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 69 | 'maximum_expiration': 5000 70 | }) 71 | @call_api(token=LONG_LASTING) 72 | def test_max_exp(self, status, body): 73 | self.assertEqual(FORBIDDEN, status) 74 | self.assertEqual('Token claims invalid: ["exp"]="exceeds maximum allowed expiration"', body.get('message')) 75 | 76 | @create_api({ 77 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 78 | }) 79 | @call_api(token=BAD_SIGNATURE) 80 | def test_bad_signature(self, status, body): 81 | self.assertEqual(UNAUTHORIZED, status) 82 | self.assertEqual('Bad token; invalid signature', body.get('message')) 83 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/TestConsumerMapping.py: -------------------------------------------------------------------------------- 1 | from tests.utils import * 2 | 3 | 4 | class TestConsumerMapping(unittest.TestCase): 5 | TMP_CUSTOM_ID = str(uuid.uuid4()) 6 | 7 | @create_api({ 8 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 9 | 'consumer_match': True 10 | }) 11 | @authenticate(create_consumer=True) 12 | @call_api() 13 | def test_map_consumer(self, status, body): 14 | self.assertEqual(OK, status) 15 | self.assertEqual(1, len([h['value'] for h in body.get('headers') if h['name'] == 'x-consumer-id'])) 16 | 17 | @create_api({ 18 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 19 | 'consumer_match': True, 20 | 'consumer_match_claim_custom_id': True 21 | }) 22 | @authenticate(create_consumer=True, custom_id=TMP_CUSTOM_ID) 23 | @call_api() 24 | def test_map_consumer_custom_id(self, status, body): 25 | self.assertEqual(OK, status) 26 | self.assertEqual([self.TMP_CUSTOM_ID], 27 | [h['value'] for h in body.get('headers') if h['name'] == 'x-consumer-custom-id']) 28 | 29 | @create_api({ 30 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 31 | 'consumer_match': True, 32 | 'consumer_match_claim': 'preferred_username', 33 | 'consumer_match_ignore_not_found': True 34 | }) 35 | @authenticate() 36 | @call_api() 37 | def test_map_consumer_not_found(self, status, body): 38 | self.assertEqual(OK, status) 39 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/TestIssuers.py: -------------------------------------------------------------------------------- 1 | from tests.utils import * 2 | 3 | 4 | class TestIssuers(unittest.TestCase): 5 | 6 | @create_api({ 7 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 8 | }) 9 | @authenticate() 10 | @call_api() 11 | def test_allow_all_iss(self, status, body): 12 | self.assertEqual(OK, status) 13 | 14 | @create_api({ 15 | 'allowed_iss': [ 16 | 'http://localhost:8080/auth/realms/not_found', 17 | 'http://localhost:8080/auth/realms/master' 18 | ] 19 | }) 20 | @authenticate() 21 | @call_api() 22 | def test_allow_all_iss_double(self, status, body): 23 | self.assertEqual(OK, status) 24 | 25 | @create_api({ 26 | 'allowed_iss': [ 27 | 'http://localhost:8080/auth/realms/not_found' 28 | ] 29 | }) 30 | @authenticate() 31 | @call_api() 32 | def test_allow_all_iss_rainy(self, status, body): 33 | self.assertEqual(UNAUTHORIZED, status) 34 | self.assertEqual('Token issuer not allowed', body.get('message')) 35 | 36 | @create_api({ 37 | 'allowed_iss': [ 38 | 'http://localhost:8080/auth/realms/.*' 39 | ] 40 | }) 41 | @authenticate() 42 | @call_api() 43 | def test_allow_all_iss_rainy(self, status, body): 44 | self.assertEqual(OK, status) 45 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/TestKeyRotation.py: -------------------------------------------------------------------------------- 1 | from tests.utils import * 2 | 3 | 4 | def delete_key_if_present(name): 5 | k = kc_get_key(name) 6 | if k is not None: 7 | try: 8 | kc_delete_key(k) 9 | except: 10 | pass 11 | 12 | 13 | class TestKeyRotation(unittest.TestCase): 14 | 15 | def setUp(self): 16 | @create_api({ 17 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 18 | 'iss_key_grace_period': 1 19 | }) 20 | def makeApi(**kwargs): 21 | self.endpoint = kwargs['api_endpoint'] 22 | 23 | makeApi() 24 | 25 | def tearDown(self): 26 | delete_key_if_present('new_key_1') 27 | delete_key_if_present('new_key_2') 28 | 29 | @authenticate() 30 | def get_token(self, token=None): 31 | return token 32 | 33 | def call_api(self, token, expected_status): 34 | @authenticate() 35 | @call_api(token=token, endpoint=self.endpoint) 36 | def call_with_token(status, body): 37 | self.assertEqual(expected_status, status) 38 | 39 | call_with_token() 40 | 41 | def test_key_rotation(self): 42 | token_1 = self.get_token() 43 | self.call_api(token_1, OK) 44 | 45 | new_key_1 = kc_add_key('new_key_1', 120) 46 | time.sleep(1) 47 | 48 | token_2 = self.get_token() 49 | 50 | self.call_api(token_1, OK) 51 | self.call_api(token_2, OK) 52 | 53 | kc_add_key('new_key_2', 130) 54 | kc_delete_key(new_key_1) 55 | time.sleep(1) 56 | 57 | token_3 = self.get_token() 58 | 59 | self.call_api(token_3, OK) 60 | self.call_api(token_1, OK) 61 | self.call_api(token_2, UNAUTHORIZED) 62 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/TestRoles.py: -------------------------------------------------------------------------------- 1 | from tests.utils import * 2 | 3 | 4 | class TestRoles(unittest.TestCase): 5 | 6 | @create_api({ 7 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'] 8 | }) 9 | @authenticate() 10 | @call_api() 11 | def test_no_auth(self, status, body): 12 | self.assertEqual(OK, status) 13 | 14 | @skip("Need to update tests") 15 | @create_api({ 16 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 17 | 'roles': ['test_role'] 18 | }) 19 | @authenticate() 20 | @call_api() 21 | def test_roles_auth(self, status, body): 22 | if not KC_VERSION.startswith('3'): 23 | self.skipTest("Test not supported for " + KC_VERSION) 24 | self.assertEqual(OK, status) 25 | 26 | @create_api({ 27 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 28 | 'roles': ['not_found'] 29 | }) 30 | @authenticate() 31 | @call_api() 32 | def test_roles_auth_rainy(self, status, body): 33 | self.assertEqual(FORBIDDEN, status) 34 | self.assertEqual('Access token does not have the required scope/role: Missing required role', body.get('message')) 35 | 36 | @skip("Need to update tests") 37 | @create_api({ 38 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 39 | 'roles': ['test_role', 'not_found'] 40 | }) 41 | @authenticate() 42 | @call_api() 43 | def test_roles_auth_double(self, status, body): 44 | if not KC_VERSION.startswith('3'): 45 | self.skipTest("Test not supported for " + KC_VERSION) 46 | self.assertEqual(OK, status) 47 | 48 | @create_api({ 49 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 50 | 'realm_roles': ['uma_authorization'] 51 | }) 52 | @authenticate() 53 | @call_api() 54 | def test_realm_roles_auth(self, status, body): 55 | self.assertEqual(OK, status) 56 | 57 | @create_api({ 58 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 59 | 'realm_roles': ['not_found'] 60 | }) 61 | @authenticate() 62 | @call_api() 63 | def test_realm_roles_auth_rainy(self, status, body): 64 | self.assertEqual(FORBIDDEN, status) 65 | self.assertEqual('Access token does not have the required scope/role: Missing required realm role', body.get('message')) 66 | 67 | @create_api({ 68 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 69 | 'realm_roles': ['uma_authorization', 'not_found'] 70 | }) 71 | @authenticate() 72 | @call_api() 73 | def test_realm_roles_auth_double(self, status, body): 74 | self.assertEqual(OK, status) 75 | 76 | @create_api({ 77 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 78 | 'client_roles': ['account:manage-account'] 79 | }) 80 | @authenticate() 81 | @call_api() 82 | def test_client_roles_auth(self, status, body): 83 | self.assertEqual(OK, status) 84 | 85 | @create_api({ 86 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 87 | 'client_roles': ['account:manage-something-else'] 88 | }) 89 | @authenticate() 90 | @call_api() 91 | def test_client_roles_auth_rainy(self, status, body): 92 | self.assertEqual(FORBIDDEN, status) 93 | self.assertEqual('Access token does not have the required scope/role: Missing required role', body.get('message')) 94 | 95 | @create_api({ 96 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 97 | 'client_roles': ['account:manage-account', 'account:manage-something-else'] 98 | }) 99 | @authenticate() 100 | @call_api() 101 | def test_client_roles_auth_double(self, status, body): 102 | self.assertEqual(OK, status) 103 | 104 | @create_api({ 105 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 106 | 'client_roles': ['user:do-user-stuff'] 107 | }) 108 | @authenticate() 109 | @call_api() 110 | def test_client_roles_auth(self, status, body): 111 | self.assertEqual(FORBIDDEN, status) 112 | self.assertEqual('Access token does not have the required scope/role: Missing required role', body.get('message')) 113 | 114 | @create_api({ 115 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 116 | 'scope': ['email'] 117 | }) 118 | @authenticate() 119 | @call_api() 120 | def test_client_scope(self, status, body): 121 | if KC_VERSION.startswith('3'): 122 | self.skipTest("Test not supported for " + KC_VERSION) 123 | self.assertEqual(OK, status) 124 | 125 | @create_api({ 126 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 127 | 'scope': ['email', 'not_found'] 128 | }) 129 | @authenticate() 130 | @call_api() 131 | def test_client_scope_double(self, status, body): 132 | if KC_VERSION.startswith('3'): 133 | self.skipTest("Test not supported for " + KC_VERSION) 134 | self.assertEqual(OK, status) 135 | 136 | @create_api({ 137 | 'allowed_iss': ['http://localhost:8080/auth/realms/master'], 138 | 'scope': ['not_found'] 139 | }) 140 | @authenticate() 141 | @call_api() 142 | def test_client_scope_rainy(self, status, body): 143 | self.assertEqual(FORBIDDEN, status) 144 | self.assertEqual('Access token does not have the required scope/role: Missing required scope', body.get('message')) 145 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | 5 | CLIENT_ID = os.environ.get("CLIENT_ID", "test") 6 | CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "c0bc799c-4dfc-4841-af01-0f1a00171c32") 7 | 8 | KONG_API = os.environ.get("KONG_API", "http://localhost:8000") 9 | KONG_ADMIN = os.environ.get("KONG_ADMIN", "http://localhost:8001") 10 | 11 | KC_USER = os.environ.get("KC_USER", "admin") 12 | KC_PASS = os.environ.get("KC_PASS", "admin") 13 | KC_HOST = os.environ.get("KC_HOST", "http://localhost:8080/auth") 14 | KC_REALM = KC_HOST + "/realms/master" 15 | 16 | r = requests.post(KC_REALM + "/protocol/openid-connect/token", data={ 17 | 'grant_type': 'password', 18 | 'client_id': 'admin-cli', 19 | 'username': KC_USER, 20 | 'password': KC_PASS 21 | }) 22 | 23 | assert r.status_code == 200 24 | KC_ADMIN_TOKEN = r.json()['access_token'] 25 | 26 | r = requests.get(KC_HOST + '/admin/serverinfo', headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}) 27 | assert r.status_code == 200 28 | KC_VERSION = r.json()['systemInfo']['version'] 29 | -------------------------------------------------------------------------------- /tests/integration_tests/tests/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import unittest 4 | import uuid 5 | 6 | from tests.config import * 7 | 8 | OK = 200 9 | CREATED = 201 10 | NO_CONTENT = 204 11 | UNAUTHORIZED = 401 12 | FORBIDDEN = 403 13 | 14 | 15 | def get_kong_version(): 16 | r = requests.get(KONG_ADMIN) 17 | assert r.status_code == OK 18 | return r.json()['version'] 19 | 20 | 21 | def get_kc_public_key(realm_url): 22 | r = requests.get(realm_url) 23 | assert r.status_code == OK 24 | return r.json()['public_key'] 25 | 26 | 27 | def get_kc_token(client_id, client_secret): 28 | r = requests.post(KC_REALM + "/protocol/openid-connect/token", data={ 29 | 'grant_type': 'client_credentials', 30 | 'client_id': client_id, 31 | 'client_secret': client_secret 32 | }) 33 | assert r.status_code == OK 34 | return r.json()['access_token'] 35 | 36 | 37 | def ensure_plugin(): 38 | r = requests.get(KONG_ADMIN + "/jwt-keycloak") 39 | assert r.status_code == OK 40 | res = r.json() 41 | if len(res['data']) == 0: 42 | r = requests.post(KONG_ADMIN + "/jwt-keycloak", data={ 43 | "iss": KC_REALM, 44 | "public_key": get_kc_public_key(KC_REALM) 45 | }) 46 | assert r.status_code == CREATED 47 | time.sleep(0.5) 48 | 49 | 50 | def create_consumer(client_id, **kwargs): 51 | custom_id = kwargs.get('custom_id', client_id) 52 | r = requests.post(KONG_ADMIN + "/consumers", json={ 53 | "username": client_id, 54 | "custom_id": custom_id 55 | }) 56 | assert r.status_code == CREATED or r.status_code == 409 57 | time.sleep(0.5) 58 | return kwargs.get('custom_id', r.json()['id']) 59 | 60 | 61 | def delete_consumer(client_id): 62 | r = requests.delete(KONG_ADMIN + "/consumers/" + client_id) 63 | assert r.status_code == NO_CONTENT 64 | time.sleep(0.5) 65 | 66 | 67 | def create_api(config, expected_response=CREATED): 68 | def real_decorator(func): 69 | def wrapper(*args, **kwargs): 70 | api_name = "test" + str(random.randint(1, 1000000)) 71 | r = requests.post(KONG_ADMIN + "/services", data={ 72 | "name": api_name, 73 | "url": "http://mockbin.org/headers" 74 | }) 75 | assert r.status_code == CREATED 76 | r = requests.post(KONG_ADMIN + "/services/" + api_name + "/routes", data={ 77 | "name": api_name, 78 | "paths[]": "/" + api_name 79 | }) 80 | assert r.status_code == CREATED 81 | r = requests.post(KONG_ADMIN + "/services/" + api_name + "/plugins", json={ 82 | "name": "jwt-keycloak", 83 | "config": config 84 | }) 85 | assert r.status_code == expected_response 86 | kwargs['api_endpoint'] = KONG_API + "/" + api_name 87 | time.sleep(1) 88 | result = func(*args, **kwargs) 89 | return result 90 | 91 | return wrapper 92 | 93 | return real_decorator 94 | 95 | 96 | def call_api(token=None, method='get', params=None, endpoint=None): 97 | def real_decorator(func): 98 | def wrapper(*args, **kwargs): 99 | if token is not None: 100 | headers = {"Authorization": "Bearer " + token} 101 | elif kwargs.get('token') is not None: 102 | headers = {"Authorization": "Bearer " + kwargs.get('token')} 103 | else: 104 | headers = None 105 | 106 | if endpoint is None: 107 | e = kwargs['api_endpoint'] 108 | else: 109 | e = endpoint 110 | 111 | r = requests.request(method, e, params=params, headers=headers) 112 | result = func(*args, r.status_code, r.json()) 113 | return result 114 | 115 | return wrapper 116 | 117 | return real_decorator 118 | 119 | 120 | def create_client(client_id, **kwargs): 121 | id = str(uuid.uuid4()) 122 | 123 | if client_id is None: 124 | client_id = str(uuid.uuid4()) 125 | 126 | if kwargs.get('create_consumer'): 127 | client_id = create_consumer(client_id, **kwargs) 128 | client_secret = str(uuid.uuid4()) 129 | r = requests.post(KC_HOST + "/admin/realms/master/clients", 130 | headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}, 131 | json={ 132 | 'id': id, 133 | 'clientId': client_id, 134 | 'clientAuthenticatorType': 'client-secret', 135 | 'secret': client_secret, 136 | 'serviceAccountsEnabled': True, 137 | 'standardFlowEnabled': False, 138 | 'publicClient': False, 139 | 'enabled': True 140 | }) 141 | 142 | assert r.status_code == 201 143 | 144 | r = requests.post(KC_HOST + "/admin/realms/master/clients/" + id + '/roles', 145 | headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}, 146 | json={ 147 | 'name': 'test_role' 148 | }) 149 | 150 | assert r.status_code == 201 151 | 152 | return client_id, client_secret 153 | 154 | 155 | def authenticate(**kwargs_outer): 156 | def real_decorator(func): 157 | def wrapper(*args, **kwargs): 158 | client_id, client_secret = create_client(None, **kwargs_outer) 159 | result = func(*args, token=get_kc_token(client_id, client_secret), **kwargs) 160 | return result 161 | 162 | return wrapper 163 | 164 | return real_decorator 165 | 166 | 167 | def skip(reason): 168 | def real_decorator(func): 169 | def wrapper(*args, **kwargs): 170 | unittest.TestCase.skipTest(None, reason) 171 | return 172 | 173 | return wrapper 174 | 175 | return real_decorator 176 | 177 | 178 | def kc_get_key(name): 179 | r = requests.get(KC_HOST + "/admin/realms/master/components?parent=master&type=org.keycloak.keys.KeyProvider", 180 | headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}) 181 | assert r.status_code == 200 182 | r = [a for a in r.json() if a['name'] == name] 183 | if len(r) == 1: 184 | return r[0] 185 | return None 186 | 187 | 188 | def kc_delete_key(key): 189 | r = requests.delete(KC_HOST + "/admin/realms/master/components/" + key['id'], 190 | headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}) 191 | assert r.status_code == 204 192 | 193 | 194 | def kc_add_key(name, priority): 195 | r = requests.post(KC_HOST + "/admin/realms/master/components", 196 | headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}, 197 | json={ 198 | 'config': { 199 | 'active': [True], 200 | 'algorithm': ['RS256'], 201 | 'enabled': [True], 202 | 'keySize': [2048], 203 | 'priority': [priority] 204 | }, 205 | 'name': name, 206 | 'parentId': 'master', 207 | 'providerId': 'rsa-generated', 208 | 'providerType': 'org.keycloak.keys.KeyProvider' 209 | }) 210 | 211 | assert r.status_code == 201 212 | 213 | r = requests.get(r.headers['Location'], headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN}) 214 | assert r.status_code == 200 215 | 216 | return r.json() 217 | -------------------------------------------------------------------------------- /tests/unit_tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.12 2 | 3 | ENV LUA_VERSION=5.1.5 4 | ENV LUAROCKS_VERSION=3.4.0 5 | ENV OPENRESTY_PUB_KEY="http://openresty.org/package/admin@openresty.com-5ea678a6.rsa.pub" 6 | 7 | # Install dependencies 8 | RUN apk add --no-cache \ 9 | ca-certificates \ 10 | openssl \ 11 | curl \ 12 | unzip \ 13 | gcc \ 14 | git \ 15 | libc-dev \ 16 | libressl-dev \ 17 | yaml-dev \ 18 | make \ 19 | m4 \ 20 | zlib-dev \ 21 | bsd-compat-headers 22 | 23 | # Install openresty 24 | RUN wget -O "/etc/apk/keys/$(basename ${OPENRESTY_PUB_KEY})" ${OPENRESTY_PUB_KEY} \ 25 | && echo "http://openresty.org/package/alpine/v3.12/main" >> /etc/apk/repositories \ 26 | && apk update \ 27 | && apk add --no-cache openresty-resty 28 | 29 | # Install Lua 30 | RUN wget -c https://www.lua.org/ftp/lua-${LUA_VERSION}.tar.gz -O - | tar -xzf - \ 31 | && cd lua-${LUA_VERSION} \ 32 | && make -j"$(nproc)" posix \ 33 | && make install \ 34 | && cd .. \ 35 | && rm -rf lua-${LUA_VERSION} 36 | 37 | # Install luarocks 38 | RUN wget -c https://luarocks.github.io/luarocks/releases/luarocks-${LUAROCKS_VERSION}.tar.gz -O - | tar -xzf - \ 39 | && cd luarocks-${LUAROCKS_VERSION} \ 40 | && ./configure --with-lua=/usr/local \ 41 | && make build \ 42 | && make install \ 43 | && cd .. \ 44 | && rm -rf luarocks-${LUAROCKS_VERSION} 45 | 46 | # Install kong and busted 47 | RUN luarocks install busted 48 | ARG KONG_VERSION 49 | RUN luarocks install kong ${KONG_VERSION}-0 50 | 51 | # Install plugin 52 | COPY ./*.rockspec /tmp 53 | COPY ./LICENSE /tmp/LICENSE 54 | COPY ./src /tmp/src 55 | WORKDIR /tmp 56 | ARG PLUGIN_VERSION 57 | RUN luarocks make 58 | 59 | # Add custom busted binary 60 | COPY tests/unit_tests/busted_bin /usr/bin/busted 61 | COPY tests/unit_tests/busted_bin /usr/local/bin/busted 62 | 63 | # Copy and run tests 64 | COPY tests/unit_tests/tests /tests 65 | WORKDIR /tests 66 | CMD ["busted", "."] 67 | -------------------------------------------------------------------------------- /tests/unit_tests/busted_bin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env resty 2 | 3 | local DEFAULT_RESTY_FLAGS="-c 4096" 4 | 5 | do 6 | local lines = getmetatable(io.output()).lines 7 | 8 | getmetatable(io.output()).lines = function(self, ...) 9 | local iter = lines(self, ...) 10 | 11 | return function() 12 | local ok, ret = pcall(iter) 13 | if ok then 14 | return ret 15 | end 16 | end 17 | end 18 | end 19 | 20 | if not os.getenv("KONG_BUSTED_RESPAWNED") then 21 | -- initial run, so go update the environment 22 | local script = {} 23 | for line in io.popen("set"):lines() do 24 | local ktvar, val = line:match("^KONG_TEST_([^=]*)=(.*)") 25 | if ktvar then 26 | -- reinserted KONG_TEST_xxx as KONG_xxx; append 27 | table.insert(script, "export KONG_" .. ktvar .. "=" ..val) 28 | end 29 | 30 | local var = line:match("^(KONG_[^=]*)") 31 | if var then 32 | -- remove existing KONG_xxx and KONG_TEST_xxx variables; prepend 33 | table.insert(script, 1, "unset " .. var) 34 | end 35 | end 36 | -- add cli recursion detection 37 | table.insert(script, "export KONG_BUSTED_RESPAWNED=1") 38 | 39 | -- rebuild the invoked commandline, while inserting extra resty-flags 40 | local resty_flags = DEFAULT_RESTY_FLAGS 41 | local cmd = { "exec" } 42 | for i = -1, #arg do 43 | if arg[i]:sub(1, 12) == "RESTY_FLAGS=" then 44 | resty_flags = arg[i]:sub(13, -1) 45 | 46 | else 47 | table.insert(cmd, "'" .. arg[i] .. "'") 48 | end 49 | end 50 | 51 | if resty_flags then 52 | table.insert(cmd, 3, resty_flags) 53 | end 54 | 55 | table.insert(script, table.concat(cmd, " ")) 56 | 57 | -- recurse cli command, with proper variables (un)set for clean testing 58 | local _, _, rc = os.execute(table.concat(script, "; ")) 59 | os.exit(rc) 60 | end 61 | 62 | pcall(require, "luarocks.loader") 63 | 64 | require("kong.globalpatches")({ 65 | cli = true, 66 | rbusted = true 67 | }) 68 | 69 | -- Busted command-line runner 70 | require 'busted.runner'({ standalone = false }) -------------------------------------------------------------------------------- /tests/unit_tests/tests/.busted: -------------------------------------------------------------------------------- 1 | return { 2 | default = { 3 | verbose = false, 4 | coverage = false, 5 | -- output = "gtest", 6 | }, 7 | } -------------------------------------------------------------------------------- /tests/unit_tests/tests/key_conversion_spec.lua: -------------------------------------------------------------------------------- 1 | -- local keycloak_keys = require("kong.plugins.jwt-keycloak.keycloak_keys") 2 | -- local get_issuer_keys = keycloak_keys.get_issuer_keys 3 | -- local get_wellknown_endpoint = keycloak_keys.get_wellknown_endpoint 4 | -- local get_request = keycloak_keys.get_request 5 | 6 | -- local well_known_template = "%s/.well-known/openid-configuration" 7 | 8 | -- describe("Keycloak key conversion", function() 9 | -- it("should convert the jwk to pem correctly", function() 10 | -- local issuer = "http://localhost:8080/auth/realms/master" 11 | 12 | -- res1, err1 = get_issuer_keys(get_wellknown_endpoint(well_known_template, issuer)) 13 | -- res2, err2 = get_request(issuer, "http") 14 | 15 | -- assert.same(res2['public_key'], res1[1]) 16 | -- end) 17 | 18 | -- it("should fail on invalid issuer", function() 19 | 20 | -- local issuer = "http://localhost:8080/auth/realms/does_not_exist" 21 | 22 | -- res1, err1 = get_issuer_keys(get_wellknown_endpoint(well_known_template, issuer)) 23 | 24 | -- assert.same(nil, res1) 25 | -- assert.same('Failed calling url http://localhost:8080/auth/realms/does_not_exist/.well-known/openid-configuration response status 404', err1) 26 | -- end) 27 | 28 | -- it("should fail on bad issuer", function() 29 | -- local issuer = "http://localhost:8081/auth/realms/does_not_exist" 30 | 31 | -- res1, err1 = get_issuer_keys(get_wellknown_endpoint(well_known_template, issuer)) 32 | 33 | -- assert.same(nil, res1) 34 | -- assert.same('Failed calling url http://localhost:8081/auth/realms/does_not_exist/.well-known/openid-configuration response status closed', err1) 35 | -- end) 36 | 37 | -- end) 38 | -------------------------------------------------------------------------------- /tests/unit_tests/tests/validators_client_roles_spec.lua: -------------------------------------------------------------------------------- 1 | local validate_client_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_client_roles 2 | 3 | local test_claims = { 4 | resource_access = { 5 | account = { 6 | roles = { 7 | "manage-account", 8 | "manage-account-links", 9 | "view-profile" 10 | } 11 | } 12 | } 13 | } 14 | 15 | local function deepcopy(orig) 16 | local orig_type = type(orig) 17 | local copy 18 | if orig_type == 'table' then 19 | copy = {} 20 | for orig_key, orig_value in next, orig, nil do 21 | copy[deepcopy(orig_key)] = deepcopy(orig_value) 22 | end 23 | setmetatable(copy, deepcopy(getmetatable(orig))) 24 | else -- number, string, boolean, etc 25 | copy = orig 26 | end 27 | return copy 28 | end 29 | 30 | describe("Validator", function() 31 | describe("for client roles should", function() 32 | it("handle a nil allowed roles", function() 33 | local valid = validate_client_roles(nil, test_claims) 34 | assert.same(true, valid) 35 | end) 36 | 37 | it("handle an empty list of allowed roles", function() 38 | local valid = validate_client_roles({}, test_claims) 39 | assert.same(true, valid) 40 | end) 41 | 42 | it("handle a missing azp", function() 43 | local claims = deepcopy(test_claims) 44 | claims.resource_access = nil 45 | 46 | local valid, err = validate_client_roles({"test_role"}, claims) 47 | assert.same(nil, valid) 48 | assert.same("Missing required resource_access claim", err) 49 | end) 50 | 51 | it("handle a valid roles", function() 52 | local valid = validate_client_roles({"account:manage-account"}, test_claims) 53 | assert.same(true, valid) 54 | 55 | local valid = validate_client_roles({"account:valid_role", "account:view-profile"}, test_claims) 56 | assert.same(true, valid) 57 | end) 58 | 59 | it("handle a missing required role roles", function() 60 | local valid, err = validate_client_roles({"account:valid_role"}, test_claims) 61 | assert.same(nil, valid) 62 | assert.same("Missing required role", err) 63 | end) 64 | end) 65 | end) -------------------------------------------------------------------------------- /tests/unit_tests/tests/validators_issuers_spec.lua: -------------------------------------------------------------------------------- 1 | local validate_issuer = require("kong.plugins.jwt-keycloak.validators.issuers").validate_issuer 2 | 3 | local test_claims = { 4 | iss = "http://keycloak-headless/auth/realms/master" 5 | } 6 | 7 | describe("Validator", function() 8 | describe("for issuers should", function() 9 | it("handle when allowed issuers is nil", function() 10 | local valid, err = validate_issuer(nil, "") 11 | assert.same(nil, valid) 12 | assert.same("Allowed issuers is empty", err) 13 | end) 14 | 15 | it("handle when allowed issuers is empty list", function() 16 | local valid, err = validate_issuer({}, "") 17 | assert.same(nil, valid) 18 | assert.same("Allowed issuers is empty", err) 19 | end) 20 | 21 | it("handle when iss claim is missing", function() 22 | local valid, err = validate_issuer( 23 | {"http://keycloak-headless/auth/realms/master"}, 24 | {} 25 | ) 26 | assert.same(nil, valid) 27 | assert.same("Missing issuer claim", err) 28 | end) 29 | 30 | it("handle single valid issuer", function() 31 | local valid, err = validate_issuer( 32 | {"http://keycloak-headless/auth/realms/master"}, 33 | test_claims 34 | ) 35 | assert.same(true, valid) 36 | end) 37 | 38 | it("handle invalid issuer", function() 39 | local valid, err = validate_issuer( 40 | {"http://localhost:8080/auth/realms/master"}, 41 | test_claims 42 | ) 43 | assert.same(nil, valid) 44 | assert.same("Token issuer not allowed", err) 45 | end) 46 | 47 | it("handle multiple valid issuers", function() 48 | local valid, err = validate_issuer({ 49 | "http://keycloak-headless/auth/realms/master", 50 | "http://localhost:8080/auth/realms/master" 51 | }, 52 | test_claims 53 | ) 54 | assert.same(true, valid) 55 | end) 56 | 57 | it("handle matching issuer", function() 58 | local valid, err = validate_issuer( 59 | {"http://keycloak%-headless/auth/realms/.+"}, 60 | test_claims 61 | ) 62 | assert.same(true, valid) 63 | end) 64 | end) 65 | end) -------------------------------------------------------------------------------- /tests/unit_tests/tests/validators_realm_roles_spec.lua: -------------------------------------------------------------------------------- 1 | local validate_realm_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_realm_roles 2 | 3 | local test_claims = { 4 | realm_access = { 5 | roles = { 6 | "offline_access", 7 | "uma_authorization" 8 | } 9 | } 10 | } 11 | 12 | describe("Validator", function() 13 | describe("for roles should", function() 14 | it("handle a nil allowed roles", function() 15 | local valid = validate_realm_roles(nil, test_claims) 16 | assert.same(true, valid) 17 | end) 18 | 19 | it("handle an empty list of allowed roles", function() 20 | local valid = validate_realm_roles({}, test_claims) 21 | assert.same(true, valid) 22 | end) 23 | 24 | it("handle a valid roles", function() 25 | local valid = validate_realm_roles({"offline_access"}, test_claims) 26 | assert.same(true, valid) 27 | 28 | local valid = validate_realm_roles({"valid_role", "uma_authorization"}, test_claims) 29 | assert.same(true, valid) 30 | end) 31 | 32 | it("handle a missing required role roles", function() 33 | local valid, err = validate_realm_roles({"test_role_invalid"}, test_claims) 34 | assert.same(nil, valid) 35 | assert.same("Missing required realm role", err) 36 | end) 37 | 38 | it("handle missing claim", function() 39 | local valid, err = validate_realm_roles({"test_role_2"}, {}) 40 | assert.same(nil, valid) 41 | assert.same("Missing required realm_access.roles claim", err) 42 | end) 43 | end) 44 | end) -------------------------------------------------------------------------------- /tests/unit_tests/tests/validators_roles_spec.lua: -------------------------------------------------------------------------------- 1 | local validate_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_roles 2 | 3 | local test_claims = { 4 | azp = "test_client", 5 | resource_access = { 6 | test_client = { 7 | roles = { 8 | "test_role", 9 | "test_role_2" 10 | } 11 | } 12 | } 13 | } 14 | 15 | local function deepcopy(orig) 16 | local orig_type = type(orig) 17 | local copy 18 | if orig_type == 'table' then 19 | copy = {} 20 | for orig_key, orig_value in next, orig, nil do 21 | copy[deepcopy(orig_key)] = deepcopy(orig_value) 22 | end 23 | setmetatable(copy, deepcopy(getmetatable(orig))) 24 | else -- number, string, boolean, etc 25 | copy = orig 26 | end 27 | return copy 28 | end 29 | 30 | describe("Validator", function() 31 | describe("for roles should", function() 32 | it("handle a nil allowed roles", function() 33 | local valid = validate_roles(nil, test_claims) 34 | assert.same(true, valid) 35 | end) 36 | 37 | it("handle an empty list of allowed roles", function() 38 | local valid = validate_roles({}, test_claims) 39 | assert.same(true, valid) 40 | end) 41 | 42 | it("handle a missing azp", function() 43 | local claims = deepcopy(test_claims) 44 | claims.azp = nil 45 | 46 | local valid, err = validate_roles({"test_role"}, claims) 47 | assert.same(nil, valid) 48 | assert.same("Missing required azp claim", err) 49 | end) 50 | 51 | it("handle a valid roles", function() 52 | local valid = validate_roles({"test_role"}, test_claims) 53 | assert.same(true, valid) 54 | 55 | local valid = validate_roles({"valid_role", "test_role_2"}, test_claims) 56 | assert.same(true, valid) 57 | end) 58 | 59 | it("handle a missing required role roles", function() 60 | local valid, err = validate_roles({"test_role_invalid"}, test_claims) 61 | assert.same(nil, valid) 62 | assert.same("Missing required role", err) 63 | end) 64 | 65 | it("handle missing role list for azp", function() 66 | local claims = deepcopy(test_claims) 67 | claims.azp = "test_client_2" 68 | 69 | local valid, err = validate_roles({"test_role_2"}, claims) 70 | assert.same(nil, valid) 71 | assert.same("Missing required role", err) 72 | end) 73 | end) 74 | end) -------------------------------------------------------------------------------- /tests/unit_tests/tests/validators_scope_spec.lua: -------------------------------------------------------------------------------- 1 | local validate_scope = require("kong.plugins.jwt-keycloak.validators.scope").validate_scope 2 | 3 | local test_claims = { 4 | scope = "profile email dashed-scope" 5 | } 6 | 7 | describe("Validator", function() 8 | describe("for scope should", function() 9 | it("handle a when allowed scopes is nil", function() 10 | local valid, err = validate_scope(nil, test_claims) 11 | assert.same(true, valid) 12 | end) 13 | 14 | it("handle a when allowed scopes is empty list", function() 15 | local valid, err = validate_scope({}, test_claims) 16 | assert.same(true, valid) 17 | end) 18 | 19 | it("handle a when jwt claims is nil", function() 20 | local valid, err = validate_scope({"profile"}, nil) 21 | assert.same(nil, valid) 22 | assert.same("Missing required scope claim", err) 23 | end) 24 | 25 | it("handle a when scope claim is nil", function() 26 | local valid, err = validate_scope({"profile"}, {}) 27 | assert.same(nil, valid) 28 | assert.same("Missing required scope claim", err) 29 | end) 30 | 31 | it("handle a valid scope", function() 32 | local valid, err = validate_scope({"profile"}, test_claims) 33 | assert.same(true, valid) 34 | end) 35 | 36 | it("handle a invalid scope", function() 37 | local valid, err = validate_scope({"account"}, test_claims) 38 | assert.same(nil, valid) 39 | assert.same("Missing required scope", err) 40 | end) 41 | 42 | it("handle a multiple scopes", function() 43 | local valid, err = validate_scope({"account", "email"}, test_claims) 44 | assert.same(true, valid) 45 | end) 46 | 47 | it("handle pattern chars in scope", function() 48 | local valid, err = validate_scope({"dashed-scope"}, test_claims) 49 | assert.same(true, valid) 50 | end) 51 | 52 | it("handle partial scope match", function() 53 | local valid, err = validate_scope({"dashed"}, test_claims) 54 | assert.same(nil, valid) 55 | assert.same("Missing required scope", err) 56 | end) 57 | end) 58 | end) --------------------------------------------------------------------------------