diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index adfa1fe775a6..fc54282854e9 100644 --- a/apisix-master-0.rockspec +++ b/apisix-master-0.rockspec @@ -47,7 +47,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.2.0-1", + "lua-resty-session = 4.3.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 fa3fbf8174dc..2aae373e564b 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -56,7 +56,9 @@ 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. +-- when the latter is unset. The revocation error cache needs the lookup cache: +-- the session library only allocates its cache when revocation_cache_ttl > 0, +-- so with the lookup cache off the error TTL is set to 0 as well. local function build_session_opts(session_conf) if not session_conf then return nil @@ -68,6 +70,18 @@ local function build_session_opts(session_conf) "use session.absolute_timeout instead") end end + if session_conf.revocation and session_conf.revocation_cache_ttl == 0 then + local error_ttl = session_conf.revocation_error_cache_ttl + -- the error cache needs the lookup cache, so a non-zero error TTL, set + -- or defaulted by the library, cannot apply without it + if error_ttl == nil or error_ttl > 0 then + if error_ttl then + core.log.warn("session.revocation_error_cache_ttl is ignored when ", + "session.revocation_cache_ttl is 0") + end + session_conf.revocation_error_cache_ttl = 0 + end + end return session_conf end @@ -295,7 +309,33 @@ local schema = { description = "keepalive timeout in milliseconds", }, } - } + }, + revocation = { + type = "string", + enum = {"redis"}, + description = "Redis-backed revocation for cookie sessions.", + }, + revocation_fail_mode = { + type = "string", + enum = {"open", "closed"}, + description = + "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"] = { @@ -306,6 +346,19 @@ local schema = { ["then"] = { required = {"redis"}, }, + allOf = { + { + ["if"] = { + required = {"revocation"}, + }, + ["then"] = { + required = {"redis"}, + properties = { + storage = { const = "cookie" }, + }, + }, + }, + }, additionalProperties = false, }, realm = { @@ -1467,6 +1520,17 @@ local function authenticate(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/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index f4778bfa53fc..32d8d6185ef6 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -92,7 +92,11 @@ 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.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`. Forced to `0` when `revocation_cache_ttl` is `0`. | +| 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,6 +361,28 @@ 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, 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", + "revocation_cache_ttl": 5, + "revocation_error_cache_ttl": 1, + "redis": { + "host": "127.0.0.1", + "port": 6379, + "prefix": "oidc:session:" + } +} +``` + +`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. Opening a cookie session checks the revocation store, so each worker keeps a small cache of recent lookups to avoid one Redis call per request. `revocation_cache_ttl` is the worker-local TTL for successful lookups, and `revocation_error_cache_ttl` is the TTL for store errors. A logout takes effect immediately on the worker that handled it and on other workers once their cached entry expires, so `revocation_cache_ttl` is also the longest a revoked session can still be accepted elsewhere. Set `revocation_cache_ttl` to `0` to check Redis on every request; the error cache is then disabled as well. Omit `revocation` 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 ee77105e2bd6..eae3f58a95f5 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -91,7 +91,11 @@ 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.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`。当 `revocation_cache_ttl` 为 `0` 时强制为 `0`。 | +| 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,6 +360,28 @@ spec: 详见[实现授权码授权](../tutorials/keycloak-oidc.md#实现-authorization-code-grant),获取使用 `openid-connect` 插件与 Keycloak 集成并使用授权码流程的完整示例。 +### Cookie 会话吊销 + +Cookie 会话存储在客户端,APISIX 通常无法单独将其失效。要启用服务端吊销,请将 `session.revocation` 设置为 `redis` 并配置 Redis 拒绝列表: + +```json +"session": { + "secret": "your-session-secret-min-16-chars", + "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, + "prefix": "oidc:session:" + } +} +``` + +`revocation_fail_mode` 默认为 `open`,即吊销存储不可用时继续接受会话。使用 `closed` 则会拒绝打开和销毁会话。打开 Cookie 会话时会查询吊销存储,因此每个 worker 会缓存最近的查询结果,避免每个请求都访问一次 Redis。`revocation_cache_ttl` 是成功查询的 worker 本地 TTL,`revocation_error_cache_ttl` 是存储错误的 TTL。登出在处理该请求的 worker 上立即生效,在其他 worker 上则在其缓存条目过期后生效,因此 `revocation_cache_ttl` 也是已吊销会话在其他 worker 上仍可能被接受的最长时间。将 `revocation_cache_ttl` 设为 `0` 则每个请求都查询 Redis,此时错误缓存也会被禁用。省略 `revocation` 即不启用 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..08a4ca9bbaf6 --- /dev/null +++ b/t/plugin/openid-connect-revocation.t @@ -0,0 +1,516 @@ +# +# 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", + "revocation": "redis", + "redis": { + "host": "127.0.0.1", + "port": 6379, + "prefix": "oidc:revocation:" + } + } + } + }, + "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 + 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=", res.status) + } + } +--- response_body +authenticated=200 +replayed=302 + + + +=== TEST 2: forwarded and optional session revocation paths +--- config + location /t { + content_by_lua_block { + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local test_cases = { + { + name = "revocation uses library fail-open default", + session = { + secret = secret, + storage = "cookie", + revocation = "redis", + redis = { + host = "127.0.0.1", + prefix = "oidc:session:", + }, + }, + check = function(opts) + assert(opts.revocation == "redis") + assert(opts.revocation_fail_mode == nil) + assert(opts.redis.host == "127.0.0.1") + assert(opts.redis.prefix == "oidc:session:") + end, + }, + { + name = "explicit fail open", + session = { + secret = secret, + revocation = "redis", + 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 = "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 = "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 = { + 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.revocation_fail_mode == 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 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", + }, + error_field = "revocation_fail_mode", + }, + { + name = "missing redis", + session = { + secret = secret, + revocation = "redis", + }, + error_field = "allOf", + }, + { + name = "redis session storage", + session = { + secret = secret, + storage = "redis", + revocation = "redis", + redis = { host = "127.0.0.1" }, + }, + error_field = "allOf", + }, + } + + 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 + + + +=== 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 + + + +=== TEST 5: revocation lookups are cached per session, and a zero TTL disables the cache +--- config + location /session { + content_by_lua_block { + local session = require("resty.session") + local plugin = require("apisix.plugins.openid-connect") + + local probe = package.loaded.revocation_probe or {gets = 0} + package.loaded.revocation_probe = probe + + local conf = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + cookie_name = ngx.var.arg_name, + revocation = "redis", + } + if ngx.var.arg_ttl then + conf.revocation_cache_ttl = tonumber(ngx.var.arg_ttl) + end + local opts = plugin._build_session_opts(conf) + opts.revocation = { + get = function() probe.gets = probe.gets + 1 end, + set = function() return true end, + } + + if ngx.var.arg_action == "save" then + local s = session.new(opts) + s:set("k", "v") + assert(s:save()) + ngx.say("saved") + else + local _, err, exists = session.open(opts) + assert(exists, err) + ngx.say("opened") + end + } + } + + location /t { + content_by_lua_block { + local http = require("resty.http") + local base = "http://127.0.0.1:" .. ngx.var.server_port .. "/session?" + + -- open one cookie session three times and count store lookups; each + -- step is its own request, as the session reads its cookie from nginx + local function lookups(name, ttl) + local query = "name=" .. name .. (ttl and ("&ttl=" .. ttl) or "") + local httpc = http.new() + local res = assert(httpc:request_uri(base .. query .. "&action=save")) + local cookie = res.headers["Set-Cookie"] + if type(cookie) == "table" then + cookie = cookie[1] + end + cookie = cookie:match("^[^;]+") + + local probe = package.loaded.revocation_probe + probe.gets = 0 + for _ = 1, 3 do + res = assert(httpc:request_uri(base .. query .. "&action=open", + {headers = {Cookie = cookie}})) + assert(res.status == 200, res.body) + end + return probe.gets + end + + -- ttl 0 goes first: lua-resty-session allocates its cache lazily, and + -- only a fresh worker shows whether a zero TTL still works without it + ngx.say("ttl 0: ", lookups("cached_a", 0)) + ngx.say("default ttl: ", lookups("cached_b")) + ngx.say("ttl 5: ", lookups("cached_c", 5)) + } + } +--- response_body +ttl 0: 3 +default ttl: 1 +ttl 5: 1 + + + +=== TEST 6: build_session_opts disables the revocation error cache only when the lookup cache is off +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local build = plugin._build_session_opts + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + + local opts = build({secret = secret, revocation = "redis", + revocation_cache_ttl = 0, + revocation_error_cache_ttl = 5}) + ngx.say("ttl 0: ", opts.revocation_cache_ttl, " ", opts.revocation_error_cache_ttl) + + opts = build({secret = secret, revocation = "redis", + revocation_cache_ttl = 3, + revocation_error_cache_ttl = 5}) + ngx.say("ttl 3: ", opts.revocation_cache_ttl, " ", opts.revocation_error_cache_ttl) + + opts = build({secret = secret, revocation = "redis", + revocation_cache_ttl = 0}) + ngx.say("ttl 0, error unset: ", tostring(opts.revocation_error_cache_ttl)) + + opts = build({secret = secret, revocation = "redis"}) + ngx.say("unset: ", tostring(opts.revocation_cache_ttl), " ", + tostring(opts.revocation_error_cache_ttl)) + + opts = build({secret = secret, revocation_cache_ttl = 0}) + ngx.say("no revocation: ", tostring(opts.revocation_error_cache_ttl)) + } + } +--- response_body +ttl 0: 0 0 +ttl 3: 3 5 +ttl 0, error unset: 0 +unset: nil nil +no revocation: nil +--- error_log +session.revocation_error_cache_ttl is ignored