Skip to content

Commit 39d34b4

Browse files
feat: add kid-based signature validation with enhanced error handling
1 parent 10acf68 commit 39d34b4

File tree

8 files changed

+298
-27
lines changed

8 files changed

+298
-27
lines changed

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ services:
9191
environment:
9292
- LUA_PATH=/opt/kong-plugin-jwt-keycloak/src/?.lua;/opt/kong-plugin-jwt-keycloak/?.lua;;
9393
- LUA_CPATH=/usr/local/lib/lua/5.1/?.so;;
94+
- HTTP_PROXY=
95+
- HTTPS_PROXY=
96+
- NO_PROXY="localhost,127.0.0.1"
97+
- http_proxy=
98+
- https_proxy=
99+
- no_proxy="localhost,127.0.0.1"
94100
entrypoint: [ "/bin/sh", "-c", "cd /opt/tests && /bin/sh ./run_tests.sh" ]
95101

96102
httpbin:

spec/01-unit/keycloak_keys_spec.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,19 @@ describe("Plugin: jwt-keycloak (keycloak_keys)", function()
5454
assert.is_function(keycloak_keys.get_request)
5555
end)
5656
end)
57+
58+
describe("get_issuer_keys", function()
59+
it("should return keys and aligned kids from JWKS", function()
60+
local well_known_endpoint = "https://keycloak.example.com/auth/realms/test/.well-known/openid-configuration"
61+
62+
local keys, kids, err = keycloak_keys.get_issuer_keys(well_known_endpoint)
63+
64+
assert.is_nil(err)
65+
assert.is_table(keys)
66+
assert.is_table(kids)
67+
assert.equals(2, #keys)
68+
assert.equals(2, #kids)
69+
assert.same({ "kid1", "kid2" }, kids)
70+
end)
71+
end)
5772
end)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
-- SPDX-FileCopyrightText: 2025 Deutsche Telekom AG
2+
--
3+
-- SPDX-License-Identifier: Apache-2.0
4+
5+
local helpers = require "spec.helpers"
6+
7+
describe("Plugin: jwt-keycloak (signature validator)", function()
8+
local signature_validator
9+
10+
before_each(function()
11+
helpers.setup_kong_mock()
12+
signature_validator = require "kong.plugins.jwt-keycloak.validators.signature"
13+
end)
14+
15+
after_each(function()
16+
helpers.teardown_kong_mock()
17+
package.loaded["kong.plugins.jwt-keycloak.validators.signature"] = nil
18+
end)
19+
20+
it("should reject when kid is missing", function()
21+
local jwt = {
22+
header = { alg = "RS256" },
23+
}
24+
25+
local public_keys = {
26+
keys = { "key1" },
27+
kids = { "kid1" },
28+
}
29+
30+
local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys)
31+
32+
assert.is_table(err)
33+
assert.equals(401, err.status)
34+
assert.equals("Invalid token: kid header missing", err.message)
35+
end)
36+
37+
it("should reject when kids table is missing", function()
38+
local jwt = {
39+
header = { alg = "RS256", kid = "kid1" },
40+
}
41+
42+
local public_keys = {
43+
keys = { "key1" },
44+
-- kids missing
45+
}
46+
47+
local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys)
48+
49+
assert.is_table(err)
50+
assert.equals(401, err.status)
51+
assert.equals("Unable to find public key for token kid", err.message)
52+
end)
53+
54+
it("should reject when kid is not found in public keys", function()
55+
local jwt = {
56+
header = { alg = "RS256", kid = "kidX" },
57+
}
58+
59+
local public_keys = {
60+
keys = { "key1", "key2" },
61+
kids = { "kid1", "kid2" },
62+
}
63+
64+
local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys)
65+
66+
assert.is_table(err)
67+
assert.equals(401, err.status)
68+
assert.equals("Unable to find public key for token kid", err.message)
69+
end)
70+
71+
it("should accept when signature verifies with kid-matched key", function()
72+
local call_count = 0
73+
local used_keys = {}
74+
75+
local jwt = {
76+
header = { alg = "RS256", kid = "kid2" },
77+
verify_signature = function(self, key)
78+
call_count = call_count + 1
79+
table.insert(used_keys, key)
80+
return key == "KEY_FOR_KID2"
81+
end
82+
}
83+
84+
local public_keys = {
85+
keys = { "KEY_FOR_KID1", "KEY_FOR_KID2" },
86+
kids = { "kid1", "kid2" },
87+
}
88+
89+
local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys)
90+
91+
assert.is_nil(err)
92+
assert.equals(1, call_count)
93+
assert.same({ "KEY_FOR_KID2" }, used_keys)
94+
end)
95+
96+
it("should reject when signature does not verify with kid-matched key", function()
97+
local jwt = {
98+
header = { alg = "RS256", kid = "kid2" },
99+
verify_signature = function(self, key)
100+
return false
101+
end
102+
}
103+
104+
local public_keys = {
105+
keys = { "KEY_FOR_KID1", "KEY_FOR_KID2" },
106+
kids = { "kid1", "kid2" },
107+
}
108+
109+
local err = signature_validator.validate_signature_with_kid({}, jwt, public_keys)
110+
111+
assert.is_table(err)
112+
assert.equals(401, err.status)
113+
assert.equals("Invalid token signature", err.message)
114+
end)
115+
end)

spec/helpers.lua

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,25 +85,71 @@ end
8585
-- Mock cjson.safe
8686
local mock_cjson_safe = {
8787
decode = function(data)
88-
-- Simple JSON decoder for tests
88+
-- Minimal JSON decoder for tests, tailored to the structures we use
89+
-- Well-known configuration with jwks_uri
90+
if data:find('"jwks_uri"') then
91+
local jwks_uri = data:match('"jwks_uri"%s*:%s*"([^"]+)"')
92+
return { jwks_uri = jwks_uri }, nil
93+
end
94+
95+
-- JWKS document with keys and kids
96+
if data:find('"keys"') then
97+
-- For tests, return 2 RSA keys with kids kid1 and kid2
98+
return {
99+
keys = {
100+
{
101+
kid = "kid1",
102+
kty = "RSA",
103+
n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtmY7sFdl7oahqT_Rc59oKHM78bF8HGmKuHqUL6v3Ohl80UR8QFN5Y8o3h8DGf9LUz0p8H2I",
104+
e = "AQAB"
105+
},
106+
{
107+
kid = "kid2",
108+
kty = "RSA",
109+
n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtmY7sFdl7oahqT_Rc59oKHM78bF8HGmKuHqUL6v3Ohl80UR8QFN5Y8o3h8DGf9LUz0p8H2I",
110+
e = "AQAB"
111+
}
112+
}
113+
}, nil
114+
end
115+
116+
-- Generic test payload
89117
if data == '{"test": "data"}' then
90-
return { test = "data" }
118+
return { test = "data" }, nil
91119
end
120+
92121
return nil, "parse error"
93122
end
94123
}
95124

96125
-- Mock socket modules
97-
local mock_http = {
98-
request = function(options)
99-
return "result", 200
126+
local function http_request_mock(options)
127+
local url = options.url
128+
local sink = options.sink
129+
130+
local body
131+
if url and url:find("openid%-configuration") then
132+
body = '{"jwks_uri": "https://keycloak.example.com/auth/realms/test/jwks"}'
133+
elseif url and url:find("jwks") then
134+
body = '{"keys": []}' -- actual keys are provided by mock_cjson_safe.decode
135+
else
136+
body = '{"test": "data"}'
100137
end
138+
139+
if sink then
140+
local writer = sink
141+
writer(body)
142+
end
143+
144+
return true, 200
145+
end
146+
147+
local mock_http = {
148+
request = http_request_mock
101149
}
102150

103151
local mock_https = {
104-
request = function(options)
105-
return "result", 200
106-
end
152+
request = http_request_mock
107153
}
108154

109155
local mock_ltn12 = {

src/handler.lua

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ local validate_scope = require("kong.plugins.jwt-keycloak.validators.scope").val
1414
local validate_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_roles
1515
local validate_realm_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_realm_roles
1616
local validate_client_roles = require("kong.plugins.jwt-keycloak.validators.roles").validate_client_roles
17+
local signature_validator = require("kong.plugins.jwt-keycloak.validators.signature")
1718

1819
local re_gmatch = ngx.re.gmatch
1920
local decode_base64 = ngx.decode_base64
@@ -106,19 +107,24 @@ end
106107
-------------------------------------------------------------------------------
107108
local function custom_helper_issuer_get_keys(well_known_endpoint, cafile)
108109
kong.log.debug('Getting public keys from token issuer')
109-
local keys, err = keycloak_keys.get_issuer_keys(well_known_endpoint, cafile)
110+
local keys, kids, err = keycloak_keys.get_issuer_keys(well_known_endpoint, cafile)
110111
if err then
111112
return nil, err
112113
end
113114

114115
local decoded_keys = {}
116+
local key_ids = {}
115117
for i, key in ipairs(keys) do
116118
decoded_keys[i] = custom_base64_decode(key)
119+
if kids then
120+
key_ids[i] = kids[i]
121+
end
117122
end
118123

119124
kong.log.debug('Number of keys retrieved: ' .. table.getn(decoded_keys))
120125
return {
121126
keys = decoded_keys,
127+
kids = key_ids,
122128
updated_at = socket.gettime()
123129
}
124130
end
@@ -149,28 +155,35 @@ local function custom_validate_token_signature(conf, jwt, second_call)
149155
})
150156
end
151157

152-
-- Verify signatures
153-
for _, k in ipairs(public_keys.keys) do
154-
if jwt:verify_signature(k) then
155-
kong.log.debug('JWT signature verified')
156-
return nil
157-
end
158+
-- Delegate kid-based selection and signature validation to dedicated validator
159+
local err_tbl = signature_validator.validate_signature_with_kid(conf, jwt, public_keys)
160+
if not err_tbl then
161+
kong.log.debug('JWT signature verified using kid-matched key')
162+
return nil
158163
end
159164

160165
-- We could not validate signature, try to get a new keyset?
161166
local since_last_update = socket.gettime() - public_keys.updated_at
162167
if not second_call and since_last_update > conf.iss_key_grace_period then
163-
kong.log.debug('Could not validate signature. Keys updated last ' .. since_last_update .. ' seconds ago')
168+
kong.log.debug('Could not validate signature with kid-matched key. Keys updated last ' .. since_last_update .. ' seconds ago')
164169
-- can it be that the signature key of the issuer has changed ... ?
165170
-- invalidate the old keys in kong cache and do a current lookup to the signature keys
166171
-- of the token issuer
167172
kong.cache:invalidate_local(issuer_cache_key)
168173
return custom_validate_token_signature(conf, jwt, true)
169174
end
170175

171-
security_event('ua222', 'ua, invalid token signature')
172-
return kong.response.exit(401, {
173-
message = "Invalid token signature"
176+
-- After optional refresh we still failed; map error message to appropriate security event
177+
if err_tbl.message == "Invalid token: kid header missing" then
178+
security_event('ua201', 'ua, token integrity wrong, kid missing')
179+
elseif err_tbl.message == "Unable to find public key for token kid" then
180+
security_event('ua221', 'ua, public key for kid not available')
181+
else
182+
security_event('ua222', 'ua, invalid token signature')
183+
end
184+
185+
return kong.response.exit(err_tbl.status, {
186+
message = err_tbl.message
174187
})
175188
end
176189

src/keycloak_keys.lua

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,28 @@ local function get_issuer_keys(well_known_endpoint)
4949

5050
local res, err = get_request(well_known_endpoint, req.scheme, req.port)
5151
if err then
52-
return nil, err
52+
return nil, nil, err
5353
end
5454

55-
local res, err = get_request(res['jwks_uri'], req.scheme, req.port)
56-
if err then
57-
return nil, err
55+
local jwks, jwks_err = get_request(res["jwks_uri"], req.scheme, req.port)
56+
if jwks_err then
57+
return nil, nil, jwks_err
5858
end
5959

6060
local keys = {}
61-
for i, key in ipairs(res['keys']) do
61+
local kids = {}
62+
for i, key in ipairs(jwks["keys"]) do
6263
keys[i] = string.gsub(
63-
convert.convert_kc_key(key),
64+
convert.convert_kc_key(key),
6465
"[\r\n]+", ""
6566
)
67+
kids[i] = key.kid
6668
end
67-
return keys, nil
69+
70+
-- Preserve original behavior for existing callers by returning keys as first
71+
-- value and error as third value. The second return value contains the list
72+
-- of kids aligned by index with the keys array.
73+
return keys, kids, nil
6874
end
6975

7076
return {

0 commit comments

Comments
 (0)