diff --git a/.github/workflows/centos7-ci.yml b/.github/workflows/centos7-ci.yml index 14b6067c8959..dc8e0da15f05 100644 --- a/.github/workflows/centos7-ci.yml +++ b/.github/workflows/centos7-ci.yml @@ -68,7 +68,7 @@ jobs: run: | docker run --rm -itd -p 6379:6379 --name apisix_redis redis:3.0-alpine docker run --rm -itd -e HTTP_PORT=8888 -e HTTPS_PORT=9999 -p 8888:8888 -p 9999:9999 mendhak/http-https-echo - docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 -p 8443:8443 sshniro/keycloak-apisix + docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 -p 8443:8443 sshniro/keycloak-apisix:1.0.0 docker network create kafka-net --driver bridge docker run --name zookeeper-server -d -p 2181:2181 --network kafka-net -e ALLOW_ANONYMOUS_LOGIN=yes bitnami/zookeeper:3.6.0 docker run --name kafka-server1 -d --network kafka-net -e ALLOW_PLAINTEXT_LISTENER=yes -e KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-server:2181 -e KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 -p 9092:9092 -e KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true bitnami/kafka:latest diff --git a/.travis/linux_openresty_common_runner.sh b/.travis/linux_openresty_common_runner.sh index 04e2d3626612..cd7372481365 100755 --- a/.travis/linux_openresty_common_runner.sh +++ b/.travis/linux_openresty_common_runner.sh @@ -24,7 +24,7 @@ before_install() { docker run --rm -itd -p 6379:6379 --name apisix_redis redis:3.0-alpine docker run --rm -itd -e HTTP_PORT=8888 -e HTTPS_PORT=9999 -p 8888:8888 -p 9999:9999 mendhak/http-https-echo # Runs Keycloak version 10.0.2 with inbuilt policies for unit tests - docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 -p 8443:8443 sshniro/keycloak-apisix + docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 -p 8443:8443 sshniro/keycloak-apisix:1.0.0 # spin up kafka cluster for tests (1 zookeper and 1 kafka instance) docker pull bitnami/zookeeper:3.6.0 docker pull bitnami/kafka:latest diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 113bec51304b..38081ad6f40c 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -148,6 +148,9 @@ http { lua_shared_dict jwks 1m; # cache for JWKs lua_shared_dict introspection 10m; # cache for JWT verification results + # for authz-keycloak + lua_shared_dict access_tokens 1m; # cache for service account access tokens + # for custom shared dict {% if http.lua_shared_dicts then %} {% for cache_key, cache_size in pairs(http.lua_shared_dicts) do %} @@ -385,16 +388,16 @@ http { {% end %} {% end %} {% -- if enable_ipv6 %} + {% if ssl.ssl_trusted_certificate ~= nil then %} + lua_ssl_trusted_certificate {* ssl.ssl_trusted_certificate *}; + {% end %} + {% if ssl.enable then %} ssl_certificate {* ssl.ssl_cert *}; ssl_certificate_key {* ssl.ssl_cert_key *}; ssl_session_cache shared:SSL:20m; ssl_session_timeout 10m; - {% if ssl.ssl_trusted_certificate ~= nil then %} - lua_ssl_trusted_certificate {* ssl.ssl_trusted_certificate *}; - {% end %} - ssl_protocols {* ssl.ssl_protocols *}; ssl_ciphers {* ssl.ssl_ciphers *}; ssl_prefer_server_ciphers on; diff --git a/apisix/plugins/authz-keycloak.lua b/apisix/plugins/authz-keycloak.lua index 8c2bee353f1a..60240a513046 100644 --- a/apisix/plugins/authz-keycloak.lua +++ b/apisix/plugins/authz-keycloak.lua @@ -22,41 +22,81 @@ local ngx = ngx local plugin_name = "authz-keycloak" 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}, - permissions = { - type = "array", - items = { - type = "string", - minLength = 1, maxLength = 100 - }, - uniqueItems = true - }, + resource_registration_endpoint = {type = "string", minLength = 1, maxLength = 4096}, + client_id = {type = "string", minLength = 1, maxLength = 100}, + audience = {type = "string", minLength = 1, maxLength = 100, + description = "Deprecated, use `client_id` instead."}, + 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 }, - audience = {type = "string", minLength = 1, maxLength = 100}, - timeout = {type = "integer", minimum = 1000, default = 3000}, policy_enforcement_mode = { type = "string", enum = {"ENFORCING", "PERMISSIVE"}, default = "ENFORCING" }, + permissions = { + type = "array", + items = { + type = "string", + minLength = 1, maxLength = 100 + }, + uniqueItems = true + }, + 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}, - ssl_verify = {type = "boolean", default = true}, + keepalive_pool = {type = "integer", minimum = 1, default = 5} }, - anyOf = { - {required = {"discovery"}}, - {required = {"token_endpoint"}}} + allOf = { + -- Require discovery or token endpoint. + { + anyOf = { + {required = {"discovery"}}, + {required = {"token_endpoint"}} + } + }, + -- Require client_id or audience. + { + anyOf = { + {required = {"client_id"}}, + {required = {"audience"}} + } + }, + -- 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"}} + } + } + } + } + } } @@ -69,225 +109,566 @@ local _M = { function _M.check_schema(conf) + -- Check for deprecated audience attribute and emit warnings if used. + if conf.audience then + log.warn("Plugin attribute `audience` is deprecated, use `client_id` instead.") + if conf.client_id then + log.warn("Ignoring `audience` attribute in favor of `client_id`.") + end + end return core.schema.check(schema, conf) end +-- Return the configured client ID parameter. +local function authz_keycloak_get_client_id(conf) + if conf.client_id then + -- Prefer client_id, if given. + return conf.client_id + end + + return conf.audience +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 + 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) + 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 - log.debug("cache set: success=", success, " err=", err, " forcible=", forcible) + params.keepalive = conf.keepalive end - 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) + 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 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 + 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 + 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) + -- 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 + if not res then + err = "JSON decoding failed: " .. err + end end - end - return res, err -end - - -local function decorate_request(http_request_decorator, req) - return http_request_decorator and http_request_decorator(req) or req + return res, err end -- Get the Discovery metadata from the specified URL. -local function authz_keycloak_discover(url, ssl_verify, keepalive, timeout, - exptime, proxy_opts, http_request_decorator) - log.debug("authz_keycloak_discover: URL is: " .. url) - - local json, err - local v = authz_keycloak_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() - authz_keycloak_configure_timeouts(httpc, timeout) - authz_keycloak_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) +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 - log.debug("response data: " .. res.body) - json, err = authz_keycloak_parse_json_response(res) - if json then - authz_keycloak_cache_set("discovery", url, core.json.encode(json), exptime or 24 * 60 * 60) - else - err = "could not decode JSON from Discovery data" .. (err and (": " .. err) or '') - log.error(err) - end + json = core.json.decode(v) end - else - json = core.json.decode(v) - end - - return json, err + return json, err end --- Turn a discovery url set in the opts dictionary into the discovered information. -local function authz_keycloak_ensure_discovered_data(opts) - local err - if type(opts.discovery) == "string" then - local discovery - discovery, err = authz_keycloak_discover(opts.discovery, opts.ssl_verify, opts.keepalive, - opts.timeout, opts.jwk_expires_in, opts.proxy_opts, - opts.http_request_decorator) - if not err then - opts.discovery = discovery +-- 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 - end - return err + 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 -local function is_path_protected(conf) - -- TODO if permissions are empty lazy load paths from Keycloak - if conf.permissions == nil then - return false +-- 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 or 300) + - 1 - (conf.access_token_expires_leeway or 0) +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 or 3600) + - 1 - (conf.refresh_token_expires_leeway or 0) +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 = authz_keycloak_get_client_id(conf) + 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 500, "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 - return true + + if not session then + -- No session available. Create a new one. + + core.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 -local function evaluate_permissions(conf, token) - if not is_path_protected(conf) and conf.policy_enforcement_mode == "ENFORCING" then - return 403 +-- 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 500, 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 500, err + return 500, 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 + return 500, 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. + return 500, err + end + else + -- Use statically configured permissions. + permission = conf.permissions + end + + -- Return 403 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. + 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 - log.error("Unable to determine token endpoint.") - return 500, "Unable to determine token endpoint." + err = "Unable to determine token endpoint." + log.error(err) + return 500, err end log.debug("Token endpoint: ", token_endpoint) - local httpc = http.new() - httpc:set_timeout(conf.timeout) + local httpc = authz_keycloak_get_http_client(conf) local params = { method = "POST", body = ngx.encode_args({ grant_type = conf.grant_type, - audience = conf.audience, + audience = authz_keycloak_get_client_id(conf), response_mode = "decision", - permission = conf.permissions + permission = permission }), - ssl_verify = conf.ssl_verify, headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Authorization"] = token } } - if conf.keepalive then - params.keepalive_timeout = conf.keepalive_timeout - params.keepalive_pool = conf.keepalive_pool - else - params.keepalive = conf.keepalive - end + params = authz_keycloak_configure_params(params, conf) - local httpc_res, httpc_err = httpc:request_uri(token_endpoint, params) + local res, err = httpc:request_uri(token_endpoint, params) - if not httpc_res then - log.error("error while sending authz request to ", token_endpoint, ": ", httpc_err) - return 500, httpc_err + if not res then + err = "Error while sending authz request to " .. token_endpoint .. ": " .. err + log.error(err) + return 500, err end - if httpc_res.status >= 400 then - log.error("status code: ", httpc_res.status, " msg: ", httpc_res.body) - return httpc_res.status, httpc_res.body + 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) + 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 @@ -313,7 +694,7 @@ function _M.access(conf, ctx) return 401, {message = "Missing JWT token in request"} end - local status, body = evaluate_permissions(conf, jwt_token) + local status, body = evaluate_permissions(conf, ctx, jwt_token) if status then return status, body end diff --git a/doc/plugins/authz-keycloak.md b/doc/plugins/authz-keycloak.md index d9d8a51a2755..f2979c7477eb 100644 --- a/doc/plugins/authz-keycloak.md +++ b/doc/plugins/authz-keycloak.md @@ -38,24 +38,50 @@ For more information on Keycloak, refer to [Keycloak Authorization Docs](https:/ ## Attributes -| Name | Type | Requirement | Default | Valid | Description | -| ----------------------- | ------------- | ----------- | --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| discovery | string | optional | | https://host.domain/auth/realms/foo/.well-known/uma2-configuration | URL to discovery document for Keycloak Authorization Services. | -| token_endpoint | string | optional | | https://host.domain/auth/realms/foo/protocol/openid-connect/token | A OAuth2-compliant Token Endpoint that supports the `urn:ietf:params:oauth:grant-type:uma-ticket` grant type. Overrides value from discovery, if given. | -| grant_type | string | optional | "urn:ietf:params:oauth:grant-type:uma-ticket" | ["urn:ietf:params:oauth:grant-type:uma-ticket"] | | -| audience | string | optional | | | The client identifier of the resource server to which the client is seeking access.
This parameter is mandatory when parameter permission is defined. | -| permissions | array[string] | optional | | | A string representing a set of one or more resources and scopes the client is seeking access. The format of the string must be: `RESOURCE_ID#SCOPE_ID`. | -| timeout | integer | optional | 3000 | [1000, ...] | Timeout(ms) for the http connection with the Identity Server. | -| ssl_verify | boolean | optional | true | | Verify if SSL cert matches hostname. | -| policy_enforcement_mode | string | optional | "ENFORCING" | ["ENFORCING", "PERMISSIVE"] | | - -### Endpoints - -Endpoints can optionally be discovered by providing a URL pointing to Keycloak's discovery document for Authorization Services for the realm -in the `discovery` attribute. The token endpoint URL will then be determined from that document. Alternatively, the token endpoint can be -specified explicitly via the `token_endpoint` attribute. - -One of `discovery` and `token_endpoint` has to be set. If both are given, the value from `token_endpoint` is used. +| Name | Type | Requirement | Default | Valid | Description | +| ------------------------------ | ------------- | ----------- | --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| discovery | string | optional | | https://host.domain/auth/realms/foo/.well-known/uma2-configuration | URL to discovery document for Keycloak Authorization Services. | +| token_endpoint | string | optional | | https://host.domain/auth/realms/foo/protocol/openid-connect/token | A OAuth2-compliant Token Endpoint that supports the `urn:ietf:params:oauth:grant-type:uma-ticket` grant type. Overrides value from discovery, if given. | +| resource_registration_endpoint | string | optional | | https://host.domain/auth/realms/foo/authz/protection/resource_set | A Keycloak Protection API-compliant resource registration endpoint. Overrides value from discovery, if given. | +| client_id | string | optional | | | The client identifier of the resource server to which the client is seeking access. One of `client_id` or `audience` is required. | +| audience | string | optional | | | Legacy parameter now replaced by `client_id`. Kept for backwards compatibility. One of `client_id` or `audience` is required. | +| client_secret | string | optional | | | The client secret, if required. | +| grant_type | string | optional | "urn:ietf:params:oauth:grant-type:uma-ticket" | ["urn:ietf:params:oauth:grant-type:uma-ticket"] | | +| policy_enforcement_mode | string | optional | "ENFORCING" | ["ENFORCING", "PERMISSIVE"] | | +| permissions | array[string] | optional | | | Static permission to request, an array of strings each representing a resources and optionally one or more scopes the client is seeking access. | +| lazy_load_paths | boolean | optional | false | | Dynamically resolve the request URI to resource(s) using the resource registration endpoint instead of using the static permission. | +| http_method_as_scope | boolean | optional | false | | Map HTTP request type to scope of same name and add to all permissions requested. | +| timeout | integer | optional | 3000 | [1000, ...] | Timeout(ms) for the http connection with the Identity Server. | +| ssl_verify | boolean | optional | true | | Verify if TLS certificate matches hostname. | +| cache_ttl_seconds | integer | optional | 86400 (equivalent to 24h) | positive integer >= 1 | The maximum period in seconds up to which the plugin caches discovery documents and tokens, used by the plugin to authenticate to Keycloak. | +| keepalive | boolean | optional | true | | Enable HTTP keep-alive to keep connections open after use. Set to `true` if you expect a lot of requests to Keycloak. | +| keepalive_timeout | integer | optional | 60000 | positive integer >= 1000 | Idle timeout after which established HTTP connections will be closed. | +| keepalive_pool | integer | optional | 5 | positive integer >= 1 | Maximum number of connections in the connection pool. | + +### Discovery and Endpoints + +The plugin can discover Keycloak API endpoints from a URL in the `discovery` attribute that points to +Keycloak's discovery document for Authorization Services for the respective realm. This is the recommended +option and typically most convenient. + +If the discovery document is available, the plugin determines the token endpoint URL from it. If present, the +`token_endpoint` attribute overrides the URL. + +Analogously, the plugin determines the registration endpoint from the discovery document. The +`resource_registration_endpoint` overrides, if present. + +### Client ID and Secret + +The plugin needs the `client_id` attribute to identify itself when interacting with Keycloak. +For backwards compatibility, you can still use the `audience` attribute as well instead. The plugin +prefers `client_id` over `audience` if both are configured. + +The plugin always needs the `client_id` or `audience` to specify the context in which Keycloak +should evaluate permissions. + +If `lazy_load_paths` is `true` then the plugin additionally needs to obtain an access token for +itself from Keycloak. In this case, if the client access to Keycloak is confidential, the plugin +needs the `client_secret` attribute as well. ### Policy Enforcement Mode @@ -69,6 +95,35 @@ Specifies how policies are enforced when processing authorization requests sent - Requests are allowed even when there is no policy associated with a given resource. +### Permissions + +When handling an incoming request, the plugin can determine the permissions to check with Keycloak either +statically, or dynamically from properties of the request. + +If `lazy_load_paths` is `false`, the plugin takes the permissions from the `permissions` attribute. Each entry +needs to be formatted as expected by the token endpoint's `permission` parameter; +see https://www.keycloak.org/docs/latest/authorization_services/index.html#_service_obtaining_permissions. +Note that a valid permission can be a single resource, or a resource paired with one or more scopes. + +if `lazy_load_paths` is `true`, the plugin resolves the request URI to one or more resources, as configured +in Keycloak. It uses the resource registration endpoint to do so. The plugin uses the resolved resources +as the permissions to check. + +Note that this requires that the plugin can obtain a separate access token for itself from the token endpoint. +Therefore, in the respective client settings in Keycloak, make sure to set the `Service Accounts Enabled` +option. Also make sure that the issued access token contains the `resource_access` claim with the +`uma_protection` role. Otherwise, plugin may be unable to query resources through the Protection API. + +### Automatic Mapping of HTTP Method to Scope + +This option is often used together with `lazy_load_paths`, but can also be used with a static permission list. + +If the `http_method_as_scope` attribute is set to `true`, the plugin maps the request's HTTP method to a scope +of the same name. The scope is then added to every permission to check. + +If `lazy_load_paths` is `false`, the plugin adds the mapped scope to any of the static permissions configured +in the `permissions` attribute, even if they contain one or more scopes alreay. + ## How To Enable Create a `route` and enable the `authz-keycloak` plugin on the route: diff --git a/t/APISIX.pm b/t/APISIX.pm index 227012f17e12..304327dc11e9 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -369,6 +369,8 @@ _EOC_ lua_shared_dict balancer_ewma_last_touched_at 1m; lua_shared_dict plugin-limit-count-redis-cluster-slot-lock 1m; lua_shared_dict tracing_buffer 10m; # plugin skywalking + lua_shared_dict access_tokens 1m; # plugin authz-keycloak + lua_shared_dict discovery 1m; # plugin authz-keycloak lua_shared_dict plugin-api-breaker 10m; lua_capture_error_log 1m; # plugin error-log-logger diff --git a/t/lib/server.lua b/t/lib/server.lua index e5edde9e9484..588a5698d388 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -56,6 +56,12 @@ function _M.hello_() end +-- Fake endpoint, needed for testing authz-keycloak plugin. +function _M.course_foo() + ngx.say("course foo") +end + + function _M.server_port() ngx.print(ngx.var.server_port) end diff --git a/t/plugin/authz-keycloak.t b/t/plugin/authz-keycloak.t index 06b613cf5f71..02c9c5cec13f 100644 --- a/t/plugin/authz-keycloak.t +++ b/t/plugin/authz-keycloak.t @@ -24,15 +24,15 @@ run_tests; __DATA__ -=== TEST 1: sanity (using token endpoint) +=== TEST 1: minimal valid configuration w/o discovery --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") local ok, err = plugin.check_schema({ - token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", - grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" - }) + client_id = "foo", + token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token" + }) if not ok then ngx.say(err) end @@ -49,15 +49,15 @@ done -=== TEST 2: sanity (using discovery endpoint) +=== TEST 2: minimal valid configuration with discovery --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") local ok, err = plugin.check_schema({ - discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration", - grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" - }) + client_id = "foo", + discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration" + }) if not ok then ngx.say(err) end @@ -74,18 +74,15 @@ done -=== TEST 3: full schema check +=== TEST 3: minimal valid configuration with audience --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") - local ok, err = plugin.check_schema({discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration", - token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", - permissions = {"res:customer#scopes:view"}, - timeout = 1000, - audience = "University", - grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" - }) + local ok, err = plugin.check_schema({ + audience = "foo", + discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration" + }) if not ok then ngx.say(err) end @@ -102,12 +99,17 @@ done -=== TEST 4: token_endpoint and discovery both missing +=== TEST 4: minimal valid configuration w/o discovery when lazy_load_paths=true --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") - local ok, err = plugin.check_schema({permissions = {"res:customer#scopes:view"}}) + local ok, err = plugin.check_schema({ + client_id = "foo", + lazy_load_paths = true, + token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", + resource_registration_endpoint = "https://host.domain/auth/realms/foo/authz/protection/resource_set" + }) if not ok then ngx.say(err) end @@ -118,410 +120,152 @@ done --- request GET /t --- response_body -object matches none of the requireds: ["discovery"] or ["token_endpoint"] done --- no_error_log [error] -=== TEST 5: add plugin with view course permissions (using token endpoint) +=== TEST 5: minimal valid configuration with discovery when lazy_load_paths=true --- config location /t { content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "authz-keycloak": { - "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", - "permissions": ["course_resource#view"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }]], - [[{ - "node": { - "value": { - "plugins": { - "authz-keycloak": { - "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", - "permissions": ["course_resource#view"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }, - "key": "/apisix/routes/1" - }, - "action": "set" - }]] - ) - - if code >= 300 then - ngx.status = code + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({ + client_id = "foo", + lazy_load_paths = true, + discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration" + }) + if not ok then + ngx.say(err) end - ngx.say(body) + + ngx.say("done") } } --- request GET /t --- response_body -passed +done --- no_error_log [error] -=== TEST 6: Get access token for teacher and access view course route +=== TEST 6: full schema check --- config location /t { content_by_lua_block { - local json_decode = require("toolkit.json").decode - local http = require "resty.http" - local httpc = http.new() - local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" - local res, err = httpc:request_uri(uri, { - method = "POST", - body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", - headers = { - ["Content-Type"] = "application/x-www-form-urlencoded" - } - }) - - if res.status == 200 then - local body = json_decode(res.body) - local accessToken = body["access_token"] - - - uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" - local res, err = httpc:request_uri(uri, { - method = "GET", - headers = { - ["Authorization"] = "Bearer " .. accessToken, - } - }) - - if res.status == 200 then - ngx.say(true) - else - ngx.say(false) - end - else - ngx.say(false) + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({ + discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration", + token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", + resource_registration_endpoint = "https://host.domain/auth/realms/foo/authz/protection/resource_set", + client_id = "University", + audience = "University", + client_secret = "secret", + grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket", + policy_enforcement_mode = "ENFORCING", + permissions = {"res:customer#scopes:view"}, + lazy_load_paths = false, + http_method_as_scope = false, + timeout = 1000, + ssl_verify = false, + cache_ttl_seconds = 1000, + keepalive = true, + keepalive_timeout = 10000, + keepalive_pool = 5 + }) + if not ok then + ngx.say(err) end + + ngx.say("done") } } --- request GET /t --- response_body -true +done --- no_error_log [error] -=== TEST 7: invalid access token +=== TEST 7: token_endpoint and discovery both missing --- config location /t { content_by_lua_block { - local http = require "resty.http" - local httpc = http.new() - local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" - local res, err = httpc:request_uri(uri, { - method = "GET", - headers = { - ["Authorization"] = "Bearer wrong_token", - } - }) - if res.status == 401 then - ngx.say(true) + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({client_id = "foo"}) + if not ok then + ngx.say(err) end - } - } ---- request -GET /t ---- response_body -true ---- error_log -Invalid bearer token - - - -=== TEST 8: add plugin with view course permissions (using discovery) ---- config - location /t { - content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "authz-keycloak": { - "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", - "permissions": ["course_resource#view"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }]], - [[{ - "node": { - "value": { - "plugins": { - "authz-keycloak": { - "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", - "permissions": ["course_resource#view"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }, - "key": "/apisix/routes/1" - }, - "action": "set" - }]] - ) - if code >= 300 then - ngx.status = code - end - ngx.say(body) + ngx.say("done") } } --- request GET /t --- response_body -passed +allOf 1 failed: object matches none of the requireds: ["discovery"] or ["token_endpoint"] +done --- no_error_log [error] -=== TEST 9: Get access token for teacher and access view course route +=== TEST 8: client_id and audience both missing --- config location /t { content_by_lua_block { - local json_decode = require("toolkit.json").decode - local http = require "resty.http" - local httpc = http.new() - local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" - local res, err = httpc:request_uri(uri, { - method = "POST", - body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", - headers = { - ["Content-Type"] = "application/x-www-form-urlencoded" - } - }) - - if res.status == 200 then - local body = json_decode(res.body) - local accessToken = body["access_token"] - - - uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" - local res, err = httpc:request_uri(uri, { - method = "GET", - headers = { - ["Authorization"] = "Bearer " .. accessToken, - } - }) - - if res.status == 200 then - ngx.say(true) - else - ngx.say(false) - end - else - ngx.say(false) + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({discovery = "https://host.domain/auth/realms/foo/.well-known/uma2-configuration"}) + if not ok then + ngx.say(err) end + + ngx.say("done") } } --- request GET /t --- response_body -true +allOf 2 failed: object matches none of the requireds: ["client_id"] or ["audience"] +done --- no_error_log [error] -=== TEST 10: invalid access token +=== TEST 9: resource_registration_endpoint and discovery both missing and lazy_load_paths is true --- config location /t { content_by_lua_block { - local http = require "resty.http" - local httpc = http.new() - local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" - local res, err = httpc:request_uri(uri, { - method = "GET", - headers = { - ["Authorization"] = "Bearer wrong_token", - } - }) - if res.status == 401 then - ngx.say(true) + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({ + client_id = "foo", + token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", + lazy_load_paths = true + }) + if not ok then + ngx.say(err) end - } - } ---- request -GET /t ---- response_body -true ---- error_log -Invalid bearer token - - -=== TEST 11: add plugin for delete course route ---- config - location /t { - content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "authz-keycloak": { - "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", - "permissions": ["course_resource#delete"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }]], - [[{ - "node": { - "value": { - "plugins": { - "authz-keycloak": { - "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", - "permissions": ["course_resource#delete"], - "audience": "course_management", - "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - "timeout": 3000 - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1982": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello1" - }, - "key": "/apisix/routes/1" - }, - "action": "set" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) + ngx.say("done") } } --- request GET /t --- response_body -passed +allOf 3 failed: object matches none of the requireds +done --- no_error_log [error] -=== TEST 12: Get access token for student and delete course ---- config - location /t { - content_by_lua_block { - local json_decode = require("toolkit.json").decode - local http = require "resty.http" - local httpc = http.new() - local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" - local res, err = httpc:request_uri(uri, { - method = "POST", - body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=student@gmail.com&password=123456", - headers = { - ["Content-Type"] = "application/x-www-form-urlencoded" - } - }) - - if res.status == 200 then - local body = json_decode(res.body) - local accessToken = body["access_token"] - - - uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" - local res, err = httpc:request_uri(uri, { - method = "GET", - headers = { - ["Authorization"] = "Bearer " .. accessToken, - } - }) - - if res.status == 403 then - ngx.say(true) - else - ngx.say(false) - end - else - ngx.say(false) - end - } - } ---- request -GET /t ---- response_body -true ---- error_log -{"error":"access_denied","error_description":"not_authorized"} - - - -=== TEST 13: Add https endpoint with ssl_verify true (default) +=== TEST 10: Add https endpoint with ssl_verify true (default) --- config location /t { content_by_lua_block { @@ -533,7 +277,7 @@ true "authz-keycloak": { "token_endpoint": "https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token", "permissions": ["course_resource#delete"], - "audience": "course_management", + "client_id": "course_management", "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", "timeout": 3000 } @@ -553,7 +297,7 @@ true "authz-keycloak": { "token_endpoint": "https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token", "permissions": ["course_resource#delete"], - "audience": "course_management", + "client_id": "course_management", "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", "timeout": 3000 } @@ -587,7 +331,7 @@ passed -=== TEST 14: TEST with fake token and https endpoint +=== TEST 11: TEST with fake token and https endpoint --- config location /t { content_by_lua_block { @@ -613,11 +357,11 @@ GET /t --- response_body false --- error_log -error while sending authz request to https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token: 18: self signed certificate +Error while sending authz request to https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token: 18: self signed certificate -=== TEST 15: Add htttps endpoint with ssl_verify false +=== TEST 12: Add https endpoint with ssl_verify false --- config location /t { content_by_lua_block { @@ -629,7 +373,7 @@ error while sending authz request to https://127.0.0.1:8443/auth/realms/Universi "authz-keycloak": { "token_endpoint": "https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token", "permissions": ["course_resource#delete"], - "audience": "course_management", + "client_id": "course_management", "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", "timeout": 3000, "ssl_verify": false @@ -650,7 +394,7 @@ error while sending authz request to https://127.0.0.1:8443/auth/realms/Universi "authz-keycloak": { "token_endpoint": "https://127.0.0.1:8443/auth/realms/University/protocol/openid-connect/token", "permissions": ["course_resource#delete"], - "audience": "course_management", + "client_id": "course_management", "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", "timeout": 3000, "ssl_verify": false @@ -685,7 +429,7 @@ passed -=== TEST 16: TEST for https based token verification with ssl_verify false +=== TEST 13: TEST for https based token verification with ssl_verify false --- config location /t { content_by_lua_block { @@ -711,4 +455,4 @@ GET /t --- response_body false --- error_log -status code: 401 msg: {"error":"HTTP 401 Unauthorized"} +Request denied: HTTP 401 Unauthorized. Body: {"error":"HTTP 401 Unauthorized"} diff --git a/t/plugin/authz-keycloak2.t b/t/plugin/authz-keycloak2.t new file mode 100644 index 000000000000..953828d96943 --- /dev/null +++ b/t/plugin/authz-keycloak2.t @@ -0,0 +1,839 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +log_level('debug'); +repeat_each(1); +no_long_string(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: add plugin with view course permissions (using token endpoint) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#view"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#view"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 2: Get access token for teacher and access view course route +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 3: invalid access token +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer wrong_token", + } + }) + if res.status == 401 then + ngx.say(true) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +Invalid bearer token + + + +=== TEST 4: add plugin with view course permissions (using discovery) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "permissions": ["course_resource#view"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "permissions": ["course_resource#view"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: Get access token for teacher and access view course route +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 6: invalid access token +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer wrong_token", + } + }) + if res.status == 401 then + ngx.say(true) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +Invalid bearer token + + + +=== TEST 7: add plugin for delete course route +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#delete"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#delete"], + "client_id": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 8: Get access token for student and delete course +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=student@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 403 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +{"error":"access_denied","error_description":"not_authorized"} + + + +=== TEST 9: add plugin with lazy_load_paths and http_method_as_scope +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "lazy_load_paths": true, + "http_method_as_scope": true + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/course/foo" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "lazy_load_paths": true, + "http_method_as_scope": true + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/course/foo" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 10: Get access token for teacher and access view course route. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 11: Get access token for student and access view course route. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=student@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 12: Get access token for teacher and delete course. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "DELETE", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 13: Get access token for student and try to delete course. Should fail. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=student@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "DELETE", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 403 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +{"error":"access_denied","error_description":"not_authorized"} + + + +=== TEST 14: add plugin with lazy_load_paths and http_method_as_scope (using audience) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "audience": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "lazy_load_paths": true, + "http_method_as_scope": true + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/course/foo" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "discovery": "http://127.0.0.1:8090/auth/realms/University/.well-known/uma2-configuration", + "audience": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "lazy_load_paths": true, + "http_method_as_scope": true + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/course/foo" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 15: Get access token for teacher and access view course route. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + + +=== TEST 16: Get access token for student and access view course route. +--- config + location /t { + content_by_lua_block { + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=student@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/course/foo" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error]