├── .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)
--------------------------------------------------------------------------------