From b279b3c8d8b925649b116f87d066244e2e314c4d Mon Sep 17 00:00:00 2001 From: chrisFrodo <77098270+chrisFrodo@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:45:54 +0200 Subject: [PATCH 1/5] add JWE support for OIDC compliance --- lib/resty/openidc.lua | 47 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index 6597cb2..ad852a7 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -58,6 +58,21 @@ local DEBUG = ngx.DEBUG local ERROR = ngx.ERR local WARN = ngx.WARN +-- Split string in multiple parts +local function split_string(str, delim) + local result = {} + local sep = string.format("([^%s]+)", delim) + for m in str:gmatch(sep) do + result[#result+1]=m + end + return result +end + +-- Test if string start with +local function string_starts(input_string, partern) + return string.sub(input_string, 1, string.len(input_string)) == patern +end + local function token_auth_method_precondition(method, required_field) return function(opts) if not opts[required_field] then @@ -972,7 +987,37 @@ symmetric_secret, expected_algs, ...) end -- otherwise the JWT is invalid and load_jwt produces an error end - local jwt_obj = r_jwt:load_jwt(jwt_string, nil) + -- dertermine if jwt uses JWS or JWE + local tokens = split_string(jwt_string, "%.") + local num_token = #tokens + local is_jwe = num_token > 3 + + local jwt_obj + if is_jwe then + jwe_header = cjson.decode(unb64(tokens[1])) + + if jwe_header.alg == "RSA-OAEP-256" then + if jwe_header.kid == opts.client_rsa_private_enc_key_id then + local jwe_obj = nil + jwe_obj = r_jwt:load_jwt(jwt_string, opts.client_rsa_private_enc_key) + -- Test if JWE payload exist or not + if jwe_obj.payload == nil and jwe_obj.internal ~= nil then + jwt_obj = r_jwt:load_jwt(jwe_obj.internal.json_payload, nil) + else + jwt_obj = jwe_obj + end + else + reason = "jwe_header.kid not matching client_rsa_private_enc_key_id" + return nil, reason + end + else + reason = "jwe_header.alg not supported by the jwt.lua library" + return nil, reason + end + else + jwt_obj = r_jwt:load_jwt(jwt_string, nil) + end + if not jwt_obj.valid then local reason = "invalid jwt" if jwt_obj.reason then From 4b9661b3d2b6433b9829d957fea3cdda9fcf6a2d Mon Sep 17 00:00:00 2001 From: chrisFrodo <77098270+chrisFrodo@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:47:12 +0200 Subject: [PATCH 2/5] fix bug with userinfo sent as JWT --- lib/resty/openidc.lua | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index ad852a7..b4e46b7 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -419,7 +419,21 @@ local function openidc_parse_json_response(response, ignore_body_on_success) res = cjson_s.decode(response.body) if not res then - err = "JSON decoding failed" + -- Try to parse body as JWT + local r_jwt = require("resty.jwt") + local jwt_obj = r_jwt:load_jwt(response.body, nil) + + -- Test if body successfully parsed + if not jwt_obj.valid then + local reason = "invalid jwt" + if jwt_obj.reason then + reason = reason .. ": " .. jwt_obj.reason + end + return nil, reason + else + res = jwt_obj.payload + end + end end From 95969728d3188d20e9dfce907064f286cf129110 Mon Sep 17 00:00:00 2001 From: chrisFrodo <77098270+chrisFrodo@users.noreply.github.com> Date: Thu, 24 Nov 2022 09:47:42 +0100 Subject: [PATCH 3/5] Move JWE decryypting logic of the "userinfo" from "openidc_parse_json_response" to "openidc.call_userinfo_endpoint" --- lib/resty/openidc.lua | 49 +++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index b4e46b7..05baad4 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -68,10 +68,6 @@ local function split_string(str, delim) return result end --- Test if string start with -local function string_starts(input_string, partern) - return string.sub(input_string, 1, string.len(input_string)) == patern -end local function token_auth_method_precondition(method, required_field) return function(opts) @@ -419,21 +415,8 @@ local function openidc_parse_json_response(response, ignore_body_on_success) res = cjson_s.decode(response.body) if not res then - -- Try to parse body as JWT - local r_jwt = require("resty.jwt") - local jwt_obj = r_jwt:load_jwt(response.body, nil) - - -- Test if body successfully parsed - if not jwt_obj.valid then - local reason = "invalid jwt" - if jwt_obj.reason then - reason = reason .. ": " .. jwt_obj.reason - end - return nil, reason - else - res = jwt_obj.payload - end - + err = "JSON decoding failed" + end end @@ -666,7 +649,33 @@ function openidc.call_userinfo_endpoint(opts, access_token) log(DEBUG, "userinfo response: ", res.body) -- parse the response from the user info endpoint - return openidc_parse_json_response(res) + local ignore_body_on_success = true + local json = nil + json, err = openidc_parse_json_response(res, ignore_body_on_success) + + -- Decode json if response is valid + if not json and not err then + json = cjson_s.decode(res.body) + + if not json then + -- Try to parse body as JWT + local r_jwt = require("resty.jwt") + local jwt_obj = r_jwt:load_jwt(res.body, nil) + + -- Test if body successfully parsed + if not jwt_obj.valid then + err = "invalid jwt" + if jwt_obj.reason then + err = err .. ": " .. jwt_obj.reason + end + return nil, err + else + res = jwt_obj.payload + end + end + end + + return res, err end local function can_use_token_auth_method(method, opts) From 6bad6742edea5f967c0cc2eaa64c7a3c140e86d5 Mon Sep 17 00:00:00 2001 From: chrisFrodo <77098270+chrisFrodo@users.noreply.github.com> Date: Thu, 24 Nov 2022 14:50:17 +0100 Subject: [PATCH 4/5] Edit the "openidc_load_jwt_and_verify_crypto" and adding minimal documentation to the README.md --- AUTHORS | 1 + README.md | 109 ++++++++++++++++++++++++++++++++++++++++++ lib/resty/openidc.lua | 69 +++++++++++++++----------- 3 files changed, 151 insertions(+), 28 deletions(-) diff --git a/AUTHORS b/AUTHORS index e2fd56d..d9b02f8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,3 +36,4 @@ reporting bugs, providing fixes, suggesting useful features or other: Eduardo Gonçalves Thorsten Fleischmann Tilmann Hars + Chris Frodo diff --git a/README.md b/README.md index 91738c5..297fb59 100644 --- a/README.md +++ b/README.md @@ -637,6 +637,115 @@ http { } ``` +## Sample Configuration for Keycloak OpenID Connect authentication with encrypted tokens + +Sample `nginx.conf` configuration to authenticate using OpenID Connect against a Keycloak 18.0 Authorization Server. +With this configuration, the authorization server returns encrypted tokens for each OIDC tokens. + +```nginx +events { + worker_connections 128; +} + +http { + + lua_package_path '~/lua/?.lua;;'; + + resolver 8.8.8.8; + + lua_ssl_trusted_certificate /opt/local/etc/openssl/cert.pem; + lua_ssl_verify_depth 5; + + # cache for validation results + lua_shared_dict introspection 10m; + + lua_package_cpath "/usr/local/include/lua/?.so;;"; + lua_package_path "/usr/local/openresty/luajit/share/lua/?.lua;/usr/local/lib/lua/?.lua;/usr/local/share/lua/5.1/?.lua;"; + lua_shared_dict discovery 1m; + lua_shared_dict jwks 1m; + + server { + # General settings + listen 443 ssl; + root /var/www/html; + resolver 127.0.0.1:5353; + + # Logs settings + access_log /etc/nginx/log/app-access.log; + error_log /etc/nginx/log/app-error.log; + + # SSL Settings + ssl_certificate /etc/nginx/keys/tls.crt; + ssl_certificate_key /etc/nginx/keys/tls.key; + ssl_verify_depth 2; + ssl_trusted_certificate /etc/nginx/ca/caBundle.crt; + + location = /favicon.ico { + log_not_found off; + } + + # Publish a statically generated JWKS to be used by the OP + location /public/jwks.json { + alias /etc/nginx/public/jwks.json; + + } + + location /secure { + access_by_lua_block { + local opts = { + redirect_uri_path = "/secure/redirect_uri", + discovery = "https:///realms//.well-known/openid-configuration", + client_id = "", + -- Set the client to use JWS as an authentication method + token_endpoint_auth_method = "private_key_jwt", + client_rsa_private_key =[[ +MIIEogIBAAKCAQEAiThmpvXBYdur716D2q7fYKirKxzZIU5QrkBGDvUOwg5izcTv +[...] +h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= + ]], + client_rsa_private_key_id = "265tDmmRsigvKPz8oygR0GcNdGX_naMP2cEGXR9Ueo0", + + + -- Encryption settings + -- client_rsa_private_enc_key is the RSA private key to be used to decrypt the JWE access_token / id_token generated by OP to authenticate the lua-resty-openidc RP. + -- client_rsa_private_enc_key_id is the key id to be set in the JWE header to identify which public key the OP has use to encrypt the access_token / id_token. + client_rsa_private_enc_key = [[ +-----BEGIN PRIVATE KEY----- +MIIJKgIBAAKCAgEA1jhYqRlY7WiW36fzdFo4dxkwQXQhouhDlqJSu5MRiaPpwVLn +[...] +5zgUDtKKOXrDePay6pcaqjLKRc2nB8ljeNpYGsrQHAiK20EckOjHJZoH+1dy0Q== +-----END PRIVATE KEY----- + ]], + client_rsa_private_enc_key_id = "AAAtDmmRsigvKPz8oygR0GcNdGX_naMP2cEGXR9Ueo0", + -- end encryption settings + + + redirect_uri_scheme = "https", + session_contents = {id_token=true, user=true, enc_id_token=true, access_token=true}, + token_signing_alg_values_expected = {"RS256"}, + ssl_verify = "yes", + scope = "openid email profile", + + logout_path = "/secure/logout", + redirect_after_logout_uri = "https:///realms//protocol/openid-connect/logout", + redirect_after_logout_with_id_token_hint = false, + post_logout_redirect_uri = "https:///" + + } + local openidc = require("resty.openidc") + local res, err = openidc.authenticate(opts) + if err then + ngx.status = 403 + ngx.say(err) + ngx.exit(ngx.HTTP_FORBIDDEN) + end + ngx.req.set_header('REMOTE_USER', res.id_token.email) + } + } + } +} +``` + ## Logging Logging can be customized, including using custom logger and remapping OpenIDC's diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index 05baad4..f507908 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -58,17 +58,6 @@ local DEBUG = ngx.DEBUG local ERROR = ngx.ERR local WARN = ngx.WARN --- Split string in multiple parts -local function split_string(str, delim) - local result = {} - local sep = string.format("([^%s]+)", delim) - for m in str:gmatch(sep) do - result[#result+1]=m - end - return result -end - - local function token_auth_method_precondition(method, required_field) return function(opts) if not opts[required_field] then @@ -997,9 +986,28 @@ end local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_secret, symmetric_secret, expected_algs, ...) local r_jwt = require("resty.jwt") - local enc_hdr, enc_payload, enc_sign = string.match(jwt_string, '^(.+)%.(.+)%.(.*)$') - if enc_payload and (not enc_sign or enc_sign == "") then - local jwt = openidc_load_jwt_none_alg(enc_hdr, enc_payload) + local jwt_obj + + -- Expect a JWT encoded as a JWE, extracting parts + local part1, part2, part3, part4, part5 = string.match(jwt_string, '^(.+)%.(.+)%.(.+)%.(.+)%.(.*)$') + + -- No parts extracted, try to extract parts of a JWS encoded JWT + if not part1 and not part2 then + part1, part2, part3= string.match(jwt_string, '^(.+)%.(.+)%.(.*)$') + part4, part5 = nil + end + + log(DEBUG, "part 1 : ",part1, ", part 2 : ",part2, ", part 3 : ",part3, ", part 4 : ",part4, ", part 5 : ",part5) + -- Determine type of JWT (simple JWT, JWS, JWE) : + + -- Case : is a simple JWT + if part1 and not part2 and not part3 and not part4 and not part5 then + return nil, "token is not secured, it's a simple JWT." + + -- Case : is an unsigned JWS + elseif part1 and part2 and (not part3 or part3 == "") and not part4 and not part5 then + -- part1 = JOSE Header, part2 = Payload, part3 = Signature, others are unused + local jwt = openidc_load_jwt_none_alg(part1, part2) if jwt then if opts.accept_none_alg then log(DEBUG, "accept JWT with alg \"none\" and no signature") @@ -1008,20 +1016,27 @@ symmetric_secret, expected_algs, ...) return jwt, "token uses \"none\" alg but accept_none_alg is not enabled" end end -- otherwise the JWT is invalid and load_jwt produces an error - end - -- dertermine if jwt uses JWS or JWE - local tokens = split_string(jwt_string, "%.") - local num_token = #tokens - local is_jwe = num_token > 3 + -- Case : is a signed JWS + elseif part1 and part2 and part3 and not part4 and not part5 then + -- part1 = JOSE Header, part2 = Payload, part3 = Signature, others are unused + jwt_obj = r_jwt:load_jwt(jwt_string, nil) - local jwt_obj - if is_jwe then - jwe_header = cjson.decode(unb64(tokens[1])) + -- Case : is a JWE, without or with preshared key + elseif (part1 and part2 and part3 and part4 and not part5) or + (part1 and part2 and part3 and part4 and part5) then + -- part1 = JOSE Header, part2 = Initialization Vector, part3 = Cyphertext, part4 = Authentication Tag , others are unused + -- or + -- part1 = JOSE Header, part2 = Pre-shared key, part3 = Initialization Vector, part4 = Cyphertext, part5 = Authentication Tag + local jwe_header = cjson.decode(unb64(part1)) + local jwe_obj = nil + + -- Limiration imposed by lua-resty-jwt v0.2.3 : + -- the "alg" must be either "RSA-OAEP-256" or "DIR" (function parse_jwe in lib jwt.lua, line 256 ) + if jwe_header.alg == "RSA-OAEP-256" then if jwe_header.kid == opts.client_rsa_private_enc_key_id then - local jwe_obj = nil jwe_obj = r_jwt:load_jwt(jwt_string, opts.client_rsa_private_enc_key) -- Test if JWE payload exist or not if jwe_obj.payload == nil and jwe_obj.internal ~= nil then @@ -1030,15 +1045,13 @@ symmetric_secret, expected_algs, ...) jwt_obj = jwe_obj end else - reason = "jwe_header.kid not matching client_rsa_private_enc_key_id" - return nil, reason + return nil, "jwe_header.kid not matching client_rsa_private_enc_key_id" end else - reason = "jwe_header.alg not supported by the jwt.lua library" - return nil, reason + return nil, "jwe_header.alg not supported by the jwt.lua library" end else - jwt_obj = r_jwt:load_jwt(jwt_string, nil) + end if not jwt_obj.valid then From 8e3b7e29f90b19b4e42e6907e82a66c80dc5bd85 Mon Sep 17 00:00:00 2001 From: chrisFrodo <77098270+chrisFrodo@users.noreply.github.com> Date: Thu, 24 Nov 2022 15:24:22 +0100 Subject: [PATCH 5/5] Include tests for JWE part --- lib/resty/openidc.lua | 60 +++--- tests/spec/id_token_validation_spec.lua | 162 +++++++++++++++ tests/spec/test_support.lua | 249 ++++++++++++++++-------- 3 files changed, 362 insertions(+), 109 deletions(-) diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index f507908..a4aafa5 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -638,33 +638,20 @@ function openidc.call_userinfo_endpoint(opts, access_token) log(DEBUG, "userinfo response: ", res.body) -- parse the response from the user info endpoint - local ignore_body_on_success = true - local json = nil - json, err = openidc_parse_json_response(res, ignore_body_on_success) - - -- Decode json if response is valid - if not json and not err then - json = cjson_s.decode(res.body) - - if not json then - -- Try to parse body as JWT - local r_jwt = require("resty.jwt") - local jwt_obj = r_jwt:load_jwt(res.body, nil) + local json + json, err = openidc_parse_json_response(res) - -- Test if body successfully parsed - if not jwt_obj.valid then - err = "invalid jwt" - if jwt_obj.reason then - err = err .. ": " .. jwt_obj.reason - end - return nil, err - else - res = jwt_obj.payload - end + -- If err, try to decode as jwt + if not json and err then + local r_jwt = require("resty.jwt") + local jwt_obj = r_jwt:load_jwt(res.body, nil) + if jwt_obj.valid then + json = jwt_obj.payload + err = nil end end - return res, err + return json, err end local function can_use_token_auth_method(method, opts) @@ -1015,7 +1002,10 @@ symmetric_secret, expected_algs, ...) else return jwt, "token uses \"none\" alg but accept_none_alg is not enabled" end - end -- otherwise the JWT is invalid and load_jwt produces an error + else + -- Return error when token look like a JWT or the token is unsigned but shouldn't (alg other than \"none\"") + return nil, "invalid unsigned jwt" + end -- Case : is a signed JWS elseif part1 and part2 and part3 and not part4 and not part5 then @@ -1034,15 +1024,26 @@ symmetric_secret, expected_algs, ...) -- Limiration imposed by lua-resty-jwt v0.2.3 : -- the "alg" must be either "RSA-OAEP-256" or "DIR" (function parse_jwe in lib jwt.lua, line 256 ) - - if jwe_header.alg == "RSA-OAEP-256" then + if not jwe_header.alg then + return nil, "jwe_header is missing the \"alg\" parameter" + elseif jwe_header.alg == "RSA-OAEP-256" then + if not opts.client_rsa_private_enc_key then + return nil, "OIDC config is missing a private RSA key" + elseif not opts.client_rsa_private_enc_key_id then + return nil, "OIDC config is missing a private RSA kid" + end + if jwe_header.kid == opts.client_rsa_private_enc_key_id then jwe_obj = r_jwt:load_jwt(jwt_string, opts.client_rsa_private_enc_key) -- Test if JWE payload exist or not if jwe_obj.payload == nil and jwe_obj.internal ~= nil then jwt_obj = r_jwt:load_jwt(jwe_obj.internal.json_payload, nil) - else - jwt_obj = jwe_obj + elseif type(jwe_obj.payload) == 'string' then + jwt_obj = r_jwt:load_jwt(jwe_obj.payload, nil) + elseif type(jwe_obj.payload) == 'table' then + return nil, "jwe_payload must be signed before beeing encrypted" + else + return nil, "jwe token cannot be decrypted" end else return nil, "jwe_header.kid not matching client_rsa_private_enc_key_id" @@ -1051,7 +1052,7 @@ symmetric_secret, expected_algs, ...) return nil, "jwe_header.alg not supported by the jwt.lua library" end else - + return nil, "invalid jwt" end if not jwt_obj.valid then @@ -1249,6 +1250,7 @@ local function openidc_authorization_response(opts, session) log(ERROR, "error calling userinfo endpoint: " .. err) elseif user then if id_token.sub ~= user.sub then + err = "\"sub\" claim in id_token (\"" .. (id_token.sub or "null") .. "\") is not equal to the \"sub\" claim returned from the userinfo endpoint (\"" .. (user.sub or "null") .. "\")" log(ERROR, err) else diff --git a/tests/spec/id_token_validation_spec.lua b/tests/spec/id_token_validation_spec.lua index 1f49d94..151f5c2 100644 --- a/tests/spec/id_token_validation_spec.lua +++ b/tests/spec/id_token_validation_spec.lua @@ -384,3 +384,165 @@ describe("when the id token is signed by an algorithm not announced by discovery end) end) +describe("when the id_token is encrypted with a pre-shared key", function() + describe("and the key managment algorithm is \"RSA-OAEP-256\"", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("login succeeds", function() + assert.are.equals(302, status) + end) + + end) + +end) + +describe("when the id_token cannot be decrypted", function() + describe("because the wrong RSA key is used", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_longer_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe token cannot be decrypted") + end) + end) + describe("because the wrong RSA kid is used", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAWrongencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe_header.kid not matching client_rsa_private_enc_key_id") + end) + end) + describe("because the wrong RSA kid is used", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAWrongencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe_header.kid not matching client_rsa_private_enc_key_id") + end) + end) + describe("because an unsupported key managment algorithm is used", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + jwe_fake_alg = "true", + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe_header.alg not supported by the jwt.lua library") + end) + end) + describe("because an unsupported encryption algorithm is used", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + jwe_fake_enc = "true", + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe token cannot be decrypted") + end) + end) + describe("because the token is faked", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + jwe_fake_jwe = "true", + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe token cannot be decrypted") + end) + end) +end) + +describe("when the id_token is encrypted and use a RSA \"alg\" and the config", function() + describe("is missing a private RSA key", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("OIDC config is missing a private RSA key") + end) + end) + describe("is missing a private RSA kid", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem") + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("OIDC config is missing a private RSA kid") + end) + end) +end) + +describe("when the id_token is encrypted but not signed", function() + test_support.start_server({ + jwe_enc_rsa_key = test_support.load("/spec/public_rsa_key.pem"), + jwe_signed_payload = "false", + oidc_opts = { + client_rsa_private_enc_key = test_support.load("/spec/private_rsa_key.pem"), + client_rsa_private_enc_key_id = "RSAencKID" + } + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("authenticate returns an error", function() + assert.error_log_contains("jwe_payload must be signed before beeing encrypted") + end) +end) + +-- TODO : Test valid 4 part JWE + -- Not implemented yet in openidc.lua : linked to direct symetric encryption (AES) + +-- TODO : Test no configured AES key + -- Not implemented yet in openidc.lua : linked to symetric encryption (AES) + +-- TODO : Test invalid JWE with AES => load_jwt fails + -- Not implemented yet in openidc.lua : linked to symetric encryption (AES) \ No newline at end of file diff --git a/tests/spec/test_support.lua b/tests/spec/test_support.lua index 84aee71..17e67a8 100644 --- a/tests/spec/test_support.lua +++ b/tests/spec/test_support.lua @@ -64,6 +64,22 @@ function test_support.self_signed_jwt(payload, alg, signature) end local DEFAULT_JWT_SIGN_SECRET = test_support.load("/spec/private_rsa_key.pem") +local DEFAULT_JWE_ENC_RSA_KEY = "" +local DEFAULT_JWE_ENC_RSA_KID = "RSAencKID" +local DEFAULT_JWE_DEC_RSA_KEY = test_support.load("/spec/private_rsa_key.pem") +local DEFAULT_JWE_ENC_AES_KEY = "" +local DEFAULT_JWE_ENC_AES_KID = "AESencKID" +local DEFAULT_JWE_TOKEN_HEADER = { + typ = "JWE", + alg = "RSA-OAEP-256", + enc = "A128CBC-HS256", + kid = DEFAULT_JWE_ENC_RSA_KID +} +local DEFAULT_JWE_FAKE_ALG = "false" +local DEFAULT_JWE_FAKE_ENC = "false" +local DEFAULT_JWE_FAKE_JWE = "false" + +local DEFAULT_JWE_SIGNED_PAYLOAD = "true" local DEFAULT_JWK = test_support.load("/spec/rsa_key_jwk_with_x5c.json") @@ -90,6 +106,94 @@ local DEFAULT_UNAUTH_ACTION = "nil" local DEFAULT_DELAY_RESPONSE = "0" +local DEFAULT_INIT_TEMPLATE = [[ +local test_globals = {} +local sign_secret = [=[ +JWT_SIGN_SECRET]=] +local jwe_enc_rsa_key = [=[JWE_ENC_RSA_KEY]=] +local jwe_enc_aes_key = [=[JWE_ENC_AES_KEY]=] + -- ground work for future implementation of JWE using AES 'alg' + +if os.getenv('coverage') then + require("luacov.runner")("/spec/luacov/settings.luacov") +end +test_globals.oidc = require "resty.openidc" +test_globals.cjson = require "cjson" + +test_globals.jwks = [=[JWK]=] +test_globals.use_jwe = jwe_enc_rsa_key ~= "" or jwe_enc_aes_key ~= "" + +test_globals.delay = function(delay_response) + if delay_response > 0 then + ngx.sleep(delay_response / 1000) + end +end + +test_globals.b64url = function(s) + return ngx.encode_base64(test_globals.cjson.encode(s)):gsub('+','-'):gsub('/','_') +end + +test_globals.create_jwt = function(payload, fake_signature) + if not fake_signature then + local jwt_content = { + header = TOKEN_HEADER, + payload = payload + } + local jwt = require "resty.jwt" + return jwt:sign(sign_secret, jwt_content) + else + local header = test_globals.b64url({ + typ = "JWT", + alg = "AB256" + }) + return header .. "." .. test_globals.b64url(payload) .. ".NOT_A_VALID_SIGNATURE" + end +end + +test_globals.create_jwe = function(payload, fake_alg, fake_enc, fake_jwe) + if jwe_enc_rsa_key ~= "" then + local jwe_header = JWE_TOKEN_HEADER + if fake_alg then + jwe_header.alg = "WRONG_ALG" + end + if fake_enc then + jwe_header.enc = "WRONG_ENC" + end + + if fake_alg or fake_enc or fake_jwe then + return test_globals.b64url(jwe_header) .. ".NOT_A_VALID_PRESHARED_KEY.NOT_A_VALID_IV.NOT_A_VALID_CIPHERTEXT.NOT_A_VALID_MAC" + else + local jwt_content = { + header = jwe_header, + payload = payload + } + local jwt = require "resty.jwt" + return jwt:sign(jwe_enc_rsa_key, jwt_content) + end + elseif jwe_enc_aes_key ~= "" then + ngx.log(ngx.ERR, "JWE w/ AES test not implemented yet") + return nil + else + ngx.log(ngx.ERR, "Something went wrong while creating the JWE") + return nil + end +end + +test_globals.query_decorator = function(req) + req.query = "foo=bar" + return req +end + +test_globals.body_decorator = function(req) + local body = ngx.decode_args(req.body) + body.foo = "bar" + req.body = ngx.encode_args(body) + return req +end + +return test_globals +]] + local DEFAULT_CONFIG_TEMPLATE = [[ worker_processes 1; pid /tmp/server/logs/nginx.pid; @@ -101,51 +205,10 @@ events { http { access_log /tmp/server/logs/access.log; - lua_package_path '~/lua/?.lua;;'; + lua_package_path '~/lua/?.lua;/tmp/server/conf/?.lua;;'; lua_shared_dict discovery 1m; init_by_lua_block { - sign_secret = [=[ -JWT_SIGN_SECRET]=] - if os.getenv('coverage') then - require("luacov.runner")("/spec/luacov/settings.luacov") - end - oidc = require "resty.openidc" - cjson = require "cjson" - delay = function(delay_response) - if delay_response > 0 then - ngx.sleep(delay_response / 1000) - end - end - b64url = function(s) - return ngx.encode_base64(cjson.encode(s)):gsub('+','-'):gsub('/','_') - end - create_jwt = function(payload, fake_signature) - if not fake_signature then - local jwt_content = { - header = TOKEN_HEADER, - payload = payload - } - local jwt = require "resty.jwt" - return jwt:sign(sign_secret, jwt_content) - else - local header = b64url({ - typ = "JWT", - alg = "AB256" - }) - return header .. "." .. b64url(payload) .. ".NOT_A_VALID_SIGNATURE" - end - end - query_decorator = function(req) - req.query = "foo=bar" - return req - end - body_decorator = function(req) - local body = ngx.decode_args(req.body) - body.foo = "bar" - req.body = ngx.encode_args(body) - return req - end - jwks = [=[JWK]=] + test_globals = require("test_globals") } resolver 8.8.8.8; @@ -160,7 +223,7 @@ JWT_SIGN_SECRET]=] location /jwt { content_by_lua_block { - local jwt_token = create_jwt(ACCESS_TOKEN, FAKE_ACCESS_TOKEN_SIGNATURE) + local jwt_token = test_globals.create_jwt(ACCESS_TOKEN, FAKE_ACCESS_TOKEN_SIGNATURE) ngx.header.content_type = 'text/plain' ngx.say(jwt_token) } @@ -168,10 +231,10 @@ JWT_SIGN_SECRET]=] location /jwk { content_by_lua_block { - ngx.log(ngx.ERR, "jwk uri_args: " .. cjson.encode(ngx.req.get_uri_args())) + ngx.log(ngx.ERR, "jwk uri_args: " .. test_globals.cjson.encode(ngx.req.get_uri_args())) ngx.header.content_type = 'application/json;charset=UTF-8' - delay(JWK_DELAY_RESPONSE) - ngx.say(jwks) + test_globals.delay(JWK_DELAY_RESPONSE) + ngx.say(test_globals.jwks) } } @@ -183,9 +246,9 @@ JWT_SIGN_SECRET]=] access_by_lua_block { local opts = OIDC_CONFIG if opts.decorate then - opts.http_request_decorator = opts.decorate == "body" and body_decorator or query_decorator + opts.http_request_decorator = opts.decorate == "body" and test_globals.body_decorator or test_globals.query_decorator end - local res, err, target, session = oidc.authenticate(opts, nil, UNAUTH_ACTION) + local res, err, target, session = test_globals.oidc.authenticate(opts, nil, UNAUTH_ACTION) if err then ngx.status = 401 ngx.log(ngx.ERR, "authenticate failed: " .. err) @@ -204,10 +267,10 @@ JWT_SIGN_SECRET]=] access_by_lua_block { local opts = OIDC_CONFIG if opts.decorate then - opts.http_request_decorator = opts.decorate == "body" and body_decorator or query_decorator + opts.http_request_decorator = opts.decorate == "body" and test_globals.body_decorator or test_globals.query_decorator end local uri = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri - local res, err, target, session = oidc.authenticate(opts, uri, UNAUTH_ACTION) + local res, err, target, session = test_globals.oidc.authenticate(opts, uri, UNAUTH_ACTION) if err then ngx.status = 401 ngx.log(ngx.ERR, "authenticate failed: " .. err) @@ -253,15 +316,23 @@ JWT_SIGN_SECRET]=] end local jwt_token if NONE_ALG_ID_TOKEN_SIGNATURE then - local header = b64url({ + local header = test_globals.b64url({ typ = "JWT", alg = "none" }) - jwt_token = header .. "." .. b64url(id_token) .. "." + jwt_token = header .. "." .. test_globals.b64url(id_token) .. "." else - jwt_token = create_jwt(id_token, FAKE_ID_TOKEN_SIGNATURE) - if BREAK_ID_TOKEN_SIGNATURE then - jwt_token = jwt_token:sub(1, -6) .. "XXXXX" + if not test_globals.use_jwe then + jwt_token = test_globals.create_jwt(id_token, FAKE_ID_TOKEN_SIGNATURE) + if BREAK_ID_TOKEN_SIGNATURE then + jwt_token = jwt_token:sub(1, -6) .. "XXXXX" + end + else + if JWE_SIGNED_PAYLOAD then + jwt_token = test_globals.create_jwe(test_globals.create_jwt(id_token), JWE_FAKE_ALG, JWE_FAKE_ENC, JWE_FAKE_JWE) + else + jwt_token = test_globals.create_jwe(id_token, JWE_FAKE_ALG, JWE_FAKE_ENC, JWE_fake_JWE) + end end end local token_response = { @@ -272,8 +343,8 @@ JWT_SIGN_SECRET]=] if args.grant_type == "authorization_code" or REFRESH_RESPONSE_CONTAINS_ID_TOKEN then token_response.id_token = jwt_token end - delay(TOKEN_DELAY_RESPONSE) - ngx.say(cjson.encode(token_response)) + test_globals.delay(TOKEN_DELAY_RESPONSE) + ngx.say(test_globals.cjson.encode(token_response)) } } @@ -281,24 +352,24 @@ JWT_SIGN_SECRET]=] content_by_lua_block { local opts = VERIFY_OPTS if opts.decorate then - opts.http_request_decorator = query_decorator + opts.http_request_decorator = test_globals.query_decorator end - local json, err, token = oidc.bearer_jwt_verify(opts) + local json, err, token = test_globals.oidc.bearer_jwt_verify(opts) if err then ngx.status = 401 ngx.log(ngx.ERR, "Invalid token: " .. err) else ngx.status = 204 - ngx.log(ngx.ERR, "Valid token: " .. cjson.encode(json)) + ngx.log(ngx.ERR, "Valid token: " .. test_globals.cjson.encode(json)) end } } location /discovery { content_by_lua_block { - ngx.log(ngx.ERR, "discovery uri_args: " .. cjson.encode(ngx.req.get_uri_args())) + ngx.log(ngx.ERR, "discovery uri_args: " .. test_globals.cjson.encode(ngx.req.get_uri_args())) ngx.header.content_type = 'application/json;charset=UTF-8' - delay(DISCOVERY_DELAY_RESPONSE) + test_globals.delay(DISCOVERY_DELAY_RESPONSE) ngx.say([=[{ "authorization_endpoint": "http://127.0.0.1/authorize", "token_endpoint": "http://127.0.0.1/token", @@ -311,11 +382,11 @@ JWT_SIGN_SECRET]=] location /user-info { content_by_lua_block { - delay(USERINFO_DELAY_RESPONSE) + test_globals.delay(USERINFO_DELAY_RESPONSE) local auth = ngx.req.get_headers()["Authorization"] ngx.log(ngx.ERR, "userinfo authorization header: " .. (auth and auth or "")) ngx.header.content_type = 'application/json;charset=UTF-8' - ngx.say(cjson.encode(USERINFO)) + ngx.say(test_globals.cjson.encode(USERINFO)) } } @@ -337,8 +408,8 @@ JWT_SIGN_SECRET]=] ngx.log(ngx.ERR, "no cookie in introspection call") end ngx.header.content_type = 'application/json;charset=UTF-8' - delay(INTROSPECTION_DELAY_RESPONSE) - ngx.say(cjson.encode(INTROSPECTION_RESPONSE)) + test_globals.delay(INTROSPECTION_DELAY_RESPONSE) + ngx.say(test_globals.cjson.encode(INTROSPECTION_RESPONSE)) } } @@ -346,22 +417,22 @@ JWT_SIGN_SECRET]=] content_by_lua_block { local opts = INTROSPECTION_OPTS if opts.decorate then - opts.http_request_decorator = body_decorator + opts.http_request_decorator = test_globals.body_decorator end - local json, err = oidc.introspect(opts) + local json, err = test_globals.oidc.introspect(opts) if err then ngx.status = 401 ngx.log(ngx.ERR, "Introspection error: " .. err) else ngx.header.content_type = 'application/json;charset=UTF-8' - ngx.say(cjson.encode(json)) + ngx.say(test_globals.cjson.encode(json)) end } } location /access_token { content_by_lua_block { - local access_token, err = oidc.access_token(ACCESS_TOKEN_OPTS) + local access_token, err = test_globals.oidc.access_token(ACCESS_TOKEN_OPTS) if not access_token then ngx.status = 401 ngx.log(ngx.ERR, "access_token error: " .. (err or 'no message')) @@ -375,8 +446,8 @@ JWT_SIGN_SECRET]=] location /revoke_tokens { content_by_lua_block { local opts = OIDC_CONFIG - local res, err, target, session = oidc.authenticate(opts, nil, UNAUTH_ACTION) - local r = oidc.revoke_tokens(opts, session) + local res, err, target, session = test_globals.oidc.authenticate(opts, nil, UNAUTH_ACTION) + local r = test_globals.oidc.revoke_tokens(opts, session) ngx.header.content_type = 'text/plain' ngx.say('revoke-result: ' .. tostring(r)) } @@ -393,7 +464,7 @@ JWT_SIGN_SECRET]=] ngx.log(ngx.ERR, "no cookie in introspection call") end ngx.header.content_type = 'application/json;charset=UTF-8' - delay(REVOCATION_DELAY_RESPONSE) + test_globals.delay(REVOCATION_DELAY_RESPONSE) ngx.status = 200 ngx.say('INVALID JSON.') } @@ -424,7 +495,7 @@ end local DEFAULT_INTROSPECTION_RESPONSE = merge({active=true}, DEFAULT_ACCESS_TOKEN) -local function write_config(out, custom_config) +local function write_template(out, template, custom_config) custom_config = custom_config or {} local oidc_config = merge(merge({}, DEFAULT_OIDC_CONFIG), custom_config["oidc_opts"] or {}) local id_token = merge(merge({}, DEFAULT_ID_TOKEN), custom_config["id_token"] or {}) @@ -432,6 +503,8 @@ local function write_config(out, custom_config) local verify_opts = merge(merge({}, DEFAULT_VERIFY_OPTS), custom_config["verify_opts"] or {}) local access_token = merge(merge({}, DEFAULT_ACCESS_TOKEN), custom_config["access_token"] or {}) local token_header = merge(merge({}, DEFAULT_TOKEN_HEADER), custom_config["token_header"] or {}) + local jwe_token_header = merge(merge({}, DEFAULT_JWE_TOKEN_HEADER), custom_config["jwe_token_header"] or {}) + local userinfo = merge(merge({}, DEFAULT_ID_TOKEN), custom_config["userinfo"] or {}) local introspection_response = merge(merge({}, DEFAULT_INTROSPECTION_RESPONSE), custom_config["introspection_response"] or {}) @@ -443,6 +516,7 @@ local function write_config(out, custom_config) local refreshing_token_fails = custom_config["refreshing_token_fails"] or DEFAULT_REFRESHING_TOKEN_FAILS local refresh_response_contains_id_token = custom_config["refresh_response_contains_id_token"] or DEFAULT_REFRESH_RESPONSE_CONTAINS_ID_TOKEN local access_token_opts = merge(merge({}, DEFAULT_OIDC_CONFIG), custom_config["access_token_opts"] or {}) + for _, k in ipairs(custom_config["remove_id_token_claims"] or {}) do id_token[k] = nil end @@ -464,8 +538,9 @@ local function write_config(out, custom_config) for _, k in ipairs(custom_config["remove_introspection_config_keys"] or {}) do introspection_opts[k] = nil end - local config = DEFAULT_CONFIG_TEMPLATE + local content = template :gsub("OIDC_CONFIG", serpent.block(oidc_config, {comment = false })) + :gsub("JWE_TOKEN_HEADER", serpent.block(jwe_token_header, {comment = false })) :gsub("TOKEN_HEADER", serpent.block(token_header, {comment = false })) :gsub("JWT_SIGN_SECRET", custom_config["jwt_sign_secret"] or DEFAULT_JWT_SIGN_SECRET) :gsub("VERIFY_OPTS", serpent.block(verify_opts, {comment = false })) @@ -492,7 +567,17 @@ local function write_config(out, custom_config) :gsub("ID_TOKEN", serpent.block(id_token, {comment = false })) :gsub("ACCESS_TOKEN", serpent.block(access_token, {comment = false })) :gsub("UNAUTH_ACTION", custom_config["unauth_action"] and ('"' .. custom_config["unauth_action"] .. '"') or DEFAULT_UNAUTH_ACTION) - out:write(config) + :gsub("JWE_ENC_RSA_KEY", custom_config["jwe_enc_rsa_key"] or DEFAULT_JWE_ENC_RSA_KEY) + :gsub("JWE_ENC_RSA_KID", custom_config["jwe_enc_rsa_kid"] or DEFAULT_JWE_ENC_RSA_KID) + :gsub("JWE_DEC_RSA_KEY", custom_config["jwe_dec_rsa_key"] or DEFAULT_JWE_DEC_RSA_KEY) + :gsub("JWE_DEC_RSA_KID", custom_config["jwe_enc_rsa_kid"] or DEFAULT_JWE_ENC_RSA_KID) + :gsub("JWE_ENC_AES_KEY", custom_config["jwe_enc_aes_key"] or DEFAULT_JWE_ENC_AES_KEY) + :gsub("JWE_ENC_AES_KID", custom_config["jwe_enc_aes_kid"] or DEFAULT_JWE_ENC_AES_KID) + :gsub("JWE_SIGNED_PAYLOAD", custom_config["jwe_signed_payload"] or DEFAULT_JWE_SIGNED_PAYLOAD) + :gsub("JWE_FAKE_ALG", custom_config["jwe_fake_alg"] or DEFAULT_JWE_FAKE_ALG) + :gsub("JWE_FAKE_ENC", custom_config["jwe_fake_enc"] or DEFAULT_JWE_FAKE_ENC) + :gsub("JWE_FAKE_JWE", custom_config["jwe_fake_jwe"] or DEFAULT_JWE_FAKE_JWE) + out:write(content) end -- starts a server instance with some customizations of the configuration. @@ -535,8 +620,11 @@ function test_support.start_server(custom_config) assert(os.execute("rm -rf /tmp/server"), "failed to remove old server dir") assert(os.execute("mkdir -p /tmp/server/conf"), "failed to create server dir") assert(os.execute("mkdir -p /tmp/server/logs"), "failed to create log dir") - local out = assert(io.open("/tmp/server/conf/nginx.conf", "w")) - write_config(out, custom_config) + local out = assert(io.open("/tmp/server/conf/test_globals.lua", "w")) + write_template(out, DEFAULT_INIT_TEMPLATE, custom_config) + assert(out:close()) + out = assert(io.open("/tmp/server/conf/nginx.conf", "w")) + write_template(out, DEFAULT_CONFIG_TEMPLATE, custom_config) assert(out:close()) assert(os.execute("openresty -c /tmp/server/conf/nginx.conf > /dev/null"), "failed to start nginx") end @@ -592,7 +680,8 @@ end -- returns a Cookie header value based on all cookies requested via -- Set-Cookie inside headers function test_support.extract_cookies(headers) - local pair = headers["set-cookie"] or '' + local h = headers or {} + local pair = h["set-cookie"] or '' local semi = pair:find(";") if semi then pair = pair:sub(1, semi - 1)