This is an automated email from the ASF dual-hosted git repository. baoyuan 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 12b0824e2 feat: support OIDC claim validator (#8772) (#11824) 12b0824e2 is described below commit 12b0824e23e82ee097dcdd3fa7faafe35dc674c8 Author: qianz <qianzhoube...@gmail.com> AuthorDate: Tue Jul 22 08:46:50 2025 +0800 feat: support OIDC claim validator (#8772) (#11824) --- apisix/plugins/openid-connect.lua | 56 +++++-- docs/en/latest/plugins/openid-connect.md | 1 + docs/zh/latest/plugins/openid-connect.md | 1 + t/plugin/openid-connect2.t | 273 +++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 12 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 6c94ea5d6..759e0c608 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -15,19 +15,22 @@ -- limitations under the License. -- -local core = require("apisix.core") -local ngx_re = require("ngx.re") -local openidc = require("resty.openidc") -local random = require("resty.random") -local string = string -local ngx = ngx -local ipairs = ipairs -local type = type -local concat = table.concat +local core = require("apisix.core") +local ngx_re = require("ngx.re") +local openidc = require("resty.openidc") +local random = require("resty.random") +local jsonschema = require('jsonschema') +local string = string +local ngx = ngx +local ipairs = ipairs +local type = type +local tostring = tostring +local pcall = pcall +local concat = table.concat local ngx_encode_base64 = ngx.encode_base64 -local plugin_name = "openid-connect" +local plugin_name = "openid-connect" local schema = { @@ -317,6 +320,11 @@ local schema = { items = { type = "string" } + }, + claim_schema = { + description = "JSON schema of OIDC response claim", + type = "object", + default = nil, } }, encrypt_fields = {"client_secret", "client_rsa_private_key"}, @@ -331,7 +339,6 @@ local _M = { schema = schema, } - function _M.check_schema(conf) if conf.ssl_verify == "no" then -- we used to set 'ssl_verify' to "no" @@ -357,10 +364,16 @@ function _M.check_schema(conf) return false, err end + if conf.claim_schema then + local ok, res = pcall(jsonschema.generate_validator, conf.claim_schema) + if not ok then + return false, "check claim_schema failed: " .. tostring(res) + end + end + return true end - local function get_bearer_access_token(ctx) -- Get Authorization header, maybe. local auth_header = core.request.header(ctx, "Authorization") @@ -528,6 +541,18 @@ local function required_scopes_present(required_scopes, http_scopes) return true end +local function validate_claims_in_oidcauth_response(resp, conf) + if not conf.claim_schema then + return true + end + local data = { + user = resp.user, + access_token = resp.access_token, + id_token = resp.id_token, + } + return core.schema.check(conf.claim_schema, data) +end + function _M.rewrite(plugin_conf, ctx) local conf = core.table.clone(plugin_conf) @@ -682,6 +707,13 @@ function _M.rewrite(plugin_conf, ctx) end if response then + local ok, err = validate_claims_in_oidcauth_response(response, conf) + if not ok then + core.log.error("OIDC claim validation failed: ", err) + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. + '", error="invalid_token", error_description="' .. err .. '"' + return ngx.HTTP_UNAUTHORIZED + end -- If the openidc module has returned a response, it may contain, -- respectively, the access token, the ID token, the refresh token, -- and the userinfo. diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index 6d5b7b71f..86c3395f0 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -100,6 +100,7 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | introspection_expiry_claim | string | False | exp | | Name of the expiry claim, which controls the TTL of the cached and introspected access token. | | introspection_addon_headers | array[string] | False | | | Used to append additional header values to the introspection HTTP request. If the specified header does not exist in origin request, value will not be appended. | | claim_validator.issuer.valid_issuers | string[] | False | | | Whitelist the vetted issuers of the jwt. When not passed by the user, the issuer returned by discovery endpoint will be used. In case both are missing, the issuer will not be validated. | +| claim_schema | object | False | | | JSON schema of OIDC response claim. Example: `{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}` - validates that the response contains a required string field `access_token`. | 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/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index b490d979e..b9470200a 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -100,6 +100,7 @@ description: openid-connect 插件支持与 OpenID Connect (OIDC) 身份提供 | introspection_expiry_claim | string | 否 | exp | | 到期声明的名称,它控制缓存和自省访问令牌的 TTL。| | introspection_addon_headers | array[string] | 否 | | | 用于将其他标头值附加到自省 HTTP 请求。如果原始请求中不存在指定的标头,则不会附加值。| | claim_validator.issuer.valid_issuers | string[] | 否 | | | 将经过审查的 jwt 发行者列入白名单。当用户未传递时,将使用发现端点返回的颁发者。如果两者均缺失,发行人将无法得到验证| +| claim_schema | object | 否 | | | OIDC 响应 claim 的 JSON schema。示例:`{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}` - 验证响应中包含必需的字符串字段 `access_token`。 | 注意:schema 中还定义了 `encrypt_fields = {"client_secret"}`,这意味着该字段将会被加密存储在 etcd 中。具体参考 [加密存储字段](../plugin-develop.md#加密存储字段)。 diff --git a/t/plugin/openid-connect2.t b/t/plugin/openid-connect2.t index bbb775beb..3d7dac541 100644 --- a/t/plugin/openid-connect2.t +++ b/t/plugin/openid-connect2.t @@ -401,3 +401,276 @@ passed --- response_body true --- error_code: 302 + + + +=== TEST 11: Set up route with plugin matching URI `/*` and point plugin to local Keycloak instance and set claim validator. +--- 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": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "introspection_endpoint_auth_method": "client_secret_post", + "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect", + "set_access_token_header": true, + "access_token_in_authorization_header": false, + "set_id_token_header": true, + "set_userinfo_header": true, + "set_refresh_token_header": true, + "claim_schema": { + "type": "object", + "properties": { + "access_token": { "type" : "string"}, + "id_token": { "type" : "object"}, + "user": { "type" : "object"} + }, + "required" : ["access_token","id_token","user"] + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 12: Access route w/o bearer token and go through the full OIDC Relying Party authentication process and validate claim successfully. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local login_keycloak = require("lib.keycloak").login_keycloak + local concatenate_cookies = require("lib.keycloak").concatenate_cookies + + local httpc = http.new() + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + local res, err = login_keycloak(uri, "teac...@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = concatenate_cookies(res.headers['Set-Cookie']) + -- Make the final call back to the original URI. + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + -- No response, must be an error. + ngx.status = 500 + ngx.say(err) + return + elseif res.status ~= 200 then + -- Not a valid response. + -- Use 500 to indicate error. + ngx.status = 500 + ngx.say("Invoking the original URI didn't return the expected result.") + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- response_body_like +uri: /uri +cookie: .* +host: 127.0.0.1:1984 +user-agent: .* +x-access-token: ey.* +x-id-token: ey.* +x-real-ip: 127.0.0.1 +x-refresh-token: ey.* +x-userinfo: ey.* + + + +=== TEST 13: Set up route with plugin matching URI `/*` and point plugin to local Keycloak instance and set claim validator with more strict schema. +--- 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": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "introspection_endpoint_auth_method": "client_secret_post", + "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect", + "set_access_token_header": true, + "access_token_in_authorization_header": false, + "set_id_token_header": true, + "set_userinfo_header": true, + "set_refresh_token_header": true, + "claim_schema": { + "type": "object", + "properties": { + "access_token": { "type" : "string"}, + "id_token": { "type" : "object"}, + "user": { "type" : "object"}, + "user1": { "type" : "object"} + }, + "required" : ["access_token","id_token","user","user1"] + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 14: Access route w/o bearer token and go through the full OIDC Relying Party authentication process and fail to validate claim. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local login_keycloak = require("lib.keycloak").login_keycloak + local concatenate_cookies = require("lib.keycloak").concatenate_cookies + + local httpc = http.new() + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + local res, err = login_keycloak(uri, "teac...@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = concatenate_cookies(res.headers['Set-Cookie']) + -- Make the final call back to the original URI. + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + -- No response, must be an error. + ngx.status = 500 + ngx.say(err) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- error_code: 401 +--- error_log +property "user1" is required + + + +=== TEST 15: Set up route with plugin matching URI `/*` and point plugin to local Keycloak instance and set invalid claim schema. +--- 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": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "introspection_endpoint_auth_method": "client_secret_post", + "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect", + "set_access_token_header": true, + "access_token_in_authorization_header": false, + "set_id_token_header": true, + "set_userinfo_header": true, + "set_refresh_token_header": true, + "claim_schema": { + "type": "object", + "properties": { + "access_token": { "type" : "string"}, + "id_token": { "type" : "object"}, + "user": { "type" : "invalid_type"} + }, + "required" : ["access_token","id_token","user"] + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like +{"error_msg":"failed to check the configuration of plugin openid-connect err: check claim_schema failed: .*: invalid JSON type: invalid_type"}