Skip to content

feat: support OIDC claim validator (#8772) #11824

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions apisix/plugins/openid-connect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@
-- 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

Expand Down Expand Up @@ -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"},
Expand All @@ -331,6 +339,7 @@ local _M = {
schema = schema,
}

local generic_claim_validator = nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use the core.schema method directly? You can refer to the implementation of the request-validation plugin

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @beardnick, any update?


function _M.check_schema(conf)
if conf.ssl_verify == "no" then
Expand All @@ -357,6 +366,14 @@ 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, "generate claim_schema validator failed: " .. tostring(res)
end
generic_claim_validator = res
end

return true
end

Expand Down Expand Up @@ -528,6 +545,18 @@ local function required_scopes_present(required_scopes, http_scopes)
return true
end

local function validate_claims_in_oidcauth_response(resp)
if not generic_claim_validator then
return true, nil
end
local data = {
user = resp.user,
access_token = resp.access_token,
id_token = resp.id_token,
}
return generic_claim_validator(data)
end

function _M.rewrite(plugin_conf, ctx)
local conf = core.table.clone(plugin_conf)

Expand Down Expand Up @@ -682,6 +711,13 @@ function _M.rewrite(plugin_conf, ctx)
end

if response then
local ok, err = validate_claims_in_oidcauth_response( response)
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.
Expand Down
1 change: 1 addition & 0 deletions docs/en/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ description: OpenID Connect allows the client to obtain user information from th
| introspection_expiry_claim | string | False | | | Name of the expiry claim, which controls the TTL of the cached and introspected access token. The default value is 0, which means this option is not used and the plugin defaults to use the TTL passed by expiry claim defined in `introspection_expiry_claim`. If `introspection_interval` is larger than 0 and less than the TTL passed by expiry claim defined in `introspection_expiry_claim`, use `introspection_interval`. |
| introspection_addon_headers | string[] | False | | | Array of strings. 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).

Expand Down
1 change: 1 addition & 0 deletions docs/zh/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ description: OpenID Connect(OIDC)是基于 OAuth 2.0 的身份认证协议
| introspection_expiry_claim | string | 否 | | | 过期声明的名称,用于控制缓存和内省访问令牌的 TTL。 |
| introspection_addon_headers | string[] | 否 | | | `introspection_addon_headers` 是字符串列表,用于配置额外添加到内省 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#加密存储字段)。

Expand Down
273 changes: 273 additions & 0 deletions t/plugin/openid-connect2.t
Original file line number Diff line number Diff line change
Expand Up @@ -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, "teacher@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, "teacher@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: generate claim_schema validator failed: .*: invalid JSON type: invalid_type"}
Loading