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