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)