diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 107ebe4..bdddf52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,5 +27,27 @@ jobs: - uses: actions/checkout@v4 with: submodules: "true" + + - name: Show Docker info + run: | + docker --version + docker compose version + - name: Run Tests run: docker compose build --no-cache && docker compose up --exit-code-from tests tests + + - name: Show container status + if: failure() + run: docker compose ps + + - name: Show Kong logs + if: failure() + run: docker compose logs kong + + - name: Show Keycloak logs + if: failure() + run: docker compose logs kc + + - name: Show test logs + if: failure() + run: docker compose logs tests diff --git a/Dockerfile b/Dockerfile index f214e97..acd8363 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ RUN if [ -x "$(command -v apk)" ]; then apk add --no-cache $FIX_DEPENDENCIES; \ elif [ -x "$(command -v apt-get)" ]; then apt-get remove --purge -y $FIX_DEPENDENCIES; \ fi -ARG PLUGIN_VERSION=1.6.0-1 +ARG PLUGIN_VERSION=1.7.0-1 RUN luarocks install /tmp/kong-plugin-jwt-keycloak-${PLUGIN_VERSION}.all.rock USER kong diff --git a/README.md b/README.md index 87e5435..9ac6bd2 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ luarocks install kong-plugin-jwt-keycloak #### Packing the rock ```bash -export PLUGIN_VERSION=1.6.0-1 +export PLUGIN_VERSION=1.7.0-1 luarocks make luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION} ``` @@ -96,7 +96,7 @@ luarocks pack kong-plugin-jwt-keycloak ${PLUGIN_VERSION} #### Installing the rock ```bash -export PLUGIN_VERSION=1.6.0-1 +export PLUGIN_VERSION=1.7.0-1 luarocks install jwt-keycloak-${PLUGIN_VERSION}.all.rock ``` diff --git a/docker-compose.yml b/docker-compose.yml index eeb8cd4..4211ebd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ x-kong-image: &kong-image dockerfile: Dockerfile args: KONG_VERSION: ${KONG_VERSION:-3.9.1} - PLUGIN_VERSION: 1.6.0-1 + PLUGIN_VERSION: 1.7.0-1 environment: KONG_DATABASE: postgres KONG_PG_HOST: postgres @@ -30,6 +30,8 @@ services: depends_on: - postgres entrypoint: [ "/bin/sh", "-c", "until kong migrations bootstrap; do echo waiting for database; sleep 2; done;" ] + networks: + - kong-net kong: <<: *kong-image depends_on: @@ -39,6 +41,8 @@ services: ports: - "8000:8000" - "8001:8001" + networks: + - kong-net postgres: image: postgres:16 environment: @@ -47,6 +51,8 @@ services: POSTGRES_PASSWORD: kong volumes: - pg_data:/var/lib/postgresql/data + networks: + - kong-net kc-pg: image: postgres:16 environment: @@ -55,6 +61,8 @@ services: POSTGRES_PASSWORD: postgres volumes: - pg_kc_data:/var/lib/postgresql/data + networks: + - kong-net kc: image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION:-26.2} depends_on: @@ -75,6 +83,8 @@ services: - KC_HOSTNAME_STRICT=false - KC_HTTP_RELATIVE_PATH=/auth - KC_HTTP_ENABLED=true + networks: + - kong-net tests: build: @@ -91,12 +101,27 @@ services: environment: - LUA_PATH=/opt/kong-plugin-jwt-keycloak/src/?.lua;/opt/kong-plugin-jwt-keycloak/?.lua;; - LUA_CPATH=/usr/local/lib/lua/5.1/?.so;; + - HTTP_PROXY= + - HTTPS_PROXY= + - NO_PROXY="localhost,127.0.0.1" + - http_proxy= + - https_proxy= + - no_proxy="localhost,127.0.0.1" + networks: + - kong-net entrypoint: [ "/bin/sh", "-c", "cd /opt/tests && /bin/sh ./run_tests.sh" ] httpbin: image: mccutchen/go-httpbin:2.18.3 ports: - "8080" + networks: + - kong-net + +networks: + kong-net: + driver: bridge + volumes: pg_data: pg_kc_data: diff --git a/kong-plugin-jwt-keycloak-1.6.0-1.rockspec b/kong-plugin-jwt-keycloak-1.7.0-1.rockspec similarity index 76% rename from kong-plugin-jwt-keycloak-1.6.0-1.rockspec rename to kong-plugin-jwt-keycloak-1.7.0-1.rockspec index c2982f6..578cce3 100644 --- a/kong-plugin-jwt-keycloak-1.6.0-1.rockspec +++ b/kong-plugin-jwt-keycloak-1.7.0-1.rockspec @@ -1,6 +1,6 @@ local plugin_name = "jwt-keycloak" local package_name = "kong-plugin-" .. plugin_name -local package_version = "1.6.0" +local package_version = "1.7.0" local rockspec_revision = "1" local github_account_name = "telekom" @@ -38,8 +38,9 @@ build = { ["kong.plugins."..plugin_name..".keycloak_keys"] = "src/keycloak_keys.lua", ["kong.plugins."..plugin_name..".key_conversion"] = "src/key_conversion.lua", ["kong.plugins."..plugin_name..".gateway.securitylog"] = "src/gateway/securitylog.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", + ["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", + ["kong.plugins."..plugin_name..".validators.signature"] = "src/validators/signature.lua", } } \ No newline at end of file diff --git a/kong-plugin-jwt-keycloak-1.6.0-1.rockspec.license b/kong-plugin-jwt-keycloak-1.7.0-1.rockspec.license similarity index 100% rename from kong-plugin-jwt-keycloak-1.6.0-1.rockspec.license rename to kong-plugin-jwt-keycloak-1.7.0-1.rockspec.license diff --git a/spec/01-unit/keycloak_keys_spec.lua b/spec/01-unit/keycloak_keys_spec.lua index 600f100..a3c9b74 100644 --- a/spec/01-unit/keycloak_keys_spec.lua +++ b/spec/01-unit/keycloak_keys_spec.lua @@ -54,4 +54,24 @@ describe("Plugin: jwt-keycloak (keycloak_keys)", function() assert.is_function(keycloak_keys.get_request) end) end) + + describe("get_issuer_keys", function() + it("should return keys and aligned kids from JWKS", function() + local well_known_endpoint = "https://keycloak.example.com/auth/realms/test/.well-known/openid-configuration" + + local keys, kids, err, key_metadata = keycloak_keys.get_issuer_keys(well_known_endpoint) + + assert.is_nil(err) + assert.is_table(keys) + assert.is_table(kids) + assert.is_table(key_metadata) + assert.equals(2, #keys) + assert.equals(2, #kids) + assert.equals(2, #key_metadata) + assert.same({ "kid1", "kid2" }, kids) + -- Verify metadata structure + assert.is_table(key_metadata[1]) + assert.is_table(key_metadata[2]) + end) + end) end) \ No newline at end of file diff --git a/spec/01-unit/validators/signature_spec.lua b/spec/01-unit/validators/signature_spec.lua new file mode 100644 index 0000000..572fbd5 --- /dev/null +++ b/spec/01-unit/validators/signature_spec.lua @@ -0,0 +1,301 @@ +-- SPDX-FileCopyrightText: 2025 Deutsche Telekom AG +-- +-- SPDX-License-Identifier: Apache-2.0 + +local helpers = require "spec.helpers" + +describe("Plugin: jwt-keycloak (signature validator)", function() + local signature_validator + + before_each(function() + helpers.setup_kong_mock() + signature_validator = require "kong.plugins.jwt-keycloak.validators.signature" + end) + + after_each(function() + helpers.teardown_kong_mock() + package.loaded["kong.plugins.jwt-keycloak.validators.signature"] = nil + end) + + it("should accept when kid is missing but single matching key exists", function() + local jwt = { + header = { alg = "RS256" }, + verify_signature = function(self, key) + return key == "KEY_FOR_RS256" + end + } + + local public_keys = { + keys = { "KEY_FOR_RS256" }, + kids = { "kid1" }, + key_metadata = { { alg = "RS256", use = "sig" } } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + end) + + it("should reject when kid is missing and multiple keys match algorithm", function() + local jwt = { + header = { alg = "RS256" }, + } + + local public_keys = { + keys = { "key1", "key2" }, + kids = { "kid1", "kid2" }, + key_metadata = { + { alg = "RS256", use = "sig", kty = "RSA" }, + { alg = "RS256", use = "sig", kty = "RSA" } + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("kid header required: multiple keys match token algorithm", err.message) + end) + + it("should reject when kid is missing and no key matches algorithm", function() + local jwt = { + header = { alg = "RS256" }, + } + + local public_keys = { + keys = { "key1" }, + kids = { "kid1" }, + key_metadata = { { alg = "ES256", use = "sig", kty = "EC" } } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("No matching public key found for token algorithm", err.message) + end) + + it("should reject when kids table is missing", function() + local jwt = { + header = { alg = "RS256", kid = "kid1" }, + } + + local public_keys = { + keys = { "key1" }, + -- kids missing + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("Unable to find public key for token kid", err.message) + end) + + it("should reject when kid is not found in public keys and no match by alg is found", function() + local jwt = { + header = { alg = "RS256", kid = "kidX" }, + } + + local public_keys = { + keys = { "key1", "key2" }, + kids = { "kid1", "kid2" }, + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("Unable to find public key for token kid", err.message) + end) + + it("should accept when signature verifies with kid-matched key", function() + local call_count = 0 + local used_keys = {} + + local jwt = { + header = { alg = "RS256", kid = "kid2" }, + verify_signature = function(self, key) + call_count = call_count + 1 + table.insert(used_keys, key) + return key == "KEY_FOR_KID2" + end + } + + local public_keys = { + keys = { "KEY_FOR_KID1", "KEY_FOR_KID2" }, + kids = { "kid1", "kid2" }, + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + assert.equals(1, call_count) + assert.same({ "KEY_FOR_KID2" }, used_keys) + end) + + it("should reject when signature does not verify with kid-matched key", function() + local jwt = { + header = { alg = "RS256", kid = "kid2" }, + verify_signature = function(self, key) + return false + end + } + + local public_keys = { + keys = { "KEY_FOR_KID1", "KEY_FOR_KID2" }, + kids = { "kid1", "kid2" }, + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("Invalid token signature", err.message) + end) + + it("should match key by kty when kid is missing (EC key)", function() + local jwt = { + header = { alg = "ES256" }, + verify_signature = function(self, key) + return key == "EC_KEY" + end + } + + local public_keys = { + keys = { "RSA_KEY", "EC_KEY" }, + kids = { "kid1", "kid2" }, + key_metadata = { + { kty = "RSA" }, + { kty = "EC" } + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + end) + + it("should filter out keys with use=enc when kid is missing", function() + local jwt = { + header = { alg = "RS256" }, + verify_signature = function(self, key) + return key == "SIG_KEY" + end + } + + local public_keys = { + keys = { "ENC_KEY", "SIG_KEY" }, + kids = { "kid1", "kid2" }, + key_metadata = { + { alg = "RS256", use = "enc", kty = "RSA" }, + { alg = "RS256", use = "sig", kty = "RSA" } + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + end) + + it("should match when metadata has no alg but kty matches", function() + local jwt = { + header = { alg = "RS256" }, + verify_signature = function(self, key) + return key == "RSA_KEY" + end + } + + local public_keys = { + keys = { "RSA_KEY" }, + kids = { "kid1" }, + key_metadata = { + { kty = "RSA" } -- no alg specified + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + end) + + it("should reject when signature fails with auto-matched key", function() + local jwt = { + header = { alg = "RS256" }, + verify_signature = function(self, key) + return false -- signature verification fails + end + } + + local public_keys = { + keys = { "KEY_FOR_RS256" }, + kids = { "kid1" }, + key_metadata = { + { alg = "RS256", use = "sig", kty = "RSA" } + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("Invalid token signature", err.message) + end) + + it("should work when key_metadata is nil (backward compatibility)", function() + local jwt = { + header = { alg = "RS256" }, + } + + local public_keys = { + keys = { "key1", "key2" }, + kids = { "kid1", "kid2" }, + key_metadata = nil + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("No matching public key found for token algorithm", err.message) + end) + + it("should reject when no public keys available", function() + local jwt = { + header = { alg = "RS256" }, + } + + local public_keys = { + keys = {}, + kids = {}, + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_table(err) + assert.equals(401, err.status) + assert.equals("No public keys available", err.message) + end) + + it("should support PS256 (RSA-PSS) algorithm matching", function() + local jwt = { + header = { alg = "PS256" }, + verify_signature = function(self, key) + return key == "RSA_PSS_KEY" + end + } + + local public_keys = { + keys = { "RSA_PSS_KEY" }, + kids = { "kid1" }, + key_metadata = { + { kty = "RSA" } + } + } + + local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys) + + assert.is_nil(err) + end) + +end) diff --git a/spec/helpers.lua b/spec/helpers.lua index 6b39598..e7d2c89 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -85,25 +85,71 @@ end -- Mock cjson.safe local mock_cjson_safe = { decode = function(data) - -- Simple JSON decoder for tests + -- Minimal JSON decoder for tests, tailored to the structures we use + -- Well-known configuration with jwks_uri + if data:find('"jwks_uri"') then + local jwks_uri = data:match('"jwks_uri"%s*:%s*"([^"]+)"') + return { jwks_uri = jwks_uri }, nil + end + + -- JWKS document with keys and kids + if data:find('"keys"') then + -- For tests, return 2 RSA keys with kids kid1 and kid2 + return { + keys = { + { + kid = "kid1", + kty = "RSA", + n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtmY7sFdl7oahqT_Rc59oKHM78bF8HGmKuHqUL6v3Ohl80UR8QFN5Y8o3h8DGf9LUz0p8H2I", + e = "AQAB" + }, + { + kid = "kid2", + kty = "RSA", + n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtmY7sFdl7oahqT_Rc59oKHM78bF8HGmKuHqUL6v3Ohl80UR8QFN5Y8o3h8DGf9LUz0p8H2I", + e = "AQAB" + } + } + }, nil + end + + -- Generic test payload if data == '{"test": "data"}' then - return { test = "data" } + return { test = "data" }, nil end + return nil, "parse error" end } -- Mock socket modules -local mock_http = { - request = function(options) - return "result", 200 +local function http_request_mock(options) + local url = options.url + local sink = options.sink + + local body + if url and url:find("openid%-configuration") then + body = '{"jwks_uri": "https://keycloak.example.com/auth/realms/test/jwks"}' + elseif url and url:find("jwks") then + body = '{"keys": []}' -- actual keys are provided by mock_cjson_safe.decode + else + body = '{"test": "data"}' end + + if sink then + local writer = sink + writer(body) + end + + return true, 200 +end + +local mock_http = { + request = http_request_mock } local mock_https = { - request = function(options) - return "result", 200 - end + request = http_request_mock } local mock_ltn12 = { diff --git a/src/handler.lua b/src/handler.lua index 8f43090..cafd6a6 100644 --- a/src/handler.lua +++ b/src/handler.lua @@ -14,6 +14,7 @@ local validate_scope = require("kong.plugins.jwt-keycloak.validators.scope").val local validate_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_roles local validate_realm_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_realm_roles local validate_client_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_client_roles +local signature_validator = require("kong.plugins.jwt-keycloak.validators.signature") local re_gmatch = ngx.re.gmatch local decode_base64 = ngx.decode_base64 @@ -106,7 +107,7 @@ end ------------------------------------------------------------------------------- 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) + local keys, kids, err, key_metadata = keycloak_keys.get_issuer_keys(well_known_endpoint, cafile) if err then return nil, err end @@ -119,6 +120,8 @@ local function custom_helper_issuer_get_keys(well_known_endpoint, cafile) kong.log.debug('Number of keys retrieved: ' .. table.getn(decoded_keys)) return { keys = decoded_keys, + kids = kids, + key_metadata = key_metadata, updated_at = socket.gettime() } end @@ -149,18 +152,17 @@ local function custom_validate_token_signature(conf, jwt, second_call) }) 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 + -- Delegate kid-based selection and signature validation to dedicated validator + local err_tbl = signature_validator.validate_signature_with_kid(conf, jwt, public_keys) + if not err_tbl then + kong.log.debug('JWT signature verified using kid-matched key') + return nil 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.log.debug('Could not validate signature using keys matched by kid or alg. 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 @@ -168,9 +170,21 @@ local function custom_validate_token_signature(conf, jwt, second_call) return custom_validate_token_signature(conf, jwt, true) end - security_event('ua222', 'ua, invalid token signature') - return kong.response.exit(401, { - message = "Invalid token signature" + -- After optional refresh we still failed; map error message to appropriate security event + if err_tbl.message == "Unable to find public key for token kid" then + security_event('ua221', 'ua, public key for kid not available') + elseif err_tbl.message == "No matching public key found for token algorithm" then + security_event('ua221', 'ua, no matching public key for algorithm') + elseif err_tbl.message == "kid header required: multiple keys match token algorithm" then + security_event('ua221', 'ua, kid required when multiple keys match') + elseif err_tbl.message == "No public keys available" then + security_event('ua221', 'ua, no public keys available') + else + security_event('ua222', 'ua, invalid token signature') + end + + return kong.response.exit(err_tbl.status, { + message = err_tbl.message }) end diff --git a/src/keycloak_keys.lua b/src/keycloak_keys.lua index 08d66be..3273566 100644 --- a/src/keycloak_keys.lua +++ b/src/keycloak_keys.lua @@ -49,22 +49,35 @@ local function get_issuer_keys(well_known_endpoint) local res, err = get_request(well_known_endpoint, req.scheme, req.port) if err then - return nil, err + return nil, nil, err end - local res, err = get_request(res['jwks_uri'], req.scheme, req.port) - if err then - return nil, err + local jwks, jwks_err = get_request(res["jwks_uri"], req.scheme, req.port) + if jwks_err then + return nil, nil, jwks_err end local keys = {} - for i, key in ipairs(res['keys']) do + local kids = {} + local key_metadata = {} + for i, key in ipairs(jwks["keys"]) do keys[i] = string.gsub( - convert.convert_kc_key(key), + convert.convert_kc_key(key), "[\r\n]+", "" ) + kids[i] = key.kid + -- Store metadata for fallback key selection when kid is absent + key_metadata[i] = { + alg = key.alg, + use = key.use, + kty = key.kty + } end - return keys, nil + + -- Return keys, kids, and key_metadata aligned by index. + -- Third return value is error (nil on success). + -- Fourth return value is key_metadata array. + return keys, kids, nil, key_metadata end return { diff --git a/src/validators/signature.lua b/src/validators/signature.lua new file mode 100644 index 0000000..04c98b0 --- /dev/null +++ b/src/validators/signature.lua @@ -0,0 +1,139 @@ +-- SPDX-FileCopyrightText: 2025 Deutsche Telekom AG +-- +-- SPDX-License-Identifier: Apache-2.0 + +local M = {} + +-- Helper function to determine if a key matches the JWT header algorithm +-- Supports both RSA (RS256, RS384, RS512, PS256, PS384, PS512) and EC (ES256, ES384, ES512) algorithms +local function key_matches_algorithm(key_metadata, jwt_alg) + if not key_metadata then + return false + end + + -- If key specifies use and it's not "sig", skip it + if key_metadata.use and key_metadata.use ~= "sig" then + return false + end + + -- If key has alg specified, it must match JWT alg + if key_metadata.alg then + return key_metadata.alg == jwt_alg + end + + -- Check kty matches algorithm family + if jwt_alg then + local jwt_kty_derived = jwt_alg:sub(1, 2) + if jwt_kty_derived == "RS" or jwt_kty_derived == "PS" then + -- RSA algorithms + if key_metadata.kty and key_metadata.kty == "RSA" then + return true + end + elseif jwt_kty_derived == "ES" then + -- ECDSA algorithms + if key_metadata.kty and key_metadata.kty == "EC" then + return true + end + end + end + + -- If we get here, the key neither matches the algorithm family nor has a matching alg field, so reject it + return false +end + +-- Validates a JWT signature using a key selected by kid from the provided +-- public_keys structure. If kid is absent, attempts to find a single unambiguous +-- matching key based on JWT algorithm and key metadata. +-- +-- Parameters: +-- conf - plugin configuration (currently unused, reserved for future) +-- jwt - jwt_parser instance +-- public_keys - table with fields: +-- keys = { , , ... } +-- kids = { "kid1", "kid2", ... } +-- key_metadata = { {alg=..., use=..., kty=...}, ... } +-- +-- Returns: +-- nil on success (signature valid) +-- or { status = , message = } on failure +function M.validate_signature_with_kid(conf, jwt, public_keys) + local header = jwt.header or {} + local header_kid = header.kid + local header_alg = header.alg + + local kids = public_keys and public_keys.kids or nil + local keys = public_keys and public_keys.keys or nil + local key_metadata = public_keys and public_keys.key_metadata or nil + + if not keys or #keys == 0 then + return { + status = 401, + message = "No public keys available" + } + end + + local key_index = nil + + -- If kid is present, use it for direct lookup + if header_kid and header_kid ~= "" then + if not kids then + return { + status = 401, + message = "Unable to find public key for token kid" + } + end + + for i, kid in ipairs(kids) do + if kid == header_kid then + key_index = i + break + end + end + + if not key_index then + return { + status = 401, + message = "Unable to find public key for token kid" + } + end + else + -- kid is absent: try to find a single unambiguous matching key + local matching_indices = {} + + for i = 1, #keys do + local metadata = key_metadata and key_metadata[i] or nil + if key_matches_algorithm(metadata, header_alg) then + table.insert(matching_indices, i) + end + end + + if #matching_indices == 0 then + return { + status = 401, + message = "No matching public key found for token algorithm" + } + elseif #matching_indices > 1 then + return { + status = 401, + message = "kid header required: multiple keys match token algorithm" + } + else + key_index = matching_indices[1] + end + end + + -- Verify signature with the selected key + local key = keys[key_index] + if key and jwt.verify_signature then + if jwt:verify_signature(key) then + return nil + end + end + + return { + status = 401, + message = "Invalid token signature" + } +end + +return M diff --git a/tests/_env.sh b/tests/_env.sh index 7179616..bcf4a5b 100755 --- a/tests/_env.sh +++ b/tests/_env.sh @@ -19,7 +19,7 @@ prepare_environment() { wait_for_kong() { echo "Waiting for Kong to be ready..." for i in $(seq 1 30); do - if curl -s -o /dev/null $KONG_ADMIN_URL; then + if curl -i $KONG_ADMIN_URL; then echo "Kong is ready!" return 0 fi @@ -39,6 +39,7 @@ wait_for_keycloak() { fi sleep 1 done + curl -i $KC_URL/auth/realms/master/.well-known/openid-configuration echo "Keycloak is not ready after 45 seconds, exiting..." exit 1 } diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 39d5339..73e1f25 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -19,6 +19,11 @@ else exit 1 fi +# Test network connectivity (diagnostic) +if [ -f ./test_network_connectivity.sh ]; then + . ./test_network_connectivity.sh +fi + # Run unit tests first (if available) echo "๐Ÿงช Phase 0: Running unit tests..." if [ -f ./run_unit_tests.sh ]; then @@ -100,3 +105,13 @@ else echo "Error: test_security_logging.sh not found" exit 1 fi + +# Test 6: Test kid optional handling +echo "๐Ÿงช Phase 6: Testing optional kid valitation..." +if [ -f ./test_kid_optional.sh ]; then + . ./test_kid_optional.sh +else + echo "Error: test_kid_optional.sh not found" + exit 1 +fi + diff --git a/tests/test_kid_optional.sh b/tests/test_kid_optional.sh new file mode 100755 index 0000000..e7e7f8a --- /dev/null +++ b/tests/test_kid_optional.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2025 Deutsche Telekom AG +# +# SPDX-License-Identifier: Apache-2.0 + +# Test kid-optional JWT validation functionality + +# Source environment helpers +if [ -f ./_env.sh ]; then + . ./_env.sh +fi + +echo "๐Ÿงช Testing kid-optional JWT validation..." + +# Get a valid token from Keycloak (will have kid) +echo "๐Ÿ”‘ Getting token from Keycloak..." +TOKEN_ENDPOINT="$KC_URL/auth/realms/$KC_REALM/protocol/openid-connect/token" + +ACCESS_TOKEN=$(curl -s -X POST $TOKEN_ENDPOINT \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$KC_CLIENT_ID" \ + -d "client_secret=$KC_CLIENT_SECRET" \ + -d "grant_type=client_credentials" | jq -r '.access_token') + +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "โŒ Failed to obtain access token" + exit 1 +fi + +echo "โœ… Got valid token from Keycloak" + +# Decode token to inspect kid +HEADER=$(echo $ACCESS_TOKEN | cut -d. -f1 | base64 -d 2>/dev/null | jq .) +echo "๐Ÿ“‹ Token header:" +echo "$HEADER" + +HAS_KID=$(echo "$HEADER" | jq -r '.kid // "null"') +if [ "$HAS_KID" != "null" ]; then + echo "โœ… Token has kid: $HAS_KID" +else + echo "โš ๏ธ Token does not have kid" +fi + +# Test 1: Normal token with kid should work +echo "" +echo "๐Ÿ” Test 1: Token with kid (normal Keycloak token)..." +if ! retry_test_after_plugin_change "Token with kid validation" "200" \ + "curl -s -w \"%{http_code}\" -X GET $KONG_PROXY_URL/example/get -H \"Authorization: Bearer $ACCESS_TOKEN\" -o /dev/null"; then + echo "โŒ Test 1 failed" + exit 1 +fi +echo "โœ… Test 1 passed: Token with kid works" + +echo "โš ๏ธ The test for kid-optional JWT validation is only covered in unit tests / spec validation" diff --git a/tests/test_network_connectivity.sh b/tests/test_network_connectivity.sh new file mode 100755 index 0000000..d5c00e0 --- /dev/null +++ b/tests/test_network_connectivity.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2025 Deutsche Telekom AG +# +# SPDX-License-Identifier: Apache-2.0 + +# Test network connectivity to services + +echo "๐Ÿ” Testing network connectivity..." + +# Source environment helpers +if [ -f ./_env.sh ]; then + . ./_env.sh +fi + +# Test DNS resolution +echo "๐Ÿ“ก Testing DNS resolution..." + +echo -n " - Resolving 'kong': " +if nslookup kong > /dev/null 2>&1 || getent hosts kong > /dev/null 2>&1; then + echo "โœ… OK" +else + echo "โŒ FAILED" + echo " Error: Cannot resolve hostname 'kong'" +fi + +echo -n " - Resolving 'kc': " +if nslookup kc > /dev/null 2>&1 || getent hosts kc > /dev/null 2>&1; then + echo "โœ… OK" +else + echo "โŒ FAILED" + echo " Error: Cannot resolve hostname 'kc'" +fi + +echo -n " - Resolving 'httpbin': " +if nslookup httpbin > /dev/null 2>&1 || getent hosts httpbin > /dev/null 2>&1; then + echo "โœ… OK" +else + echo "โŒ FAILED" + echo " Error: Cannot resolve hostname 'httpbin'" +fi + +echo "" +echo "โœ… Network connectivity check complete"