You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by sp...@apache.org on 2021/01/13 15:22:45 UTC
[apisix] branch master updated: feat(authz-keycloak): Add Keycloak
Authorization Services endpoint discovery. (#3263)
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 <je...@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=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 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 {