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 {

Reply via email to