-- -- 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. -- local core = require("apisix.core") local http = require "resty.http" local sub_str = string.sub local type = type local ngx = ngx local plugin_name = "authz-keycloak" local fetch_secrets = require("apisix.secret").fetch_secrets local log = core.log local pairs = pairs local schema = { type = "object", properties = { discovery = {type = "string", minLength = 1, maxLength = 4096}, token_endpoint = {type = "string", minLength = 1, maxLength = 4096}, resource_registration_endpoint = {type = "string", minLength = 1, maxLength = 4096}, client_id = {type = "string", minLength = 1, maxLength = 100}, client_secret = {type = "string", minLength = 1, maxLength = 100}, grant_type = { type = "string", default="urn:ietf:params:oauth:grant-type:uma-ticket", enum = {"urn:ietf:params:oauth:grant-type:uma-ticket"}, minLength = 1, maxLength = 100 }, policy_enforcement_mode = { type = "string", enum = {"ENFORCING", "PERMISSIVE"}, default = "ENFORCING" }, permissions = { type = "array", items = { type = "string", minLength = 1, maxLength = 100 }, uniqueItems = true, default = {} }, lazy_load_paths = {type = "boolean", default = false}, http_method_as_scope = {type = "boolean", default = false}, timeout = {type = "integer", minimum = 1000, default = 3000}, ssl_verify = {type = "boolean", default = true}, cache_ttl_seconds = {type = "integer", minimum = 1, default = 24 * 60 * 60}, keepalive = {type = "boolean", default = true}, keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, keepalive_pool = {type = "integer", minimum = 1, default = 5}, access_denied_redirect_uri = {type = "string", minLength = 1, maxLength = 2048}, access_token_expires_in = {type = "integer", minimum = 1, default = 300}, access_token_expires_leeway = {type = "integer", minimum = 0, default = 0}, refresh_token_expires_in = {type = "integer", minimum = 1, default = 3600}, refresh_token_expires_leeway = {type = "integer", minimum = 0, default = 0}, password_grant_token_generation_incoming_uri = { type = "string", minLength = 1, maxLength = 4096 }, }, encrypt_fields = {"client_secret"}, required = {"client_id"}, allOf = { -- Require discovery or token endpoint. { anyOf = { {required = {"discovery"}}, {required = {"token_endpoint"}} } }, -- If lazy_load_paths is true, require discovery or resource registration endpoint. { anyOf = { { properties = { lazy_load_paths = {enum = {false}}, } }, { properties = { lazy_load_paths = {enum = {true}}, }, anyOf = { {required = {"discovery"}}, {required = {"resource_registration_endpoint"}} } } } } } } local _M = { version = 0.1, priority = 2000, name = plugin_name, schema = schema, } function _M.check_schema(conf) local check = {"discovery", "token_endpoint", "resource_registration_endpoint", "access_denied_redirect_uri"} core.utils.check_https(check, conf, plugin_name) core.utils.check_tls_bool({"ssl_verify"}, conf, plugin_name) return core.schema.check(schema, conf) end -- Some auxiliary functions below heavily inspired by the excellent -- lua-resty-openidc module; see https://github.com/zmartzone/lua-resty-openidc -- Retrieve value from server-wide cache, if available. local function authz_keycloak_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 -- Set value in server-wide cache, if available. local function authz_keycloak_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) if err then log.error("cache set: success=", success, " err=", err, " forcible=", forcible) else log.debug("cache set: success=", success, " err=", err, " forcible=", forcible) end end end -- Configure request parameters. local function authz_keycloak_configure_params(params, conf) -- Keepalive options. if conf.keepalive then params.keepalive_timeout = conf.keepalive_timeout params.keepalive_pool = conf.keepalive_pool else params.keepalive = conf.keepalive end -- TLS verification. params.ssl_verify = conf.ssl_verify -- Decorate parameters, maybe, and return. return conf.http_request_decorator and conf.http_request_decorator(params) or params end -- Configure timeouts. local function authz_keycloak_configure_timeouts(httpc, timeout) if timeout then if type(timeout) == "table" then httpc:set_timeouts(timeout.connect or 0, timeout.send or 0, timeout.read or 0) else httpc:set_timeout(timeout) end end end -- Set outgoing proxy options. local function authz_keycloak_configure_proxy(httpc, proxy_opts) if httpc and proxy_opts and type(proxy_opts) == "table" then log.debug("authz_keycloak_configure_proxy : use http proxy") httpc:set_proxy_options(proxy_opts) else log.debug("authz_keycloak_configure_proxy : don't use http proxy") end end -- Get and configure HTTP client. local function authz_keycloak_get_http_client(conf) local httpc = http.new() authz_keycloak_configure_timeouts(httpc, conf.timeout) authz_keycloak_configure_proxy(httpc, conf.proxy_opts) return httpc end -- Parse the JSON result from a call to the OP. local function authz_keycloak_parse_json_response(response) 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 -- Decode the response and extract the JSON object. res, err = core.json.decode(response.body) if not res then err = "JSON decoding failed: " .. err end end return res, err end -- Get the Discovery metadata from the specified URL. local function authz_keycloak_discover(conf) log.debug("authz_keycloak_discover: URL is: " .. conf.discovery) local json, err local v = authz_keycloak_cache_get("discovery", conf.discovery) 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 = authz_keycloak_get_http_client(conf) local params = authz_keycloak_configure_params({}, conf) local res, error = httpc:request_uri(conf.discovery, params) if not res then err = "Accessing discovery URL (" .. conf.discovery .. ") failed: " .. error log.error(err) else log.debug("Response data: " .. res.body) json, err = authz_keycloak_parse_json_response(res) if json then authz_keycloak_cache_set("discovery", conf.discovery, core.json.encode(json), conf.cache_ttl_seconds) else err = "could not decode JSON from Discovery data" .. (err and (": " .. err) or '') log.error(err) end end else json = core.json.decode(v) end return json, err end -- Turn a discovery url set in the conf dictionary into the discovered information. local function authz_keycloak_ensure_discovered_data(conf) local err if type(conf.discovery) == "string" then local discovery discovery, err = authz_keycloak_discover(conf) if not err then conf.discovery = discovery end end return err end -- Get an endpoint from the configuration. local function authz_keycloak_get_endpoint(conf, endpoint) if conf and conf[endpoint] then -- Use explicit entry. return conf[endpoint] elseif conf and conf.discovery and type(conf.discovery) == "table" then -- Use discovery data. return conf.discovery[endpoint] end -- Unable to obtain endpoint. return nil end -- Return the token endpoint from the configuration. local function authz_keycloak_get_token_endpoint(conf) return authz_keycloak_get_endpoint(conf, "token_endpoint") end -- Return the resource registration endpoint from the configuration. local function authz_keycloak_get_resource_registration_endpoint(conf) return authz_keycloak_get_endpoint(conf, "resource_registration_endpoint") end -- Return access_token expires_in value (in seconds). local function authz_keycloak_access_token_expires_in(conf, expires_in) return (expires_in or conf.access_token_expires_in) - 1 - conf.access_token_expires_leeway end -- Return refresh_token expires_in value (in seconds). local function authz_keycloak_refresh_token_expires_in(conf, expires_in) return (expires_in or conf.refresh_token_expires_in) - 1 - conf.refresh_token_expires_leeway end -- Ensure a valid service account access token is available for the configured client. local function authz_keycloak_ensure_sa_access_token(conf) local client_id = conf.client_id local ttl = conf.cache_ttl_seconds local token_endpoint = authz_keycloak_get_token_endpoint(conf) if not token_endpoint then log.error("Unable to determine token endpoint.") return 503, "Unable to determine token endpoint." end local session = authz_keycloak_cache_get("access-tokens", token_endpoint .. ":" .. client_id) if session then -- Decode session string. local err session, err = core.json.decode(session) if not session then -- Should never happen. return 500, err end local current_time = ngx.time() if current_time < session.access_token_expiration then -- Access token is still valid. log.debug("Access token is still valid.") return session.access_token else -- Access token has expired. log.debug("Access token has expired.") if session.refresh_token and (not session.refresh_token_expiration or current_time < session.refresh_token_expiration) then -- Try to get a new access token, using the refresh token. log.debug("Trying to get new access token using refresh token.") local httpc = authz_keycloak_get_http_client(conf) local params = { method = "POST", body = ngx.encode_args({ grant_type = "refresh_token", client_id = client_id, client_secret = conf.client_secret, refresh_token = session.refresh_token, }), headers = { ["Content-Type"] = "application/x-www-form-urlencoded" } } params = authz_keycloak_configure_params(params, conf) local res, err = httpc:request_uri(token_endpoint, params) if not res then err = "Accessing token endpoint URL (" .. token_endpoint .. ") failed: " .. err log.error(err) return nil, err end log.debug("Response data: " .. res.body) local json, err = authz_keycloak_parse_json_response(res) if not json then err = "Could not decode JSON from token endpoint" .. (err and (": " .. err) or '.') log.error(err) return nil, err end if not json.access_token then -- Clear session. log.debug("Answer didn't contain a new access token. Clearing session.") session = nil else log.debug("Got new access token.") -- Save access token. session.access_token = json.access_token -- Calculate and save access token expiry time. session.access_token_expiration = current_time + authz_keycloak_access_token_expires_in(conf, json.expires_in) -- Save refresh token, maybe. if json.refresh_token ~= nil then log.debug("Got new refresh token.") session.refresh_token = json.refresh_token -- Calculate and save refresh token expiry time. session.refresh_token_expiration = current_time + authz_keycloak_refresh_token_expires_in(conf, json.refresh_expires_in) end authz_keycloak_cache_set("access-tokens", token_endpoint .. ":" .. client_id, core.json.encode(session), ttl) end else -- No refresh token available, or it has expired. Clear session. log.debug("No or expired refresh token. Clearing session.") session = nil end end end if not session then -- No session available. Create a new one. log.debug("Getting access token for Protection API from token endpoint.") local httpc = authz_keycloak_get_http_client(conf) local params = { method = "POST", body = ngx.encode_args({ grant_type = "client_credentials", client_id = client_id, client_secret = conf.client_secret, }), headers = { ["Content-Type"] = "application/x-www-form-urlencoded" } } params = authz_keycloak_configure_params(params, conf) local current_time = ngx.time() local res, err = httpc:request_uri(token_endpoint, params) if not res then err = "Accessing token endpoint URL (" .. token_endpoint .. ") failed: " .. err log.error(err) return nil, err end log.debug("Response data: " .. res.body) local json, err = authz_keycloak_parse_json_response(res) if not json then err = "Could not decode JSON from token endpoint" .. (err and (": " .. err) or '.') log.error(err) return nil, err end if not json.access_token then err = "Response does not contain access_token field." log.error(err) return nil, err end session = {} -- Save access token. session.access_token = json.access_token -- Calculate and save access token expiry time. session.access_token_expiration = current_time + authz_keycloak_access_token_expires_in(conf, json.expires_in) -- Save refresh token, maybe. if json.refresh_token ~= nil then session.refresh_token = json.refresh_token -- Calculate and save refresh token expiry time. session.refresh_token_expiration = current_time + authz_keycloak_refresh_token_expires_in(conf, json.refresh_expires_in) end authz_keycloak_cache_set("access-tokens", token_endpoint .. ":" .. client_id, core.json.encode(session), ttl) end return session.access_token end -- Resolve a URI to one or more resource IDs. local function authz_keycloak_resolve_resource(conf, uri, sa_access_token) -- Get resource registration endpoint URL. local resource_registration_endpoint = authz_keycloak_get_resource_registration_endpoint(conf) if not resource_registration_endpoint then local err = "Unable to determine registration endpoint." log.error(err) return nil, err end log.debug("Resource registration endpoint: ", resource_registration_endpoint) local httpc = authz_keycloak_get_http_client(conf) local params = { method = "GET", query = {uri = uri, matchingUri = "true"}, headers = { ["Authorization"] = "Bearer " .. sa_access_token } } params = authz_keycloak_configure_params(params, conf) local res, err = httpc:request_uri(resource_registration_endpoint, params) if not res then err = "Accessing resource registration endpoint URL (" .. resource_registration_endpoint .. ") failed: " .. err log.error(err) return nil, err end log.debug("Response data: " .. res.body) res.body = '{"resources": ' .. res.body .. '}' local json, err = authz_keycloak_parse_json_response(res) if not json then err = "Could not decode JSON from resource registration endpoint" .. (err and (": " .. err) or '.') log.error(err) return nil, err end return json.resources end local function evaluate_permissions(conf, ctx, token) -- Ensure discovered data. local err = authz_keycloak_ensure_discovered_data(conf) if err then return 503, err end local permission if conf.lazy_load_paths then -- Ensure service account access token. local sa_access_token, err = authz_keycloak_ensure_sa_access_token(conf) if err then log.error(err) return 503, err end -- Resolve URI to resource(s). permission, err = authz_keycloak_resolve_resource(conf, ctx.var.request_uri, sa_access_token) -- Check result. if permission == nil then -- No result back from resource registration endpoint. log.error(err) return 503, err end else -- Use statically configured permissions. permission = conf.permissions end -- Return 403 or 307 if permission is empty and enforcement mode is "ENFORCING". if #permission == 0 and conf.policy_enforcement_mode == "ENFORCING" then -- Return Keycloak-style message for consistency. if conf.access_denied_redirect_uri then core.response.set_header("Location", conf.access_denied_redirect_uri) return 307 end return 403, '{"error":"access_denied","error_description":"not_authorized"}' end -- Determine scope from HTTP method, maybe. local scope if conf.http_method_as_scope then scope = ctx.var.request_method end if scope then -- Loop over permissions and add scope. for k, v in pairs(permission) do if v:find("#", 1, true) then -- Already contains scope. permission[k] = v .. ", " .. scope else -- Doesn't contain scope yet. permission[k] = v .. "#" .. scope end end end for k, v in pairs(permission) do log.debug("Requesting permission ", v, ".") end -- Get token endpoint URL. local token_endpoint = authz_keycloak_get_token_endpoint(conf) if not token_endpoint then err = "Unable to determine token endpoint." log.error(err) return 503, err end log.debug("Token endpoint: ", token_endpoint) local httpc = authz_keycloak_get_http_client(conf) local params = { method = "POST", body = ngx.encode_args({ grant_type = conf.grant_type, audience = conf.client_id, response_mode = "decision", permission = permission }), headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Authorization"] = token } } params = authz_keycloak_configure_params(params, conf) local res, err = httpc:request_uri(token_endpoint, params) if not res then err = "Error while sending authz request to " .. token_endpoint .. ": " .. err log.error(err) return 503 end log.debug("Response status: ", res.status, ", data: ", res.body) if res.status == 403 then -- Request permanently denied, e.g. due to lacking permissions. log.debug('Request denied: HTTP 403 Forbidden. Body: ', res.body) if conf.access_denied_redirect_uri then core.response.set_header("Location", conf.access_denied_redirect_uri) return 307 end return res.status, res.body elseif res.status == 401 then -- Request temporarily denied, e.g access token not valid. log.debug('Request denied: HTTP 401 Unauthorized. Body: ', res.body) return res.status, res.body elseif res.status >= 400 then -- Some other error. Log full response. log.error('Request denied: Token endpoint returned an error (status: ', res.status, ', body: ', res.body, ').') return res.status, res.body end -- Request accepted. end local function fetch_jwt_token(ctx) local token = core.request.header(ctx, "Authorization") if not token then return nil, "authorization header not available" end local prefix = sub_str(token, 1, 7) if prefix ~= 'Bearer ' and prefix ~= 'bearer ' then return "Bearer " .. token end return token end -- To get new access token by calling get token api local function generate_token_using_password_grant(conf,ctx) log.debug("generate_token_using_password_grant Function Called") local body, err = core.request.get_body() if err or not body then log.error("Failed to get request body: ", err) return 503 end local parameters = core.string.decode_args(body) local username = parameters["username"] local password = parameters["password"] if not username then local err = "username is missing." log.warn(err) return 422, {message = err} end if not password then local err = "password is missing." log.warn(err) return 422, {message = err} end local client_id = conf.client_id local token_endpoint = authz_keycloak_get_token_endpoint(conf) if not token_endpoint then local err = "Unable to determine token endpoint." log.error(err) return 503, {message = err} end local httpc = authz_keycloak_get_http_client(conf) local params = { method = "POST", body = ngx.encode_args({ grant_type = "password", client_id = client_id, client_secret = conf.client_secret, username = username, password = password }), headers = { ["Content-Type"] = "application/x-www-form-urlencoded" } } params = authz_keycloak_configure_params(params, conf) local res, err = httpc:request_uri(token_endpoint, params) if not res then err = "Accessing token endpoint URL (" .. token_endpoint .. ") failed: " .. err log.error(err) return 401, {message = "Accessing token endpoint URL failed."} end log.debug("Response data: " .. res.body) local json, err = authz_keycloak_parse_json_response(res) if not json then err = "Could not decode JSON from response" .. (err and (": " .. err) or '.') log.error(err) return 401, {message = "Could not decode JSON from response."} end return res.status, res.body end function _M.access(conf, ctx) -- resolve secrets conf = fetch_secrets(conf, true, conf, "") local headers = core.request.headers(ctx) local need_grant_token = conf.password_grant_token_generation_incoming_uri and ctx.var.request_uri == conf.password_grant_token_generation_incoming_uri and headers["content-type"] == "application/x-www-form-urlencoded" and core.request.get_method() == "POST" if need_grant_token then return generate_token_using_password_grant(conf,ctx) end log.debug("hit keycloak-auth access") local jwt_token, err = fetch_jwt_token(ctx) if not jwt_token then log.error("failed to fetch JWT token: ", err) return 401, {message = "Missing JWT token in request"} end local status, body = evaluate_permissions(conf, ctx, jwt_token) if status then return status, body end end return _M