You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by mo...@apache.org on 2023/11/21 03:36:38 UTC

(apisix) branch master updated: feat: add `required scopes` configuration property to `openid-connect` plugin (#10493)

This is an automated email from the ASF dual-hosted git repository.

monkeydluffy 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 43e83424a feat: add `required scopes` configuration property to `openid-connect` plugin (#10493)
43e83424a is described below

commit 43e83424a01d2f9bd2f78049635a7910716d9130
Author: Christos Sotiriou <cs...@users.noreply.github.com>
AuthorDate: Tue Nov 21 05:36:31 2023 +0200

    feat: add `required scopes` configuration property to `openid-connect` plugin (#10493)
---
 apisix/plugins/openid-connect.lua        |  39 ++++++
 docs/en/latest/plugins/openid-connect.md |  71 +++++------
 t/plugin/openid-connect4.t               | 200 +++++++++++++++++++++++++++++++
 3 files changed, 275 insertions(+), 35 deletions(-)

diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua
index 5c9fffc89..2bca4cb9d 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -21,6 +21,8 @@ local openidc = require("resty.openidc")
 local random  = require("resty.random")
 local string  = string
 local ngx     = ngx
+local ipairs = ipairs
+local concat = table.concat
 
 local ngx_encode_base64 = ngx.encode_base64
 
@@ -164,6 +166,13 @@ local schema = {
         authorization_params = {
             description = "Extra authorization params to the authorize endpoint",
             type = "object"
+        },
+        required_scopes = {
+            description = "List of scopes that are required to be granted to the access token",
+            type = "array",
+            items = {
+                type = "string"
+            }
         }
     },
     encrypt_fields = {"client_secret"},
@@ -319,6 +328,24 @@ local function add_access_token_header(ctx, conf, token)
     end
 end
 
+-- Function to split the scope string into a table
+local function split_scopes_by_space(scope_string)
+    local scopes = {}
+    for scope in string.gmatch(scope_string, "%S+") do
+        scopes[scope] = true
+    end
+    return scopes
+end
+
+-- Function to check if all required scopes are present
+local function required_scopes_present(required_scopes, http_scopes)
+    for _, scope in ipairs(required_scopes) do
+        if not http_scopes[scope] then
+            return false
+        end
+    end
+    return true
+end
 
 function _M.rewrite(plugin_conf, ctx)
     local conf = core.table.clone(plugin_conf)
@@ -355,6 +382,18 @@ function _M.rewrite(plugin_conf, ctx)
         end
 
         if response then
+            if conf.required_scopes then
+                local http_scopes = response.scope and split_scopes_by_space(response.scope) or {}
+                local is_authorized = required_scopes_present(conf.required_scopes, http_scopes)
+                if not is_authorized then
+                    core.log.error("OIDC introspection failed: ", "required scopes not present")
+                    local error_response = {
+                        error = "required scopes " .. concat(conf.required_scopes, ", ") ..
+                        " not present"
+                    }
+                    return 403, core.json.encode(error_response)
+                end
+            end
             -- Add configured access token header, maybe.
             add_access_token_header(ctx, conf, access_token)
 
diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md
index 1121250a7..f7b62f4b5 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -33,41 +33,42 @@ description: OpenID Connect allows the client to obtain user information from th
 
 ## Attributes
 
-| Name                                 | Type    | Required | Default               | Valid values | Description                                                                                                              |
-|--------------------------------------|---------|----------|-----------------------|--------------|--------------------------------------------------------------------------------------------------------------------------|
-| client_id                            | string  | True     |                       |              | OAuth client ID.                                                                                                         |
-| client_secret                        | string  | True     |                       |              | OAuth client secret.                                                                                                     |
-| discovery                            | string  | True     |                       |              | Discovery endpoint URL of the identity server.                                                                           |
-| scope                                | string  | False    | "openid"              |              | Scope used for authentication.                                                                                           |
-| realm                                | string  | False    | "apisix"              |              | Realm in [`WWW-Authenticate` response header](https://www.rfc-editor.org/rfc/rfc6750#section-3) accompanying an unauthorized request due to invalid bearer token.                                                                                           |
-| bearer_only                          | boolean | False    | false                 |              | When set to `true`, APISIX will only check if the authorization header in the request matches a bearer token.           |
-| logout_path                          | string  | False    | "/logout"             |              | Path for logging out.                                                                                                    |
-| post_logout_redirect_uri             | string  | False    |                       |              | URL to redirect to after logging out.                                                                                    |
-| redirect_uri                         | string  | False    | "ngx.var.request_uri" |              | URI to which the identity provider redirects back to.                                                                    |
-| timeout                              | integer | False    | 3                     | [1,...]      | Request timeout time in seconds.                                                                                         |
-| ssl_verify                           | boolean | False    | false                 |              | When set to true, verifies the identity provider's SSL certificates.                                                     |
-| introspection_endpoint               | string  | False    |                       |              | URL of the token introspection endpoint for the identity provider used to introspect access tokens. If this is unset, the introspection endpoint presented in the well-known discovery document is used [as a fallback](https://github.com/zmartzone/lua-resty-openidc/commit/cdaf824996d2b499de4c72852c91733872137c9c).                                                           |
-| introspection_endpoint_auth_method   | string  | False    | "client_secret_basic" |              | Authentication method for the token introspection endpoint. The value should be one of the authentication methods specified in the `introspection_endpoint_auth_methods_supported` [authorization server metadata](https://www.rfc-editor.org/rfc/rfc8414.html) as seen in the well-known discovery document, such as `client_secret_basic`, `client_secret_post`, `private_key_jwt`, or `client_secret [...]
-| token_endpoint_auth_method           | string  | False    |                       |              | Authentication method for the token endpoint. The value should be one of the authentication methods specified in the `token_endpoint_auth_methods_supported` [authorization server metadata](https://www.rfc-editor.org/rfc/rfc8414.html) as seen in the well-known discovery document, such as `client_secret_basic`, `client_secret_post`, `private_key_jwt`, or `client_secret_jwt`. If the configur [...]
-| public_key                           | string  | False    |                       |              | Public key to verify the token.                                                                                          |
-| use_jwks                             | boolean | False    | false                 |              | When set to `true`, uses the JWKS endpoint of the identity server to verify the token.                                   |
-| use_pkce                             | boolean | False    | false                 |              | when set to `true`, the "Proof Key for Code Exchange" as defined in RFC 7636 will be used.   |
-| token_signing_alg_values_expected    | string  | False    |                       |              | Algorithm used for signing the authentication token.                                                                     |
-| set_access_token_header              | boolean | False    | true                  |              | When set to true, sets the access token in a request header.                                                             |
-| access_token_in_authorization_header | boolean | False    | false                 |              | When set to true, sets the access token in the `Authorization` header. Otherwise, set the `X-Access-Token` header.       |
-| set_id_token_header                  | boolean | False    | true                  |              | When set to true and the ID token is available, sets the ID token in the `X-ID-Token` request header.                    |
-| set_userinfo_header                  | boolean | False    | true                  |              | When set to true and the UserInfo object is available, sets it in the `X-Userinfo` request header.                       |
-| set_refresh_token_header             | boolean | False    | false                 |              | When set to true and a refresh token object is available, sets it in the `X-Refresh-Token` request header.               |
-| session                              | object  | False    |                       |              | When bearer_only is set to false, openid-connect will use Authorization Code flow to authenticate on the IDP, so you need to set the session-related configuration. |
-| session.secret                       | string  | True     | Automatic generation  | 16 or more characters | The key used for session encrypt and HMAC operation. |
-| unauth_action                        | string  | False    | "auth"                |              | Specify the response type on unauthenticated requests. "auth" redirects to identity provider, "deny" results in a 401 response, "pass" will allow the request without authentication. |
-| proxy_opts                           | object  | False    |                       |                                  | HTTP proxy server be used to access identity server.                                                                                                                                                          |
-| proxy_opts.proxy_opts.http_proxy     | string  | False    |                       | http://proxy-server:port         | HTTP proxy server address.                                                                                                                                                                                    |
-| proxy_opts.proxy_opts.https_proxy    | string  | False    |                       | http://proxy-server:port         | HTTPS proxy server address.                                                                                                                                                                                   |
-| proxy_opts.http_proxy_authorization  | string  | False    |                       | Basic [base64 username:password] | Default `Proxy-Authorization` header value to be used with `http_proxy`.                                                                                                                                      |
-| proxy_opts.https_proxy_authorization | string  | False    |                       | Basic [base64 username:password] | As `http_proxy_authorization` but for use with `https_proxy` (since with HTTPS the authorisation is done when connecting, this one cannot be overridden by passing the `Proxy-Authorization` request header). |
-| proxy_opts.no_proxy                  | string  | False    |                       |                                  | Comma separated list of hosts that should not be proxied.                                                                                                                                                     |
-| authorization_params                 | object  | False    |                       |                                  | Additional parameters to send in the in the request to the authorization endpoint.                   |
+| Name                                 | Type     | Required | Default               | Valid values | Description                                                                                                                                                                                                                           |
+|--------------------------------------|----------|----------|-----------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| client_id                            | string   | True     |                       |              | OAuth client ID.                                                                                                                                                                                                                      |
+| client_secret                        | string   | True     |                       |              | OAuth client secret.                                                                                                                                                                                                                  |
+| discovery                            | string   | True     |                       |              | Discovery endpoint URL of the identity server.                                                                                                                                                                                        |
+| scope                                | string   | False    | "openid"              |              | Scope used for authentication.                                                                                                                                                                                                        |
+| required_scopes                      | string[] | False    |                       |              | Array of strings. Used in conjunction with the introspection endpoint (when `bearer_only` is `true`). If present, the plugin will check if the token contains all required scopes. If not, 403 will be returned with an error message |
+| realm                                | string   | False    | "apisix"              |              | Realm used for authentication.                                                                                                                                                                                                        |
+| bearer_only                          | boolean  | False    | false                 |              | When set to `true`, APISIX will only check if the authorization header in the request matches a bearer token.                                                                                                                         |
+| logout_path                          | string   | False    | "/logout"             |              | Path for logging out.                                                                                                                                                                                                                 |
+| post_logout_redirect_uri             | string   | False    |                       |              | URL to redirect to after logging out.                                                                                                                                                                                                 |
+| redirect_uri                         | string   | False    | "ngx.var.request_uri" |              | URI to which the identity provider redirects back to.                                                                                                                                                                                 |
+| timeout                              | integer  | False    | 3                     | [1,...]      | Request timeout time in seconds.                                                                                                                                                                                                      |
+| ssl_verify                           | boolean  | False    | false                 |              | When set to true, verifies the identity provider's SSL certificates.                                                                                                                                                                  |
+| introspection_endpoint               | string   | False    |                       |              | URL of the token verification endpoint of the identity server.                                                                                                                                                                        |
+| introspection_endpoint_auth_method   | string   | False    | "client_secret_basic" |              | Authentication method name for token introspection.                                                                                                                                                                                   |
+| token_endpoint_auth_method           | string   | False    |                       |              | Authentication method name for token endpoint. The default will get the first supported method specified by the OP.                                                                                                                   |
+| public_key                           | string   | False    |                       |              | Public key to verify the token.                                                                                                                                                                                                       |
+| use_jwks                             | boolean  | False    | false                 |              | When set to `true`, uses the JWKS endpoint of the identity server to verify the token.                                                                                                                                                |
+| use_pkce                             | boolean  | False    | false                 |              | when set to `true`, the "Proof Key for Code Exchange" as defined in RFC 7636 will be used.                                                                                                                                            |
+| token_signing_alg_values_expected    | string   | False    |                       |              | Algorithm used for signing the authentication token.                                                                                                                                                                                  |
+| set_access_token_header              | boolean  | False    | true                  |              | When set to true, sets the access token in a request header.                                                                                                                                                                          |
+| access_token_in_authorization_header | boolean  | False    | false                 |              | When set to true, sets the access token in the `Authorization` header. Otherwise, set the `X-Access-Token` header.                                                                                                                    |
+| set_id_token_header                  | boolean  | False    | true                  |              | When set to true and the ID token is available, sets the ID token in the `X-ID-Token` request header.                                                                                                                                 |
+| set_userinfo_header                  | boolean  | False    | true                  |              | When set to true and the UserInfo object is available, sets it in the `X-Userinfo` request header.                                                                                                                                    |
+| set_refresh_token_header             | boolean  | False    | false                 |              | When set to true and a refresh token object is available, sets it in the `X-Refresh-Token` request header.                                                                                                                            |
+| session                              | object   | False    |                       |              | When bearer_only is set to false, openid-connect will use Authorization Code flow to authenticate on the IDP, so you need to set the session-related configuration.                                                                   |
+| session.secret                       | string   | True     | Automatic generation  | 16 or more characters | The key used for session encrypt and HMAC operation.                                                                                                                                                                                  |
+| unauth_action                        | string   | False    | "auth"                |              | Specify the response type on unauthenticated requests. "auth" redirects to identity provider, "deny" results in a 401 response, "pass" will allow the request without authentication.                                                 |
+| proxy_opts                           | object   | False    |                       |                                  | HTTP proxy server be used to access identity server.                                                                                                                                                                                  |
+| proxy_opts.proxy_opts.http_proxy     | string   | False    |                       | http://proxy-server:port         | HTTP proxy server address.                                                                                                                                                                                                            |
+| proxy_opts.proxy_opts.https_proxy    | string   | False    |                       | http://proxy-server:port         | HTTPS proxy server address.                                                                                                                                                                                                           |
+| proxy_opts.http_proxy_authorization  | string   | False    |                       | Basic [base64 username:password] | Default `Proxy-Authorization` header value to be used with `http_proxy`.                                                                                                                                                              |
+| proxy_opts.https_proxy_authorization | string   | False    |                       | Basic [base64 username:password] | As `http_proxy_authorization` but for use with `https_proxy` (since with HTTPS the authorisation is done when connecting, this one cannot be overridden by passing the `Proxy-Authorization` request header).                         |
+| proxy_opts.no_proxy                  | string   | False    |                       |                                  | Comma separated list of hosts that should not be proxied.                                                                                                                                                                             |
+| authorization_params                 | object   | False    |                       |                                  | Additional parameters to send in the in the request to the authorization endpoint.                                                                                                                                                    |
 
 NOTE: `encrypt_fields = {"client_secret"}` is also defined in the schema, which means that the field will be stored encrypted in etcd. See [encrypted storage fields](../plugin-develop.md#encrypted-storage-fields).
 
diff --git a/t/plugin/openid-connect4.t b/t/plugin/openid-connect4.t
index 35f33acbe..9df55bee0 100644
--- a/t/plugin/openid-connect4.t
+++ b/t/plugin/openid-connect4.t
@@ -109,3 +109,203 @@ true
 --- error_code: 302
 --- error_log
 use http proxy
+
+
+
+=== TEST 3: Set an unsupported scope in the required scopes field
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            require("apisix.plugins.openid-connect")
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "openid-connect": {
+                                "client_id": "course_management",
+                                "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5",
+                                "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration",
+                                "redirect_uri": "http://localhost:3000",
+                                "ssl_verify": false,
+                                "timeout": 10,
+                                "bearer_only": true,
+                                "realm": "University",
+                                "required_scopes": ["unsupported"],
+                                "introspection_endpoint_auth_method": "client_secret_post",
+                                "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: Access route
+--- config
+    location /t {
+        content_by_lua_block {
+            -- Obtain valid access token from Keycloak using known username and password.
+            local json_decode = require("toolkit.json").decode
+            local http = require "resty.http"
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:8080/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"
+                    }
+                })
+
+            -- Check response from keycloak and fail quickly if there's no response.
+            if not res then
+                ngx.say(err)
+                return
+            end
+
+            -- Check if response code was ok.
+            if res.status == 200 then
+                -- Get access token from JSON response body.
+                local body = json_decode(res.body)
+                local accessToken = body["access_token"]
+
+                -- Access route using access token. Should work.
+                uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+                local res, err = httpc:request_uri(uri, {
+                    method = "GET",
+                    headers = {
+                        ["Authorization"] = "Bearer " .. body["access_token"]
+                    }
+                 })
+                if res.status == 200 then
+                    -- Route accessed successfully.
+                    ngx.say(true)
+                else
+                    -- Couldn't access route.
+                    ngx.say(false)
+                end
+            else
+                -- Response from Keycloak not ok.
+                ngx.say(false)
+            end
+        }
+    }
+--- error_log
+required scopes not present
+
+
+
+=== TEST 5: Set a supported scope in the required scopes field
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            require("apisix.plugins.openid-connect")
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "openid-connect": {
+                                "client_id": "course_management",
+                                "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5",
+                                "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration",
+                                "redirect_uri": "http://localhost:3000",
+                                "ssl_verify": false,
+                                "timeout": 10,
+                                "bearer_only": true,
+                                "realm": "University",
+                                "required_scopes": ["profile"],
+                                "introspection_endpoint_auth_method": "client_secret_post",
+                                "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: Access route
+--- config
+    location /t {
+        content_by_lua_block {
+            -- Obtain valid access token from Keycloak using known username and password.
+            local json_decode = require("toolkit.json").decode
+            local http = require "resty.http"
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:8080/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"
+                    }
+                })
+
+            -- Check response from keycloak and fail quickly if there's no response.
+            if not res then
+                ngx.say(err)
+                return
+            end
+
+            -- Check if response code was ok.
+            if res.status == 200 then
+                -- Get access token from JSON response body.
+                local body = json_decode(res.body)
+                local accessToken = body["access_token"]
+
+                -- Access route using access token. Should work.
+                uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+                local res, err = httpc:request_uri(uri, {
+                    method = "GET",
+                    headers = {
+                        ["Authorization"] = "Bearer " .. body["access_token"]
+                    }
+                 })
+                if res.status == 200 then
+                    -- Route accessed successfully.
+                    ngx.say(true)
+                else
+                    -- Couldn't access route.
+                    ngx.say(false)
+                end
+            else
+                -- Response from Keycloak not ok.
+                ngx.say(false)
+            end
+        }
+    }
+--- response_body
+true