diff --git a/AUTHORS b/AUTHORS index a39b33c..686c468 100644 --- a/AUTHORS +++ b/AUTHORS @@ -31,3 +31,4 @@ reporting bugs, providing fixes, suggesting useful features or other: Dmitriy Blok Oleander Reis Michael Johansen + Joshua Erney diff --git a/ChangeLog b/ChangeLog index 120991b..75300a5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,22 @@ 02/18/2019 - release 1.7.1 +12/17/2018 +- don't select one of the jwt token auth methods if the required key + material is not present; see #238 + +11/13/2018 +- fixed a bad error return value in certain setups of + bearer_jwt_verify; see #234; thanks @JoshTheGoldfish + +11/09/2018 +- added support for the client_secret_jwt authentication method; see #229 + +11/08/2018 +- added support for the private_key_jwt authentication method; see + #217; thanks @pamiel +>>>>>>> branch 'master' of https://github.com/zmartzone/lua-resty-openidc.git + 11/06/2018 - make sure opts.discovery is resolved when "iss" is returned as part of the authorization response; see #224 ; thanks @mijohansen diff --git a/README.md b/README.md index ca23bd5..7e4d2eb 100644 --- a/README.md +++ b/README.md @@ -112,17 +112,37 @@ http { -- if the URI starts with a / the full redirect URI becomes -- ngx.var.scheme.."://"..ngx.var.http_host..opts.redirect_uri -- unless the scheme was overridden using opts.redirect_uri_scheme or an X-Forwarded-Proto header in the incoming request - redirect_uri = "https://MY_HOST_NAME/redirect_uri" + redirect_uri = "https://MY_HOST_NAME/redirect_uri", -- up until version 1.6.1 you'd specify -- redirect_uri_path = "/redirect_uri", -- and could not set the hostname + -- The discovery endpoint of the OP. Enable to get the URI of all endpoints (Token, introspection, logout...) discovery = "https://accounts.google.com/.well-known/openid-configuration", - -- For non compliant OPs to OAuth 2.0 RFC 6749 for client Authentication (cf. https://tools.ietf.org/html/rfc6749#section-2.3.1) - -- client_id and client_secret MUST be invariant when url encoded + + -- Access to OP Token endpoint requires an authentication. Several authentication modes are supported: + --token_endpoint_auth_method = ["client_secret_basic"|"client_secret_post"|"private_key_jwt"|"client_secret_jwt"], + -- o If token_endpoint_auth_method is set to "client_secret_basic", "client_secret_post", or "client_secret_jwt", authentication to Token endpoint is using client_id and client_secret + -- For non compliant OPs to OAuth 2.0 RFC 6749 for client Authentication (cf. https://tools.ietf.org/html/rfc6749#section-2.3.1) + -- client_id and client_secret MUST be invariant when url encoded client_id = "", client_secret = "", - + -- o If token_endpoint_auth_method is set to "private_key_jwt" authentication to Token endpoint is using client_id, client_rsa_private_key and client_rsa_private_key_id to compute a signed JWT + -- client_rsa_private_key is the RSA private key to be used to sign the JWT generated by lua-resty-openidc for authentication to the OP + -- client_rsa_private_key_id (optional) is the key id to be set in the JWT header to identify which public key the OP shall use to verify the JWT signature + --client_id = "", + --client_rsa_private_key=[[-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAiThmpvXBYdur716D2q7fYKirKxzZIU5QrkBGDvUOwg5izcTv +[...] +h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= +-----END RSA PRIVATE KEY-----]], + --client_rsa_private_key_id="key id#1", + -- Life duration expressed in seconds of the signed JWT generated by lua-resty-openidc for authentication to the OP. + -- (used when token_endpoint_auth_method is set to "private_key_jwt" or "client_secret_jwt" authentication). Default is 60 seconds. + --client_jwt_assertion_expires_in = 60, + -- When using https to any OP endpoints, enforcement of SSL certificate check can be mandated ("yes") or not ("no"). + --ssl_verify = "no", + --authorization_params = { hd="zmartzone.eu" }, --scope = "openid email profile", -- Refresh the users id_token after 900 seconds without requiring re-authentication @@ -136,8 +156,6 @@ http { -- Whether the redirection after logout should include the id token as an hint (if available). This option is used only if redirect_after_logout_uri is set. --post_logout_redirect_uri = "https://www.zmartzone.eu/logoutSuccessful", -- Where does the RP requests that the OP redirects the user after logout. If this option is set to a relative URI, it will be relative to the OP's logout endpoint, not the RP's. - --token_endpoint_auth_method = ["client_secret_basic"|"client_secret_post"], - --ssl_verify = "no" --accept_none_alg = false -- if your OpenID Connect Provider doesn't sign its id tokens diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index 66590af..7ebfe48 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -66,9 +66,21 @@ local DEBUG = ngx.DEBUG local ERROR = ngx.ERR local WARN = ngx.WARN +local function token_auth_method_precondition(method, required_field) + return function(opts) + if not opts[required_field] then + log(DEBUG, "Can't use " .. method .. " without opts." .. required_field) + return false + end + return true + end +end + local supported_token_auth_methods = { client_secret_basic = true, - client_secret_post = true + client_secret_post = true, + private_key_jwt = token_auth_method_precondition('private_key_jwt', 'client_rsa_private_key'), + client_secret_jwt = token_auth_method_precondition('client_secret_jwt', 'client_secret') } local openidc = { @@ -405,13 +417,43 @@ function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name, headers.Authorization = "Basic " .. b64(ngx.escape_uri(opts.client_id) .. ":") end log(DEBUG, "client_secret_basic: authorization header '" .. headers.Authorization .. "'") - end - if auth == "client_secret_post" then + + elseif auth == "client_secret_post" then body.client_id = opts.client_id if opts.client_secret then body.client_secret = opts.client_secret end log(DEBUG, "client_secret_post: client_id and client_secret being sent in POST body") + + elseif auth == "private_key_jwt" or auth == "client_secret_jwt" then + local key = auth == "private_key_jwt" and opts.client_rsa_private_key or opts.client_secret + if not key then + return nil, "Can't use " .. auth .. " without a key." + end + body.client_id = opts.client_id + body.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + local now = ngx.time() + local assertion = { + header = { + typ = "JWT", + alg = auth == "private_key_jwt" and "RS256" or "HS256", + }, + payload = { + iss = opts.client_id, + sub = opts.client_id, + aud = endpoint, + jti = ngx.var.request_id, + exp = now + (opts.client_jwt_assertion_expires_in and opts.client_jwt_assertion_expires_in or 60), + iat = now + } + } + if auth == "private_key_jwt" then + assertion.header.kid = opts.client_rsa_private_key_id + end + + local r_jwt = require("resty.jwt") + body.client_assertion = r_jwt:sign(key, assertion) + log(DEBUG, auth .. ": client_id, client_assertion_type and client_assertion being sent in POST body") end end @@ -550,10 +592,15 @@ local function openidc_ensure_discovered_data(opts) return err end +local function can_use_token_auth_method(method, opts) + local supported = supported_token_auth_methods[method] + return supported and (type(supported) ~= 'function' or supported(opts)) +end + -- get the token endpoint authentication method local function openidc_get_token_auth_method(opts) - if opts.token_endpoint_auth_method ~= nil and not supported_token_auth_methods[opts.token_endpoint_auth_method] then + if opts.token_endpoint_auth_method ~= nil and not can_use_token_auth_method(opts.token_endpoint_auth_method, opts) then log(ERROR, "configured value for token_endpoint_auth_method (" .. opts.token_endpoint_auth_method .. ") is not supported, ignoring it") opts.token_endpoint_auth_method = nil end @@ -577,7 +624,7 @@ local function openidc_get_token_auth_method(opts) else for index, value in ipairs(opts.discovery.token_endpoint_auth_methods_supported) do log(DEBUG, index .. " => " .. value) - if supported_token_auth_methods[value] then + if can_use_token_auth_method(value, opts) then result = value log(DEBUG, "no configuration setting for option so select the first supported method specified by the OP: " .. result) break @@ -858,7 +905,7 @@ end -- parse a JWT and verify its signature (if present) local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_secret, symmetric_secret, expected_algs, ...) - local jwt = require("resty.jwt") + 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) @@ -872,7 +919,7 @@ symmetric_secret, expected_algs, ...) end -- otherwise the JWT is invalid and load_jwt produces an error end - local jwt_obj = jwt:load_jwt(jwt_string, nil) + local jwt_obj = r_jwt:load_jwt(jwt_string, nil) if not jwt_obj.valid then local reason = "invalid jwt" if jwt_obj.reason then @@ -920,7 +967,7 @@ symmetric_secret, expected_algs, ...) jwt_validators.set_system_leeway(opts.iat_slack and opts.iat_slack or 120) end - jwt_obj = jwt:verify_jwt_obj(secret, jwt_obj, ...) + jwt_obj = r_jwt:verify_jwt_obj(secret, jwt_obj, ...) if jwt_obj then log(DEBUG, "jwt: ", cjson.encode(jwt_obj), " ,valid: ", jwt_obj.valid, ", verified: ", jwt_obj.verified) end @@ -1410,7 +1457,7 @@ local function openidc_get_bearer_access_token_from_cookie(opts) local accept_token_as = opts.auth_accept_token_as or "header" if accept_token_as:find("cookie") ~= 1 then - return nul, "openidc_get_bearer_access_token_from_cookie called but auth_accept_token_as wants " + return nil, "openidc_get_bearer_access_token_from_cookie called but auth_accept_token_as wants " .. opts.auth_accept_token_as end local divider = accept_token_as:find(':') diff --git a/tests/spec/token_request_spec.lua b/tests/spec/token_request_spec.lua index a566f6c..320f410 100644 --- a/tests/spec/token_request_spec.lua +++ b/tests/spec/token_request_spec.lua @@ -30,9 +30,15 @@ describe("when the token endpoint is invoked", function() it("the request contains the client_secret parameter", function() assert_token_endpoint_call_contains("client_secret=client_secret") end) - it("the request doesn't contain a basic auth header", function() + it("the request doesn't contain any basic auth header", function() assert.is_not.error_log_contains("token authorization header: Basic") end) + it("the request doesn't contain any client_assertion_type parameter", function() + assert.is_not.error_log_contains("request body for token endpoint call: .*client_assertion_type=") + end) + it("the request doesn't contain any client_assertion parameter", function() + assert.is_not.error_log_contains("request body for token endpoint call: .*client_assertion=.*") + end) end) describe("when the token endpoint is invoked using client_secret_basic", function() @@ -51,6 +57,12 @@ describe("when the token endpoint is invoked using client_secret_basic", functio it("the request contains a basic auth header", function() assert.error_log_contains("token authorization header: Basic") end) + it("the request doesn't contain any client_assertion_type parameter", function() + assert.is_not.error_log_contains("request body for token endpoint call: .*client_assertion_type=") + end) + it("the request doesn't contain any client_assertion parameter", function() + assert.is_not.error_log_contains("request body for token endpoint call: .*client_assertion=.*") + end) end) describe("when no explicit auth method is configured #96", function() @@ -84,6 +96,40 @@ describe("when an explicit auth method is configured", function() end) end) +describe("when 'private_key_jwt' auth method is configured", function() + test_support.start_server({ + oidc_opts = { + discovery = { + token_endpoint_auth_methods_supported = { "client_secret_basic", "client_secret_post", "private_key_jwt" }, + }, + token_endpoint_auth_method = "private_key_jwt", + client_rsa_private_key = test_support.load("/spec/private_rsa_key.pem") + } + }) + teardown(test_support.stop_server) + test_support.login() + it("then it is used", function() + assert_token_endpoint_call_contains("client_assertion=ey") -- check only beginning of the assertion as it changes each time + assert_token_endpoint_call_contains("client_assertion_type=urn%%3Aietf%%3Aparams%%3Aoauth%%3Aclient%-assertion%-type%%3Ajwt%-bearer") + end) +end) + +describe("when 'private_key_jwt' auth method is configured but no key specified", function() + test_support.start_server({ + oidc_opts = { + discovery = { + token_endpoint_auth_methods_supported = { "client_secret_basic", "client_secret_post", "private_key_jwt" }, + }, + token_endpoint_auth_method = "private_key_jwt", + } + }) + teardown(test_support.stop_server) + test_support.login() + it("then it is not used", function() + assert.error_log_contains("token authorization header: Basic") + end) +end) + describe("if token endpoint is not resolvable", function() test_support.start_server({ oidc_opts = { @@ -213,3 +259,90 @@ describe("when a request_decorator has been specified when calling the token end assert_token_endpoint_call_contains("foo=bar") end) end) + +local function extract_jwt_from_error_log() + local log = test_support.load("/tmp/server/logs/error.log") + local encoded_jwt = log:match("request body for token endpoint call: .*client_assertion=([^\n&]+)") + local enc_hdr, enc_payload, enc_sign = string.match(encoded_jwt, '^(.+)%.(.+)%.(.*)$') + local base64_url_decode = function(s) + local mime = require "mime" + return mime.unb64(s:gsub('-','+'):gsub('_','/')) + end + local dkjson = require "dkjson" + return { + header = dkjson.decode(base64_url_decode(enc_hdr), 1, nil), + payload = dkjson.decode(base64_url_decode(enc_payload), 1, nil), + signature = enc_sign + } +end + +describe("when the token endpoint is invoked using client_secret_jwt", function() + test_support.start_server({ + oidc_opts = { + discovery = { + token_endpoint_auth_methods_supported = { "client_secret_jwt" }, + } + } + }) + teardown(test_support.stop_server) + test_support.login() + it("the request doesn't contain the client_secret as parameter", function() + assert.is_not.error_log_contains("request body for token endpoint call: .*client_secret=client_secret.*") + end) + it("the request doesn't contain a basic auth header", function() + assert.is_not.error_log_contains("token authorization header: Basic") + end) + it("the request contains the proper client_assertion_type parameter", function() + -- url.escape escapes the "-" while openidc doesn't so we must revert the encoding for comparison + local at = test_support.urlescape_for_regex("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + :gsub("%%%%2d", "%%-") + assert.error_log_contains("request body for token endpoint call: .*client_assertion_type="..at..".*", true) + end) + it("the request contains a client_assertion parameter", function() + assert.error_log_contains("request body for token endpoint call: .*client_assertion=.*") + end) + describe("then the submitted JWT", function() + local jwt = extract_jwt_from_error_log() + it("has a proper HMAC header", function() + assert.are.equal("JWT", jwt.header.typ) + assert.are.equal("HS256", jwt.header.alg) + end) + it("is signed", function() + assert.truthy(jwt.signature) + end) + it("contains the client_id as iss claim", function() + assert.are.equal("client_id", jwt.payload.iss) + end) + it("contains the client_id as sub claim", function() + assert.are.equal("client_id", jwt.payload.sub) + end) + it("contains the token endpoint as aud claim", function() + assert.are.equal("http://127.0.0.1/token", jwt.payload.aud) + end) + it("contains a jti claim", function() + assert.truthy(jwt.payload.jti) + end) + it("contains a non-expired exp claim", function() + assert.truthy(jwt.payload.exp) + assert.is_true(jwt.payload.exp > os.time()) + end) + end) +end) + +describe("when 'client_secret_jwt' auth method is configured but no key specified", function() + test_support.start_server({ + oidc_opts = { + discovery = { + token_endpoint_auth_methods_supported = { "client_secret_basic", "client_secret_post", "client_secret_jwt" }, + }, + token_endpoint_auth_method = "client_secret_jwt", + }, + remove_oidc_config_keys = { "client_secret" } + }) + teardown(test_support.stop_server) + test_support.login() + it("then it is not used", function() + assert.error_log_contains("token authorization header: Basic") + end) +end) +