--[[ Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. *************************************************************************** Copyright (C) 2017-2019 ZmartZone IAM Copyright (C) 2015-2017 Ping Identity Corporation All rights reserved. For further information please contact: Ping Identity Corporation 1099 18th St Suite 2950 Denver, CO 80202 303.468.2900 http://www.pingidentity.com DISCLAIMER OF WARRANTIES: THE SOFTWARE PROVIDED HEREUNDER IS PROVIDED ON AN "AS IS" BASIS, WITHOUT ANY WARRANTIES OR REPRESENTATIONS EXPRESS, IMPLIED OR STATUTORY; INCLUDING, WITHOUT LIMITATION, WARRANTIES OF QUALITY, PERFORMANCE, NONINFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. NOR ARE THERE ANY WARRANTIES CREATED BY A COURSE OR DEALING, COURSE OF PERFORMANCE OR TRADE USAGE. FURTHERMORE, THERE ARE NO WARRANTIES THAT THE SOFTWARE WILL MEET YOUR NEEDS OR BE FREE FROM ERRORS, OR THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. @Author: Hans Zandbelt - hans.zandbelt@zmartzone.eu --]] local require = require local cjson = require("cjson") local cjson_s = require("cjson.safe") local http = require("resty.http") local r_session = require("resty.session") local string = string local ipairs = ipairs local pairs = pairs local type = type local ngx = ngx local b64 = ngx.encode_base64 local unb64 = ngx.decode_base64 local log = ngx.log 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, 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 = { _VERSION = "1.7.5" } openidc.__index = openidc local function store_in_session(opts, feature) -- We don't have a whitelist of features to enable if not opts.session_contents then return true end return opts.session_contents[feature] end -- set value in server-wide cache if available local function openidc_cache_set(type, key, value, exp) local dict = ngx.shared[type] if dict and (exp > 0) then local success, err, forcible = dict:set(key, value, exp) log(DEBUG, "cache set: success=", success, " err=", err, " forcible=", forcible) end end -- retrieve value from server-wide cache if available local function openidc_cache_get(type, key) local dict = ngx.shared[type] local value if dict then value = dict:get(key) if value then log(DEBUG, "cache hit: type=", type, " key=", key) end end return value end -- invalidate values of server-wide cache local function openidc_cache_invalidate(type) local dict = ngx.shared[type] if dict then log(DEBUG, "flushing cache for " .. type) dict.flush_all(dict) local nbr = dict.flush_expired(dict) end end -- invalidate all server-wide caches function openidc.invalidate_caches() openidc_cache_invalidate("discovery") openidc_cache_invalidate("jwks") openidc_cache_invalidate("introspection") openidc_cache_invalidate("jwt_verification") end -- validate the contents of and id_token local function openidc_validate_id_token(opts, id_token, nonce) -- check issuer if opts.discovery.issuer ~= id_token.iss then log(ERROR, "issuer \"", id_token.iss, "\" in id_token is not equal to the issuer from the discovery document \"", opts.discovery.issuer, "\"") return false end -- check sub if not id_token.sub then log(ERROR, "no \"sub\" claim found in id_token") return false end -- check nonce if nonce and nonce ~= id_token.nonce then log(ERROR, "nonce \"", id_token.nonce, "\" in id_token is not equal to the nonce that was sent in the request \"", nonce, "\"") return false end -- check issued-at timestamp if not id_token.iat then log(ERROR, "no \"iat\" claim found in id_token") return false end local slack = opts.iat_slack and opts.iat_slack or 120 if id_token.iat > (ngx.time() + slack) then log(ERROR, "id_token not yet valid: id_token.iat=", id_token.iat, ", ngx.time()=", ngx.time(), ", slack=", slack) return false end -- check expiry timestamp if not id_token.exp then log(ERROR, "no \"exp\" claim found in id_token") return false end if (id_token.exp + slack) < ngx.time() then log(ERROR, "token expired: id_token.exp=", id_token.exp, ", ngx.time()=", ngx.time()) return false end -- check audience (array or string) if not id_token.aud then log(ERROR, "no \"aud\" claim found in id_token") return false end if (type(id_token.aud) == "table") then for _, value in pairs(id_token.aud) do if value == opts.client_id then return true end end log(ERROR, "no match found token audience array: client_id=", opts.client_id) return false elseif (type(id_token.aud) == "string") then if id_token.aud ~= opts.client_id then log(ERROR, "token audience does not match: id_token.aud=", id_token.aud, ", client_id=", opts.client_id) return false end end return true end local function get_first(table_or_string) local res = table_or_string if table_or_string and type(table_or_string) == 'table' then res = table_or_string[1] end return res end local function get_first_header(headers, header_name) local header = headers[header_name] return get_first(header) end local function get_first_header_and_strip_whitespace(headers, header_name) local header = get_first_header(headers, header_name) return header and header:gsub('%s', '') end local function get_forwarded_parameter(headers, param_name) local forwarded = get_first_header(headers, 'Forwarded') local params = {} if forwarded then local function parse_parameter(pv) local name, value = pv:match("^%s*([^=]+)%s*=%s*(.-)%s*$") if name and value then if value:sub(1, 1) == '"' then value = value:sub(2, -2) end params[name:lower()] = value end end -- this assumes there is no quoted comma inside the header's value -- which should be fine as comma is not legal inside a node name, -- a URI scheme or a host name. The only thing that might bite us -- are extensions. local first_part = forwarded local first_comma = forwarded:find("%s*,%s*") if first_comma then first_part = forwarded:sub(1, first_comma - 1) end first_part:gsub("[^;]+", parse_parameter) end return params[param_name:gsub("^%s*(.-)%s*$", "%1"):lower()] end local function get_scheme(headers) return get_forwarded_parameter(headers, 'proto') or get_first_header_and_strip_whitespace(headers, 'X-Forwarded-Proto') or ngx.var.scheme end local function get_host_name_from_x_header(headers) local header = get_first_header_and_strip_whitespace(headers, 'X-Forwarded-Host') return header and header:gsub('^([^,]+),?.*$', '%1') end local function get_host_name(headers) return get_forwarded_parameter(headers, 'host') or get_host_name_from_x_header(headers) or ngx.var.http_host end -- assemble the redirect_uri local function openidc_get_redirect_uri(opts, session) local path = opts.redirect_uri_path if opts.redirect_uri then if opts.redirect_uri:sub(1, 1) == '/' then path = opts.redirect_uri else return opts.redirect_uri end end local headers = ngx.req.get_headers() local scheme = opts.redirect_uri_scheme or get_scheme(headers) local host = get_host_name(headers) if not host then -- possibly HTTP 1.0 and no Host header if session then session:close() end ngx.exit(ngx.HTTP_BAD_REQUEST) end return scheme .. "://" .. host .. path end -- perform base64url decoding local function openidc_base64_url_decode(input) local reminder = #input % 4 if reminder > 0 then local padlen = 4 - reminder input = input .. string.rep('=', padlen) end input = input:gsub('%-', '+'):gsub('_', '/') return unb64(input) end -- perform base64url encoding local function openidc_base64_url_encode(input) local output = b64(input, true) return output:gsub('%+', '-'):gsub('/', '_') end local function openidc_combine_uri(uri, params) if params == nil or next(params) == nil then return uri end local sep = "?" if string.find(uri, "?", 1, true) then sep = "&" end return uri .. sep .. ngx.encode_args(params) end local function decorate_request(http_request_decorator, req) return http_request_decorator and http_request_decorator(req) or req end local function openidc_s256(verifier) local sha256 = (require 'resty.sha256'):new() sha256:update(verifier) return openidc_base64_url_encode(sha256:final()) end -- send the browser of to the OP's authorization endpoint local function openidc_authorize(opts, session, target_url, prompt) local resty_random = require("resty.random") local resty_string = require("resty.string") local err -- generate state and nonce local state = resty_string.to_hex(resty_random.bytes(16)) local nonce = (opts.use_nonce == nil or opts.use_nonce) and resty_string.to_hex(resty_random.bytes(16)) local code_verifier = opts.use_pkce and openidc_base64_url_encode(resty_random.bytes(32)) -- assemble the parameters to the authentication request local params = { client_id = opts.client_id, response_type = "code", scope = opts.scope and opts.scope or "openid email profile", redirect_uri = openidc_get_redirect_uri(opts, session), state = state, } if nonce then params.nonce = nonce end if prompt then params.prompt = prompt end if opts.display then params.display = opts.display end if code_verifier then params.code_challenge_method = 'S256' params.code_challenge = openidc_s256(code_verifier) end -- merge any provided extra parameters if opts.authorization_params then for k, v in pairs(opts.authorization_params) do params[k] = v end end -- store state in the session session.data.original_url = target_url session.data.state = state session.data.nonce = nonce session.data.code_verifier = code_verifier session.data.last_authenticated = ngx.time() if opts.lifecycle and opts.lifecycle.on_created then err = opts.lifecycle.on_created(session) if err then log(WARN, "failed in `on_created` handler: " .. err) return err end end session:save() -- redirect to the /authorization endpoint ngx.header["Cache-Control"] = "no-cache, no-store, max-age=0" return ngx.redirect(openidc_combine_uri(opts.discovery.authorization_endpoint, params)) end -- parse the JSON result from a call to the OP local function openidc_parse_json_response(response, ignore_body_on_success) local ignore_body_on_success = ignore_body_on_success or false local err local res -- check the response from the OP if response.status ~= 200 then err = "response indicates failure, status=" .. response.status .. ", body=" .. response.body else if ignore_body_on_success then return nil, nil end -- decode the response and extract the JSON object res = cjson_s.decode(response.body) if not res then err = "JSON decoding failed" end end return res, err end local function openidc_configure_timeouts(httpc, timeout) if timeout then if type(timeout) == "table" then local r, e = httpc:set_timeouts(timeout.connect or 0, timeout.send or 0, timeout.read or 0) else local r, e = httpc:set_timeout(timeout) end end end -- Set outgoing proxy options local function openidc_configure_proxy(httpc, proxy_opts) if httpc and proxy_opts and type(proxy_opts) == "table" then log(DEBUG, "openidc_configure_proxy : use http proxy") httpc:set_proxy_options(proxy_opts) else log(DEBUG, "openidc_configure_proxy : don't use http proxy") end end -- make a call to the token endpoint function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name, ignore_body_on_success) local ignore_body_on_success = ignore_body_on_success or false local ep_name = endpoint_name or 'token' if not endpoint then return nil, 'no endpoint URI for ' .. ep_name end local headers = { ["Content-Type"] = "application/x-www-form-urlencoded" } if auth then if auth == "client_secret_basic" then if opts.client_secret then headers.Authorization = "Basic " .. b64(ngx.escape_uri(opts.client_id) .. ":" .. ngx.escape_uri(opts.client_secret)) else -- client_secret must not be set if Windows Integrated Authentication (WIA) is used with -- Active Directory Federation Services (AD FS) 4.0 (or newer) on Windows Server 2016 (or newer) headers.Authorization = "Basic " .. b64(ngx.escape_uri(opts.client_id) .. ":") end log(DEBUG, "client_secret_basic: authorization header '" .. headers.Authorization .. "'") 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 local pass_cookies = opts.pass_cookies if pass_cookies then if ngx.req.get_headers()["Cookie"] then local t = {} for cookie_name in string.gmatch(pass_cookies, "%S+") do local cookie_value = ngx.var["cookie_" .. cookie_name] if cookie_value then table.insert(t, cookie_name .. "=" .. cookie_value) end end headers.Cookie = table.concat(t, "; ") end end log(DEBUG, "request body for " .. ep_name .. " endpoint call: ", ngx.encode_args(body)) local httpc = http.new() openidc_configure_timeouts(httpc, opts.timeout) openidc_configure_proxy(httpc, opts.proxy_opts) local res, err = httpc:request_uri(endpoint, decorate_request(opts.http_request_decorator, { method = "POST", body = ngx.encode_args(body), headers = headers, ssl_verify = (opts.ssl_verify ~= "no"), keepalive = (opts.keepalive ~= "no") })) if not res then err = "accessing " .. ep_name .. " endpoint (" .. endpoint .. ") failed: " .. err log(ERROR, err) return nil, err end log(DEBUG, ep_name .. " endpoint response: ", res.body) return openidc_parse_json_response(res, ignore_body_on_success) end -- computes access_token expires_in value (in seconds) local function openidc_access_token_expires_in(opts, expires_in) return (expires_in or opts.access_token_expires_in or 3600) - 1 - (opts.access_token_expires_leeway or 0) end local function openidc_load_jwt_none_alg(enc_hdr, enc_payload) local header = cjson_s.decode(openidc_base64_url_decode(enc_hdr)) local payload = cjson_s.decode(openidc_base64_url_decode(enc_payload)) if header and payload and header.alg == "none" then return { raw_header = enc_hdr, raw_payload = enc_payload, header = header, payload = payload, signature = '' } end return nil end -- get the Discovery metadata from the specified URL local function openidc_discover(url, ssl_verify, keepalive, timeout, exptime, proxy_opts, http_request_decorator) log(DEBUG, "openidc_discover: URL is: " .. url) local json, err local v = openidc_cache_get("discovery", url) if not v then log(DEBUG, "discovery data not in cache, making call to discovery endpoint") -- make the call to the discovery endpoint local httpc = http.new() openidc_configure_timeouts(httpc, timeout) openidc_configure_proxy(httpc, proxy_opts) local res, error = httpc:request_uri(url, decorate_request(http_request_decorator, { ssl_verify = (ssl_verify ~= "no"), keepalive = (keepalive ~= "no") })) if not res then err = "accessing discovery url (" .. url .. ") failed: " .. error log(ERROR, err) else log(DEBUG, "response data: " .. res.body) json, err = openidc_parse_json_response(res) if json then openidc_cache_set("discovery", url, cjson.encode(json), exptime or 24 * 60 * 60) else err = "could not decode JSON from Discovery data" .. (err and (": " .. err) or '') log(ERROR, err) end end else json = cjson.decode(v) end return json, err end -- turn a discovery url set in the opts dictionary into the discovered information local function openidc_ensure_discovered_data(opts) local err if type(opts.discovery) == "string" then local discovery discovery, err = openidc_discover(opts.discovery, opts.ssl_verify, opts.keepalive, opts.timeout, opts.discovery_expires_in, opts.proxy_opts, opts.http_request_decorator) if not err then opts.discovery = discovery end end return err end -- make a call to the userinfo endpoint function openidc.call_userinfo_endpoint(opts, access_token) local err = openidc_ensure_discovered_data(opts) if err then return nil, err end if not (opts and opts.discovery and opts.discovery.userinfo_endpoint) then log(DEBUG, "no userinfo endpoint supplied") return nil, nil end local headers = { ["Authorization"] = "Bearer " .. access_token, } log(DEBUG, "authorization header '" .. headers.Authorization .. "'") local httpc = http.new() openidc_configure_timeouts(httpc, opts.timeout) openidc_configure_proxy(httpc, opts.proxy_opts) local res, err = httpc:request_uri(opts.discovery.userinfo_endpoint, decorate_request(opts.http_request_decorator, { headers = headers, ssl_verify = (opts.ssl_verify ~= "no"), keepalive = (opts.keepalive ~= "no") })) if not res then err = "accessing (" .. opts.discovery.userinfo_endpoint .. ") failed: " .. err return nil, err end log(DEBUG, "userinfo response: ", res.body) -- parse the response from the user info endpoint return openidc_parse_json_response(res) 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 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 local result if opts.discovery.token_endpoint_auth_methods_supported ~= nil then -- if set check to make sure the discovery data includes the selected client auth method if opts.token_endpoint_auth_method ~= nil then for index, value in ipairs(opts.discovery.token_endpoint_auth_methods_supported) do log(DEBUG, index .. " => " .. value) if value == opts.token_endpoint_auth_method then log(DEBUG, "configured value for token_endpoint_auth_method (" .. opts.token_endpoint_auth_method .. ") found in token_endpoint_auth_methods_supported in metadata") result = opts.token_endpoint_auth_method break end end if result == nil then log(ERROR, "configured value for token_endpoint_auth_method (" .. opts.token_endpoint_auth_method .. ") NOT found in token_endpoint_auth_methods_supported in metadata") return nil end else for index, value in ipairs(opts.discovery.token_endpoint_auth_methods_supported) do log(DEBUG, index .. " => " .. value) 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 end end end else result = opts.token_endpoint_auth_method end -- set a sane default if auto-configuration failed if result == nil then result = "client_secret_basic" end log(DEBUG, "token_endpoint_auth_method result set to " .. result) return result end -- ensure that discovery and token auth configuration is available in opts local function ensure_config(opts) local err err = openidc_ensure_discovered_data(opts) if err then return err end -- set the authentication method for the token endpoint opts.token_endpoint_auth_method = openidc_get_token_auth_method(opts) end -- query for discovery endpoint data function openidc.get_discovery_doc(opts) local err = openidc_ensure_discovered_data(opts) if err then log(ERROR, "error getting endpoints definition using discovery endpoint") end return opts.discovery, err end local function openidc_jwks(url, force, ssl_verify, keepalive, timeout, exptime, proxy_opts, http_request_decorator) log(DEBUG, "openidc_jwks: URL is: " .. url .. " (force=" .. force .. ") (decorator=" .. (http_request_decorator and type(http_request_decorator) or "nil")) local json, err, v if force == 0 then v = openidc_cache_get("jwks", url) end if not v then log(DEBUG, "cannot use cached JWKS data; making call to jwks endpoint") -- make the call to the jwks endpoint local httpc = http.new() openidc_configure_timeouts(httpc, timeout) openidc_configure_proxy(httpc, proxy_opts) local res, error = httpc:request_uri(url, decorate_request(http_request_decorator, { ssl_verify = (ssl_verify ~= "no"), keepalive = (keepalive ~= "no") })) if not res then err = "accessing jwks url (" .. url .. ") failed: " .. error log(ERROR, err) else log(DEBUG, "response data: " .. res.body) json, err = openidc_parse_json_response(res) if json then openidc_cache_set("jwks", url, cjson.encode(json), exptime or 24 * 60 * 60) end end else json = cjson.decode(v) end return json, err end local function split_by_chunk(text, chunkSize) local s = {} for i = 1, #text, chunkSize do s[#s + 1] = text:sub(i, i + chunkSize - 1) end return s end local function get_jwk(keys, kid) local rsa_keys = {} for _, value in pairs(keys) do if value.kty == "RSA" and (not value.use or value.use == "sig") then table.insert(rsa_keys, value) end end if kid == nil then if #rsa_keys == 1 then log(DEBUG, "returning only RSA key of JWKS for keyid-less JWT") return rsa_keys[1], nil else return nil, "JWT doesn't specify kid but the keystore contains multiple RSA keys" end end for _, value in pairs(rsa_keys) do if value.kid == kid then return value, nil end end return nil, "RSA key with id " .. kid .. " not found" end local wrap = ('.'):rep(64) local envelope = "-----BEGIN %s-----\n%s\n-----END %s-----\n" local function der2pem(data, typ) typ = typ:upper() or "CERTIFICATE" data = b64(data) return string.format(envelope, typ, data:gsub(wrap, '%0\n', (#data - 1) / 64), typ) end local function encode_length(length) if length < 0x80 then return string.char(length) elseif length < 0x100 then return string.char(0x81, length) elseif length < 0x10000 then return string.char(0x82, math.floor(length / 0x100), length % 0x100) end error("Can't encode lengths over 65535") end local function encode_sequence(array, of) local encoded_array = array if of then encoded_array = {} for i = 1, #array do encoded_array[i] = of(array[i]) end end encoded_array = table.concat(encoded_array) return string.char(0x30) .. encode_length(#encoded_array) .. encoded_array end local function encode_binary_integer(bytes) if bytes:byte(1) > 127 then -- We currenly only use this for unsigned integers, -- however since the high bit is set here, it would look -- like a negative signed int, so prefix with zeroes bytes = "\0" .. bytes end return "\2" .. encode_length(#bytes) .. bytes end local function encode_sequence_of_integer(array) return encode_sequence(array, encode_binary_integer) end local function encode_bit_string(array) local s = "\0" .. array -- first octet holds the number of unused bits return "\3" .. encode_length(#s) .. s end local function openidc_pem_from_x5c(x5c) log(DEBUG, "Found x5c, getting PEM public key from x5c entry of json public key") local chunks = split_by_chunk(b64(openidc_base64_url_decode(x5c[1])), 64) local pem = "-----BEGIN CERTIFICATE-----\n" .. table.concat(chunks, "\n") .. "\n-----END CERTIFICATE-----" log(DEBUG, "Generated PEM key from x5c:", pem) return pem end local function openidc_pem_from_rsa_n_and_e(n, e) log(DEBUG, "getting PEM public key from n and e parameters of json public key") local der_key = { openidc_base64_url_decode(n), openidc_base64_url_decode(e) } local encoded_key = encode_sequence_of_integer(der_key) local pem = der2pem(encode_sequence({ encode_sequence({ "\6\9\42\134\72\134\247\13\1\1\1" -- OID :rsaEncryption .. "\5\0" -- ASN.1 NULL of length 0 }), encode_bit_string(encoded_key) }), "PUBLIC KEY") log(DEBUG, "Generated pem key from n and e: ", pem) return pem end local function openidc_pem_from_jwk(opts, kid) local err = openidc_ensure_discovered_data(opts) if err then return nil, err end if not opts.discovery.jwks_uri or not (type(opts.discovery.jwks_uri) == "string") or (opts.discovery.jwks_uri == "") then return nil, "opts.discovery.jwks_uri is not present or not a string" end local cache_id = opts.discovery.jwks_uri .. '#' .. (kid or '') local v = openidc_cache_get("jwks", cache_id) if v then return v end local jwk, jwks for force = 0, 1 do jwks, err = openidc_jwks(opts.discovery.jwks_uri, force, opts.ssl_verify, opts.keepalive, opts.timeout, opts.jwk_expires_in, opts.proxy_opts, opts.http_request_decorator) if err then return nil, err end jwk, err = get_jwk(jwks.keys, kid) if jwk and not err then break end end if err then return nil, err end local x5c = jwk.x5c if x5c and #(jwk.x5c) == 0 then log(WARN, "Found invalid JWK with empty x5c array, ignoring x5c claim") x5c = nil end local pem if x5c then pem = openidc_pem_from_x5c(x5c) elseif jwk.kty == "RSA" and jwk.n and jwk.e then pem = openidc_pem_from_rsa_n_and_e(jwk.n, jwk.e) else return nil, "don't know how to create RSA key/cert for " .. cjson.encode(jwk) end openidc_cache_set("jwks", cache_id, pem, opts.jwk_expires_in or 24 * 60 * 60) return pem end -- does lua-resty-jwt and/or we know how to handle the algorithm of the JWT? local function is_algorithm_supported(jwt_header) return jwt_header and jwt_header.alg and (jwt_header.alg == "none" or string.sub(jwt_header.alg, 1, 2) == "RS" or string.sub(jwt_header.alg, 1, 2) == "HS") end -- is the JWT signing algorithm an asymmetric one whose key might be -- obtained from the discovery endpoint? local function uses_asymmetric_algorithm(jwt_header) return string.sub(jwt_header.alg, 1, 2) == "RS" end -- is the JWT signing algorithm one that has been expected? local function is_algorithm_expected(jwt_header, expected_algs) if expected_algs == nil or not jwt_header or not jwt_header.alg then return true end if type(expected_algs) == 'string' then expected_algs = { expected_algs } end for _, alg in ipairs(expected_algs) do if alg == jwt_header.alg then return true end end return false 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 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) if jwt then if opts.accept_none_alg then log(DEBUG, "accept JWT with alg \"none\" and no signature") return jwt 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 end 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 reason = reason .. ": " .. jwt_obj.reason end return nil, reason end if not is_algorithm_expected(jwt_obj.header, expected_algs) then local alg = jwt_obj.header and jwt_obj.header.alg or "no algorithm at all" return nil, "token is signed by unexpected algorithm \"" .. alg .. "\"" end local secret if is_algorithm_supported(jwt_obj.header) then if uses_asymmetric_algorithm(jwt_obj.header) then if opts.secret then log(WARN, "using deprecated option `opts.secret` for asymmetric key; switch to `opts.public_key` instead") end secret = asymmetric_secret or opts.secret if not secret and opts.discovery then log(DEBUG, "using discovery to find key") local err secret, err = openidc_pem_from_jwk(opts, jwt_obj.header.kid) if secret == nil then log(ERROR, err) return nil, err end end else if opts.secret then log(WARN, "using deprecated option `opts.secret` for symmetric key; switch to `opts.symmetric_key` instead") end secret = symmetric_secret or opts.secret end end if #{ ... } == 0 then -- an empty list of claim specs makes lua-resty-jwt add default -- validators for the exp and nbf claims if they are -- present. These validators need to know the configured slack -- value local jwt_validators = require("resty.jwt-validators") jwt_validators.set_system_leeway(opts.iat_slack and opts.iat_slack or 120) end 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 if not jwt_obj.verified then local reason = "jwt signature verification failed" if jwt_obj.reason then reason = reason .. ": " .. jwt_obj.reason end return jwt_obj, reason end return jwt_obj end -- -- Load and validate id token from the id_token properties of the token endpoint response -- Parameters : -- - opts the openidc module options -- - jwt_id_token the id_token from the id_token properties of the token endpoint response -- - session the current session -- Return the id_token, nil if valid -- Return nil, the error if invalid -- local function openidc_load_and_validate_jwt_id_token(opts, jwt_id_token, session) local jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, jwt_id_token, opts.public_key, opts.client_secret, opts.discovery.id_token_signing_alg_values_supported) if err then local alg = (jwt_obj and jwt_obj.header and jwt_obj.header.alg) or '' local is_unsupported_signature_error = jwt_obj and not jwt_obj.verified and not is_algorithm_supported(jwt_obj.header) if is_unsupported_signature_error then if opts.accept_unsupported_alg == nil or opts.accept_unsupported_alg then log(WARN, "ignored id_token signature as algorithm '" .. alg .. "' is not supported") else err = "token is signed using algorithm \"" .. alg .. "\" which is not supported by lua-resty-jwt" log(ERROR, err) return nil, err end else log(ERROR, "id_token '" .. alg .. "' signature verification failed") return nil, err end end local id_token = jwt_obj.payload log(DEBUG, "id_token header: ", cjson.encode(jwt_obj.header)) log(DEBUG, "id_token payload: ", cjson.encode(jwt_obj.payload)) -- validate the id_token contents if openidc_validate_id_token(opts, id_token, session.data.nonce) == false then err = "id_token validation failed" log(ERROR, err) return nil, err end return id_token end -- handle a "code" authorization response from the OP local function openidc_authorization_response(opts, session) local args = ngx.req.get_uri_args() local err, log_err, client_err if not args.code or not args.state then err = "unhandled request to the redirect_uri: " .. ngx.var.request_uri log(ERROR, err) return nil, err, session.data.original_url, session end -- check that the state returned in the response against the session; prevents CSRF if args.state ~= session.data.state then log_err = "state from argument: " .. (args.state and args.state or "nil") .. " does not match state restored from session: " .. (session.data.state and session.data.state or "nil") client_err = "state from argument does not match state restored from session" log(ERROR, log_err) return nil, client_err, session.data.original_url, session end err = ensure_config(opts) if err then return nil, err, session.data.original_url, session end -- check the iss if returned from the OP if args.iss and args.iss ~= opts.discovery.issuer then log_err = "iss from argument: " .. args.iss .. " does not match expected issuer: " .. opts.discovery.issuer client_err = "iss from argument does not match expected issuer" log(ERROR, log_err) return nil, client_err, session.data.original_url, session end -- check the client_id if returned from the OP if args.client_id and args.client_id ~= opts.client_id then log_err = "client_id from argument: " .. args.client_id .. " does not match expected client_id: " .. opts.client_id client_err = "client_id from argument does not match expected client_id" log(ERROR, log_err) return nil, client_err, session.data.original_url, session end -- assemble the parameters to the token endpoint local body = { grant_type = "authorization_code", code = args.code, redirect_uri = openidc_get_redirect_uri(opts, session), state = session.data.state, code_verifier = session.data.code_verifier } log(DEBUG, "Authentication with OP done -> Calling OP Token Endpoint to obtain tokens") local current_time = ngx.time() -- make the call to the token endpoint local json json, err = openidc.call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method) if err then return nil, err, session.data.original_url, session end local id_token, err = openidc_load_and_validate_jwt_id_token(opts, json.id_token, session); if err then return nil, err, session.data.original_url, session end -- mark this sessions as authenticated session.data.authenticated = true -- clear state, nonce and code_verifier to protect against potential misuse session.data.nonce = nil session.data.state = nil session.data.code_verifier = nil if store_in_session(opts, 'id_token') then session.data.id_token = id_token end if store_in_session(opts, 'user') then -- call the user info endpoint -- TODO: should this error be checked? local user user, err = openidc.call_userinfo_endpoint(opts, json.access_token) if err then 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 session.data.user = user end end end if store_in_session(opts, 'enc_id_token') then session.data.enc_id_token = json.id_token end if store_in_session(opts, 'access_token') then session.data.access_token = json.access_token session.data.access_token_expiration = current_time + openidc_access_token_expires_in(opts, json.expires_in) if json.refresh_token ~= nil then session.data.refresh_token = json.refresh_token end end if opts.lifecycle and opts.lifecycle.on_authenticated then err = opts.lifecycle.on_authenticated(session, id_token, json) if err then log(WARN, "failed in `on_authenticated` handler: " .. err) return nil, err, session.data.original_url, session end end -- save the session with the obtained id_token session:save() -- redirect to the URL that was accessed originally log(DEBUG, "OIDC Authorization Code Flow completed -> Redirecting to original URL (" .. session.data.original_url .. ")") ngx.redirect(session.data.original_url) return nil, nil, session.data.original_url, session end -- token revocation (RFC 7009) local function openidc_revoke_token(opts, token_type_hint, token) if not opts.discovery.revocation_endpoint then log(DEBUG, "no revocation endpoint supplied. unable to revoke " .. token_type_hint .. ".") return nil end local token_type_hint = token_type_hint or nil local body = { token = token } if token_type_hint then body['token_type_hint'] = token_type_hint end local token_type_log = token_type_hint or 'token' -- ensure revocation endpoint auth method is properly discovered local err = ensure_config(opts) if err then log(ERROR, "revocation of " .. token_type_log .. " unsuccessful: " .. err) return false end -- call the revocation endpoint local _ _, err = openidc.call_token_endpoint(opts, opts.discovery.revocation_endpoint, body, opts.token_endpoint_auth_method, "revocation", true) if err then log(ERROR, "revocation of " .. token_type_log .. " unsuccessful: " .. err) return false else log(DEBUG, "revocation of " .. token_type_log .. " successful") return true end end function openidc.revoke_token(opts, token_type_hint, token) local err = openidc_ensure_discovered_data(opts) if err then log(ERROR, "revocation of " .. (token_type_hint or "token (no type specified)") .. " unsuccessful: " .. err) return false end return openidc_revoke_token(opts, token_type_hint, token) end function openidc.revoke_tokens(opts, session) local err = openidc_ensure_discovered_data(opts) if err then log(ERROR, "revocation of tokens unsuccessful: " .. err) return false end local access_token = session.data.access_token local refresh_token = session.data.refresh_token local access_token_revoke, refresh_token_revoke if refresh_token then access_token_revoke = openidc_revoke_token(opts, "refresh_token", refresh_token) end if access_token then refresh_token_revoke = openidc_revoke_token(opts, "access_token", access_token) end return access_token_revoke and refresh_token_revoke end local openidc_transparent_pixel = "\137\080\078\071\013\010\026\010\000\000\000\013\073\072\068\082" .. "\000\000\000\001\000\000\000\001\008\004\000\000\000\181\028\012" .. "\002\000\000\000\011\073\068\065\084\120\156\099\250\207\000\000" .. "\002\007\001\002\154\028\049\113\000\000\000\000\073\069\078\068" .. "\174\066\096\130" -- handle logout local function openidc_logout(opts, session) local session_token = session.data.enc_id_token local access_token = session.data.access_token local refresh_token = session.data.refresh_token local err if opts.lifecycle and opts.lifecycle.on_logout then err = opts.lifecycle.on_logout(session) if err then log(WARN, "failed in `on_logout` handler: " .. err) return err end end session:destroy() if opts.revoke_tokens_on_logout then log(DEBUG, "revoke_tokens_on_logout is enabled. " .. "trying to revoke access and refresh tokens...") if refresh_token then openidc_revoke_token(opts, "refresh_token", refresh_token) end if access_token then openidc_revoke_token(opts, "access_token", access_token) end end local headers = ngx.req.get_headers() local header = get_first(headers['Accept']) if header and header:find("image/png") then ngx.header["Cache-Control"] = "no-cache, no-store" ngx.header["Pragma"] = "no-cache" ngx.header["P3P"] = "CAO PSA OUR" ngx.header["Expires"] = "0" ngx.header["X-Frame-Options"] = "DENY" ngx.header.content_type = "image/png" ngx.print(openidc_transparent_pixel) ngx.exit(ngx.OK) return elseif opts.redirect_after_logout_uri or opts.discovery.end_session_endpoint then local uri if opts.redirect_after_logout_uri then uri = opts.redirect_after_logout_uri else uri = opts.discovery.end_session_endpoint end local params = {} if (opts.redirect_after_logout_with_id_token_hint or not opts.redirect_after_logout_uri) and session_token then params["id_token_hint"] = session_token end if opts.post_logout_redirect_uri then params["post_logout_redirect_uri"] = opts.post_logout_redirect_uri end return ngx.redirect(openidc_combine_uri(uri, params)) elseif opts.discovery.ping_end_session_endpoint then local params = {} if opts.post_logout_redirect_uri then params["TargetResource"] = opts.post_logout_redirect_uri end return ngx.redirect(openidc_combine_uri(opts.discovery.ping_end_session_endpoint, params)) end ngx.header.content_type = "text/html" ngx.say("Logged Out") ngx.exit(ngx.OK) end -- returns a valid access_token (eventually refreshing the token) local function openidc_access_token(opts, session, try_to_renew) local err if session.data.access_token == nil then return nil, err end local current_time = ngx.time() if current_time < session.data.access_token_expiration then return session.data.access_token, err end if not try_to_renew then return nil, "token expired" end if session.data.refresh_token == nil then return nil, "token expired and no refresh token available" end log(DEBUG, "refreshing expired access_token: ", session.data.access_token, " with: ", session.data.refresh_token) -- retrieve token endpoint URL from discovery endpoint if necessary err = ensure_config(opts) if err then return nil, err end -- assemble the parameters to the token endpoint local body = { grant_type = "refresh_token", refresh_token = session.data.refresh_token, scope = opts.scope and opts.scope or "openid email profile" } local json json, err = openidc.call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method) if err then return nil, err end local id_token if json.id_token then id_token, err = openidc_load_and_validate_jwt_id_token(opts, json.id_token, session) if err then log(ERROR, "invalid id token, discarding tokens returned while refreshing") return nil, err end end log(DEBUG, "access_token refreshed: ", json.access_token, " updated refresh_token: ", json.refresh_token) session.data.access_token = json.access_token session.data.access_token_expiration = current_time + openidc_access_token_expires_in(opts, json.expires_in) if json.refresh_token then session.data.refresh_token = json.refresh_token end if json.id_token and (store_in_session(opts, 'enc_id_token') or store_in_session(opts, 'id_token')) then log(DEBUG, "id_token refreshed: ", json.id_token) if store_in_session(opts, 'enc_id_token') then session.data.enc_id_token = json.id_token end if store_in_session(opts, 'id_token') then session.data.id_token = id_token end end -- save the session with the new access_token and optionally the new refresh_token and id_token using a new sessionid local regenerated regenerated, err = session:regenerate() if err then log(ERROR, "failed to regenerate session: " .. err) return nil, err end if opts.lifecycle and opts.lifecycle.on_regenerated then err = opts.lifecycle.on_regenerated(session) if err then log(WARN, "failed in `on_regenerated` handler: " .. err) return nil, err end end return session.data.access_token, err end local function openidc_get_path(uri) local without_query = uri:match("(.-)%?") or uri return without_query:match(".-//[^/]+(/.*)") or without_query end local function openidc_get_redirect_uri_path(opts) return opts.redirect_uri and openidc_get_path(opts.redirect_uri) or opts.redirect_uri_path end local function is_session(o) return o ~= nil and o.start and type(o.start) == "function" end -- main routine for OpenID Connect user authentication function openidc.authenticate(opts, target_url, unauth_action, session_or_opts) if opts.redirect_uri_path then log(WARN, "using deprecated option `opts.redirect_uri_path`; switch to using an absolute URI and `opts.redirect_uri` instead") end local err local session if is_session(session_or_opts) then session = session_or_opts else local session_error session, session_error = r_session.start(session_or_opts) if session == nil then log(ERROR, "Error starting session: " .. session_error) return nil, session_error, target_url, session end end target_url = target_url or ngx.var.request_uri local access_token -- see if this is a request to the redirect_uri i.e. an authorization response local path = openidc_get_path(target_url) if path == openidc_get_redirect_uri_path(opts) then log(DEBUG, "Redirect URI path (" .. path .. ") is currently navigated -> Processing authorization response coming from OP") if not session.present then err = "request to the redirect_uri path but there's no session state found" log(ERROR, err) return nil, err, target_url, session end return openidc_authorization_response(opts, session) end -- see if this is a request to logout if path == (opts.logout_path or "/logout") then log(DEBUG, "Logout path (" .. path .. ") is currently navigated -> Processing local session removal before redirecting to next step of logout process") err = ensure_config(opts) if err then return nil, err, session.data.original_url, session end openidc_logout(opts, session) return nil, nil, target_url, session end local token_expired = false local try_to_renew = opts.renew_access_token_on_expiry == nil or opts.renew_access_token_on_expiry if session.present and session.data.authenticated and store_in_session(opts, 'access_token') then -- refresh access_token if necessary access_token, err = openidc_access_token(opts, session, try_to_renew) if err then log(ERROR, "lost access token:" .. err) err = nil end if not access_token then token_expired = true end end log(DEBUG, "session.present=", session.present, ", session.data.id_token=", session.data.id_token ~= nil, ", session.data.authenticated=", session.data.authenticated, ", opts.force_reauthorize=", opts.force_reauthorize, ", opts.renew_access_token_on_expiry=", opts.renew_access_token_on_expiry, ", try_to_renew=", try_to_renew, ", token_expired=", token_expired) -- if we are not authenticated then redirect to the OP for authentication -- the presence of the id_token is check for backwards compatibility if not session.present or not (session.data.id_token or session.data.authenticated) or opts.force_reauthorize or (try_to_renew and token_expired) then if unauth_action == "pass" then if token_expired then session.data.authenticated = false return nil, 'token refresh failed', target_url, session end return nil, err, target_url, session end if unauth_action == 'deny' then return nil, 'unauthorized request', target_url, session end err = ensure_config(opts) if err then return nil, err, session.data.original_url, session end log(DEBUG, "Authentication is required - Redirecting to OP Authorization endpoint") openidc_authorize(opts, session, target_url, opts.prompt) return nil, nil, target_url, session end -- silently reauthenticate if necessary (mainly used for session refresh/getting updated id_token data) if opts.refresh_session_interval ~= nil then if session.data.last_authenticated == nil or (session.data.last_authenticated + opts.refresh_session_interval) < ngx.time() then err = ensure_config(opts) if err then return nil, err, session.data.original_url, session end log(DEBUG, "Silent authentication is required - Redirecting to OP Authorization endpoint") openidc_authorize(opts, session, target_url, "none") return nil, nil, target_url, session end end if store_in_session(opts, 'id_token') then -- log id_token contents log(DEBUG, "id_token=", cjson.encode(session.data.id_token)) end -- return the id_token to the caller Lua script for access control purposes return { id_token = session.data.id_token, access_token = access_token, user = session.data.user }, err, target_url, session end -- get a valid access_token (eventually refreshing the token), or nil if there's no valid access_token function openidc.access_token(opts, session_opts) local session = r_session.start(session_opts) local token, err = openidc_access_token(opts, session, true) session:close() return token, err end -- get an OAuth 2.0 bearer access token from the HTTP request cookies local function openidc_get_bearer_access_token_from_cookie(opts) local err log(DEBUG, "getting bearer access token from Cookie") local accept_token_as = opts.auth_accept_token_as or "header" if accept_token_as:find("cookie") ~= 1 then 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(':') local cookie_name = divider and accept_token_as:sub(divider + 1) or "PA.global" log(DEBUG, "bearer access token from cookie named: " .. cookie_name) local cookies = ngx.req.get_headers()["Cookie"] if not cookies then err = "no Cookie header found" log(ERROR, err) return nil, err end local cookie_value = ngx.var["cookie_" .. cookie_name] if not cookie_value then err = "no Cookie " .. cookie_name .. " found" log(ERROR, err) end return cookie_value, err end -- get an OAuth 2.0 bearer access token from the HTTP request local function openidc_get_bearer_access_token(opts) local err local accept_token_as = opts.auth_accept_token_as or "header" if accept_token_as:find("cookie") == 1 then return openidc_get_bearer_access_token_from_cookie(opts) end -- get the access token from the Authorization header local headers = ngx.req.get_headers() local header_name = opts.auth_accept_token_as_header_name or "Authorization" local header = get_first(headers[header_name]) if header == nil or header:find(" ") == nil then err = "no Authorization header found" log(ERROR, err) return nil, err end local divider = header:find(' ') if string.lower(header:sub(0, divider - 1)) ~= string.lower("Bearer") then err = "no Bearer authorization header value found" log(ERROR, err) return nil, err end local access_token = header:sub(divider + 1) if access_token == nil then err = "no Bearer access token value found" log(ERROR, err) return nil, err end return access_token, err end local function get_introspection_endpoint(opts) local introspection_endpoint = opts.introspection_endpoint if not introspection_endpoint then local err = openidc_ensure_discovered_data(opts) if err then return nil, "opts.introspection_endpoint not said and " .. err end local endpoint = opts.discovery and opts.discovery.introspection_endpoint if endpoint then return endpoint end end return introspection_endpoint end local function get_introspection_cache_prefix(opts) return (opts.cache_segment and opts.cache_segment.gsub(',', '_') or 'DEFAULT') .. ',' .. (get_introspection_endpoint(opts) or 'nil-endpoint') .. ',' .. (opts.client_id or 'no-client_id') .. ',' .. (opts.client_secret and 'secret' or 'no-client_secret') .. ':' end local function get_cached_introspection(opts, access_token) local introspection_cache_ignore = opts.introspection_cache_ignore or false if not introspection_cache_ignore then return openidc_cache_get("introspection", get_introspection_cache_prefix(opts) .. access_token) end end local function set_cached_introspection(opts, access_token, encoded_json, ttl) local introspection_cache_ignore = opts.introspection_cache_ignore or false if not introspection_cache_ignore then openidc_cache_set("introspection", get_introspection_cache_prefix(opts) .. access_token, encoded_json, ttl) end end -- main routine for OAuth 2.0 token introspection function openidc.introspect(opts) -- get the access token from the request local access_token, err = openidc_get_bearer_access_token(opts) if access_token == nil then return nil, err end -- see if we've previously cached the introspection result for this access token local json local v = get_cached_introspection(opts, access_token) if v then json = cjson.decode(v) return json, err end -- assemble the parameters to the introspection (token) endpoint local token_param_name = opts.introspection_token_param_name and opts.introspection_token_param_name or "token" local body = {} body[token_param_name] = access_token if opts.client_id then body.client_id = opts.client_id end if opts.client_secret then body.client_secret = opts.client_secret end -- merge any provided extra parameters if opts.introspection_params then for key, val in pairs(opts.introspection_params) do body[key] = val end end -- call the introspection endpoint local introspection_endpoint introspection_endpoint, err = get_introspection_endpoint(opts) if err then return nil, err end json, err = openidc.call_token_endpoint(opts, introspection_endpoint, body, opts.introspection_endpoint_auth_method, "introspection") if not json then return json, err end if not json.active then err = "invalid token" return json, err end -- cache the results local introspection_cache_ignore = opts.introspection_cache_ignore or false local expiry_claim = opts.introspection_expiry_claim or "exp" if not introspection_cache_ignore and json[expiry_claim] then local introspection_interval = opts.introspection_interval or 0 local ttl = json[expiry_claim] if expiry_claim == "exp" then --https://tools.ietf.org/html/rfc7662#section-2.2 ttl = ttl - ngx.time() end if introspection_interval > 0 then if ttl > introspection_interval then ttl = introspection_interval end end log(DEBUG, "cache token ttl: " .. ttl) set_cached_introspection(opts, access_token, cjson.encode(json), ttl) end return json, err end local function get_jwt_verification_cache_prefix(opts) local signing_alg_values_expected = (opts.accept_none_alg and 'none' or 'no-none') local expected_algs = opts.token_signing_alg_values_expected or {} if type(expected_algs) == 'string' then expected_algs = { expected_algs } end for _, alg in ipairs(expected_algs) do signing_alg_values_expected = signing_alg_values_expected .. ',' .. alg end return (opts.cache_segment and opts.cache_segment.gsub(',', '_') or 'DEFAULT') .. ',' .. (opts.public_key or 'no-pubkey') .. ',' .. (opts.symmetric_key or 'no-symkey') .. ',' .. signing_alg_values_expected .. ':' end local function get_cached_jwt_verification(opts, access_token) local jwt_verification_cache_ignore = opts.jwt_verification_cache_ignore or false if not jwt_verification_cache_ignore then return openidc_cache_get("jwt_verification", get_jwt_verification_cache_prefix(opts) .. access_token) end end local function set_cached_jwt_verification(opts, access_token, encoded_json, ttl) local jwt_verification_cache_ignore = opts.jwt_verification_cache_ignore or false if not jwt_verification_cache_ignore then openidc_cache_set("jwt_verification", get_jwt_verification_cache_prefix(opts) .. access_token, encoded_json, ttl) end end -- main routine for OAuth 2.0 JWT token validation -- optional args are claim specs, see jwt-validators in resty.jwt function openidc.jwt_verify(access_token, opts, ...) local err local json local v = get_cached_jwt_verification(opts, access_token) local slack = opts.iat_slack and opts.iat_slack or 120 if not v then local jwt_obj jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, access_token, opts.public_key, opts.symmetric_key, opts.token_signing_alg_values_expected, ...) if not err then json = jwt_obj.payload local encoded_json = cjson.encode(json) log(DEBUG, "jwt: ", encoded_json) set_cached_jwt_verification(opts, access_token, encoded_json, json.exp and json.exp - ngx.time() or 120) end else -- decode from the cache json = cjson.decode(v) end -- check the token expiry if json then if json.exp and json.exp + slack < ngx.time() then log(ERROR, "token expired: json.exp=", json.exp, ", ngx.time()=", ngx.time()) err = "JWT expired" end end return json, err end function openidc.bearer_jwt_verify(opts, ...) local json -- get the access token from the request local access_token, err = openidc_get_bearer_access_token(opts) if access_token == nil then return nil, err end log(DEBUG, "access_token: ", access_token) json, err = openidc.jwt_verify(access_token, opts, ...) return json, err, access_token end -- Passing nil to any of the arguments resets the configuration to default function openidc.set_logging(new_log, new_levels) log = new_log and new_log or ngx.log DEBUG = new_levels.DEBUG and new_levels.DEBUG or ngx.DEBUG ERROR = new_levels.ERROR and new_levels.ERROR or ngx.ERR WARN = new_levels.WARN and new_levels.WARN or ngx.WARN end return openidc