Skip to content

Commit b6f80f5

Browse files
change(auth): require configured jwt claims, harden empty claims_to_verify and key-auth anonymous fallback (#13468)
1 parent 7f2eaa7 commit b6f80f5

6 files changed

Lines changed: 300 additions & 14 deletions

File tree

apisix/plugins/jwt-auth/parser.lua

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -229,21 +229,43 @@ end
229229

230230

231231
function _M.verify_claims(self, claims, conf)
232-
if not claims then
233-
claims = default_claims
232+
-- When `claims_to_verify` is not configured (nil or an explicitly empty
233+
-- array), fall back to the default claims (exp/nbf) and validate them only
234+
-- if they are present in the payload. This closes the expired-token hole
235+
-- while staying lenient for tokens that legitimately omit these claims.
236+
-- An empty array must NOT skip validation, otherwise it reopens the bypass.
237+
if not claims or #claims == 0 then
238+
for _, claim_name in ipairs(default_claims) do
239+
local claim = self.payload[claim_name]
240+
if claim ~= nil then
241+
local checker = claims_checker[claim_name]
242+
if type(claim) ~= checker.type then
243+
return false, "claim " .. claim_name .. " is not a " .. checker.type
244+
end
245+
local ok, err = checker.check(claim, conf)
246+
if not ok then
247+
return false, err
248+
end
249+
end
250+
end
251+
252+
return true
234253
end
235254

255+
-- When `claims_to_verify` is explicitly configured, the listed claims are
256+
-- required: they must exist in the payload and be valid.
236257
for _, claim_name in ipairs(claims) do
237258
local claim = self.payload[claim_name]
238-
if claim then
239-
local checker = claims_checker[claim_name]
240-
if type(claim) ~= checker.type then
241-
return false, "claim " .. claim_name .. " is not a " .. checker.type
242-
end
243-
local ok, err = checker.check(claim, conf)
244-
if not ok then
245-
return false, err
246-
end
259+
if claim == nil then
260+
return false, "claim " .. claim_name .. " is missing"
261+
end
262+
local checker = claims_checker[claim_name]
263+
if type(claim) ~= checker.type then
264+
return false, "claim " .. claim_name .. " is not a " .. checker.type
265+
end
266+
local ok, err = checker.check(claim, conf)
267+
if not ok then
268+
return false, err
247269
end
248270
end
249271

apisix/plugins/key-auth.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@ function _M.rewrite(conf, ctx)
108108
core.response.set_header("WWW-Authenticate", "apikey realm=\"" .. conf.realm .. "\"")
109109
return 401, { message = err}
110110
end
111+
-- Strip credentials before falling back to the anonymous consumer when
112+
-- hide_credentials is enabled. find_consumer() only strips on the
113+
-- successful-auth path, so without this an invalid credential would be
114+
-- forwarded upstream during anonymous fallback. A request may carry the
115+
-- credential in both the header and the query string, so clean up both.
116+
if conf.hide_credentials then
117+
if core.request.header(ctx, conf.header) then
118+
core.request.set_header(ctx, conf.header, nil)
119+
end
120+
local args = core.request.get_uri_args(ctx) or {}
121+
if args[conf.query] then
122+
args[conf.query] = nil
123+
core.request.set_uri_args(ctx, args)
124+
end
125+
end
111126
consumer, consumer_conf, err = consumer_mod.get_anonymous_consumer(conf.anonymous_consumer)
112127
if not consumer then
113128
err = "key-auth failed to authenticate the request, code: 401. error: " .. err

t/lib/server.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,22 @@ function _M.print_uri_detailed()
413413
ngx.say("ngx.var.request_uri: ", ngx.var.request_uri)
414414
end
415415

416+
-- echo back exactly what the upstream received: the full request URI (with
417+
-- query string) and every request header. Lets tests assert on what was
418+
-- actually proxied upstream instead of scanning the error log.
419+
function _M.print_request_received()
420+
ngx.say("request_uri: ", ngx.var.request_uri)
421+
local headers = ngx.req.get_headers()
422+
local keys = {}
423+
for k in pairs(headers) do
424+
keys[#keys + 1] = k
425+
end
426+
table.sort(keys)
427+
for _, k in ipairs(keys) do
428+
ngx.say(k, ": ", headers[k])
429+
end
430+
end
431+
416432
function _M.headers()
417433
local args = ngx.req.get_uri_args()
418434
for name, val in pairs(args) do

t/plugin/jwt-auth-more-algo.t

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,18 @@ passed
325325
326326
327327
328-
=== TEST 13: verify success with expired token
328+
=== TEST 13: configured claim (nbf) missing from token -> rejected
329+
# claims_to_verify lists nbf, but this token only carries exp. A claim that is
330+
# explicitly configured is required, so the missing nbf is rejected.
329331
--- request
330332
GET /hello
331333
--- more_headers
332334
Authorization: eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2Mzg3MDUwMX0.SJNbFYR1qlIOD7D8aItkB9hYxdhc0d_JGaLgVjOCDOAHd8CSJuHp_R6YQniRDq8S
333-
--- response_body
334-
hello world
335+
--- error_code: 401
336+
--- response_body eval
337+
qr/failed to verify jwt/
338+
--- error_log
339+
claim nbf is missing
335340
336341
337342
@@ -363,6 +368,33 @@ hello world
363368
location /t {
364369
content_by_lua_block {
365370
local t = require("lib.test_admin").test
371+
372+
-- TEST 12 left claims_to_verify=["nbf"] on this route. Now that
373+
-- configured claims are required, that stale requirement would reject
374+
-- the EdDSA token in TEST 17 (which carries exp but no nbf). This test
375+
-- group targets signature verification, so restore a clean jwt-auth
376+
-- config on the route first.
377+
local code = t('/apisix/admin/routes/1',
378+
ngx.HTTP_PUT,
379+
[[{
380+
"plugins": {
381+
"jwt-auth": {}
382+
},
383+
"upstream": {
384+
"nodes": {
385+
"127.0.0.1:1980": 1
386+
},
387+
"type": "roundrobin"
388+
},
389+
"uri": "/hello"
390+
}]]
391+
)
392+
if code >= 300 then
393+
ngx.status = code
394+
ngx.say("failed to reset route")
395+
return
396+
end
397+
366398
local code, body = t('/apisix/admin/consumers',
367399
ngx.HTTP_PUT,
368400
[[{

t/plugin/jwt-auth.t

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,3 +1301,130 @@ passed
13011301
{"message":"failed to verify jwt"}
13021302
--- error_log
13031303
failed to verify jwt: algorithm mismatch, expected RS256
1304+
1305+
1306+
1307+
=== TEST 53: add consumer for default-claims verification
1308+
--- config
1309+
location /t {
1310+
content_by_lua_block {
1311+
local t = require("lib.test_admin").test
1312+
local code, body = t('/apisix/admin/consumers',
1313+
ngx.HTTP_PUT,
1314+
[[{
1315+
"username": "jack",
1316+
"plugins": {
1317+
"jwt-auth": {
1318+
"key": "user-key",
1319+
"secret": "my-secret-key"
1320+
}
1321+
}
1322+
}]]
1323+
)
1324+
1325+
if code >= 300 then
1326+
ngx.status = code
1327+
end
1328+
ngx.say(body)
1329+
}
1330+
}
1331+
--- response_body
1332+
passed
1333+
1334+
1335+
1336+
=== TEST 54: enable jwt-auth WITHOUT claims_to_verify (default exp/nbf path)
1337+
--- config
1338+
location /t {
1339+
content_by_lua_block {
1340+
local t = require("lib.test_admin").test
1341+
local code, body = t('/apisix/admin/routes/1',
1342+
ngx.HTTP_PUT,
1343+
[[{
1344+
"plugins": {
1345+
"jwt-auth": {}
1346+
},
1347+
"upstream": {
1348+
"nodes": {
1349+
"127.0.0.1:1980": 1
1350+
},
1351+
"type": "roundrobin"
1352+
},
1353+
"uri": "/hello"
1354+
}]]
1355+
)
1356+
1357+
if code >= 300 then
1358+
ngx.status = code
1359+
end
1360+
ngx.say(body)
1361+
}
1362+
}
1363+
--- response_body
1364+
passed
1365+
1366+
1367+
1368+
=== TEST 55: expired token with no claims_to_verify configured -> rejected
1369+
--- request
1370+
GET /hello?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2Mzg3MDUwMX0.pPNVvh-TQsdDzorRwa-uuiLYiEBODscp9wv0cwD6c68
1371+
--- error_code: 401
1372+
--- response_body
1373+
{"message":"failed to verify jwt"}
1374+
--- error_log
1375+
failed to verify jwt: 'exp' claim expired at Tue, 23 Jul 2019 08:28:21 GMT
1376+
1377+
1378+
1379+
=== TEST 56: token without exp claim and no claims_to_verify configured -> accepted
1380+
--- request
1381+
GET /hello?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSJ9._7aoTZdzQDT0r9swHTcHb3nsujexcGjSTU-LRzTRVyY
1382+
--- response_body
1383+
hello world
1384+
--- no_error_log
1385+
[error]
1386+
1387+
1388+
1389+
=== TEST 57: enable jwt-auth with an explicit empty claims_to_verify array
1390+
--- config
1391+
location /t {
1392+
content_by_lua_block {
1393+
local t = require("lib.test_admin").test
1394+
local code, body = t('/apisix/admin/routes/1',
1395+
ngx.HTTP_PUT,
1396+
[[{
1397+
"plugins": {
1398+
"jwt-auth": {
1399+
"claims_to_verify": []
1400+
}
1401+
},
1402+
"upstream": {
1403+
"nodes": {
1404+
"127.0.0.1:1980": 1
1405+
},
1406+
"type": "roundrobin"
1407+
},
1408+
"uri": "/hello"
1409+
}]]
1410+
)
1411+
1412+
if code >= 300 then
1413+
ngx.status = code
1414+
end
1415+
ngx.say(body)
1416+
}
1417+
}
1418+
--- response_body
1419+
passed
1420+
1421+
1422+
1423+
=== TEST 58: expired token with an explicit empty claims_to_verify -> still rejected
1424+
--- request
1425+
GET /hello?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2Mzg3MDUwMX0.pPNVvh-TQsdDzorRwa-uuiLYiEBODscp9wv0cwD6c68
1426+
--- error_code: 401
1427+
--- response_body
1428+
{"message":"failed to verify jwt"}
1429+
--- error_log
1430+
failed to verify jwt: 'exp' claim expired at Tue, 23 Jul 2019 08:28:21 GMT

t/plugin/key-auth-anonymous-consumer.t

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,77 @@ GET /hello
221221
failed to get anonymous consumer not-found-anonymous
222222
--- response_body
223223
{"message":"Invalid user authorization"}
224+
225+
226+
227+
=== TEST 8: enable key-auth with anonymous consumer and hide_credentials
228+
# Route to an upstream that echoes back the request URI (with query string) and
229+
# every request header it received, so the tests can assert on what is actually
230+
# proxied upstream. Scanning the error log is unreliable here: nginx always logs
231+
# the original client request line, which still contains the credential even
232+
# after it is stripped from the upstream request.
233+
--- config
234+
location /t {
235+
content_by_lua_block {
236+
local t = require("lib.test_admin").test
237+
local code, body = t('/apisix/admin/routes/1',
238+
ngx.HTTP_PUT,
239+
[[{
240+
"plugins": {
241+
"key-auth": {
242+
"query": "auth",
243+
"anonymous_consumer": "anonymous",
244+
"hide_credentials": true
245+
}
246+
},
247+
"upstream": {
248+
"nodes": {
249+
"127.0.0.1:1980": 1
250+
},
251+
"type": "roundrobin"
252+
},
253+
"uri": "/print_request_received"
254+
}]]
255+
)
256+
257+
if code >= 300 then
258+
ngx.status = code
259+
end
260+
ngx.say(body)
261+
}
262+
}
263+
--- request
264+
GET /t
265+
--- response_body
266+
passed
267+
268+
269+
270+
=== TEST 9: invalid key in header falls back to anonymous, credential not forwarded upstream
271+
# The upstream body must contain the anonymous marker (X-Consumer-Username:
272+
# anonymous, injected by apisix) AND must not contain the credential anywhere,
273+
# proving the invalid key was stripped before the request was proxied upstream.
274+
--- request
275+
GET /print_request_received
276+
--- more_headers
277+
apikey: invalid-key
278+
--- response_body_like eval
279+
qr/(?s)^(?!.*invalid-key).*x-consumer-username: anonymous/
280+
281+
282+
283+
=== TEST 10: invalid key in query falls back to anonymous, credential not forwarded upstream
284+
--- request
285+
GET /print_request_received?auth=invalid-key
286+
--- response_body_like eval
287+
qr/(?s)^(?!.*invalid-key).*x-consumer-username: anonymous/
288+
289+
290+
291+
=== TEST 11: invalid key in BOTH header and query -> neither forwarded upstream
292+
--- request
293+
GET /print_request_received?auth=invalid-key
294+
--- more_headers
295+
apikey: invalid-key
296+
--- response_body_like eval
297+
qr/(?s)^(?!.*invalid-key).*x-consumer-username: anonymous/

0 commit comments

Comments
 (0)