blob: 587628f74bd40fdeeb86168e2b7dc7eb65f73315 [file] [log] [blame]
--
-- 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)
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