summaryrefslogtreecommitdiffstats
path: root/server/resty/openidc.lua
diff options
context:
space:
mode:
Diffstat (limited to 'server/resty/openidc.lua')
-rw-r--r--server/resty/openidc.lua1870
1 files changed, 1870 insertions, 0 deletions
diff --git a/server/resty/openidc.lua b/server/resty/openidc.lua
new file mode 100644
index 0000000..246414e
--- /dev/null
+++ b/server/resty/openidc.lua
@@ -0,0 +1,1870 @@
+--[[
+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("<html><body>Logged Out</body></html>")
+ 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