Skip to content

Commit 952b412

Browse files
authored
Merge pull request #156 from bodewig/tighten-jwt-signature-validation
Tighten jwt signature validation
2 parents 26b9a7b + d2b08a7 commit 952b412

File tree

6 files changed

+282
-40
lines changed

6 files changed

+282
-40
lines changed

ChangeLog

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
04/27/2018
2+
- disabled support for "none" alg tokens introduced with 1.5.2 by default.
3+
If you want to enable it, you will now have to explicitly set the accept_none_alg
4+
option to true.
5+
- id tokens using a signature algorithm not announced by the discovery
6+
endpoint are now rejected.
7+
- you can now specify which signing algorithm you expect a bearer token to
8+
use in order to avoid being tricked into accepting a rogue token signed
9+
with a symmetric key when expecting an asymmetric cypher.
10+
- added an option to reject tokens signed by an algorithm not supported by lua-resty-jwt
11+
112
04/19/2018
213
- added support for passing bearer token as cookie
314
- added support introspection interval

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ http {
111111
--token_endpoint_auth_method = ["client_secret_basic"|"client_secret_post"],
112112
--ssl_verify = "no"
113113
114+
--accept_none_alg = false
115+
-- if your OpenID Connect Provider doesn't sign its id tokens
116+
-- (uses the "none" signature algorithm) then set this to true.
117+
118+
--accept_unsupported_alg = true
119+
-- if you want to reject tokens signed using an algorithm
120+
-- not supported by lua-resty-jwt set this to false. If
121+
-- you leave it unset or set it to true, the token signature will not be
122+
-- verified when an unsupported algorithm is used.
123+
114124
--renew_access_token_on_expiry = true
115125
-- whether this plugin shall try to silently renew the access token once it is expired if a refresh token is available.
116126
-- if it fails to renew the token, the user will be redirected to the authorization endpoint.
@@ -233,6 +243,24 @@ lAc5Csj0o5Q+oEhPUAVBIF07m4rd0OvAVPOCQ2NJhQSL1oWASbf+fg==
233243
-- contains "jwks_uri" entry; the jwks endpoint must provide either an "x5c" entry
234244
-- or both the "n" modulus and "e" exponent entries for RSA signature verification
235245
-- discovery = "https://accounts.google.com/.well-known/openid-configuration",
246+
247+
-- the signature algorithm that you expect has been used;
248+
-- can be a single string or a table.
249+
-- You should set this for security reasons in order to
250+
-- avoid accepting a token claiming to be signed by HMAC
251+
-- using a public RSA key.
252+
--token_signing_alg_values_expected = { "RS256" }
253+
254+
-- if you want to accept unsigned tokens (using the
255+
-- "none" signature algorithm) then set this to true.
256+
--accept_none_alg = false
257+
258+
-- if you want to reject tokens signed using an algorithm
259+
-- not supported by lua-resty-jwt set this to false. If
260+
-- you leave it unset, the token signature will not be
261+
-- verified at all.
262+
--accept_unsupported_alg = true
263+
236264
}
237265
238266
-- call bearer_jwt_verify for OAuth 2.0 JWT validation

lib/resty/openidc.lua

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,6 @@ local function openidc_load_jwt_none_alg(enc_hdr, enc_payload)
451451
local header = cjson_s.decode(openidc_base64_url_decode(enc_hdr))
452452
local payload = cjson_s.decode(openidc_base64_url_decode(enc_payload))
453453
if header and payload and header.alg == "none" then
454-
ngx.log(ngx.DEBUG, "accept JWT with alg \"none\" and no signature from \"code\" flow")
455454
return {
456455
raw_header = enc_hdr,
457456
raw_payload = enc_payload,
@@ -742,13 +741,37 @@ local function uses_asymmetric_algorithm(jwt_header)
742741
return string.sub(jwt_header.alg, 1, 2) == "RS"
743742
end
744743

744+
-- is the JWT signing algorithm one that has been expected?
745+
local function is_algorithm_expected(jwt_header, expected_algs)
746+
if expected_algs == nil or not jwt_header or not jwt_header.alg then
747+
return true
748+
end
749+
if type(expected_algs) == 'string' then
750+
expected_algs = { expected_algs }
751+
end
752+
for _, alg in ipairs(expected_algs) do
753+
if alg == jwt_header.alg then
754+
return true
755+
end
756+
end
757+
return false
758+
end
759+
745760
-- parse a JWT and verify its signature (if present)
746-
local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_secret, symmetric_secret, ...)
761+
local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_secret,
762+
symmetric_secret, expected_algs, ...)
747763
local jwt = require "resty.jwt"
748764
local enc_hdr, enc_payload, enc_sign = string.match(jwt_string, '^(.+)%.(.+)%.(.*)$')
749765
if enc_payload and (not enc_sign or enc_sign == "") then
750766
local jwt = openidc_load_jwt_none_alg(enc_hdr, enc_payload)
751-
if jwt then return jwt end -- otherwise the JWT is invalid and load_jwt produces an error
767+
if jwt then
768+
if opts.accept_none_alg then
769+
ngx.log(ngx.DEBUG, "accept JWT with alg \"none\" and no signature")
770+
return jwt
771+
else
772+
return jwt, "token uses \"none\" alg but accept_none_alg is not enabled"
773+
end
774+
end -- otherwise the JWT is invalid and load_jwt produces an error
752775
end
753776

754777
local jwt_obj = jwt:load_jwt(jwt_string, nil)
@@ -760,6 +783,11 @@ local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_s
760783
return nil, reason
761784
end
762785

786+
if not is_algorithm_expected(jwt_obj.header, expected_algs) then
787+
local alg = jwt_obj.header and jwt_obj.header.alg or "no algorithm at all"
788+
return nil, "token is signed by unexpected algorithm \"" .. alg .. "\""
789+
end
790+
763791
local secret
764792
if is_algorithm_supported(jwt_obj.header) then
765793
if uses_asymmetric_algorithm(jwt_obj.header) then
@@ -853,13 +881,20 @@ local function openidc_authorization_response(opts, session)
853881
end
854882

855883
local jwt_obj
856-
jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, json.id_token, opts.secret, opts.client_secret)
884+
jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, json.id_token, opts.secret, opts.client_secret,
885+
opts.discovery.id_token_signing_alg_values_supported)
857886
if err then
887+
local alg = (jwt_obj and jwt_obj.header and jwt_obj.header.alg) or ''
858888
local is_unsupported_signature_error = jwt_obj and not jwt_obj.verified and not is_algorithm_supported(jwt_obj.header)
859889
if is_unsupported_signature_error then
860-
ngx.log(ngx.WARN, "ignored id_token signature as algorithm '" .. jwt_obj.header.alg .. "' is not supported")
890+
if opts.accept_unsupported_alg == nil or opts.accept_unsupported_alg then
891+
ngx.log(ngx.WARN, "ignored id_token signature as algorithm '" .. alg .. "' is not supported")
892+
else
893+
err = "token is signed using algorithm \"" .. alg .. "\" which is not supported by lua-resty-jwt"
894+
ngx.log(ngx.ERR, err)
895+
return nil, err, session.data.original_url, session
896+
end
861897
else
862-
local alg = (jwt_obj and jwt_obj.header and jwt_obj.header.alg) or ''
863898
ngx.log(ngx.ERR, "id_token '" .. alg .. "' signature verification failed")
864899
return nil, err, session.data.original_url, session
865900
end
@@ -1352,7 +1387,8 @@ function openidc.jwt_verify(access_token, opts, ...)
13521387
local v = openidc_cache_get("introspection", access_token)
13531388
if not v then
13541389
local jwt_obj
1355-
jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, access_token, opts.secret, opts.secret, ...)
1390+
jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, access_token, opts.secret, opts.secret,
1391+
opts.token_signing_alg_values_expected, ...)
13561392
if not err then
13571393
json = jwt_obj.payload
13581394
ngx.log(ngx.DEBUG, "jwt: ", cjson.encode(json))

tests/spec/bearer_token_verification_spec.lua

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -215,24 +215,48 @@ describe("when the access token doesn't contain the exp claim at all", function(
215215
end)
216216

217217
describe("when using a JWT not signed but using the 'none' alg", function()
218-
test_support.start_server({
219-
verify_opts = {
220-
discovery = {
221-
jwks_uri = "http://127.0.0.1/jwk",
222-
}
223-
},
224-
jwk = test_support.load("/spec/jwks_with_two_keys.json"),
225-
})
226-
teardown(test_support.stop_server)
227-
local jwt = test_support.self_signed_jwt({
228-
exp = os.time() + 3600,
229-
})
230-
local _, status = http.request({
231-
url = "http://127.0.0.1/verify_bearer_token",
232-
headers = { authorization = "Bearer " .. jwt }
233-
})
234-
it("the token is valid", function()
235-
assert.are.equals(204, status)
218+
describe("and we are willing to accept the none alg", function()
219+
test_support.start_server({
220+
verify_opts = {
221+
discovery = {
222+
jwks_uri = "http://127.0.0.1/jwk",
223+
},
224+
accept_none_alg = true,
225+
},
226+
jwk = test_support.load("/spec/jwks_with_two_keys.json"),
227+
})
228+
teardown(test_support.stop_server)
229+
local jwt = test_support.self_signed_jwt({
230+
exp = os.time() + 3600,
231+
})
232+
local _, status = http.request({
233+
url = "http://127.0.0.1/verify_bearer_token",
234+
headers = { authorization = "Bearer " .. jwt }
235+
})
236+
it("the token is valid", function()
237+
assert.are.equals(204, status)
238+
end)
239+
end)
240+
describe("and we are not willing to accept the none alg", function()
241+
test_support.start_server({
242+
verify_opts = {
243+
discovery = {
244+
jwks_uri = "http://127.0.0.1/jwk",
245+
},
246+
},
247+
jwk = test_support.load("/spec/jwks_with_two_keys.json"),
248+
})
249+
teardown(test_support.stop_server)
250+
local jwt = test_support.self_signed_jwt({
251+
exp = os.time() + 3600,
252+
})
253+
local _, status = http.request({
254+
url = "http://127.0.0.1/verify_bearer_token",
255+
headers = { authorization = "Bearer " .. jwt }
256+
})
257+
it("the token is invalid", function()
258+
assert.are.equals(401, status)
259+
end)
236260
end)
237261
end)
238262

@@ -535,3 +559,29 @@ describe("when the token is signed by an RSA key but jwks_uri is empty", functio
535559
assert.error_log_contains("Invalid token:.*jwks_uri is not present or not a string")
536560
end)
537561
end)
562+
563+
describe("when expecting an RSA signature but token uses HMAC", function()
564+
test_support.start_server({
565+
verify_opts = {
566+
secret = test_support.load("/spec/public_rsa_key.pem"),
567+
token_signing_alg_values_expected = "RS256"
568+
},
569+
jwt_verify_secret = test_support.load("/spec/public_rsa_key.pem"),
570+
token_header = {
571+
alg = "HS256",
572+
}
573+
})
574+
teardown(test_support.stop_server)
575+
local jwt = test_support.trim(http.request("http://127.0.0.1/jwt"))
576+
local _, status = http.request({
577+
url = "http://127.0.0.1/verify_bearer_token",
578+
headers = { authorization = "Bearer " .. jwt }
579+
})
580+
it("the response is invalid", function()
581+
assert.are.equals(401, status)
582+
end)
583+
it("an error has been logged", function()
584+
assert.error_log_contains("Invalid token:.*token is signed by unexpected algorithm \"HS256\"")
585+
end)
586+
end)
587+

tests/spec/id_token_validation_spec.lua

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -234,16 +234,64 @@ describe("when the id token signature uses a symmetric algorithm", function()
234234
end)
235235

236236
describe("when the id claims to be signed by an unsupported algorithm", function()
237-
test_support.start_server({
238-
fake_id_token_signature = "true"
239-
})
240-
teardown(test_support.stop_server)
241-
local _, status = test_support.login()
242-
it("login succeeds", function()
243-
assert.are.equals(302, status)
237+
describe("and accept_unsupported_alg is not set", function()
238+
test_support.start_server({
239+
fake_id_token_signature = "true",
240+
oidc_opts = {
241+
discovery = {
242+
id_token_signing_alg_values_supported = { "AB256" }
243+
}
244+
}
245+
})
246+
teardown(test_support.stop_server)
247+
local _, status = test_support.login()
248+
it("login succeeds", function()
249+
assert.are.equals(302, status)
250+
end)
251+
it("an error is logged", function()
252+
assert.error_log_contains("ignored id_token signature as algorithm 'AB256' is not supported")
253+
end)
254+
end)
255+
describe("and accept_unsupported_alg is true", function()
256+
test_support.start_server({
257+
fake_id_token_signature = "true",
258+
oidc_opts = {
259+
discovery = {
260+
id_token_signing_alg_values_supported = { "AB256" }
261+
},
262+
accept_unsupported_alg = true
263+
}
264+
})
265+
teardown(test_support.stop_server)
266+
local _, status = test_support.login()
267+
it("login succeeds", function()
268+
assert.are.equals(302, status)
269+
end)
270+
it("an error is logged", function()
271+
assert.error_log_contains("ignored id_token signature as algorithm 'AB256' is not supported")
272+
end)
244273
end)
245-
it("an error is logged", function()
246-
assert.error_log_contains("ignored id_token signature as algorithm 'AB256' is not supported")
274+
describe("and accept_unsupported_alg is false", function()
275+
test_support.start_server({
276+
fake_id_token_signature = "true",
277+
oidc_opts = {
278+
discovery = {
279+
id_token_signing_alg_values_supported = { "AB256" }
280+
},
281+
accept_unsupported_alg = false
282+
}
283+
})
284+
teardown(test_support.stop_server)
285+
local _, status = test_support.login()
286+
it("login has failed", function()
287+
assert.are.equals(401, status)
288+
end)
289+
it("an error message has been logged", function()
290+
assert.error_log_contains("token is signed using algorithm \"AB256\" which is not supported by lua%-resty%-jwt")
291+
end)
292+
it("authenticate returns an error", function()
293+
assert.error_log_contains("authenticate failed: token is signed using algorithm \"AB256\" which is not supported by lua%-resty%-jwt")
294+
end)
247295
end)
248296
end)
249297

@@ -264,3 +312,59 @@ describe("when the id token signature is invalid", function()
264312
end)
265313
end)
266314

315+
describe("when the id token signature uses the 'none' alg", function()
316+
describe("and we are not willing to accept the none alg", function()
317+
test_support.start_server({
318+
none_alg_id_token_signature = "true",
319+
})
320+
teardown(test_support.stop_server)
321+
local _, status = test_support.login()
322+
it("login has failed", function()
323+
assert.are.equals(401, status)
324+
end)
325+
it("an error message has been logged", function()
326+
assert.error_log_contains("id_token 'none' signature verification failed")
327+
end)
328+
it("authenticate returns an error", function()
329+
assert.error_log_contains("authenticate failed: token uses \"none\" alg but accept_none_alg is not enabled")
330+
end)
331+
end)
332+
describe("and we are willing to accept the none alg", function()
333+
test_support.start_server({
334+
none_alg_id_token_signature = "true",
335+
oidc_opts = {
336+
accept_none_alg = true,
337+
}
338+
})
339+
teardown(test_support.stop_server)
340+
local _, status = test_support.login()
341+
it("login succeeds", function()
342+
assert.are.equals(302, status)
343+
end)
344+
it("an message has been logged", function()
345+
assert.error_log_contains("accept JWT with alg \"none\" and no signature")
346+
end)
347+
end)
348+
end)
349+
350+
describe("when the id token is signed by an algorithm not announced by discovery endpoint", function()
351+
test_support.start_server({
352+
oidc_opts = {
353+
discovery = {
354+
id_token_signing_alg_values_supported = { "HS256" }
355+
}
356+
}
357+
})
358+
teardown(test_support.stop_server)
359+
local _, status = test_support.login()
360+
it("login has failed", function()
361+
assert.are.equals(401, status)
362+
end)
363+
it("an error message has been logged", function()
364+
assert.error_log_contains("token is signed by unexpected algorithm \"RS256\"")
365+
end)
366+
it("authenticate returns an error", function()
367+
assert.error_log_contains("authenticate failed: token is signed by unexpected algorithm \"RS256\"")
368+
end)
369+
end)
370+

0 commit comments

Comments
 (0)