From 3cfb3556ed750150df8ae8639f1aa522a2fef010 Mon Sep 17 00:00:00 2001
From: Robert Grumann <46533412+ZeeFiix@users.noreply.github.com>
Date: Sun, 13 Aug 2023 22:35:58 +0200
Subject: [PATCH] Fixes gbbirkisson/kong-plugin-jwt-keycloak#38 Adapt the
plugin to kong v3.0.0 breaking changes (#6)
---
.dockerignore | 1 -
Dockerfile | 10 +-
Makefile | 22 +-
README.md | 78 ++-
kong-plugin-jwt-keycloak-1.1.0-1.rockspec | 34 -
kong-plugin-jwt-keycloak-1.3.0-1.rockspec | 44 ++
luarocks.Dockerfile | 4 +-
makefiles/helpers.mk | 31 +
makefiles/keycloak.mk | 14 +-
makefiles/kong.mk | 26 +-
src/handler.lua | 660 ++++++++++--------
src/keycloak_keys.lua | 2 +-
src/schema.lua | 70 +-
src/validators/issuers.lua | 2 +-
src/validators/roles.lua | 2 +-
src/validators/scope.lua | 2 +-
tests/Makefile | 4 +-
tests/integration_tests/Dockerfile | 5 +-
tests/integration_tests/tests/TestBasics.py | 92 ++-
.../tests/TestConsumerMapping.py | 19 +-
.../{TestRoles.py => TestRolesAndScopes.py} | 53 +-
tests/integration_tests/tests/config.py | 20 +-
tests/integration_tests/tests/setup.py | 65 ++
tests/integration_tests/tests/utils.py | 74 +-
tests/unit_tests/Dockerfile | 7 +-
tests/unit_tests/tests/.busted | 2 +-
.../tests/validators_scope_spec.lua | 2 +-
27 files changed, 884 insertions(+), 461 deletions(-)
delete mode 100644 kong-plugin-jwt-keycloak-1.1.0-1.rockspec
create mode 100644 kong-plugin-jwt-keycloak-1.3.0-1.rockspec
create mode 100644 makefiles/helpers.mk
rename tests/integration_tests/tests/{TestRoles.py => TestRolesAndScopes.py} (63%)
create mode 100644 tests/integration_tests/tests/setup.py
diff --git a/.dockerignore b/.dockerignore
index 3da11fb..351849d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,7 +4,6 @@
Dockerfile
Makefile
README.md
-Dockerfile
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/Dockerfile b/Dockerfile
index 4fc8461..427b12e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,15 @@
## Build plugin
ARG KONG_VERSION
-FROM kong:${KONG_VERSION} as builder
+FROM docker.io/kong:${KONG_VERSION} as builder
# Root needed to install dependencies
USER root
-RUN apk --no-cache add zip
+# Starting from kong 3.2 they move from alpine to debian .. so conditional install logic is needed
+ARG DISTO_ADDONS="zip"
+RUN if [ -x "$(command -v apk)" ]; then apk add --no-cache $DISTO_ADDONS; \
+ elif [ -x "$(command -v apt-get)" ]; then apt-get update && apt-get install $DISTO_ADDONS; \
+ fi
WORKDIR /tmp
COPY ./*.rockspec /tmp
@@ -15,7 +19,7 @@ ARG PLUGIN_VERSION
RUN luarocks make && luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION}
## Create Image
-FROM kong:${KONG_VERSION}
+FROM docker.io/kong:${KONG_VERSION}
ENV KONG_PLUGINS="bundled,jwt-keycloak"
diff --git a/Makefile b/Makefile
index 1d19ed7..ecdde80 100644
--- a/Makefile
+++ b/Makefile
@@ -1,19 +1,27 @@
+.DEFAULT_GOAL:=all
+
include makefiles/*.mk
REPOSITORY?=telekom-digioss
IMAGE?=kong-plugin-jwt-keycloak
-KONG_VERSION?=2.8.3
+KONG_VERSION?=3.4.0
FULL_IMAGE_NAME:=${REPOSITORY}/${IMAGE}:${KONG_VERSION}
-PLUGIN_VERSION?=1.1.0-1
+PLUGIN_VERSION?=1.3.0-1
-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
+# Tests version separated with spaces
+TEST_VERSIONS?=2.8.1 3.0.0 3.1.0 3.2.2 3.3.0
### Docker ###
+default:
+ @echo "!! Running all builds, pulls and startups !!"
+
+all: default helpers-start keycloak-rm kong-stop keycloak-start build kong-restart
+
build:
@echo "Building image ..."
- docker build --pull -q -t ${FULL_IMAGE_NAME} --build-arg KONG_VERSION=${KONG_VERSION} --build-arg PLUGIN_VERSION=${PLUGIN_VERSION} .
+ docker build --pull -q -t ${FULL_IMAGE_NAME} --build-arg KONG_VERSION=${KONG_VERSION} --build-arg PLUGIN_VERSION=${PLUGIN_VERSION} . --progress=plain --no-cache
run: build
docker run -it --rm ${FULL_IMAGE_NAME} kong start --vv
@@ -34,7 +42,7 @@ upload:
start: kong-db-start kong-start
restart: kong-stop kong-start
restart-all: stop start
-stop: kong-stop kong-db-stop
+stop: kong-stop kong-db-stop keycloak-stop keycloak-rm helpers-stop
test-unit: keycloak-start
@echo ======================================================================
@@ -47,7 +55,7 @@ test-unit: keycloak-start
@echo "Unit tests passed with kong version ${KONG_VERSION}"
@echo ======================================================================
-test-integration: restart-all sleep keycloak-start
+test-integration: #keycloak-stop keycloak-rm keycloak-start restart-all sleep
@echo ======================================================================
@echo "Testing kong version ${KONG_VERSION} with ${KONG_DATABASE}"
@echo
@@ -64,7 +72,7 @@ test-all: keycloak-start
@echo "Starting integration tests for multiple versions"
@set -e; for t in $(TEST_VERSIONS); do \
$(MAKE) --no-print-directory test-unit PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t ; \
- $(MAKE) --no-print-directory test-integration PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t KONG_DATABASE=postgres ; \
+ $(MAKE) --no-print-directory test-integration PLUGIN_VERSION=${PLUGIN_VERSION} KONG_VERSION=$$t KONG_DATABASE=postgres ; \
done
@echo "All test successful"
diff --git a/README.md b/README.md
index dd63c52..4211b39 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,18 @@
Kong plugin jwt-keycloak
-> **:warning: No longer maintained!**
+> **⚠️ This fork is maintained for a limited set of version combinations**
>
-> 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!
+> The official author of the plugin no longer maintains it since 24.08.2021
+> Details see:
+>
+> We will continue to use this plugin in our project and maintain here this fork for it.
+> But we do not need backward compatible changes... so we will not test it to be able to work with older versions of kong.
+>
+> Supported version matrix:
+> * kong dependencies of version 2.8.1 .... and higher versions
+> * postgres database of version 12.x ... and higher versions
+> * keycloak versions in the way how redhat-sso contains the versions in their product starting from keycloak 9.0
+
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.
@@ -44,33 +54,30 @@ If you have any suggestion or comments, please feel free to open an issue on thi
## Tested and working for
-| Kong Version | Tests passing |
-| ------------ | :----------------: |
-| 0.13.x | :x: |
-| 0.14.x | :x: |
-| 1.0.x | :white_check_mark: |
-| 1.1.x | :white_check_mark: |
-| 1.2.x | :white_check_mark: |
-| 1.3.x | :white_check_mark: |
-| 1.4.x | :white_check_mark: |
-| 1.5.x | :white_check_mark: |
-| 2.0.x | :white_check_mark: |
-| 2.1.x | :white_check_mark: |
-| 2.2.x | :white_check_mark: |
-| 2.3.x | :white_check_mark: |
-
-| Keycloak Version | Tests passing |
-| ---------------- | :----------------: |
-| 3.X.X | :white_check_mark: |
-| 4.X.X | :white_check_mark: |
-| 5.X.X | :white_check_mark: |
-| 6.X.X | :white_check_mark: |
-| 7.X.X | :white_check_mark: |
-| 8.X.X | :white_check_mark: |
-| 9.X.X | :white_check_mark: |
-| 10.X.X | :white_check_mark: |
-| 11.X.X | :white_check_mark: |
-| 12.X.X | :white_check_mark: |
+There are a few limitations about testing combinations:
+* Kong only provides a limited set off their lua code on luarocks
+
+ for this reason currently only these version combinations can be validated
+* Redhat / Jboss / Keycloak provides also not all latest updates of RHSSO base versions of keycloak
+
+ For this reason not the latest patch versions on the contained rhsso product versions can be used for testing
+
+
+| Kong Version | Tests passing |
+| ------------------ | :----------------: |
+| 2.8.1 | ✅ |
+| 3.0.0 | ✅ |
+| 3.1.0 | ✅ |
+| 3.2.2 | ✅ |
+| 3.3.0 | ✅ |
+| 3.4.0 | ✅ |
+
+
+| Keycloak Version | Tests passing |
+| ------------------ | :----------------: |
+| 9.0.3 (RHSSO-7.4) | ✅ |
+| 15.0.2 (RHSSO-7.5) | ✅ |
+| 18.0.2 (RHSSO-7.6) | ✖️ [Issue](https://github.com/telekom-digioss/kong-plugin-jwt-keycloak/issues/5) |
## Installation
@@ -85,7 +92,7 @@ luarocks install kong-plugin-jwt-keycloak
#### Packing the rock
```bash
-export PLUGIN_VERSION=1.1.0-1
+export PLUGIN_VERSION=1.3.0-1
luarocks make
luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION}
```
@@ -93,7 +100,7 @@ luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION}
#### Installing the rock
```bash
-export PLUGIN_VERSION=1.1.0-1
+export PLUGIN_VERSION=1.3.0-1
luarocks install jwt-keycloak-${PLUGIN_VERSION}.all.rock
```
@@ -151,6 +158,7 @@ curl -X POST http://localhost:8001/plugins \
| 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`. |
| 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`. |
| 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. |
+| config.header_names | no | `authorization` | A list of HTTP header names that Kong will inspect to retrieve JWTs. `OPTIONS` requests will always be allowed. |
| 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. |
| config.algorithm | no | `RS256` | The algorithm used to verify the token’s signature. Can be `HS256`, `HS384`, `HS512`, `RS256`, or `ES256`. |
| 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). |
@@ -171,14 +179,14 @@ Create service and add the plugin to it, and lastly create a route:
```bash
curl -X POST http://localhost:8001/services \
- --data "name=mockbin-echo" \
- --data "url=http://mockbin.org/echo"
+ --data "name=httpbin-anything" \
+ --data "url=http://localhost:8093/anything"
-curl -X POST http://localhost:8001/services/mockbin-echo/plugins \
+curl -X POST http://localhost:8001/services/httpbin-anything/plugins \
--data "name=jwt-keycloak" \
--data "config.allowed_iss=http://localhost:8080/auth/realms/master"
-curl -X POST http://localhost:8001/services/mockbin-echo/routes \
+curl -X POST http://localhost:8001/services/httpbin-anything/routes \
--data "paths=/"
```
diff --git a/kong-plugin-jwt-keycloak-1.1.0-1.rockspec b/kong-plugin-jwt-keycloak-1.1.0-1.rockspec
deleted file mode 100644
index e01e63b..0000000
--- a/kong-plugin-jwt-keycloak-1.1.0-1.rockspec
+++ /dev/null
@@ -1,34 +0,0 @@
-package = "kong-plugin-jwt-keycloak"
-
-version = "1.1.0-1"
--- The version '0.1.0' is the source code version, the trailing '1' is the version of this rockspec.
--- whenever the source version changes, the rockspec should be reset to 1. The rockspec version is only
--- updated (incremented) when this file changes, but the source remains the same.
-
-local pluginName = package:match("^kong%-plugin%-(.+)$") -- "jwt-keycloak"
-supported_platforms = {"linux", "macosx"}
-
-source = {
- url = "git://github.com/gbbirkisson/kong-plugin-jwt-keycloak",
- tag = "v1.1.0",
-}
-description = {
- summary = "A Kong plugin that will validate tokens issued by keycloak",
- homepage = "https://github.com/gbbirkisson/kong-plugin-jwt-keycloak",
- license = "Apache 2.0"
-}
-dependencies = {
- "lua ~> 5"
-}
-build = {
- type = "builtin",
- modules = {
- ["kong.plugins.jwt-keycloak.validators.issuers"] = "src/validators/issuers.lua",
- ["kong.plugins.jwt-keycloak.validators.roles"] = "src/validators/roles.lua",
- ["kong.plugins.jwt-keycloak.validators.scope"] = "src/validators/scope.lua",
- ["kong.plugins.jwt-keycloak.handler"] = "src/handler.lua",
- ["kong.plugins.jwt-keycloak.key_conversion"] = "src/key_conversion.lua",
- ["kong.plugins.jwt-keycloak.keycloak_keys"] = "src/keycloak_keys.lua",
- ["kong.plugins.jwt-keycloak.schema"] = "src/schema.lua",
- }
-}
\ No newline at end of file
diff --git a/kong-plugin-jwt-keycloak-1.3.0-1.rockspec b/kong-plugin-jwt-keycloak-1.3.0-1.rockspec
new file mode 100644
index 0000000..e395b34
--- /dev/null
+++ b/kong-plugin-jwt-keycloak-1.3.0-1.rockspec
@@ -0,0 +1,44 @@
+local plugin_name = "jwt-keycloak"
+local package_name = "kong-plugin-" .. plugin_name
+local package_version = "1.3.0"
+local rockspec_revision = "1"
+
+local github_account_name = "telekom-digioss"
+local github_repo_name = package_name
+local git_checkout = package_version == "dev" and "master" or package_version
+
+
+package = package_name
+version = package_version .. "-" .. rockspec_revision
+supported_platforms = { "linux", "macosx" }
+source = {
+ url = "git+https://github.com/"..github_account_name.."/"..github_repo_name..".git",
+ branch = git_checkout,
+}
+
+
+description = {
+ summary = "A Kong plugin that will validate tokens issued by keycloak",
+ homepage = "https://"..github_account_name..".github.io/"..github_repo_name,
+ license = "Apache 2.0",
+}
+
+
+dependencies = {
+ "lua ~> 5"
+}
+
+
+build = {
+ type = "builtin",
+ modules = {
+ -- TODO: add any additional code files added to the plugin
+ ["kong.plugins."..plugin_name..".handler"] = "src/handler.lua",
+ ["kong.plugins."..plugin_name..".schema"] = "src/schema.lua",
+ ["kong.plugins."..plugin_name..".keycloak_keys"] = "src/keycloak_keys.lua",
+ ["kong.plugins."..plugin_name..".key_conversion"] = "src/key_conversion.lua",
+ ["kong.plugins."..plugin_name..".validators.issuers"] = "src/validators/issuers.lua",
+ ["kong.plugins."..plugin_name..".validators.roles"] = "src/validators/roles.lua",
+ ["kong.plugins."..plugin_name..".validators.scope"] = "src/validators/scope.lua",
+ }
+}
diff --git a/luarocks.Dockerfile b/luarocks.Dockerfile
index be3eb57..f1d8ff1 100644
--- a/luarocks.Dockerfile
+++ b/luarocks.Dockerfile
@@ -1,4 +1,4 @@
-FROM kong:2.8.3 as builder
+FROM kong:3.1.0 as builder
USER root
@@ -9,7 +9,7 @@ RUN apk add --no-cache git zip && \
luarocks install ${LUAROCKS_MODULE} && \
luarocks pack ${LUAROCKS_MODULE}
-FROM kong:2.8.3
+FROM kong:3.1.0
USER root
diff --git a/makefiles/helpers.mk b/makefiles/helpers.mk
new file mode 100644
index 0000000..01b6af0
--- /dev/null
+++ b/makefiles/helpers.mk
@@ -0,0 +1,31 @@
+HTTPBIN_IMAGE:=docker.io/kennethreitz/httpbin
+HTTPBIN_CONTAINER_NAME:=kong_test_httpbin
+HTTPBIN_PORT:=8093
+
+PGADMIN_IMAGE:=docker.io/dpage/pgadmin4:7.5
+PGADMIN_CONTAINER_NAME:=kong_test_pgadmin6
+PGADMIN_PORT:=5050
+PGADMIN_DEFAULT_EMAIL=pgadmin@subdomain.domain
+PGADMIN_DEFAULT_PASSWORD=pgadmin
+
+helpers-start:
+ @echo "Starting Helpers ..."
+ @docker start ${HTTPBIN_CONTAINER_NAME} || docker run -d \
+ --name ${HTTPBIN_CONTAINER_NAME} \
+ -p ${HTTPBIN_PORT}:80 \
+ ${HTTPBIN_IMAGE}
+ @docker start ${PGADMIN_CONTAINER_NAME} || docker run -d \
+ --name ${PGADMIN_CONTAINER_NAME} \
+ --net host \
+ -e "PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}" \
+ -e "PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}" \
+ -e "PGADMIN_CONFIG_DEBUG=True" \
+ -e "PGADMIN_LISTEN_PORT=${PGADMIN_DEFAULT_PASSWORD}" \
+ ${PGADMIN_IMAGE}
+
+helpers-stop:
+ @echo "Stopping Helpers ..."
+ - @docker stop ${HTTPBIN_CONTAINER_NAME}
+ - @docker rm ${HTTPBIN_CONTAINER_NAME}
+ - @docker stop ${PGADMIN_CONTAINER_NAME}
+ - @docker rm ${PGADMIN_CONTAINER_NAME}
diff --git a/makefiles/keycloak.mk b/makefiles/keycloak.mk
index efc768b..ef1e714 100644
--- a/makefiles/keycloak.mk
+++ b/makefiles/keycloak.mk
@@ -1,5 +1,6 @@
-KEYCLOAK_IMAGE:=jboss/keycloak:12.0.2
-KEYCLOAK_CONTAINER_NAME:=kc_local
+KEYCLOAK_IMAGE:=quay.io/keycloak/keycloak:15.0.2
+# KEYCLOAK_IMAGE:=quay.io/keycloak/keycloak:18.0.2 #--> Look deeper. There is a problem wir keycloak key rotation test results
+KEYCLOAK_CONTAINER_NAME:=kong_test_keycloack
KEYCLOAK_PORT:=8080
KEYCLOAK_ADMIN_USER:=admin
KEYCLOAK_ADMIN_PASS:=admin
@@ -11,7 +12,10 @@ keycloak-start:
-p ${KEYCLOAK_PORT}:8080 \
-e KEYCLOAK_USER=${KEYCLOAK_ADMIN_USER} \
-e KEYCLOAK_PASSWORD=${KEYCLOAK_ADMIN_PASS} \
+ -e KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN_USER} \
+ -e KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASS} \
${KEYCLOAK_IMAGE}
+ # start-dev --http-relative-path /auth # needed starting from keycloak 18
@bash -c 'while ! timeout 1 bash -c "echo > /dev/tcp/localhost/8080"; do sleep 1; done'
keycloak-stop:
@@ -21,3 +25,9 @@ keycloak-stop:
keycloak-rm: keycloak-stop
@echo "Removing Keycloak"
- @docker rm ${KEYCLOAK_CONTAINER_NAME}
+
+keycloak-restart: keycloak-rm keycloak-start
+ @echo "Restarted Keycloak..."
+
+keycloak-logs:
+ - @docker logs --follow ${KEYCLOAK_CONTAINER_NAME}
diff --git a/makefiles/kong.mk b/makefiles/kong.mk
index 85f2a0f..7c8c92e 100644
--- a/makefiles/kong.mk
+++ b/makefiles/kong.mk
@@ -2,14 +2,14 @@ KONG_CONTAINER_NAME:=kong
KONG_PORT:=8000
KONG_ADMIN_PORT:=8001
-KONG_DB_CONTAINER_NAME:=kongdb
+KONG_DB_CONTAINER_NAME:=kong_test_postgres
KONG_DB_PORT:=5432
KONG_DB_USER:=kong
KONG_DB_PASS:=kong
KONG_DB_NAME:=kong
KONG_DATABASE?=postgres
-POSTGRES_IMAGE:=postgres:11.2-alpine
+POSTGRES_IMAGE:=docker.io/postgres:11.2-alpine
wait-for-log:
@while ! docker logs ${CONTAINER} | grep -q "${PATTERN}"; do sleep 5; done
@@ -48,7 +48,7 @@ kong-db-stop:
kong-start: build
@echo "Creating kong..."
- @docker run -d --rm \
+ @docker run -d \
--name ${KONG_CONTAINER_NAME} \
--net=host \
-e "KONG_LOG_LEVEL=debug" \
@@ -62,20 +62,28 @@ kong-start: build
-e "KONG_PG_PASSWORD=${KONG_DB_PASS}" \
-e "KONG_PG_DATABASE=${KONG_DB_NAME}" \
-e "KONG_ADMIN_LISTEN=0.0.0.0:8001" \
+ -e "KONG_NGINX_WORKER_PROCESSES"="1" \
${FULL_IMAGE_NAME} kong start --vv
kong-stop:
@echo "Removing Kong..."
- @docker stop ${KONG_CONTAINER_NAME}
+ - @docker rm ${KONG_CONTAINER_NAME}
-kong-log:
+kong-restart: kong-stop kong-db-stop kong-db-start kong-start
+ - @docker logs ${KONG_CONTAINER_NAME}
+
+kong-logs:
- @docker logs -f ${KONG_CONTAINER_NAME}
-kong-err-proxy:
+kong-logs-proxy:
+ - @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 proxy_access.log
+
+kong-logs-admin:
+ - @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 admin_access.log
+
+kong-logs-proxy-err:
- @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 /proxy_error.log
-kong-err-admin:
+kong-logs-admin-err:
- @docker exec -it ${KONG_CONTAINER_NAME} tail -f -n 100 /admin_error.log
-
-kong-restart: kong-stop kong-db-stop kong-create
- - @docker logs ${KONG_CONTAINER_NAME}
diff --git a/src/handler.lua b/src/handler.lua
index dfb924d..2f6e9c9 100644
--- a/src/handler.lua
+++ b/src/handler.lua
@@ -1,6 +1,7 @@
-local BasePlugin = require "kong.plugins.base_plugin"
local constants = require "kong.constants"
local jwt_decoder = require "kong.plugins.jwt.jwt_parser"
+local kong_meta = require "kong.meta"
+
local socket = require "socket"
local keycloak_keys = require("kong.plugins.jwt-keycloak.keycloak_keys")
@@ -12,8 +13,6 @@ local validate_client_roles = require("kong.plugins.jwt-keycloak.validators.role
local re_gmatch = ngx.re.gmatch
-local JwtKeycloakHandler = BasePlugin:extend()
-
local priority_env_var = "JWT_KEYCLOAK_PRIORITY"
local priority
if os.getenv(priority_env_var) then
@@ -23,360 +22,445 @@ else
end
kong.log.debug('JWT_KEYCLOAK_PRIORITY: ' .. priority)
-JwtKeycloakHandler.PRIORITY = priority
-JwtKeycloakHandler.VERSION = "1.1.0"
+local JwtKeycloakHandler = {
+ VERSION = kong_meta.version,
+ PRIORITY = priority,
+}
+
+-------------------------------------------------------------------------------
+-- custom helper function of the extended plugin "jwt-keycloak"
+-- --> this is not contained in the official "jwt" pluging
+-------------------------------------------------------------------------------
+local function custom_helper_table_to_string(tbl)
+ local result = ""
+ for k, v in pairs(tbl) do
+ -- Check the key type (ignore any numerical keys - assume its an array)
+ if type(k) == "string" then
+ result = result.."[\""..k.."\"]".."="
+ end
+
+ -- Check the value type
+ if type(v) == "table" then
+ result = result..custom_helper_table_to_string(v)
+ elseif type(v) == "boolean" then
+ result = result..tostring(v)
+ else
+ result = result.."\""..v.."\""
+ end
+ result = result..","
+ end
+ -- Remove leading commas from the result
+ if result ~= "" then
+ result = result:sub(1, result:len()-1)
+ end
+ return result
+end
+
+-------------------------------------------------------------------------------
+-- custom helper function of the extended plugin "jwt-keycloak"
+-- --> this is not contained in the official "jwt" pluging
+-------------------------------------------------------------------------------
+local function custom_helper_issuer_get_keys(well_known_endpoint, cafile)
+ kong.log.debug('Getting public keys from token issuer')
+ local keys, err = keycloak_keys.get_issuer_keys(well_known_endpoint, cafile)
+ if err then
+ return nil, err
+ end
+
+ local decoded_keys = {}
+ for i, key in ipairs(keys) do
+ decoded_keys[i] = jwt_decoder:base64_decode(key)
+ end
+
+ kong.log.debug('Number of keys retrieved: ' .. table.getn(decoded_keys))
+ return {
+ keys = decoded_keys,
+ updated_at = socket.gettime(),
+ }
+end
+
+-------------------------------------------------------------------------------
+-- custom keycloak specific extension for the plugin "jwt-keycloak"
+-- --> This is for one of the main benefits when using this plugin
+--
+-- This validates the token against the token issuer is the token is really
+-- issued by this instance. The URL from inside the token from the "iss"
+-- information is taken to connect with the token issuer instance.
+-------------------------------------------------------------------------------
+local function custom_validate_token_signature(conf, jwt, second_call)
+ local issuer_cache_key = 'issuer_keys_' .. jwt.claims.iss
+
+ local well_known_endpoint = keycloak_keys.get_wellknown_endpoint(conf.well_known_template, jwt.claims.iss)
+ -- Retrieve public keys
+ local public_keys, err = kong.cache:get(issuer_cache_key, nil, custom_helper_issuer_get_keys, well_known_endpoint, conf.cafile)
+
+ if not public_keys then
+ if err then
+ kong.log.err(err)
+ end
+ return kong.response.exit(403, { message = "Unable to get public key for issuer" })
+ end
+
+ -- Verify signatures
+ for _, k in ipairs(public_keys.keys) do
+ if jwt:verify_signature(k) then
+ kong.log.debug('JWT signature verified')
+ return nil
+ end
+ end
+
+ -- We could not validate signature, try to get a new keyset?
+ local since_last_update = socket.gettime() - public_keys.updated_at
+ if not second_call and since_last_update > conf.iss_key_grace_period then
+ kong.log.debug('Could not validate signature. Keys updated last ' .. since_last_update .. ' seconds ago')
+ -- can it be that the signature key of the issuer has changed ... ?
+ -- invalidate the old keys in kong cache and do a current lookup to the signature keys
+ -- of the token issuer
+ kong.cache:invalidate_local(issuer_cache_key)
+ return custom_validate_token_signature(conf, jwt, true)
+ end
+
+ return kong.response.exit(401, { message = "Invalid token signature" })
+end
+-------------------------------------------------------------------------------
+-- custom keycloak specific extension for the plugin "jwt-keycloak"
+-- --> This is for one of the main benefits when using this plugin
+--
+-- The extension of this plugin uses kong cache to store things...
+-- so it is needed also to handle invalidation properly.
+-- See:
+-- https://github.com/gbbirkisson/kong-plugin-jwt-keycloak/issues/28
+-- https://docs.konghq.com/gateway-oss/2.2.x/plugin-development/entities-cache/#manual-cache-invalidation
+-------------------------------------------------------------------------------
local function get_consumer_custom_id_cache_key(custom_id)
- return "custom_id_key_" .. custom_id
+ return "custom_id_key_" .. custom_id
end
local function invalidate_customer(data)
- local customer = data.entity
- if data.operation == "update" then
- customer = data.old_entity
- end
-
- local key = get_consumer_custom_id_cache_key(customer.custom_id)
- kong.log.debug("invalidating customer " .. key)
- kong.cache:invalidate(key)
+ local customer = data.entity
+ if data.operation == "update" then
+ customer = data.old_entity
+ end
+
+ local key = get_consumer_custom_id_cache_key(customer.custom_id)
+ kong.log.debug("invalidating customer " .. key)
+ kong.cache:invalidate(key)
end
+-- register at startup for events to be able to receive invalidate request needs
function JwtKeycloakHandler:init_worker()
- JwtKeycloakHandler.super.init_worker(self)
-
- kong.worker_events.register(invalidate_customer, "crud", "consumers")
+ kong.worker_events.register(invalidate_customer, "crud", "consumers")
end
-function table_to_string(tbl)
- local result = ""
- for k, v in pairs(tbl) do
- -- Check the key type (ignore any numerical keys - assume its an array)
- if type(k) == "string" then
- result = result.."[\""..k.."\"]".."="
- end
- -- Check the value type
- if type(v) == "table" then
- result = result..table_to_string(v)
- elseif type(v) == "boolean" then
- result = result..tostring(v)
- else
- result = result.."\""..v.."\""
- end
- result = result..","
- end
- -- Remove leading commas from the result
- if result ~= "" then
- result = result:sub(1, result:len()-1)
- end
- return result
-end
+-------------------------------------------------------------------------------
+-- Starting from here the "official" code of the community kong OSS version
+-- plugin "jwt" is forked and in some places then extended with the special
+-- logic from this plugin.
+--
+-- We use this ordering by intention that way .. if a new version of the
+-- "jwt" plugin from kong is released .. these changes can me merged also
+-- to this plugin here .... make the maintenance as easy as possible ...
+--
+-- This code is in sync with kong verion "3.3.0" jwt plugin as a baseline
+-------------------------------------------------------------------------------
+
--- Retrieve a JWT in a request.
-- Checks for the JWT in URI parameters, then in cookies, and finally
--- in the `Authorization` header.
--- @param request ngx request object
+-- in the configured header_names (defaults to `[Authorization]`).
-- @param conf Plugin configuration
-- @return token JWT token contained in request (can be a table) or nil
-- @return err
-local function retrieve_token(conf)
- local args = kong.request.get_query()
- for _, v in ipairs(conf.uri_param_names) do
- if args[v] then
- return args[v]
+local function retrieve_tokens(conf)
+ local token_set = {}
+ local args = kong.request.get_query()
+ for _, v in ipairs(conf.uri_param_names) do
+ local token = args[v] -- can be a table
+ if token then
+ if type(token) == "table" then
+ for _, t in ipairs(token) do
+ if t ~= "" then
+ token_set[t] = true
+ end
end
- end
- local var = ngx.var
- for _, v in ipairs(conf.cookie_names) do
- local cookie = var["cookie_" .. v]
- if cookie and cookie ~= "" then
- return cookie
- end
- end
+ elseif token ~= "" then
+ token_set[token] = true
+ end
+ end
+ end
+
+ local var = ngx.var
+ for _, v in ipairs(conf.cookie_names) do
+ local cookie = var["cookie_" .. v]
+ if cookie and cookie ~= "" then
+ token_set[cookie] = true
+ end
+ end
+
+ local request_headers = kong.request.get_headers()
+ for _, v in ipairs(conf.header_names) do
+ local token_header = request_headers[v]
+ if token_header then
+ if type(token_header) == "table" then
+ token_header = token_header[1]
+ end
+ local iterator, iter_err = re_gmatch(token_header, "\\s*[Bb]earer\\s+(.+)")
+ if not iterator then
+ kong.log.err(iter_err)
+ break
+ end
+
+ local m, err = iterator()
+ if err then
+ kong.log.err(err)
+ break
+ end
- local authorization_header = kong.request.get_header("authorization")
- if authorization_header then
- local iterator, iter_err = re_gmatch(authorization_header, "\\s*[Bb]earer\\s+(.+)")
- if not iterator then
- return nil, iter_err
+ if m and #m > 0 then
+ if m[1] ~= "" then
+ token_set[m[1]] = true
end
+ end
+ end
+ end
- local m, err = iterator()
- if err then
- return nil, err
- end
+ local tokens_n = 0
+ local tokens = {}
+ for token, _ in pairs(token_set) do
+ tokens_n = tokens_n + 1
+ tokens[tokens_n] = token
+ end
- if m and #m > 0 then
- return m[1]
- end
- end
-end
+ if tokens_n == 0 then
+ return nil
+ end
-function JwtKeycloakHandler:new()
- JwtKeycloakHandler.super.new(self, "jwt-keycloak")
-end
+ if tokens_n == 1 then
+ return tokens[1]
+ end
-local function load_consumer(consumer_id, anonymous)
- local result, err = kong.db.consumers:select { id = consumer_id }
- if not result then
- if anonymous and not err then
- err = 'anonymous consumer "' .. consumer_id .. '" not found'
- end
- return nil, err
- end
- return result
+ return tokens
end
-local function load_consumer_by_custom_id(custom_id)
- local result, err = kong.db.consumers:select_by_custom_id(custom_id)
- if not result then
- return nil, err
- end
- return result
-end
local function set_consumer(consumer, credential, token)
- local set_header = kong.service.request.set_header
- local clear_header = kong.service.request.clear_header
-
- if consumer and consumer.id then
- set_header(constants.HEADERS.CONSUMER_ID, consumer.id)
- else
- clear_header(constants.HEADERS.CONSUMER_ID)
- end
-
- if consumer and consumer.custom_id then
- kong.log.debug("found consumer " .. consumer.custom_id)
- set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id)
- else
- clear_header(constants.HEADERS.CONSUMER_CUSTOM_ID)
- end
-
- if consumer and consumer.username then
- set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username)
- else
- clear_header(constants.HEADERS.CONSUMER_USERNAME)
- end
-
- kong.client.authenticate(consumer, credential)
-
- if credential then
- kong.ctx.shared.authenticated_jwt_token = token -- TODO: wrap in a PDK function?
- ngx.ctx.authenticated_jwt_token = token -- backward compatibilty only
-
- if credential.username then
- set_header(constants.HEADERS.CREDENTIAL_USERNAME, credential.username)
- else
- clear_header(constants.HEADERS.CREDENTIAL_USERNAME)
- end
-
- clear_header(constants.HEADERS.ANONYMOUS)
-
- else
- clear_header(constants.HEADERS.CREDENTIAL_USERNAME)
- set_header(constants.HEADERS.ANONYMOUS, true)
- end
+ kong.client.authenticate(consumer, credential)
+
+ local set_header = kong.service.request.set_header
+ local clear_header = kong.service.request.clear_header
+
+ if consumer and consumer.id then
+ set_header(constants.HEADERS.CONSUMER_ID, consumer.id)
+ else
+ clear_header(constants.HEADERS.CONSUMER_ID)
+ end
+
+ if consumer and consumer.custom_id then
+ kong.log.debug("found consumer " .. consumer.custom_id)
+ set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id)
+ else
+ clear_header(constants.HEADERS.CONSUMER_CUSTOM_ID)
+ end
+
+ if consumer and consumer.username then
+ set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username)
+ else
+ clear_header(constants.HEADERS.CONSUMER_USERNAME)
+ end
+
+ if credential and credential.key then
+ set_header(constants.HEADERS.CREDENTIAL_IDENTIFIER, credential.key)
+ else
+ clear_header(constants.HEADERS.CREDENTIAL_IDENTIFIER)
+ end
+
+ if credential then
+ clear_header(constants.HEADERS.ANONYMOUS)
+ else
+ set_header(constants.HEADERS.ANONYMOUS, true)
+ end
+
+ ngx.ctx.authenticated_jwt_token = token -- backward compatibilty only
+ kong.ctx.shared.authenticated_jwt_token = token -- TODO: wrap in a PDK function?
end
-local function get_keys(well_known_endpoint, cafile)
- kong.log.debug('Getting public keys from keycloak')
- local keys, err = keycloak_keys.get_issuer_keys(well_known_endpoint, cafile)
- if err then
- return nil, err
- end
-
- local decoded_keys = {}
- for i, key in ipairs(keys) do
- decoded_keys[i] = jwt_decoder:base64_decode(key)
- end
- kong.log.debug('Number of keys retrieved: ' .. table.getn(decoded_keys))
- return {
- keys = decoded_keys,
- updated_at = socket.gettime(),
- }
+-------------------------------------------------------------------------------
+-- custom keycloak specific extension for the plugin "jwt-keycloak"
+-- --> This is for one of the main benefits when using this plugin
+--
+-- The extension of this plugin provides the possibility to enforce "matching"
+-- of consumer id from the token against the kong user object in the config
+-- in a very configurable way.
+-------------------------------------------------------------------------------
+local function custom_load_consumer_by_custom_id(custom_id)
+ local result, err = kong.db.consumers:select_by_custom_id(custom_id)
+ if not result then
+ return nil, err
+ end
+ return result
end
-local function validate_signature(conf, jwt, second_call)
- local issuer_cache_key = 'issuer_keys_' .. jwt.claims.iss
+local function custom_match_consumer(conf, jwt)
+ local consumer, err
+ local consumer_id = jwt.claims[conf.consumer_match_claim]
- local well_known_endpoint = keycloak_keys.get_wellknown_endpoint(conf.well_known_template, jwt.claims.iss)
- -- Retrieve public keys
- local public_keys, err = kong.cache:get(issuer_cache_key, nil, get_keys, well_known_endpoint, conf.cafile)
+ if conf.consumer_match_claim_custom_id then
+ local consumer_cache_key = get_consumer_custom_id_cache_key(consumer_id)
+ consumer, err = kong.cache:get(consumer_cache_key, nil, custom_load_consumer_by_custom_id, consumer_id, true)
+ else
+ local consumer_cache_key = kong.db.consumers:cache_key(consumer_id)
+ consumer, err = kong.cache:get(consumer_cache_key, nil, kong.client.load_consumer, consumer_id, true)
+ end
- if not public_keys then
- if err then
- kong.log.err(err)
- end
- return kong.response.exit(403, { message = "Unable to get public key for issuer" })
- end
+ if err then
+ kong.log.err(err)
+ end
- -- Verify signatures
- for _, k in ipairs(public_keys.keys) do
- if jwt:verify_signature(k) then
- kong.log.debug('JWT signature verified')
- return nil
- end
- end
+ if not consumer and not conf.consumer_match_ignore_not_found then
+ kong.log.debug("Unable to find consumer " .. consumer_id .." for token")
+ return false, { status = 401, message = "Unable to find consumer " .. consumer_id .." for token" }
+ end
- -- We could not validate signature, try to get a new keyset?
- local since_last_update = socket.gettime() - public_keys.updated_at
- if not second_call and since_last_update > conf.iss_key_grace_period then
- kong.log.debug('Could not validate signature. Keys updated last ' .. since_last_update .. ' seconds ago')
- kong.cache:invalidate_local(issuer_cache_key)
- return validate_signature(conf, jwt, true)
- end
+ if consumer then
+ set_consumer(consumer, nil, nil)
+ end
- return kong.response.exit(401, { message = "Invalid token signature" })
+ return true
end
-local function match_consumer(conf, jwt)
- local consumer, err
- local consumer_id = jwt.claims[conf.consumer_match_claim]
+-------------------------------------------------------------------------------
+-- Now again module names which also exist in original "jwt" kong OSS plugin
+-------------------------------------------------------------------------------
- if conf.consumer_match_claim_custom_id then
- local consumer_cache_key = get_consumer_custom_id_cache_key(consumer_id)
- consumer, err = kong.cache:get(consumer_cache_key, nil, load_consumer_by_custom_id, consumer_id, true)
+local function do_authentication(conf)
+ local token, err = retrieve_tokens(conf)
+ if err then
+ kong.log.err(err)
+ return kong.response.exit(500, { message = "An unexpected error occurred" })
+ end
+
+ local token_type = type(token)
+ if token_type ~= "string" then
+ if token_type == "nil" then
+ return false, { status = 401, message = "Unauthorized" }
+ elseif token_type == "table" then
+ return false, { status = 401, message = "Multiple tokens provided" }
else
- local consumer_cache_key = kong.db.consumers:cache_key(consumer_id)
- consumer, err = kong.cache:get(consumer_cache_key, nil, load_consumer, consumer_id, true)
+ return false, { status = 401, message = "Unrecognizable token" }
end
+ end
- if err then
- kong.log.err(err)
- end
+ -- Decode token to find out who the consumer is
+ local jwt, err = jwt_decoder:new(token)
+ if err then
+ return false, { status = 401, message = "Bad token; " .. tostring(err) }
+ end
- if not consumer and not conf.consumer_match_ignore_not_found then
- kong.log.debug("Unable to find consumer " .. consumer_id .." for token")
- return false, { status = 401, message = "Unable to find consumer " .. consumer_id .." for token" }
- end
-
- if consumer then
- set_consumer(consumer, nil, nil)
- end
-
- return true
-end
+ local claims = jwt.claims
+ local header = jwt.header
-local function do_authentication(conf)
- -- Retrieve token
- local token, err = retrieve_token(conf)
- if err then
- kong.log.err(err)
- return kong.response.exit(500, { message = "An unexpected error occurred" })
- end
-
- local token_type = type(token)
- if token_type ~= "string" then
- if token_type == "nil" then
- return false, { status = 401, message = "Unauthorized" }
- elseif token_type == "table" then
- return false, { status = 401, message = "Multiple tokens provided" }
- else
- return false, { status = 401, message = "Unrecognizable token" }
- end
- end
+ -- Verify that the issuer is allowed
+ if not validate_issuer(conf.allowed_iss, jwt.claims) then
+ return false, { status = 401, message = "Token issuer not allowed" }
+ end
- -- Decode token
- local jwt, err = jwt_decoder:new(token)
- if err then
- return false, { status = 401, message = "Bad token; " .. tostring(err) }
- end
+ local algorithm = conf.algorithm or "HS256"
- -- Verify algorithim
- if jwt.header.alg ~= (conf.algorithm or "HS256") then
- return false, {status = 403, message = "Invalid algorithm"}
- end
+ -- Verify "alg"
+ if jwt.header.alg ~= algorithm then
+ return false, { status = 403, message = "Invalid algorithm" }
+ end
- -- Verify the JWT registered claims
- local ok_claims, errors = jwt:verify_registered_claims(conf.claims_to_verify)
- if not ok_claims then
- return false, { status = 401, message = "Token claims invalid: " .. table_to_string(errors) }
- end
+ -- Now verify the JWT signature
+ err = custom_validate_token_signature(conf, jwt)
+ if err ~= nil then
+ return false, err
+ end
- -- Verify maximum expiration
- if conf.maximum_expiration ~= nil and conf.maximum_expiration > 0 then
- local ok, errors = jwt:check_maximum_expiration(conf.maximum_expiration)
- if not ok then
- return false, { status = 403, message = "Token claims invalid: " .. table_to_string(errors) }
- end
- end
+ -- Verify the JWT registered claims
+ local ok_claims, errors = jwt:verify_registered_claims(conf.claims_to_verify)
+ if not ok_claims then
+ return false, { status = 401, message = "Token claims invalid: " .. custom_helper_table_to_string(errors) }
+ end
- -- Verify that the issuer is allowed
- if not validate_issuer(conf.allowed_iss, jwt.claims) then
- return false, { status = 401, message = "Token issuer not allowed" }
- end
+ -- Verify maximum expiration
- err = validate_signature(conf, jwt)
- if err ~= nil then
- return false, err
+ -- Verify the JWT registered claims
+ if conf.maximum_expiration ~= nil and conf.maximum_expiration > 0 then
+ local ok, errors = jwt:check_maximum_expiration(conf.maximum_expiration)
+ if not ok then
+ return false, { status = 403, message = "Token claims invalid: " .. custom_helper_table_to_string(errors) }
end
+ end
- -- Match consumer
- if conf.consumer_match then
- local ok, err = match_consumer(conf, jwt)
- if not ok then
- return ok, err
- end
+ -- Match consumer
+ if conf.consumer_match then
+ local ok, err = custom_match_consumer(conf, jwt)
+ if not ok then
+ return ok, err
end
+ end
- -- Verify roles or scopes
- local ok, err = validate_scope(conf.scope, jwt.claims)
+ -- Verify roles or scopes
+ local ok, err = validate_scope(conf.scope, jwt.claims)
- if ok then
- ok, err = validate_realm_roles(conf.realm_roles, jwt.claims)
- end
+ if ok then
+ ok, err = validate_realm_roles(conf.realm_roles, jwt.claims)
+ end
- if ok then
- ok, err = validate_roles(conf.roles, jwt.claims)
- end
+ if ok then
+ ok, err = validate_roles(conf.roles, jwt.claims)
+ end
- if ok then
- ok, err = validate_client_roles(conf.client_roles, jwt.claims)
- end
+ if ok then
+ ok, err = validate_client_roles(conf.client_roles, jwt.claims)
+ end
- if ok then
- kong.ctx.shared.jwt_keycloak_token = jwt
- return true
- end
+ if ok then
+ kong.ctx.shared.jwt_keycloak_token = jwt
+ return true
+ end
- return false, { status = 403, message = "Access token does not have the required scope/role: " .. err }
+ return false, { status = 403, message = "Access token does not have the required scope/role: " .. err }
end
function JwtKeycloakHandler:access(conf)
- JwtKeycloakHandler.super.access(self)
+ -- check if preflight request and whether it should be authenticated
+ if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then
+ return
+ end
+
+ if conf.anonymous and kong.client.get_credential() then
+ -- we're already authenticated, and we're configured for using anonymous,
+ -- hence we're in a logical OR between auth methods and we're already done.
+ return
+ end
+
+ local ok, err = do_authentication(conf)
+ if not ok then
+ if conf.anonymous then
+ -- get anonymous user
+ local consumer_cache_key = kong.db.consumers:cache_key(conf.anonymous)
+ local consumer, err = kong.cache:get(consumer_cache_key, nil,
+ kong.client.load_consumer,
+ conf.anonymous, true)
+ if err then
+ kong.log.err(err)
+ return kong.response.exit(500, { message = "An unexpected error occurred during authentication" })
+ end
- -- check if preflight request and whether it should be authenticated
- if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then
- return
- end
+ set_consumer(consumer)
- if conf.anonymous and kong.client.get_credential() then
- -- we're already authenticated, and we're configured for using anonymous,
- -- hence we're in a logical OR between auth methods and we're already done.
- return
- end
-
- local ok, err = do_authentication(conf)
- if not ok then
- if conf.anonymous then
- -- get anonymous user
- local consumer_cache_key = kong.db.consumers:cache_key(conf.anonymous)
- local consumer, err = kong.cache:get(consumer_cache_key, nil,
- load_consumer,
- conf.anonymous, true)
- if err then
- kong.log.err(err)
- return kong.response.exit(500, { message = "An unexpected error occurred" })
- end
-
- set_consumer(consumer, nil, nil)
- else
- return kong.response.exit(err.status, err.errors or { message = err.message })
- end
+ else
+ return kong.response.exit(err.status, err.errors or { message = err.message })
end
+ end
end
+
return JwtKeycloakHandler
diff --git a/src/keycloak_keys.lua b/src/keycloak_keys.lua
index a1447c2..434dcee 100644
--- a/src/keycloak_keys.lua
+++ b/src/keycloak_keys.lua
@@ -67,4 +67,4 @@ return {
get_request = get_request,
get_issuer_keys = get_issuer_keys,
get_wellknown_endpoint = get_wellknown_endpoint,
-}
\ No newline at end of file
+}
diff --git a/src/schema.lua b/src/schema.lua
index 74ae5f6..9e93ae2 100644
--- a/src/schema.lua
+++ b/src/schema.lua
@@ -1,20 +1,62 @@
local typedefs = require "kong.db.schema.typedefs"
-return {
- name = "jwt-keycloak-endpoint",
+
+local PLUGIN_NAME = "jwt-keycloak"
+
+
+local schema = {
+ name = PLUGIN_NAME,
fields = {
{ consumer = typedefs.no_consumer },
+ { protocols = typedefs.protocols_http },
{ config = {
type = "record",
fields = {
- { uri_param_names = { type = "set", elements = { type = "string" }, default = { "jwt" }, }, },
- { cookie_names = { type = "set", elements = { type = "string" }, default = {} }, },
- { claims_to_verify = { type = "set", elements = { type = "string", one_of = { "exp", "nbf" }, }, default = { "exp" } }, },
- { anonymous = { type = "string", uuid = true, legacy = true }, },
- { run_on_preflight = { type = "boolean", default = true }, },
- { maximum_expiration = { type = "number", default = 0, between = { 0, 31536000 }, }, },
- { algorithm = { type = "string", default = "RS256" }, },
+ { uri_param_names = {
+ -- description = "A list of querystring parameters that Kong will inspect to retrieve JWTs.",
+ type = "set",
+ elements = { type = "string" },
+ default = { "jwt" },
+ }, },
+ { cookie_names = {
+ -- description = "A list of cookie names that Kong will inspect to retrieve JWTs.",
+ type = "set",
+ elements = { type = "string" },
+ default = {}
+ }, },
+ { claims_to_verify = {
+ -- description = "A list of registered claims (according to RFC 7519) that Kong can verify as well. Accepted values: one of exp or nbf.",
+ type = "set",
+ elements = {
+ type = "string",
+ one_of = { "exp", "nbf" },
+ },
+ default = { "exp" },
+ }, },
+ { anonymous = {
+ -- description = "An optional string (consumer UUID or username) value to use as an �anonymous� consumer if authentication fails.",
+ type = "string"
+ }, },
+ { run_on_preflight = {
+ -- description = "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.",
+ type = "boolean",
+ required = true,
+ default = true
+ }, },
+ { maximum_expiration = {
+ -- description = "A value between 0 and 31536000 (365 days) limiting the lifetime of the JWT to maximum_expiration seconds in the future.",
+ type = "number",
+ default = 0,
+ between = { 0, 31536000 },
+ }, },
+ { header_names = {
+ -- description = "A list of HTTP header names that Kong will inspect to retrieve JWTs.",
+ type = "set",
+ elements = { type = "string" },
+ default = { "authorization" },
+ }, },
+ { algorithm = { type = "string", default = "RS256" }, },
{ allowed_iss = { type = "set", elements = { type = "string" }, required = true }, },
{ iss_key_grace_period = { type = "number", default = 10, between = { 1, 60 }, }, },
{ well_known_template = { type = "string", default = "%s/.well-known/openid-configuration" }, },
@@ -33,4 +75,14 @@ return {
},
},
},
+ entity_checks = {
+ { conditional = {
+ if_field = "config.maximum_expiration",
+ if_match = { gt = 0 },
+ then_field = "config.claims_to_verify",
+ then_match = { contains = "exp" },
+ }, },
+ },
}
+
+return schema
diff --git a/src/validators/issuers.lua b/src/validators/issuers.lua
index 4fd8244..928658f 100644
--- a/src/validators/issuers.lua
+++ b/src/validators/issuers.lua
@@ -15,4 +15,4 @@ end
return {
validate_issuer = validate_issuer
-}
\ No newline at end of file
+}
diff --git a/src/validators/roles.lua b/src/validators/roles.lua
index 71d6272..24ad11b 100644
--- a/src/validators/roles.lua
+++ b/src/validators/roles.lua
@@ -67,4 +67,4 @@ return {
validate_client_roles = validate_client_roles,
validate_realm_roles = validate_realm_roles,
validate_roles = validate_roles
-}
\ No newline at end of file
+}
diff --git a/src/validators/scope.lua b/src/validators/scope.lua
index d260c01..5e72256 100644
--- a/src/validators/scope.lua
+++ b/src/validators/scope.lua
@@ -19,4 +19,4 @@ end
return {
validate_scope = validate_scope
-}
\ No newline at end of file
+}
diff --git a/tests/Makefile b/tests/Makefile
index 5893ddd..cb59e8f 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -6,7 +6,7 @@ _build-unit-test-image:
@docker build -q -t ${UNIT_TEST_IMAGE} --build-arg PLUGIN_VERSION=${PLUGIN_VERSION} --build-arg KONG_VERSION=${KONG_VERSION} -f unit_tests/Dockerfile ..
_tests-unit: _build-unit-test-image
- @docker run -it --rm --net=host -v ${PWD}:/jwt-keycloak:ro ${UNIT_TEST_IMAGE}
+ @docker run -it --rm --net host -v ${PWD}:/jwt-keycloak:ro ${UNIT_TEST_IMAGE}
_build-inte-test-image:
@@ -14,4 +14,4 @@ _build-inte-test-image:
@docker build -q -t ${INTE_TEST_IMAGE} -f integration_tests/Dockerfile ..
_tests-integration: _build-inte-test-image
- @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
\ No newline at end of file
+ @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
diff --git a/tests/integration_tests/Dockerfile b/tests/integration_tests/Dockerfile
index a7f969d..3c319fd 100644
--- a/tests/integration_tests/Dockerfile
+++ b/tests/integration_tests/Dockerfile
@@ -1,3 +1,4 @@
-FROM python:3.7.2-alpine
+FROM python:3.11-alpine
-RUN pip install requests
\ No newline at end of file
+RUN pip install --upgrade pip
+RUN pip install requests requests_toolbelt metadict
diff --git a/tests/integration_tests/tests/TestBasics.py b/tests/integration_tests/tests/TestBasics.py
index 11e674b..0e48ca0 100644
--- a/tests/integration_tests/tests/TestBasics.py
+++ b/tests/integration_tests/tests/TestBasics.py
@@ -1,13 +1,16 @@
from tests.utils import *
+
+# Tokendetails: "iss": "http://localhost:8080/auth/realms/master", "alg": "RS256" --> Already expired !!
STANDARD_JWT = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJObjlsNXctQ1lORHUwUGh6MTFoWUNqQ050MGJmb2ZMQjZMcGMtWk5hUkFFIn0.eyJqdGkiOiIwZDBlODEyMy1mNjIxLTQzZWQtOTBjZS0yNWNhZDZhOGQ0MGQiLCJleHAiOjE1MzY1NzgxOTQsIm5iZiI6MCwiaWF0IjoxNTM2NTc4MTM0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImIzY2RjZjcwLTljMDMtNDgwZi1hZGQwLTY4MWNkMzQyYWU1OCIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiIxMGMzZWFjNC1kNzlmLTQyOGYtYmVlMC1mNDk3MTEwNTY0NDgiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.cFOVC_tLfyTHXB0T8MMJHizVXhDfh36ZwA6BNA3Jhjm-s-_Kt4_acZtbC-jLoch2Q-A4LPGURpG48RgWfALNaRvv6R5rWwOJ3O94bsCVbsAcY7rw-UMEyWz8sO-VObJnHayybVsnfvLzKZaWCsWIRZaMsE9OtiFfRoWgqHOCqMxFl0YX_ugZGGKKfMDjO0-ie-zzRQeUKjKfNdeJSk7OcrlZp8rpP0J616AocWd_NZTiB6RIuP4zy6z28dYY4Pgw5o-_GyoGI7NyDZxTVQ17XzTl_MFV7pTD9pvYzSpGZevcSfMGh00NHdagq9qr7jF65NYuGmZuCn0jUs9TmtLezQ'
-NOT_FOUND_JWT = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLTFczaVNKQUNndkxvci1qMlAzWFdGVXZBR1dTakdGWlZ2TUNmcDhITHdnIn0.eyJqdGkiOiI1MjA5MDA1Yi02NjI2LTQ4Y2MtODg0Mi1mYzQ4MWNhZTI0MzkiLCJleHAiOjE2MjI5ODE5ODMsIm5iZiI6MCwiaWF0IjoxNTM2NTgxOTgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvTk9UX0ZPVU5EIiwiYXVkIjoidGVzdCIsInN1YiI6ImVlMTMwMWY0LTJlNmYtNGEwYi1hYjQ0LTgzNzExZGE3NWRkNyIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI4YWQ2MDNkMy1kYmYwLTQ4NmQtYWYyOC1hNmI2ZDJlODcxYzIiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.q5Sf3oL3i4dexGo8F7Qm4g4yzxApaAqbve1ijENg__08h_M6CBYf1b6J9bRrr4qriDWlyFW7N6nmyK5jyBMSVA_2oRoCxSFf4wUigjO1RcnMHC9w5Gd0DNfsA21kiNE2OiEEc-YNM5J7MXTOo5ueO79E8-8eVKjs25QipfZ_wF01MAF6bKKjVhOytBvRwUqPrxZ_37AhMcPEtcdSs0rvnKioZnNqQPTutNzvwfTwNO7neWI4RSJdSKJlx_yfxZbLHDtXRCPlqCfBFeiL1VfbJlpB-sRgAe7Lf8Mp28G_mAIzi2lm639QFjFZOLs8ykytmJSCdfsrwJZ6PO0kZBzAlw'
+# Tokendetails: "iss": "http://localhost:8080/auth/realms/master", "alg": "RS256",
BAD_SIGNATURE = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJObjlsNXctQ1lORHUwUGh6MTFoWUNqQ050MGJmb2ZMQjZMcGMtWk5hUkFFIn0.eyJqdGkiOiI0NTQwMGZiNi01MTE0LTRkNWUtOTNkOC1jYjgzYjM0MDFjMjMiLCJleHAiOjE2MjI5ODI4NjAsIm5iZiI6MCwiaWF0IjoxNTM2NTgyODYwLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImIzY2RjZjcwLTljMDMtNDgwZi1hZGQwLTY4MWNkMzQyYWU1OCIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiJiNTNjNmZhZC0xYWJjLTRmMjYtOGUzNi01MDhkOTdjMTI4NmEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRJZCI6InRlc3QiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.PtpAE8sCkSWuosm7chw_TH2qAQuRIugP-1688WtZ9ZpkrulZ1OxxfAtnJY1eCYk0C4LQd14eI5d-1srim96FGdgG0BKq4T0TknG5JgQsPignMy2JnJWz-ZozO8a6FMLfpGT0hUQyiDbLRs3VES8RV3N_2uxl0ihy_tJ_wvCU0GrBF5-e2z4R-99zWuOpPbDvnDlP6YfCxLsp77ng4HYB1rBSG9100mpkTBsL8Q48HBZk_qAVdHhGRxqTXDEMYPd3gsKNu184DAsE0I1Ea9D0QXijvH7SVoUJvmZwQ0hOtg1bzWxIeIW1sVDqshkaG58kkiomG7G-9RzKrWOxg3lyQ'
-LONG_LASTING = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvQ084ZEZ3RWxNdVNtU2d3bXlhN05iMlJNamZOQ3pxTTVrLVozMDFZUUZRIn0.eyJqdGkiOiJiN2QwZGIyOS00ZTY0LTRkNTgtOTM5Ni1hMTBjNmI2MDUyODYiLCJleHAiOjE2MjI5OTE3MjgsIm5iZiI6MCwiaWF0IjoxNTM2NTkxNzI4LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoidGVzdCIsInN1YiI6ImQ5YWU5NzJiLTVmYzEtNDFjNC1iN2I0LWRmMDE4NDYyZmFiZiIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI0ODc1N2I5MS0wOTQ0LTRkNzEtOGFmZS0zZDQ3MGI5MDc0MjciLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGVzdCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiY2xpZW50SWQiOiJ0ZXN0IiwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtdGVzdCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiZW1haWwiOiJzZXJ2aWNlLWFjY291bnQtdGVzdEBwbGFjZWhvbGRlci5vcmcifQ.GH9Qof8bJk--mZW6SCXKRKeHV39Qhin9QqBAdDqNQmABDnbmGN9vDaceowHUs6M5Yr4In2lGAWVGjRH_k6eajvZCVjZHQVELLzjjwEDrL-syIImYYT2VG0TV4pJ3K8VD0-M_aAbmeYXA9kndk3bsM157nPu-cH0XvDTzJTra2IVJc9LSXxOD26XQDzOp0Hs5VObyDDnZJscnfcq_OmnfrNjN6h1TujUqtMIw_ZEYtG64R9aRG2QF2rwLuX6ED4OoX23AjqfjOH21AoIXYRwp_RtxDB57U-dX-vSY9MfdHAb7FnEKNE2ciPOZ_2zyj6RdAMY5IkDOFyqmS-nIqLQRTA'
-
class TestBasics(unittest.TestCase):
+ ############################################################################
+ # Test if plugin denies requests if completely no token is send to the
+ # kong instance .. it needs to fail
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
@@ -16,29 +19,53 @@ def test_no_auth(self, status, body):
self.assertEqual(UNAUTHORIZED, status)
self.assertEqual('Unauthorized', body.get('message'))
+ ############################################################################
+ # Test if plugin allows preflight access without token when configured
+ # ... request is without any authentication contained
+ @create_api({
+ 'run_on_preflight': False,
+ 'allowed_iss': ['http://localhost:8080/auth/realms/master']
+ })
+ @call_api(method='options')
+ def test_preflight_success(self, status, body):
+ self.assertEqual(OK, status)
+
+ ############################################################################
+ # Test if plugin denies by default preflight requests in a unauthenticated
+ # way ... It needs to fail
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
@call_api()
- def test_preflight_rainy(self, status, body):
+ def test_preflight_failure(self, status, body):
self.assertEqual(UNAUTHORIZED, status)
self.assertEqual('Unauthorized', body.get('message'))
+ ############################################################################
+ # Test if plugin denies a request param "jwt" which contains no valid token
+ # --> It needs to be denied
@create_api({
- 'run_on_preflight': False,
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
- @call_api(method='options')
- def test_preflight(self, status, body):
- self.assertEqual(OK, status)
+ @call_api(params={"jwt": "SomeNonSenseJwtTokenValue.1234"})
+ def test_bad_token_as_param(self, status, body):
+ self.assertEqual(UNAUTHORIZED, status)
+ ############################################################################
+ # Test if plugin accepts a request param "jwt" a valid token
+ # --> It needs to be allowed
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
- @call_api(params={"jwt": 1234})
- def test_bad_token(self, status, body):
- self.assertEqual(UNAUTHORIZED, status)
+ @authenticate() # Get current requested token
+ @call_api(authentication_type={"queryparam":"jwt"})
+ def test_good_token_as_param(self, status, body):
+ self.assertEqual(OK, status)
+ ############################################################################
+ # Test if plugin denies requests if token contains a different algorithm
+ # as it is configured for the plugin.
+ # Test-Token "STANDARD_JWT" contains 'algorithm': 'RS256'
@create_api({
'algorithm': 'HS256',
'allowed_iss': ['http://localhost:8080/auth/realms/master']
@@ -48,31 +75,35 @@ def test_invalid_algorithm(self, status, body):
self.assertEqual(FORBIDDEN, status)
self.assertEqual('Invalid algorithm', body.get('message'))
- @create_api({
- 'allowed_iss': ['http://localhost:8080/auth/realms/master']
- })
- @call_api(token=STANDARD_JWT)
- def test_invalid_exp(self, status, body):
- self.assertEqual(UNAUTHORIZED, status)
- self.assertEqual('Token claims invalid: ["exp"]="token expired"', body.get('message'))
+ ############################################################################
+ # Test if plugin denies requests if token is issued by a different "iss"
+ # Token is only valid for "master" realm
@create_api({
- 'allowed_iss': ['http://localhost:8080/auth/realms/NOT_FOUND']
+ 'allowed_iss': ['http://localhost:8080/auth/realms/somethingElseThenMaster']
})
- @call_api(token=NOT_FOUND_JWT)
+ @authenticate() # Use current requested token
+ @call_api()
def test_invalid_iss(self, status, body):
- self.assertEqual(FORBIDDEN, status)
- self.assertEqual('Unable to get public key for issuer', body.get('message'))
+ self.assertEqual(UNAUTHORIZED, status)
+ self.assertEqual('Token issuer not allowed', body.get('message'))
+
+ ############################################################################
+ # Test if plugin denies requests if token is more then 10 minutes valid
+ # (in this setup here all fresh requested tokens are 20 minutes valid)
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
- 'maximum_expiration': 5000
+ 'maximum_expiration': 600
})
- @call_api(token=LONG_LASTING)
+ @authenticate() # Use current requested token
+ @call_api()
def test_max_exp(self, status, body):
self.assertEqual(FORBIDDEN, status)
self.assertEqual('Token claims invalid: ["exp"]="exceeds maximum allowed expiration"', body.get('message'))
+ ############################################################################
+ # Test if plugin denies requests if token contains a bad signature
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
@@ -80,3 +111,16 @@ def test_max_exp(self, status, body):
def test_bad_signature(self, status, body):
self.assertEqual(UNAUTHORIZED, status)
self.assertEqual('Bad token; invalid signature', body.get('message'))
+
+ ############################################################################
+ # Test if plugin denies requests if token is already expired
+ #
+ # !! Execute this as last test .. it uses a short living token which
+ # was at the beginning of this test cases requested.
+ @create_api({
+ 'allowed_iss': ['http://localhost:8080/auth/realms/master']
+ })
+ @call_api(token=TD_TOKEN_EXPIRED)
+ def test_invalid_exp(self, status, body):
+ self.assertEqual(UNAUTHORIZED, status)
+ self.assertEqual('Token claims invalid: ["exp"]="token expired"', body.get('message'))
diff --git a/tests/integration_tests/tests/TestConsumerMapping.py b/tests/integration_tests/tests/TestConsumerMapping.py
index 0002638..222d562 100644
--- a/tests/integration_tests/tests/TestConsumerMapping.py
+++ b/tests/integration_tests/tests/TestConsumerMapping.py
@@ -4,6 +4,9 @@
class TestConsumerMapping(unittest.TestCase):
TMP_CUSTOM_ID = str(uuid.uuid4())
+ ############################################################################
+ # Test if plugin sends header "x-consumer-id" to the upstream service
+ #
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'consumer_match': True
@@ -12,8 +15,13 @@ class TestConsumerMapping(unittest.TestCase):
@call_api()
def test_map_consumer(self, status, body):
self.assertEqual(OK, status)
- self.assertEqual(1, len([h['value'] for h in body.get('headers') if h['name'] == 'x-consumer-id']))
+ self.assertGreater(len(parse_json_response(parse_json_response(body, "headers"), "x-consumer-id")), 1,
+ "x-consumer-id seems to be empty but is a must for the request to upstream in case authentication was successfully." )
+
+ ############################################################################
+ # Test if plugin sends header "x-consumer-custom-id" to the upstream service
+ # which needs to contain the same value like we have kong configured ...
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'consumer_match': True,
@@ -24,8 +32,15 @@ def test_map_consumer(self, status, body):
def test_map_consumer_custom_id(self, status, body):
self.assertEqual(OK, status)
self.assertEqual([self.TMP_CUSTOM_ID],
- [h['value'] for h in body.get('headers') if h['name'] == 'x-consumer-custom-id'])
+ [parse_json_response(parse_json_response(body, "headers"), "x-consumer-custom-id")])
+
+ ############################################################################
+ # Test if plugin respects the setting of "consumer_match_ignore_not_found"
+ # and forwards the request also to the upstream service if there is no
+ # user found in kong which is equal with the value of the token claim
+ # "preferred_username"
+ #
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'consumer_match': True,
diff --git a/tests/integration_tests/tests/TestRoles.py b/tests/integration_tests/tests/TestRolesAndScopes.py
similarity index 63%
rename from tests/integration_tests/tests/TestRoles.py
rename to tests/integration_tests/tests/TestRolesAndScopes.py
index 26a4ed9..218cf9b 100644
--- a/tests/integration_tests/tests/TestRoles.py
+++ b/tests/integration_tests/tests/TestRolesAndScopes.py
@@ -1,17 +1,22 @@
from tests.utils import *
-
class TestRoles(unittest.TestCase):
+ ############################################################################
+ # Test if plugin allows requests if valid token is send to the
+ # kong instance .. it needs to work
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master']
})
@authenticate()
@call_api()
- def test_no_auth(self, status, body):
+ def test_with_valid_token_ok(self, status, body):
self.assertEqual(OK, status)
- @skip("Need to update tests")
+ ############################################################################
+ # Starting from here the "roles" tests happen
+ # TODO: Need to investigate what is the exact logic behind "roles"
+
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'roles': ['test_role']
@@ -45,6 +50,9 @@ def test_roles_auth_double(self, status, body):
self.skipTest("Test not supported for " + KC_VERSION)
self.assertEqual(OK, status)
+ ############################################################################
+ # Starting from here the "realm_roles" tests happen
+
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'realm_roles': ['uma_authorization']
@@ -73,6 +81,12 @@ def test_realm_roles_auth_rainy(self, status, body):
def test_realm_roles_auth_double(self, status, body):
self.assertEqual(OK, status)
+ ############################################################################
+ # Starting from here the "client_roles" tests happen
+
+ ############################################################################
+ # Test if plugin allows the request if role matches
+ # ... here "account:manage-account" is the valid role
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'client_roles': ['account:manage-account']
@@ -82,6 +96,9 @@ def test_realm_roles_auth_double(self, status, body):
def test_client_roles_auth(self, status, body):
self.assertEqual(OK, status)
+ ############################################################################
+ # Test if plugin blocks the request if claim exists, but role is not
+ # contained in the claim
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'client_roles': ['account:manage-something-else']
@@ -92,25 +109,37 @@ def test_client_roles_auth_rainy(self, status, body):
self.assertEqual(FORBIDDEN, status)
self.assertEqual('Access token does not have the required scope/role: Missing required role', body.get('message'))
+ ############################################################################
+ # Test if plugin allows the request if minimum one role matches
+ # ... here "account:manage-account" is the valid role
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
- 'client_roles': ['account:manage-account', 'account:manage-something-else']
+ 'client_roles': ['dummy:whatever_setting', 'account:manage-account', 'account:manage-something-else']
})
@authenticate()
@call_api()
- def test_client_roles_auth_double(self, status, body):
+ def test_client_roles_multiple_roles_oneof(self, status, body):
self.assertEqual(OK, status)
+ ############################################################################
+ # Test if plugin blocks request if needed role is not contained in token
+ # Worst case check for things which are in no token claim existing at all.
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'client_roles': ['user:do-user-stuff']
})
@authenticate()
@call_api()
- def test_client_roles_auth(self, status, body):
+ def test_client_roles_unknown_claim(self, status, body):
self.assertEqual(FORBIDDEN, status)
self.assertEqual('Access token does not have the required scope/role: Missing required role', body.get('message'))
+ ############################################################################
+ # Starting from here the "scope" tests happen
+
+ ############################################################################
+ # Test if plugin allows the request if minimum one scope matches
+ #
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'scope': ['email']
@@ -122,17 +151,25 @@ def test_client_scope(self, status, body):
self.skipTest("Test not supported for " + KC_VERSION)
self.assertEqual(OK, status)
+
+ ############################################################################
+ # Test if plugin allows the request if minimum one scope matches
+ # ... here "email" is the valid scope
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
- 'scope': ['email', 'not_found']
+ 'scope': ['not_found', 'something_else', 'email', 'nemore_dummyscope']
})
@authenticate()
@call_api()
- def test_client_scope_double(self, status, body):
+ def test_client_multiple_scopes_oneof(self, status, body):
if KC_VERSION.startswith('3'):
self.skipTest("Test not supported for " + KC_VERSION)
self.assertEqual(OK, status)
+
+ ############################################################################
+ # Test if plugin blocks request if needed scope is not contained in token
+ #
@create_api({
'allowed_iss': ['http://localhost:8080/auth/realms/master'],
'scope': ['not_found']
diff --git a/tests/integration_tests/tests/config.py b/tests/integration_tests/tests/config.py
index 511773e..5353acc 100644
--- a/tests/integration_tests/tests/config.py
+++ b/tests/integration_tests/tests/config.py
@@ -1,6 +1,10 @@
import os
-import requests
+# global variables for all the test cases
+setup_done = False
+
+TD_TOKEN_EXPIRED = None
+KC_ADMIN_TOKEN = None
CLIENT_ID = os.environ.get("CLIENT_ID", "test")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "c0bc799c-4dfc-4841-af01-0f1a00171c32")
@@ -12,17 +16,3 @@
KC_PASS = os.environ.get("KC_PASS", "admin")
KC_HOST = os.environ.get("KC_HOST", "http://localhost:8080/auth")
KC_REALM = KC_HOST + "/realms/master"
-
-r = requests.post(KC_REALM + "/protocol/openid-connect/token", data={
- 'grant_type': 'password',
- 'client_id': 'admin-cli',
- 'username': KC_USER,
- 'password': KC_PASS
-})
-
-assert r.status_code == 200
-KC_ADMIN_TOKEN = r.json()['access_token']
-
-r = requests.get(KC_HOST + '/admin/serverinfo', headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN})
-assert r.status_code == 200
-KC_VERSION = r.json()['systemInfo']['version']
diff --git a/tests/integration_tests/tests/setup.py b/tests/integration_tests/tests/setup.py
new file mode 100644
index 0000000..b116698
--- /dev/null
+++ b/tests/integration_tests/tests/setup.py
@@ -0,0 +1,65 @@
+import requests
+
+# This way how to import is really a hacky bad way, but i hat not found a way
+# to have the variables really as global variable available in all modules
+# I am not python developer ... it think this is now visible without any doubt ...
+# Help how to rework it .... in the good way ... really would be welcome
+import tests.config
+from tests.config import *
+
+from requests_toolbelt.utils import dump
+
+# helper functions
+def logging_hook(logtext, *args, **kwargs):
+ data = dump.dump_all(logtext)
+ print(data.decode('utf-8'))
+
+def keyclock_adjust_accessTokenLifespan(seconds):
+ # Set token-setting to issue tokens with different valid time periods
+ r = requests.put(KC_HOST + "/admin/realms/master",
+ headers={'authorization': 'Bearer ' + KC_ADMIN_TOKEN, 'content-type': "application/json"},
+ json={'accessTokenLifespan': str(seconds)})
+ assert r.status_code == 204
+
+############################################################################
+# Now initialize the required testdata
+#
+# Set token-setting to issue tokens for only 1 second valid for user-tokens to be able to request
+# at the beginning a token which is issued by current keycloak instance .. but directly will be expired
+# when it is used in test-cases
+if not setup_done:
+ # Request directly token with these settings
+ r = requests.post(KC_REALM + "/protocol/openid-connect/token", data={
+ 'grant_type': 'password',
+ 'client_id': 'admin-cli',
+ 'username': KC_USER,
+ 'password': KC_PASS
+ })
+ assert r.status_code == 200
+ if KC_ADMIN_TOKEN is None:
+ KC_ADMIN_TOKEN = r.json()['access_token']
+ else:
+ print("-------------- already existing KC_ADMIN_TOKEN -------------------")
+
+ # Not set keycloak to issue only 1 second valid tokens
+ keyclock_adjust_accessTokenLifespan(1)
+ r = requests.post(KC_REALM + "/protocol/openid-connect/token", data={
+ 'grant_type': 'password',
+ 'client_id': 'admin-cli',
+ 'username': KC_USER,
+ 'password': KC_PASS
+ })
+ if TD_TOKEN_EXPIRED is None:
+ TD_TOKEN_EXPIRED = r.json()['access_token']
+ else:
+ print("-------------- already existing TD_TOKEN_EXPIRED -------------------")
+ setup_done = True
+
+# Change keycloak back to issue tokens 20 minutes valid
+if setup_done:
+ keyclock_adjust_accessTokenLifespan(1200)
+
+# And now ask Keycloak about the running version information
+r = requests.get(KC_HOST + '/admin/serverinfo', headers={'Authorization': 'Bearer ' + KC_ADMIN_TOKEN})
+assert r.status_code == 200
+KC_VERSION = r.json()['systemInfo']['version']
diff --git a/tests/integration_tests/tests/utils.py b/tests/integration_tests/tests/utils.py
index 2183103..9bf751e 100644
--- a/tests/integration_tests/tests/utils.py
+++ b/tests/integration_tests/tests/utils.py
@@ -2,8 +2,13 @@
import time
import unittest
import uuid
+import json
-from tests.config import *
+import tests.config
+from tests.setup import *
+
+from requests_toolbelt.utils import dump
+from metadict import MetaDict
OK = 200
CREATED = 201
@@ -11,6 +16,18 @@
UNAUTHORIZED = 401
FORBIDDEN = 403
+def logging_hook(logtext, *args, **kwargs):
+ data = dump.dump_all(logtext)
+ print(data.decode('utf-8'))
+
+def parse_json_response(jsoninput, searchkey=None):
+ jsoninput_dict = MetaDict(jsoninput)
+ if searchkey is not None:
+ jsoninput_dict_lower_keys = {k.lower():v for k,v in jsoninput_dict.items()}
+ if searchkey.lower() in jsoninput_dict_lower_keys:
+ return jsoninput_dict_lower_keys.get(searchkey.lower())
+ ## TODO: Add handler for non jsoninput and handeled exceptions if searchkey is not found
+
def get_kong_version():
r = requests.get(KONG_ADMIN)
@@ -70,13 +87,18 @@ def wrapper(*args, **kwargs):
api_name = "test" + str(random.randint(1, 1000000))
r = requests.post(KONG_ADMIN + "/services", data={
"name": api_name,
- "url": "http://mockbin.org/headers"
+ "url": "http://localhost:8093/anything"
})
assert r.status_code == CREATED
r = requests.post(KONG_ADMIN + "/services/" + api_name + "/routes", data={
"name": api_name,
"paths[]": "/" + api_name
})
+ # If you face problems in unit tests you can uncomment this line
+ # to see the raw data
+ # print("--------------------Debugging Start--------------------")
+ # print(logging_hook(r))
+ # print("--------------------Debugging End----------------------")
assert r.status_code == CREATED
r = requests.post(KONG_ADMIN + "/services/" + api_name + "/plugins", json={
"name": "jwt-keycloak",
@@ -84,7 +106,9 @@ def wrapper(*args, **kwargs):
})
assert r.status_code == expected_response
kwargs['api_endpoint'] = KONG_API + "/" + api_name
- time.sleep(1)
+ # Wait a few seconds until kong has the changed configuration online
+ # (Otherwise http 404 is returned)
+ time.sleep(5)
result = func(*args, **kwargs)
return result
@@ -93,23 +117,47 @@ def wrapper(*args, **kwargs):
return real_decorator
-def call_api(token=None, method='get', params=None, endpoint=None):
+def call_api(token=None, method='get', params=None, endpoint=None, authentication_type=None):
def real_decorator(func):
def wrapper(*args, **kwargs):
- if token is not None:
- headers = {"Authorization": "Bearer " + token}
- elif kwargs.get('token') is not None:
- headers = {"Authorization": "Bearer " + kwargs.get('token')}
+ authentication_type_json = json.dumps(authentication_type)
+ if json.dumps(params) is not None:
+ params_dict = json.loads(json.dumps(params))
+
+ # Use Token Header Authentication if no query param is specified
+ if "queryparam" not in authentication_type_json:
+ if token is not None:
+ headers = {"Authorization": "Bearer " + token}
+ elif kwargs.get('token') is not None:
+ headers = {"Authorization": "Bearer " + kwargs.get('token')}
+ else:
+ headers = None
+ # Use query params to add token
else:
+ authentication_type_dict = json.loads(authentication_type_json)
headers = None
+ if params_dict is None:
+ params_dict = {}
+ if token is not None:
+ params_dict[authentication_type_dict['queryparam']] = token
+ if kwargs.get('token') is not None:
+ params_dict[authentication_type_dict['queryparam']] = kwargs.get('token')
if endpoint is None:
e = kwargs['api_endpoint']
else:
e = endpoint
- r = requests.request(method, e, params=params, headers=headers)
- result = func(*args, r.status_code, r.json())
+ r = requests.request(method, e, params=params_dict, headers=headers)
+ # If you face problems in unit tests you can uncomment this line
+ # to see the raw data
+ # print("--------------------Debugging Start--------------------")
+ # print(logging_hook(r))
+ # print("--------------------Debugging End----------------------")
+ try:
+ result = func(*args, r.status_code, r.json())
+ except ValueError: # When response body is empty / no json, then return None
+ result = func(*args, r.status_code, None)
return result
return wrapper
@@ -138,7 +186,11 @@ def create_client(client_id, **kwargs):
'publicClient': False,
'enabled': True
})
-
+ # If you face problems in unit tests you can uncomment this line
+ # to see the raw data
+ # print("--------------------Debugging Start--------------------")
+ # print(logging_hook(r))
+ # print("--------------------Debugging End----------------------")
assert r.status_code == 201
r = requests.post(KC_HOST + "/admin/realms/master/clients/" + id + '/roles',
diff --git a/tests/unit_tests/Dockerfile b/tests/unit_tests/Dockerfile
index 40d78f3..7b582c9 100644
--- a/tests/unit_tests/Dockerfile
+++ b/tests/unit_tests/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.12
+FROM alpine:3.18
ENV LUA_VERSION=5.1.5
ENV LUAROCKS_VERSION=3.4.0
@@ -8,6 +8,7 @@ ENV OPENRESTY_PUB_KEY="http://openresty.org/package/admin@openresty.com-5ea678a6
RUN apk add --no-cache \
ca-certificates \
openssl \
+ wget \
curl \
unzip \
gcc \
@@ -43,6 +44,10 @@ RUN wget -c https://luarocks.github.io/luarocks/releases/luarocks-${LUAROCKS_VER
&& cd .. \
&& rm -rf luarocks-${LUAROCKS_VERSION}
+# Workaround problem with old unencrypted github port 9148
+# in dependencies of old versions of some lua modules
+RUN git config --global url."https://github.com/".insteadOf git://github.com/
+
# Install kong and busted
RUN luarocks install busted
ARG KONG_VERSION
diff --git a/tests/unit_tests/tests/.busted b/tests/unit_tests/tests/.busted
index 6fecac3..2d618c7 100644
--- a/tests/unit_tests/tests/.busted
+++ b/tests/unit_tests/tests/.busted
@@ -4,4 +4,4 @@ return {
coverage = false,
-- output = "gtest",
},
-}
\ No newline at end of file
+}
diff --git a/tests/unit_tests/tests/validators_scope_spec.lua b/tests/unit_tests/tests/validators_scope_spec.lua
index 8c2359c..7dbae9b 100644
--- a/tests/unit_tests/tests/validators_scope_spec.lua
+++ b/tests/unit_tests/tests/validators_scope_spec.lua
@@ -55,4 +55,4 @@ describe("Validator", function()
assert.same("Missing required scope", err)
end)
end)
-end)
\ No newline at end of file
+end)