This is an automated email from the ASF dual-hosted git repository. spacewander pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push: new 9f6e605 feat(authz-keycloak): Add Keycloak Authorization Services endpoint discovery. (#3263) 9f6e605 is described below commit 9f6e605a150cea35b13bcb6a2945d481713eab08 Author: Jens Keiner <j...@nfft.org> AuthorDate: Wed Jan 13 16:22:34 2021 +0100 feat(authz-keycloak): Add Keycloak Authorization Services endpoint discovery. (#3263) --- apisix/cli/ngx_tpl.lua | 4 +- apisix/plugins/authz-keycloak.lua | 197 +++++++++++++++++++++++++++++++++---- doc/plugins/authz-keycloak.md | 27 ++++-- t/plugin/authz-keycloak.t | 199 +++++++++++++++++++++++++++++++++++--- 4 files changed, 381 insertions(+), 46 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 9f496d2..3790f46 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -139,8 +139,10 @@ http { lua_shared_dict tracing_buffer 10m; # plugin: skywalking lua_shared_dict plugin-api-breaker 10m; - # for openid-connect plugin + # for openid-connect and authz-keycloak plugin lua_shared_dict discovery 1m; # cache for discovery metadata documents + + # for openid-connect plugin lua_shared_dict jwks 1m; # cache for JWKs lua_shared_dict introspection 10m; # cache for JWT verification results diff --git a/apisix/plugins/authz-keycloak.lua b/apisix/plugins/authz-keycloak.lua index ff0eda0..8c2bee3 100644 --- a/apisix/plugins/authz-keycloak.lua +++ b/apisix/plugins/authz-keycloak.lua @@ -17,15 +17,16 @@ local core = require("apisix.core") local http = require "resty.http" local sub_str = string.sub -local url = require "net.url" -local tostring = tostring +local type = type local ngx = ngx local plugin_name = "authz-keycloak" +local log = core.log local schema = { type = "object", properties = { + discovery = {type = "string", minLength = 1, maxLength = 4096}, token_endpoint = {type = "string", minLength = 1, maxLength = 4096}, permissions = { type = "array", @@ -53,7 +54,9 @@ local schema = { keepalive_pool = {type = "integer", minimum = 1, default = 5}, ssl_verify = {type = "boolean", default = true}, }, - required = {"token_endpoint"} + anyOf = { + {required = {"discovery"}}, + {required = {"token_endpoint"}}} } @@ -64,10 +67,163 @@ local _M = { 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 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 + + +-- 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 + + +local function decorate_request(http_request_decorator, req) + return http_request_decorator and http_request_decorator(req) or req +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) + 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 + end + + else + json = core.json.decode(v) + end + + 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 + end + end + return err +end + + +local function authz_keycloak_get_endpoint(conf, endpoint) + if conf and conf[endpoint] then + return conf[endpoint] + elseif conf and conf.discovery and type(conf.discovery) == "table" then + return conf.discovery[endpoint] + end + + return nil +end + + +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 @@ -78,21 +234,23 @@ end local function evaluate_permissions(conf, token) - local url_decoded = url.parse(conf.token_endpoint) - local host = url_decoded.host - local port = url_decoded.port + if not is_path_protected(conf) and conf.policy_enforcement_mode == "ENFORCING" then + return 403 + end - if not port then - if url_decoded.scheme == "https" then - port = 443 - else - port = 80 - end + -- Ensure discovered data. + local err = authz_keycloak_ensure_discovered_data(conf) + if err then + return 500, err end - if not is_path_protected(conf) and conf.policy_enforcement_mode == "ENFORCING" then - return 403 + -- 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." end + log.debug("Token endpoint: ", token_endpoint) local httpc = http.new() httpc:set_timeout(conf.timeout) @@ -119,16 +277,15 @@ local function evaluate_permissions(conf, token) params.keepalive = conf.keepalive end - local httpc_res, httpc_err = httpc:request_uri(conf.token_endpoint, params) + local httpc_res, httpc_err = httpc:request_uri(token_endpoint, params) if not httpc_res then - core.log.error("error while sending authz request to [", host ,"] port[", - tostring(port), "] ", httpc_err) + log.error("error while sending authz request to ", token_endpoint, ": ", httpc_err) return 500, httpc_err end if httpc_res.status >= 400 then - core.log.error("status code: ", httpc_res.status, " msg: ", httpc_res.body) + log.error("status code: ", httpc_res.status, " msg: ", httpc_res.body) return httpc_res.status, httpc_res.body end end @@ -149,10 +306,10 @@ end function _M.access(conf, ctx) - core.log.debug("hit keycloak-auth access") + log.debug("hit keycloak-auth access") local jwt_token, err = fetch_jwt_token(ctx) if not jwt_token then - core.log.error("failed to fetch JWT token: ", err) + log.error("failed to fetch JWT token: ", err) return 401, {message = "Missing JWT token in request"} end diff --git a/doc/plugins/authz-keycloak.md b/doc/plugins/authz-keycloak.md index 972b2c7..4b6973b 100644 --- a/doc/plugins/authz-keycloak.md +++ b/doc/plugins/authz-keycloak.md @@ -38,15 +38,24 @@ For more information on Keycloak, refer to [Keycloak Authorization Docs](https:/ ## Attributes -| Name | Type | Requirement | Default | Valid | Description | -| ----------------------- | ------------- | ----------- | --------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| token_endpoint | string | required | | [1, 4096] | A OAuth2-compliant Token Endpoint that supports the `urn:ietf:params:oauth:grant-type:uma-ticket` grant type. | -| 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. <br>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"] | | +| 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. <br>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. ### Policy Enforcement Mode diff --git a/t/plugin/authz-keycloak.t b/t/plugin/authz-keycloak.t index 309c1a3..06b613c 100644 --- a/t/plugin/authz-keycloak.t +++ b/t/plugin/authz-keycloak.t @@ -24,13 +24,13 @@ run_tests; __DATA__ -=== TEST 1: sanity +=== TEST 1: sanity (using token endpoint) --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") local ok, err = plugin.check_schema({ - token_endpoint = "https://efactory-security-portal.salzburgresearch.at/", + token_endpoint = "https://host.domain/auth/realms/foo/protocol/openid-connect/token", grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" }) if not ok then @@ -49,12 +49,38 @@ done -=== TEST 2: full schema check +=== TEST 2: sanity (using discovery endpoint) --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.authz-keycloak") - local ok, err = plugin.check_schema({token_endpoint = "https://efactory-security-portal.salzburgresearch.at/", + 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" + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 3: full schema check +--- 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", @@ -76,7 +102,7 @@ done -=== TEST 3: token_endpoint missing +=== TEST 4: token_endpoint and discovery both missing --- config location /t { content_by_lua_block { @@ -92,14 +118,14 @@ done --- request GET /t --- response_body -property "token_endpoint" is required +object matches none of the requireds: ["discovery"] or ["token_endpoint"] done --- no_error_log [error] -=== TEST 4: add plugin with view course permissions +=== TEST 5: add plugin with view course permissions (using token endpoint) --- config location /t { content_by_lua_block { @@ -165,7 +191,148 @@ passed -=== TEST 5: Get access token for teacher and access view course route +=== TEST 6: 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=teac...@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 7: 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 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) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 9: Get access token for teacher and access view course route --- config location /t { content_by_lua_block { @@ -213,7 +380,7 @@ true -=== TEST 6: invalid access token +=== TEST 10: invalid access token --- config location /t { content_by_lua_block { @@ -240,7 +407,7 @@ Invalid bearer token -=== TEST 7: add plugin for delete course route +=== TEST 11: add plugin for delete course route --- config location /t { content_by_lua_block { @@ -306,7 +473,7 @@ passed -=== TEST 8: Get access token for student and delete course +=== TEST 12: Get access token for student and delete course --- config location /t { content_by_lua_block { @@ -354,7 +521,7 @@ true -=== TEST 9: Add htttps endpoint with ssl_verify true (default) +=== TEST 13: Add https endpoint with ssl_verify true (default) --- config location /t { content_by_lua_block { @@ -420,7 +587,7 @@ passed -=== TEST 10: TEST with fake token and https endpoint +=== TEST 14: TEST with fake token and https endpoint --- config location /t { content_by_lua_block { @@ -446,11 +613,11 @@ GET /t --- response_body false --- error_log -error while sending authz request to [127.0.0.1] port[8443] 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 11: Add htttps endpoint with ssl_verify false +=== TEST 15: Add htttps endpoint with ssl_verify false --- config location /t { content_by_lua_block { @@ -518,7 +685,7 @@ passed -=== TEST 12: TEST for https based token verification with ssl_verify false +=== TEST 16: TEST for https based token verification with ssl_verify false --- config location /t { content_by_lua_block {