From 0ee480e014e49c26777ea20a00e3701f01aebb32 Mon Sep 17 00:00:00 2001 From: Hockenba Date: Thu, 27 Aug 2026 12:05:25 -0400 Subject: [PATCH 1/6] feat(openid-connect): support stateless session revocation Add opt-in Redis-backed revocation for cookie sessions, with schema validation, documentation, and focused coverage. --- apisix-master-0.rockspec | 2 +- apisix/plugins/openid-connect.lua | 48 +++- docs/en/latest/plugins/openid-connect.md | 22 +- docs/zh/latest/plugins/openid-connect.md | 22 +- t/plugin/openid-connect-revocation.t | 280 +++++++++++++++++++++++ 5 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 t/plugin/openid-connect-revocation.t diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index 064aaea9057c..27893c746d7a 100644 --- a/apisix-master-0.rockspec +++ b/apisix-master-0.rockspec @@ -46,7 +46,7 @@ dependencies = { "api7-lua-resty-jwt = 0.2.6-0", "lua-resty-hmac-ffi = 0.06-1", "lua-resty-cookie = 0.4.1-1", - "lua-resty-session = 4.1.5-1", + "lua-resty-session = 4.2.0-1", "lua-resty-openapi-validator = 1.0.6-1", "opentracing-openresty = 0.1-0", "lua-resty-radixtree = 2.9.2-0", diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index b4913a741cd5..de4eb8c33865 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -52,22 +52,31 @@ local UNHANDLED_REDIRECT_URI_ERR = "unhandled request to the redirect_uri" local MAX_AUTH_FLOW_RESTARTS = 3 --- Session config is passed as-is to resty.session.start(); the only --- translation is the legacy session.cookie.lifetime alias from the --- lua-resty-session 3.x schema, which is mapped to absolute_timeout --- when the latter is unset. +-- Session config is passed to resty.session.start(). The legacy +-- session.cookie.lifetime alias is mapped to absolute_timeout, and setting +-- revocation_fail_mode enables lua-resty-session's Redis revocation backend. local function build_session_opts(session_conf) if not session_conf then return nil end - if session_conf.cookie and session_conf.cookie.lifetime then - if not session_conf.absolute_timeout then - session_conf.absolute_timeout = session_conf.cookie.lifetime + + -- Derive onto a copy: the plugin conf is only shallow cloned per request, + -- so mutating it here would persist fields that the session schema forbids. + local opts = core.table.clone(session_conf) + + if opts.cookie and opts.cookie.lifetime then + if not opts.absolute_timeout then + opts.absolute_timeout = opts.cookie.lifetime core.log.warn("session.cookie.lifetime is deprecated; ", "use session.absolute_timeout instead") end end - return session_conf + + if opts.redis and opts.storage ~= "redis" and opts.revocation_fail_mode then + opts.revocation = "redis" + end + + return opts end @@ -294,7 +303,15 @@ local schema = { description = "keepalive timeout in milliseconds", }, } - } + }, + revocation_fail_mode = { + type = "string", + enum = {"open", "closed"}, + description = + "Enables Redis-backed revocation for cookie sessions. When " + .. "the revocation store is unreachable, open treats the " + .. "session as not revoked and closed rejects open/destroy.", + }, }, required = {"secret"}, ["if"] = { @@ -305,6 +322,19 @@ local schema = { ["then"] = { required = {"redis"}, }, + allOf = { + { + ["if"] = { + required = {"revocation_fail_mode"}, + }, + ["then"] = { + required = {"redis"}, + properties = { + storage = { const = "cookie" }, + }, + }, + }, + }, additionalProperties = false, }, realm = { diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index dc3bbca2118c..8f8cabc1250c 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -89,7 +89,8 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | session.absolute_timeout | integer | False | | | Absolute session lifetime in seconds. Forwarded to lua-resty-session as `absolute_timeout`. | | session.cookie.lifetime | integer | False | | | Deprecated. Mapped to `session.absolute_timeout` at runtime when `absolute_timeout` is not set. Use `session.absolute_timeout` instead. | | session.storage | string | False | cookie | ["cookie", "redis"] | Session storage method. | -| session.redis | object | False | | | Redis configuration when `storage` is `redis`. | +| session.redis | object | False | | | Redis connection. Required when `storage` is `redis`, or when `revocation_fail_mode` enables cookie-session revocation. | +| session.revocation_fail_mode | string | False | | ["open", "closed"] | Enables Redis-backed revocation for cookie sessions. When the revocation store is unreachable, `open` treats the session as not revoked, while `closed` rejects session open and destroy operations. Requires `session.redis` and cannot be used when `session.storage` is `redis`. | | session.redis.host | string | False | 127.0.0.1 | | Redis host. | | session.redis.port | integer | False | 6379 | | Redis port. | | session.redis.username | string | False | | | Redis username. | @@ -354,6 +355,25 @@ spec: See [Implement Authorization Code Grant](../tutorials/keycloak-oidc.md#implement-authorization-code-grant) for a complete example to use the `openid-connect` Plugin to integrate with Keycloak using the authorization code flow. +### Cookie Session Revocation + +Cookie sessions are stored by the client and cannot normally be invalidated individually by APISIX. To enable server-side revocation, configure a Redis denylist and set `session.revocation_fail_mode`: + +```json +"session": { + "secret": "your-session-secret-min-16-chars", + "storage": "cookie", + "redis": { + "host": "127.0.0.1", + "port": 6379, + "prefix": "oidc:session:" + }, + "revocation_fail_mode": "closed" +} +``` + +Use `open` to continue accepting sessions if the revocation store is unavailable, or `closed` to reject session open and destroy operations. Omit `revocation_fail_mode` to leave cookie-session revocation disabled. + ### Authorization Code Flow with PAR and DPoP To use Pushed Authorization Requests (PAR), set `par.enabled` to `true`. If `par.endpoint` is not configured, the Plugin uses the PAR endpoint from the well-known discovery document. diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 1833069fc95b..45f8b3354293 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -88,7 +88,8 @@ import TabItem from '@theme/TabItem'; | session.absolute_timeout | integer | 否 | | | 会话绝对生存时间(秒)。作为 `absolute_timeout` 透传到 lua-resty-session。 | | session.cookie.lifetime | integer | 否 | | | 已弃用。当未设置 `session.absolute_timeout` 时,运行时会将该值映射到 `session.absolute_timeout`。请改用 `session.absolute_timeout`。 | | session.storage | string | 否 | cookie | ["cookie", "redis"] | 会话存储方式。 | -| session.redis | object | 否 | | | `storage` 为 `redis` 时的 Redis 配置。 | +| session.redis | object | 否 | | | Redis 连接配置。当 `storage` 为 `redis`,或使用 `revocation_fail_mode` 启用 Cookie 会话吊销时必填。 | +| session.revocation_fail_mode | string | 否 | | ["open", "closed"] | 为 Cookie 会话启用基于 Redis 的吊销功能。当吊销存储不可用时,`open` 将会话视为未吊销,`closed` 则拒绝打开和销毁会话。必须配置 `session.redis`,且不能在 `session.storage` 为 `redis` 时使用。 | | session.redis.host | string | 否 | 127.0.0.1 | | Redis 主机。 | | session.redis.port | integer | 否 | 6379 | | Redis 端口。 | | session.redis.username | string | 否 | | | Redis 用户名。 | @@ -353,6 +354,25 @@ spec: 详见[实现授权码授权](../tutorials/keycloak-oidc.md#实现-authorization-code-grant),获取使用 `openid-connect` 插件与 Keycloak 集成并使用授权码流程的完整示例。 +### Cookie 会话吊销 + +Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启用服务端吊销,请配置 Redis 拒绝列表并设置 `session.revocation_fail_mode`: + +```json +"session": { + "secret": "your-session-secret-min-16-chars", + "storage": "cookie", + "redis": { + "host": "127.0.0.1", + "port": 6379, + "prefix": "oidc:session:" + }, + "revocation_fail_mode": "closed" +} +``` + +当吊销存储不可用时,使用 `open` 可继续接受会话,使用 `closed` 则会拒绝打开和销毁会话。省略 `revocation_fail_mode` 即不启用 Cookie 会话吊销。 + ### 使用 PAR 和 DPoP 的授权码流程 如需使用 Pushed Authorization Requests (PAR),请将 `par.enabled` 设置为 `true`。如果未配置 `par.endpoint`,插件将使用 well-known 发现文档中的 PAR 端点。 diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t new file mode 100644 index 000000000000..0b25ac3d05b1 --- /dev/null +++ b/t/plugin/openid-connect-revocation.t @@ -0,0 +1,280 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: cookie session is revoked after logout +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local keycloak = require("lib.keycloak") + local test_admin = require("lib.test_admin").test + + local code, body = test_admin("/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, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + "storage": "cookie", + "redis": { + "host": "127.0.0.1", + "port": 6379, + "prefix": "oidc:revocation:" + }, + "revocation_fail_mode": "closed" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + ngx.sleep(0.1) + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + local res, err = keycloak.login_keycloak( + uri, "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie = keycloak.concatenate_cookies(res.headers["Set-Cookie"]) + local httpc = http.new() + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { Cookie = cookie }, + }) + if not res or res.status ~= 200 then + ngx.status = 500 + ngx.say("authenticated request failed: ", + res and res.status or err) + return + end + + local logout_uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/logout" + res, err = httpc:request_uri(logout_uri, { + method = "GET", + headers = { Cookie = cookie }, + }) + if not res or res.status ~= 302 then + ngx.status = 500 + ngx.say("logout failed: ", res and res.status or err) + return + end + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { Cookie = cookie }, + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + + ngx.say("authenticated=", 200) + ngx.say("replayed_authenticated=", res.status == 200) + } + } +--- response_body +authenticated=200 +replayed_authenticated=false +--- no_error_log +[crit] + + + +=== TEST 2: mapped and optional session revocation paths +--- config + location /t { + content_by_lua_block { + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local test_cases = { + { + name = "fail closed enables revocation", + session = { + secret = secret, + storage = "cookie", + redis = { + host = "127.0.0.1", + prefix = "oidc:session:", + }, + revocation_fail_mode = "closed", + }, + check = function(opts) + assert(opts.revocation == "redis") + assert(opts.revocation_fail_mode == "closed") + assert(opts.redis.host == "127.0.0.1") + assert(opts.redis.prefix == "oidc:session:") + end, + }, + { + name = "fail open enables revocation with default cookie storage", + session = { + secret = secret, + redis = { host = "127.0.0.1" }, + revocation_fail_mode = "open", + }, + check = function(opts) + assert(opts.revocation == "redis") + assert(opts.revocation_fail_mode == "open") + end, + }, + { + name = "omitting fail mode leaves revocation disabled", + session = { + secret = secret, + storage = "cookie", + redis = { host = "127.0.0.1" }, + }, + check = function(opts) + assert(opts.revocation == nil) + assert(opts.revocation_fail_mode == nil) + end, + }, + { + name = "redis session storage remains unchanged", + session = { + secret = secret, + storage = "redis", + redis = { host = "127.0.0.1" }, + }, + check = function(opts) + assert(opts.storage == "redis") + assert(opts.revocation == nil) + assert(opts.redis.host == "127.0.0.1") + end, + }, + } + + local plugin = require("apisix.plugins.openid-connect") + for _, case in ipairs(test_cases) do + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = case.session, + }) + assert(ok, case.name .. ": " .. tostring(err)) + case.check(plugin._build_session_opts(case.session)) + end + + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 3: session revocation schema failure paths +--- config + location /t { + content_by_lua_block { + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local test_cases = { + { + name = "invalid fail mode", + session = { + secret = secret, + redis = { host = "127.0.0.1" }, + revocation_fail_mode = "invalid", + }, + error_field = "revocation_fail_mode", + }, + { + name = "missing redis", + session = { + secret = secret, + revocation_fail_mode = "open", + }, + error_field = "redis", + }, + { + name = "redis session storage", + session = { + secret = secret, + storage = "redis", + redis = { host = "127.0.0.1" }, + revocation_fail_mode = "open", + }, + error_field = "storage", + }, + } + + local plugin = require("apisix.plugins.openid-connect") + for _, case in ipairs(test_cases) do + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = case.session, + }) + assert(not ok, case.name) + assert(string.find(err, case.error_field, 1, true), + case.name .. ": " .. tostring(err)) + end + + ngx.say("done") + } + } +--- response_body +done From cf2cc5edd90c9921fff8773cae495b5f3bb53229 Mon Sep 17 00:00:00 2001 From: Hockenba Date: Thu, 27 Aug 2026 22:52:09 -0400 Subject: [PATCH 2/6] refactor(openid-connect): expose session revocation backend Forward lua-resty-session revocation settings directly and make the fail-open default explicit. --- apisix/plugins/openid-connect.lua | 36 +++++++------- docs/en/latest/plugins/openid-connect.md | 14 +++--- docs/zh/latest/plugins/openid-connect.md | 14 +++--- t/plugin/openid-connect-revocation.t | 61 +++++++++++++++++------- 4 files changed, 76 insertions(+), 49 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index de4eb8c33865..0c2d5a510497 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -52,31 +52,22 @@ local UNHANDLED_REDIRECT_URI_ERR = "unhandled request to the redirect_uri" local MAX_AUTH_FLOW_RESTARTS = 3 --- Session config is passed to resty.session.start(). The legacy --- session.cookie.lifetime alias is mapped to absolute_timeout, and setting --- revocation_fail_mode enables lua-resty-session's Redis revocation backend. +-- Session config is passed as-is to resty.session.start(); the only +-- translation is the legacy session.cookie.lifetime alias from the +-- lua-resty-session 3.x schema, which is mapped to absolute_timeout +-- when the latter is unset. local function build_session_opts(session_conf) if not session_conf then return nil end - - -- Derive onto a copy: the plugin conf is only shallow cloned per request, - -- so mutating it here would persist fields that the session schema forbids. - local opts = core.table.clone(session_conf) - - if opts.cookie and opts.cookie.lifetime then - if not opts.absolute_timeout then - opts.absolute_timeout = opts.cookie.lifetime + if session_conf.cookie and session_conf.cookie.lifetime then + if not session_conf.absolute_timeout then + session_conf.absolute_timeout = session_conf.cookie.lifetime core.log.warn("session.cookie.lifetime is deprecated; ", "use session.absolute_timeout instead") end end - - if opts.redis and opts.storage ~= "redis" and opts.revocation_fail_mode then - opts.revocation = "redis" - end - - return opts + return session_conf end @@ -304,12 +295,17 @@ local schema = { }, } }, + revocation = { + type = "string", + enum = {"redis"}, + description = "Redis-backed revocation for cookie sessions.", + }, revocation_fail_mode = { type = "string", enum = {"open", "closed"}, + default = "open", description = - "Enables Redis-backed revocation for cookie sessions. When " - .. "the revocation store is unreachable, open treats the " + "When the revocation store is unreachable, open treats the " .. "session as not revoked and closed rejects open/destroy.", }, }, @@ -325,7 +321,7 @@ local schema = { allOf = { { ["if"] = { - required = {"revocation_fail_mode"}, + required = {"revocation"}, }, ["then"] = { required = {"redis"}, diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index 8f8cabc1250c..4c72065c27a3 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -89,8 +89,9 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | session.absolute_timeout | integer | False | | | Absolute session lifetime in seconds. Forwarded to lua-resty-session as `absolute_timeout`. | | session.cookie.lifetime | integer | False | | | Deprecated. Mapped to `session.absolute_timeout` at runtime when `absolute_timeout` is not set. Use `session.absolute_timeout` instead. | | session.storage | string | False | cookie | ["cookie", "redis"] | Session storage method. | -| session.redis | object | False | | | Redis connection. Required when `storage` is `redis`, or when `revocation_fail_mode` enables cookie-session revocation. | -| session.revocation_fail_mode | string | False | | ["open", "closed"] | Enables Redis-backed revocation for cookie sessions. When the revocation store is unreachable, `open` treats the session as not revoked, while `closed` rejects session open and destroy operations. Requires `session.redis` and cannot be used when `session.storage` is `redis`. | +| session.revocation | string | False | | ["redis"] | Session revocation backend. Set to `redis` to enable revocation for cookie sessions. Requires `session.redis` and cannot be used when `session.storage` is `redis`. | +| session.revocation_fail_mode | string | False | open | ["open", "closed"] | When the revocation store is unreachable, `open` treats the session as not revoked, while `closed` rejects session open and destroy operations. | +| session.redis | object | False | | | Redis connection. Required when `storage` is `redis`, or when `revocation` is `redis`. | | session.redis.host | string | False | 127.0.0.1 | | Redis host. | | session.redis.port | integer | False | 6379 | | Redis port. | | session.redis.username | string | False | | | Redis username. | @@ -357,22 +358,23 @@ See [Implement Authorization Code Grant](../tutorials/keycloak-oidc.md#implement ### Cookie Session Revocation -Cookie sessions are stored by the client and cannot normally be invalidated individually by APISIX. To enable server-side revocation, configure a Redis denylist and set `session.revocation_fail_mode`: +Cookie sessions are stored by the client and cannot normally be invalidated individually by APISIX. To enable server-side revocation, set `session.revocation` to `redis` and configure a Redis denylist: ```json "session": { "secret": "your-session-secret-min-16-chars", "storage": "cookie", + "revocation": "redis", + "revocation_fail_mode": "closed", "redis": { "host": "127.0.0.1", "port": 6379, "prefix": "oidc:session:" - }, - "revocation_fail_mode": "closed" + } } ``` -Use `open` to continue accepting sessions if the revocation store is unavailable, or `closed` to reject session open and destroy operations. Omit `revocation_fail_mode` to leave cookie-session revocation disabled. +`revocation_fail_mode` defaults to `open`, which continues accepting sessions if the revocation store is unavailable. Use `closed` to reject session open and destroy operations instead. Omit `revocation` to leave cookie-session revocation disabled. ### Authorization Code Flow with PAR and DPoP diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 45f8b3354293..4c06a46eef18 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -88,8 +88,9 @@ import TabItem from '@theme/TabItem'; | session.absolute_timeout | integer | 否 | | | 会话绝对生存时间(秒)。作为 `absolute_timeout` 透传到 lua-resty-session。 | | session.cookie.lifetime | integer | 否 | | | 已弃用。当未设置 `session.absolute_timeout` 时,运行时会将该值映射到 `session.absolute_timeout`。请改用 `session.absolute_timeout`。 | | session.storage | string | 否 | cookie | ["cookie", "redis"] | 会话存储方式。 | -| session.redis | object | 否 | | | Redis 连接配置。当 `storage` 为 `redis`,或使用 `revocation_fail_mode` 启用 Cookie 会话吊销时必填。 | -| session.revocation_fail_mode | string | 否 | | ["open", "closed"] | 为 Cookie 会话启用基于 Redis 的吊销功能。当吊销存储不可用时,`open` 将会话视为未吊销,`closed` 则拒绝打开和销毁会话。必须配置 `session.redis`,且不能在 `session.storage` 为 `redis` 时使用。 | +| session.revocation | string | 否 | | ["redis"] | 会话吊销后端。设置为 `redis` 可为 Cookie 会话启用吊销功能。必须配置 `session.redis`,且不能在 `session.storage` 为 `redis` 时使用。 | +| session.revocation_fail_mode | string | 否 | open | ["open", "closed"] | 当吊销存储不可用时,`open` 将会话视为未吊销,`closed` 则拒绝打开和销毁会话。 | +| session.redis | object | 否 | | | Redis 连接配置。当 `storage` 为 `redis`,或 `revocation` 为 `redis` 时必填。 | | session.redis.host | string | 否 | 127.0.0.1 | | Redis 主机。 | | session.redis.port | integer | 否 | 6379 | | Redis 端口。 | | session.redis.username | string | 否 | | | Redis 用户名。 | @@ -356,22 +357,23 @@ spec: ### Cookie 会话吊销 -Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启用服务端吊销,请配置 Redis 拒绝列表并设置 `session.revocation_fail_mode`: +Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启用服务端吊销,请将 `session.revocation` 设置为 `redis` 并配置 Redis 拒绝列表: ```json "session": { "secret": "your-session-secret-min-16-chars", "storage": "cookie", + "revocation": "redis", + "revocation_fail_mode": "closed", "redis": { "host": "127.0.0.1", "port": 6379, "prefix": "oidc:session:" - }, - "revocation_fail_mode": "closed" + } } ``` -当吊销存储不可用时,使用 `open` 可继续接受会话,使用 `closed` 则会拒绝打开和销毁会话。省略 `revocation_fail_mode` 即不启用 Cookie 会话吊销。 +`revocation_fail_mode` 默认为 `open`,即吊销存储不可用时继续接受会话。使用 `closed` 则会拒绝打开和销毁会话。省略 `revocation` 即不启用 Cookie 会话吊销。 ### 使用 PAR 和 DPoP 的授权码流程 diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index 0b25ac3d05b1..375ef85ceed1 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -60,12 +60,12 @@ __DATA__ "session": { "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", "storage": "cookie", + "revocation": "redis", "redis": { "host": "127.0.0.1", "port": 6379, "prefix": "oidc:revocation:" - }, - "revocation_fail_mode": "closed" + } } } }, @@ -129,47 +129,51 @@ __DATA__ ngx.say(err) return end + if res.status ~= 302 then + ngx.status = 500 + ngx.say("replayed request was not redirected: ", res.status) + return + end ngx.say("authenticated=", 200) - ngx.say("replayed_authenticated=", res.status == 200) + ngx.say("replayed=", res.status) } } --- response_body authenticated=200 -replayed_authenticated=false ---- no_error_log -[crit] +replayed=302 -=== TEST 2: mapped and optional session revocation paths +=== TEST 2: forwarded and optional session revocation paths --- config location /t { content_by_lua_block { local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" local test_cases = { { - name = "fail closed enables revocation", + name = "revocation defaults to fail open", session = { secret = secret, storage = "cookie", + revocation = "redis", redis = { host = "127.0.0.1", prefix = "oidc:session:", }, - revocation_fail_mode = "closed", }, check = function(opts) assert(opts.revocation == "redis") - assert(opts.revocation_fail_mode == "closed") + assert(opts.revocation_fail_mode == "open") assert(opts.redis.host == "127.0.0.1") assert(opts.redis.prefix == "oidc:session:") end, }, { - name = "fail open enables revocation with default cookie storage", + name = "explicit fail open", session = { secret = secret, + revocation = "redis", redis = { host = "127.0.0.1" }, revocation_fail_mode = "open", }, @@ -179,7 +183,20 @@ replayed_authenticated=false end, }, { - name = "omitting fail mode leaves revocation disabled", + name = "fail closed with default cookie storage", + session = { + secret = secret, + revocation = "redis", + redis = { host = "127.0.0.1" }, + revocation_fail_mode = "closed", + }, + check = function(opts) + assert(opts.revocation == "redis") + assert(opts.revocation_fail_mode == "closed") + end, + }, + { + name = "omitting revocation leaves it disabled", session = { secret = secret, storage = "cookie", @@ -187,7 +204,7 @@ replayed_authenticated=false }, check = function(opts) assert(opts.revocation == nil) - assert(opts.revocation_fail_mode == nil) + assert(opts.revocation_fail_mode == "open") end, }, { @@ -231,10 +248,20 @@ done content_by_lua_block { local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" local test_cases = { + { + name = "invalid revocation backend", + session = { + secret = secret, + revocation = "invalid", + redis = { host = "127.0.0.1" }, + }, + error_field = "revocation", + }, { name = "invalid fail mode", session = { secret = secret, + revocation = "redis", redis = { host = "127.0.0.1" }, revocation_fail_mode = "invalid", }, @@ -244,19 +271,19 @@ done name = "missing redis", session = { secret = secret, - revocation_fail_mode = "open", + revocation = "redis", }, - error_field = "redis", + error_field = "allOf", }, { name = "redis session storage", session = { secret = secret, storage = "redis", + revocation = "redis", redis = { host = "127.0.0.1" }, - revocation_fail_mode = "open", }, - error_field = "storage", + error_field = "allOf", }, } From 7572d22df54910a38ccb0f3cf85f0a02ec4e7033 Mon Sep 17 00:00:00 2001 From: Hockenba Date: Fri, 28 Aug 2026 01:56:49 -0400 Subject: [PATCH 3/6] fix(openid-connect): avoid unconditional revocation default Let lua-resty-session apply fail-open behavior only when revocation is enabled, preserving unchanged defaults for other sessions. --- apisix/plugins/openid-connect.lua | 1 - t/plugin/openid-connect-revocation.t | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 0c2d5a510497..3ca64900db00 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -303,7 +303,6 @@ local schema = { revocation_fail_mode = { type = "string", enum = {"open", "closed"}, - default = "open", description = "When the revocation store is unreachable, open treats the " .. "session as not revoked and closed rejects open/destroy.", diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index 375ef85ceed1..be553badf413 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -152,7 +152,7 @@ replayed=302 local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" local test_cases = { { - name = "revocation defaults to fail open", + name = "revocation uses library fail-open default", session = { secret = secret, storage = "cookie", @@ -164,7 +164,7 @@ replayed=302 }, check = function(opts) assert(opts.revocation == "redis") - assert(opts.revocation_fail_mode == "open") + assert(opts.revocation_fail_mode == nil) assert(opts.redis.host == "127.0.0.1") assert(opts.redis.prefix == "oidc:session:") end, @@ -204,7 +204,7 @@ replayed=302 }, check = function(opts) assert(opts.revocation == nil) - assert(opts.revocation_fail_mode == "open") + assert(opts.revocation_fail_mode == nil) end, }, { @@ -217,6 +217,7 @@ replayed=302 check = function(opts) assert(opts.storage == "redis") assert(opts.revocation == nil) + assert(opts.revocation_fail_mode == nil) assert(opts.redis.host == "127.0.0.1") end, }, From 0c3c35ce9b8d30d5bf8efa7b5057ad6e83d1da14 Mon Sep 17 00:00:00 2001 From: Hockenba Date: Wed, 2 Sep 2026 13:23:51 -0400 Subject: [PATCH 4/6] for lua-resty-openidc changes --- apisix/plugins/openid-connect.lua | 11 +++++ t/plugin/openid-connect-revocation.t | 74 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 3ca64900db00..753a60f8e828 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1385,6 +1385,17 @@ function _M.rewrite(plugin_conf, ctx) return 401 end + -- fail-closed only: resty.session appends the store error, so match + -- the prefix. Reject without destroying the cookie. + if core.string.find(err, "unable to check session revocation") + or core.string.find(err, "unable to mark session revoked") then + if session then + session:close() + end + core.log.error("OIDC session revocation store unavailable: ", err) + return 503 + end + -- Recoverable authorization-callback failures: a stale state -- (replayed or pruned callback), or the ID provider redirecting -- back with error=temporarily_unavailable, e.g. Keycloak after diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index be553badf413..11eb2f93b6bb 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -306,3 +306,77 @@ done } --- response_body done + + + +=== TEST 4: fail-closed revocation store errors map to 503 and do not destroy the session +--- config + location /t { + content_by_lua_block { + local old_plugin = package.loaded["apisix.plugins.openid-connect"] + local old_openidc = package.loaded["resty.openidc"] + + local authenticate_err + local session + package.loaded["resty.openidc"] = { + authenticate = function() + return nil, authenticate_err, nil, session + end, + } + package.loaded["apisix.plugins.openid-connect"] = nil + + local ok, err = pcall(function() + local plugin = require("apisix.plugins.openid-connect") + local conf = { + client_id = "a", + client_secret = "b", + discovery = "http://127.0.0.1:1/.well-known/openid-configuration", + redirect_uri = "http://127.0.0.1/cb", + ssl_verify = false, + session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" }, + } + assert(plugin.check_schema(conf)) + + local ctx = { + var = { uri = "/uri", request_uri = "/uri" }, + } + + for _, case in ipairs({ + { + name = "check on open", + err = "unable to check session revocation (connection refused)", + }, + { + name = "mark on destroy", + err = "unable to mark session revoked (timeout)", + }, + }) do + authenticate_err = case.err + session = { + close = function(self) self.closed = true end, + destroy = function(self) self.destroyed = true end, + } + + local code = plugin.rewrite(conf, ctx) + assert(code == 503, case.name .. ": got " .. tostring(code)) + assert(session.closed, case.name .. ": expected session:close()") + assert(not session.destroyed, + case.name .. ": must not call session:destroy()") + end + end) + + package.loaded["apisix.plugins.openid-connect"] = old_plugin + package.loaded["resty.openidc"] = old_openidc + + if not ok then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("done") + } + } +--- error_log +OIDC session revocation store unavailable +--- response_body +done From cac900ba37cdc018df8981743933e812d5da2915 Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Fri, 4 Sep 2026 23:28:02 -0400 Subject: [PATCH 5/6] update to newest openidc with my commit --- apisix-master-0.rockspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index 27893c746d7a..25a0da87e715 100644 --- a/apisix-master-0.rockspec +++ b/apisix-master-0.rockspec @@ -51,7 +51,7 @@ dependencies = { "opentracing-openresty = 0.1-0", "lua-resty-radixtree = 2.9.2-0", "lua-protobuf = 0.5.3-1", - "lua-resty-openidc = 1.9.0-1", + "lua-resty-openidc = 1.9.1-1", "lua-resty-saml = 0.2.5", "luafilesystem = 1.8.0-1", "nginx-lua-prometheus-api7 = 1.0.0-1", From e9022c1b9d839a222e47f0b35d5c489f1d9b74b9 Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Tue, 15 Sep 2026 10:43:02 -0400 Subject: [PATCH 6/6] in memory cache config options --- apisix/plugins/openid-connect.lua | 14 ++++++++++++++ docs/en/latest/plugins/openid-connect.md | 6 +++++- docs/zh/latest/plugins/openid-connect.md | 6 +++++- t/plugin/openid-connect-revocation.t | 15 +++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 753a60f8e828..79ac24f7e7a8 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -307,6 +307,20 @@ local schema = { "When the revocation store is unreachable, open treats the " .. "session as not revoked and closed rejects open/destroy.", }, + revocation_cache_ttl = { + type = "integer", + minimum = 0, + description = + "Worker-local TTL in seconds for successful revocation " + .. "lookups. 0 disables. Default is 5 in lua-resty-session.", + }, + revocation_error_cache_ttl = { + type = "integer", + minimum = 0, + description = + "Worker-local TTL in seconds for revocation store errors. " + .. "0 disables. Default is 5 in lua-resty-session.", + }, }, required = {"secret"}, ["if"] = { diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index 64573d76d2b6..d67328a5d4ff 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -91,6 +91,8 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | session.storage | string | False | cookie | ["cookie", "redis"] | Session storage method. | | session.revocation | string | False | | ["redis"] | Session revocation backend. Set to `redis` to enable revocation for cookie sessions. Requires `session.redis` and cannot be used when `session.storage` is `redis`. | | session.revocation_fail_mode | string | False | open | ["open", "closed"] | When the revocation store is unreachable, `open` treats the session as not revoked, while `closed` rejects session open and destroy operations. | +| session.revocation_cache_ttl | integer | False | | | Worker-local TTL in seconds for successful revocation lookups. `0` disables. lua-resty-session defaults to `5`. | +| session.revocation_error_cache_ttl | integer | False | | | Worker-local TTL in seconds for revocation store errors. `0` disables. lua-resty-session defaults to `5`. | | session.redis | object | False | | | Redis connection. Required when `storage` is `redis`, or when `revocation` is `redis`. | | session.redis.host | string | False | 127.0.0.1 | | Redis host. | | session.redis.port | integer | False | 6379 | | Redis port. | @@ -366,6 +368,8 @@ Cookie sessions are stored by the client and cannot normally be invalidated indi "storage": "cookie", "revocation": "redis", "revocation_fail_mode": "closed", + "revocation_cache_ttl": 5, + "revocation_error_cache_ttl": 1, "redis": { "host": "127.0.0.1", "port": 6379, @@ -374,7 +378,7 @@ Cookie sessions are stored by the client and cannot normally be invalidated indi } ``` -`revocation_fail_mode` defaults to `open`, which continues accepting sessions if the revocation store is unavailable. Use `closed` to reject session open and destroy operations instead. Omit `revocation` to leave cookie-session revocation disabled. +`revocation_fail_mode` defaults to `open`, which continues accepting sessions if the revocation store is unavailable. Use `closed` to reject session open and destroy operations instead. `revocation_cache_ttl` is the worker-local TTL for successful lookups; `revocation_error_cache_ttl` is the TTL for store errors. Omit `revocation` to leave cookie-session revocation disabled. ### Authorization Code Flow with PAR and DPoP diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 4c06a46eef18..a4ac2866a0b9 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -90,6 +90,8 @@ import TabItem from '@theme/TabItem'; | session.storage | string | 否 | cookie | ["cookie", "redis"] | 会话存储方式。 | | session.revocation | string | 否 | | ["redis"] | 会话吊销后端。设置为 `redis` 可为 Cookie 会话启用吊销功能。必须配置 `session.redis`,且不能在 `session.storage` 为 `redis` 时使用。 | | session.revocation_fail_mode | string | 否 | open | ["open", "closed"] | 当吊销存储不可用时,`open` 将会话视为未吊销,`closed` 则拒绝打开和销毁会话。 | +| session.revocation_cache_ttl | integer | 否 | | | 成功吊销查询的 worker 本地缓存 TTL(秒)。`0` 禁用。lua-resty-session 默认为 `5`。 | +| session.revocation_error_cache_ttl | integer | 否 | | | 吊销存储错误的 worker 本地缓存 TTL(秒)。`0` 禁用。lua-resty-session 默认为 `5`。 | | session.redis | object | 否 | | | Redis 连接配置。当 `storage` 为 `redis`,或 `revocation` 为 `redis` 时必填。 | | session.redis.host | string | 否 | 127.0.0.1 | | Redis 主机。 | | session.redis.port | integer | 否 | 6379 | | Redis 端口。 | @@ -365,6 +367,8 @@ Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启 "storage": "cookie", "revocation": "redis", "revocation_fail_mode": "closed", + "revocation_cache_ttl": 5, + "revocation_error_cache_ttl": 1, "redis": { "host": "127.0.0.1", "port": 6379, @@ -373,7 +377,7 @@ Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启 } ``` -`revocation_fail_mode` 默认为 `open`,即吊销存储不可用时继续接受会话。使用 `closed` 则会拒绝打开和销毁会话。省略 `revocation` 即不启用 Cookie 会话吊销。 +`revocation_fail_mode` 默认为 `open`,即吊销存储不可用时继续接受会话。使用 `closed` 则会拒绝打开和销毁会话。`revocation_cache_ttl` 是成功查询的 worker 本地 TTL;`revocation_error_cache_ttl` 是存储错误的 TTL。省略 `revocation` 即不启用 Cookie 会话吊销。 ### 使用 PAR 和 DPoP 的授权码流程 diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index 11eb2f93b6bb..c5d6af418843 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -195,6 +195,21 @@ replayed=302 assert(opts.revocation_fail_mode == "closed") end, }, + { + name = "forwards revocation cache ttls", + session = { + secret = secret, + revocation = "redis", + redis = { host = "127.0.0.1" }, + revocation_fail_mode = "closed", + revocation_cache_ttl = 5, + revocation_error_cache_ttl = 1, + }, + check = function(opts) + assert(opts.revocation_cache_ttl == 5) + assert(opts.revocation_error_cache_ttl == 1) + end, + }, { name = "omitting revocation leaves it disabled", session = {