From 0db8c6878c1839a5c5536726592d63d85e6644fc Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Fri, 26 Jun 2026 15:15:34 -0400 Subject: [PATCH 1/6] Sceheme changes --- apisix/plugins/openid-connect.lua | 133 +++++++++++++++++++----------- 1 file changed, 85 insertions(+), 48 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index eb0d3b526c58..41ce30573156 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -51,6 +51,60 @@ local function build_session_opts(session_conf) return session_conf end +-- Same redis option shape for session storage (`session.redis`) and the +-- revocation store (`session.revocation.redis`). +local session_redis_properties = { + host = { + type = "string", minLength = 2, default = "127.0.0.1" + }, + port = { + type = "integer", minimum = 1, default = 6379, + }, + username = { + type = "string", minLength = 1, + }, + password = { + type = "string", minLength = 0, + }, + database = { + type = "integer", minimum = 0, default = 0, + description = "redis database index", + }, + prefix = { + type = "string", + default = "sessions", + description = "prefix for keys stored in redis" + }, + ssl = { + type = "boolean", default = false, + description = "enable ssl", + }, + ssl_verify = { + type = "boolean", default = true, + description = "verify ssl certificate", + }, + server_name = { + type = "string", + description = "The server name for the new TLS SNI extension.", + }, + connect_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "connect timeout in milliseconds", + }, + send_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "send timeout in milliseconds", + }, + read_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "read timeout in milliseconds", + }, + keepalive_timeout = { + type = "integer", minimum = 1000, default = 10000, + description = "keepalive timeout in milliseconds", + }, +} + local schema = { type = "object", @@ -155,58 +209,40 @@ local schema = { }, redis = { type = "object", + properties = session_redis_properties, + }, + revocation = { + type = "object", + description = "Revocation denylist configuration", properties = { - host = { - type = "string", minLength = 2, default = "127.0.0.1" - }, - port = { - type = "integer", minimum = 1, default = 6379, - }, - username = { - type = "string", minLength = 1, - }, - password = { - type = "string", minLength = 0, - }, - database = { - type = "integer", minimum = 0, default = 0, - description = "redis database index", - }, - prefix = { + storage = { type = "string", - default = "sessions", - description = "prefix for keys stored in redis" - }, - ssl = { - type = "boolean", default = false, - description = "enable ssl", - }, - ssl_verify = { - type = "boolean", default = true, - description = "verify ssl certificate", - }, - server_name = { - type = "string", - description = "The server name for the new TLS SNI extension.", - }, - connect_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "connect timeout in milliseconds", - }, - send_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "send timeout in milliseconds", + enum = {"redis"}, + default = "redis", }, - read_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "read timeout in milliseconds", + redis = { + type = "object", + properties = session_redis_properties, }, - keepalive_timeout = { - type = "integer", minimum = 1000, default = 10000, - description = "keepalive timeout in milliseconds", + }, + ["if"] = { + properties = { + storage = { enum = {"redis"} }, }, - } - } + }, + ["then"] = { + required = {"redis"}, + }, + additionalProperties = false, + }, + revocation_fail_mode = { + type = "string", + enum = {"open", "closed"}, + default = "open", + description = + "When the revocation store is unreachable: open allows requests " + .. "based on JWT expiry only; closed always denies requests.", + }, }, required = {"secret"}, ["if"] = { @@ -465,7 +501,8 @@ local schema = { } }, encrypt_fields = {"client_secret", "client_rsa_private_key", - "session.secret", "session.redis.password"}, + "session.secret", "session.redis.password", + "session.revocation.redis.password"}, required = {"client_id", "discovery"} } From 6583d846cac8ad45c8f95793638c172e3d06991b Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Tue, 30 Jun 2026 10:11:42 -0400 Subject: [PATCH 2/6] Addressing comment - switch to mode --- apisix/plugins/openid-connect.lua | 130 ++-- docs/en/latest/plugins/openid-connect.md | 4 +- t/plugin/openid-connect-revocation.t | 724 +++++++++++++++++++++++ 3 files changed, 781 insertions(+), 77 deletions(-) create mode 100644 t/plugin/openid-connect-revocation.t diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 41ce30573156..0d5d6258f5a8 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -51,60 +51,6 @@ local function build_session_opts(session_conf) return session_conf end --- Same redis option shape for session storage (`session.redis`) and the --- revocation store (`session.revocation.redis`). -local session_redis_properties = { - host = { - type = "string", minLength = 2, default = "127.0.0.1" - }, - port = { - type = "integer", minimum = 1, default = 6379, - }, - username = { - type = "string", minLength = 1, - }, - password = { - type = "string", minLength = 0, - }, - database = { - type = "integer", minimum = 0, default = 0, - description = "redis database index", - }, - prefix = { - type = "string", - default = "sessions", - description = "prefix for keys stored in redis" - }, - ssl = { - type = "boolean", default = false, - description = "enable ssl", - }, - ssl_verify = { - type = "boolean", default = true, - description = "verify ssl certificate", - }, - server_name = { - type = "string", - description = "The server name for the new TLS SNI extension.", - }, - connect_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "connect timeout in milliseconds", - }, - send_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "send timeout in milliseconds", - }, - read_timeout = { - type = "integer", minimum = 1, default = 1000, - description = "read timeout in milliseconds", - }, - keepalive_timeout = { - type = "integer", minimum = 1000, default = 10000, - description = "keepalive timeout in milliseconds", - }, -} - local schema = { type = "object", @@ -209,31 +155,64 @@ local schema = { }, redis = { type = "object", - properties = session_redis_properties, - }, - revocation = { - type = "object", - description = "Revocation denylist configuration", properties = { - storage = { + mode = { type = "string", - enum = {"redis"}, - default = "redis", + enum = {"storage", "revocation"}, + description = + "Whether this Redis connection stores session data " + .. "or the session revocation denylist.", }, - redis = { - type = "object", - properties = session_redis_properties, + host = { + type = "string", minLength = 2, default = "127.0.0.1" }, - }, - ["if"] = { - properties = { - storage = { enum = {"redis"} }, + port = { + type = "integer", minimum = 1, default = 6379, }, - }, - ["then"] = { - required = {"redis"}, - }, - additionalProperties = false, + username = { + type = "string", minLength = 1, + }, + password = { + type = "string", minLength = 0, + }, + database = { + type = "integer", minimum = 0, default = 0, + description = "redis database index", + }, + prefix = { + type = "string", + default = "sessions", + description = "prefix for keys stored in redis" + }, + ssl = { + type = "boolean", default = false, + description = "enable ssl", + }, + ssl_verify = { + type = "boolean", default = true, + description = "verify ssl certificate", + }, + server_name = { + type = "string", + description = "The server name for the new TLS SNI extension.", + }, + connect_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "connect timeout in milliseconds", + }, + send_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "send timeout in milliseconds", + }, + read_timeout = { + type = "integer", minimum = 1, default = 1000, + description = "read timeout in milliseconds", + }, + keepalive_timeout = { + type = "integer", minimum = 1000, default = 10000, + description = "keepalive timeout in milliseconds", + }, + } }, revocation_fail_mode = { type = "string", @@ -501,8 +480,7 @@ local schema = { } }, encrypt_fields = {"client_secret", "client_rsa_private_key", - "session.secret", "session.redis.password", - "session.revocation.redis.password"}, + "session.secret", "session.redis.password"}, required = {"client_id", "discovery"} } diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index e196d6922206..2e5270cfb692 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -79,7 +79,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 configuration when `storage` is `redis`. | +| session.redis | object | False | | | Redis connection. Required when `storage` is `redis`. Optional when `storage` is `cookie` and session revocation is enabled. | +| session.redis.mode | string | False | | ["storage", "revocation"] | Role of this Redis connection. Use `storage` when `session.storage` is `redis`; use `revocation` when `session.storage` is `cookie`. Defaults to `storage` or `revocation` respectively when omitted. | +| session.revocation_fail_mode | string | False | open | ["open", "closed"] | When the revocation store is unreachable: `open` allows requests based on JWT expiry only; `closed` always denies requests. Only applies when `session.redis.mode` is `revocation`. | | 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. | diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t new file mode 100644 index 000000000000..9f1d06834b21 --- /dev/null +++ b/t/plugin/openid-connect-revocation.t @@ -0,0 +1,724 @@ +# +# 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(); +no_shuffle(); + +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: valid session with cookie storage and redis revocation denylist +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "redis", + mode = "revocation", + prefix = "oidc:session:", + }, + revocation_fail_mode = "open", + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 2: build_session_opts passes redis mode through for revocation +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local build = plugin._build_session_opts + local opts = build({ + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "redis", + mode = "revocation", + prefix = "oidc:session:", + }, + revocation_fail_mode = "open", + }) + ngx.say("storage=", tostring(opts.storage)) + ngx.say("redis.host=", opts.redis.host) + ngx.say("redis.mode=", tostring(opts.redis.mode)) + ngx.say("revocation_fail_mode=", opts.revocation_fail_mode) + } + } +--- response_body +storage=cookie +redis.host=redis +redis.mode=revocation +revocation_fail_mode=open + + + +=== TEST 3: build_session_opts passes redis mode through for session storage +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local build = plugin._build_session_opts + local opts = build({ + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "redis", + redis = { + host = "127.0.0.1", + port = 6379, + mode = "storage", + }, + }) + ngx.say("storage=", opts.storage) + ngx.say("redis.host=", opts.redis.host) + ngx.say("redis.mode=", tostring(opts.redis.mode)) + } + } +--- response_body +storage=redis +redis.host=127.0.0.1 +redis.mode=storage + + + +=== TEST 4: session.revocation is rejected (use session.redis instead) +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + revocation = { + redis = { host = "127.0.0.1" }, + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body_like +.*additional properties forbidden.*revocation.* + + + +=== TEST 5: invalid revocation_fail_mode value is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "bogus", + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body_like +.*revocation_fail_mode.* + + + +=== TEST 6: build_session_opts passes revocation_fail_mode closed +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local build = plugin._build_session_opts + local opts = build({ + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "closed", + }) + ngx.say("revocation_fail_mode=", opts.revocation_fail_mode) + ngx.say("storage=", tostring(opts.storage)) + } + } +--- response_body +revocation_fail_mode=closed +storage=cookie + + + +=== TEST 7: valid schema with revocation_fail_mode closed +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "closed", + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 8: redis.mode defaults to revocation when storage is cookie +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 9: redis.mode defaults to storage when storage is redis +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "redis", + redis = { + host = "127.0.0.1", + port = 6379, + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 10: cookie session without redis block is valid +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 11: fail closed rejects open when revocation check fails +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local session = require("resty.session") + local build = plugin._build_session_opts + + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local cookie_name = "oidc_revocation_test" + + local function extract_cookie(raw) + if type(raw) == "table" then + for _, v in ipairs(raw) do + local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if m then + return m[1] + end + end + return "" + end + local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") + return m and m[1] or "" + end + + session.init({ + secret = secret, + cookie_name = cookie_name, + }) + local probe = session.new({ revocation_fail_mode = "open" }) + if probe.revocation_fail_mode == nil then + ngx.say("skip: revocation not supported") + return + end + + local cookies = {} + session.__set_ngx_header(cookies) + local s = session.new() + s:set("test_key", "test_data") + local ok, err = s:save() + if not ok then + ngx.say("save failed: ", err) + return + end + local session_cookie = extract_cookie(cookies["Set-Cookie"]) + s:close() + + local opts = build({ + secret = secret, + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "closed", + }) + + local s2 = session.new({ + secret = opts.secret, + cookie_name = cookie_name, + revocation_fail_mode = opts.revocation_fail_mode, + revocation = { + set = function() + return true + end, + get = function() + return nil, "connection refused" + end, + }, + }) + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + ok, err = s2:open() + if ok then + ngx.say("unexpected open success") + else + ngx.say(err) + end + } + } +--- response_body eval +qr/^(skip: revocation not supported|unable to check session revocation)$/ + + + +=== TEST 12: fail open allows open when revocation check fails +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local session = require("resty.session") + local build = plugin._build_session_opts + + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local cookie_name = "oidc_revocation_test" + + local function extract_cookie(raw) + if type(raw) == "table" then + for _, v in ipairs(raw) do + local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if m then + return m[1] + end + end + return "" + end + local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") + return m and m[1] or "" + end + + session.init({ + secret = secret, + cookie_name = cookie_name, + }) + local probe = session.new({ revocation_fail_mode = "open" }) + if probe.revocation_fail_mode == nil then + ngx.say("skip: revocation not supported") + return + end + + local cookies = {} + session.__set_ngx_header(cookies) + local s = session.new() + s:set("test_key", "test_data") + local ok, err = s:save() + if not ok then + ngx.say("save failed: ", err) + return + end + local session_cookie = extract_cookie(cookies["Set-Cookie"]) + s:close() + + local opts = build({ + secret = secret, + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "open", + }) + + local s2 = session.new({ + secret = opts.secret, + cookie_name = cookie_name, + revocation_fail_mode = opts.revocation_fail_mode, + revocation = { + set = function() + return true + end, + get = function() + return nil, "connection refused" + end, + }, + }) + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + ok, err = s2:open() + if not ok then + ngx.say("open failed: ", err) + return + end + ngx.say("value=", s2:get("test_key")) + s2:close() + } + } +--- response_body eval +qr/^(skip: revocation not supported|value=test_data)$/ + + + +=== TEST 13: fail closed rejects destroy when revocation mark fails +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local session = require("resty.session") + local build = plugin._build_session_opts + + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local cookie_name = "oidc_revocation_test" + + local function extract_cookie(raw) + if type(raw) == "table" then + for _, v in ipairs(raw) do + local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if m then + return m[1] + end + end + return "" + end + local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") + return m and m[1] or "" + end + + session.init({ + secret = secret, + cookie_name = cookie_name, + }) + local probe = session.new({ revocation_fail_mode = "open" }) + if probe.revocation_fail_mode == nil then + ngx.say("skip: revocation not supported") + return + end + + local cookies = {} + session.__set_ngx_header(cookies) + local s = session.new() + s:set("test_key", "test_data") + local ok, err = s:save() + if not ok then + ngx.say("save failed: ", err) + return + end + local session_cookie = extract_cookie(cookies["Set-Cookie"]) + s:close() + + local opts = build({ + secret = secret, + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "closed", + }) + + local s2 = session.new({ + secret = opts.secret, + cookie_name = cookie_name, + }) + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + ok, err = s2:open() + if not ok then + ngx.say("open failed: ", err) + return + end + + s2.revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil + end, + } + s2.revocation_fail_mode = opts.revocation_fail_mode + + session.__set_ngx_header(cookies) + ok, err = s2:destroy() + if ok then + ngx.say("unexpected destroy success") + else + ngx.say(err) + end + } + } +--- response_body eval +qr/^(skip: revocation not supported|unable to mark session revoked)$/ + + + +=== TEST 14: fail open allows destroy when revocation mark fails +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local session = require("resty.session") + local build = plugin._build_session_opts + + local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + local cookie_name = "oidc_revocation_test" + + local function extract_cookie(raw) + if type(raw) == "table" then + for _, v in ipairs(raw) do + local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if m then + return m[1] + end + end + return "" + end + local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") + return m and m[1] or "" + end + + session.init({ + secret = secret, + cookie_name = cookie_name, + }) + local probe = session.new({ revocation_fail_mode = "open" }) + if probe.revocation_fail_mode == nil then + ngx.say("skip: revocation not supported") + return + end + + local cookies = {} + session.__set_ngx_header(cookies) + local s = session.new() + s:set("test_key", "test_data") + local ok, err = s:save() + if not ok then + ngx.say("save failed: ", err) + return + end + local session_cookie = extract_cookie(cookies["Set-Cookie"]) + s:close() + + local opts = build({ + secret = secret, + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "open", + }) + + local s2 = session.new({ + secret = opts.secret, + cookie_name = cookie_name, + }) + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + ok, err = s2:open() + if not ok then + ngx.say("open failed: ", err) + return + end + + s2.revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil + end, + } + s2.revocation_fail_mode = opts.revocation_fail_mode + + session.__set_ngx_header(cookies) + ok, err = s2:destroy() + if not ok then + ngx.say("destroy failed: ", err) + else + ngx.say("destroy ok") + end + } + } +--- response_body eval +qr/^(skip: revocation not supported|destroy ok)$/ + + + +=== TEST 15: build_session_opts forwards revocation_fail_mode to session.new +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local session = require("resty.session") + local build = plugin._build_session_opts + + local probe = session.new({ revocation_fail_mode = "open" }) + if probe.revocation_fail_mode == nil then + ngx.say("skip: revocation not supported") + return + end + + local opts = build({ + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "closed", + }) + + local s = session.new(opts) + ngx.say("revocation_fail_mode=", s.revocation_fail_mode) + ngx.say("storage=", opts.storage) + ngx.say("has_revocation=", tostring(s.revocation ~= nil)) + } + } +--- response_body eval +qr/^(skip: revocation not supported|revocation_fail_mode=closed\nstorage=cookie\nhas_revocation=true)$/ From 6265e1f36e810758192a20bb087651635fa61261 Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Tue, 30 Jun 2026 14:20:29 -0400 Subject: [PATCH 3/6] Enable revocation when mode set --- apisix/plugins/openid-connect.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 0d5d6258f5a8..fe97330789b0 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -48,6 +48,16 @@ local function build_session_opts(session_conf) "use session.absolute_timeout instead") end end + -- APISIX schema exposes revocation via session.redis.mode; lua-resty-session + -- loads the denylist when revocation=true and redis is configured for cookie storage. + local redis = session_conf.redis + if redis and redis.host and session_conf.revocation == nil then + local storage = session_conf.storage or "cookie" + local mode = redis.mode or (storage == "cookie" and "revocation" or "storage") + if storage == "cookie" and mode ~= "storage" then + session_conf.revocation = true + end + end return session_conf end From 9a400e5545944d5d9f6586802b7e7148c3a8cafe Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Tue, 30 Jun 2026 15:00:22 -0400 Subject: [PATCH 4/6] translation not needed if we have resty handle it --- apisix/plugins/openid-connect.lua | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index fe97330789b0..0d5d6258f5a8 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -48,16 +48,6 @@ local function build_session_opts(session_conf) "use session.absolute_timeout instead") end end - -- APISIX schema exposes revocation via session.redis.mode; lua-resty-session - -- loads the denylist when revocation=true and redis is configured for cookie storage. - local redis = session_conf.redis - if redis and redis.host and session_conf.revocation == nil then - local storage = session_conf.storage or "cookie" - local mode = redis.mode or (storage == "cookie" and "revocation" or "storage") - if storage == "cookie" and mode ~= "storage" then - session_conf.revocation = true - end - end return session_conf end From 7c5f3697189ff9f3e908b341ede60178820eb9e6 Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Thu, 2 Jul 2026 21:42:35 -0400 Subject: [PATCH 5/6] test(openid-connect): trim revocation tests to schema and passthrough Remove resty.session integration tests that manually injected revocation state; APISIX now passes redis.mode through to lua-resty-session unchanged. Co-authored-by: Cursor --- t/plugin/openid-connect-revocation.t | 404 ++------------------------- 1 file changed, 18 insertions(+), 386 deletions(-) diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index 9f1d06834b21..4ba482dae1d5 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -242,7 +242,7 @@ done -=== TEST 8: redis.mode defaults to revocation when storage is cookie +=== TEST 8: cookie storage with redis block omitting mode is valid --- config location /t { content_by_lua_block { @@ -271,7 +271,7 @@ done -=== TEST 9: redis.mode defaults to storage when storage is redis +=== TEST 9: redis storage with redis block omitting mode is valid --- config location /t { content_by_lua_block { @@ -327,398 +327,30 @@ done -=== TEST 11: fail closed rejects open when revocation check fails +=== TEST 11: invalid redis.mode value is rejected --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.openid-connect") - local session = require("resty.session") - local build = plugin._build_session_opts - - local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" - local cookie_name = "oidc_revocation_test" - - local function extract_cookie(raw) - if type(raw) == "table" then - for _, v in ipairs(raw) do - local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") - if m then - return m[1] - end - end - return "" - end - local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") - return m and m[1] or "" - end - - session.init({ - secret = secret, - cookie_name = cookie_name, - }) - local probe = session.new({ revocation_fail_mode = "open" }) - if probe.revocation_fail_mode == nil then - ngx.say("skip: revocation not supported") - return - end - - local cookies = {} - session.__set_ngx_header(cookies) - local s = session.new() - s:set("test_key", "test_data") - local ok, err = s:save() - if not ok then - ngx.say("save failed: ", err) - return - end - local session_cookie = extract_cookie(cookies["Set-Cookie"]) - s:close() - - local opts = build({ - secret = secret, - storage = "cookie", - redis = { - host = "127.0.0.1", - mode = "revocation", - }, - revocation_fail_mode = "closed", - }) - - local s2 = session.new({ - secret = opts.secret, - cookie_name = cookie_name, - revocation_fail_mode = opts.revocation_fail_mode, - revocation = { - set = function() - return true - end, - get = function() - return nil, "connection refused" - end, - }, - }) - session.__set_ngx_var({ - ["cookie_" .. cookie_name] = session_cookie, - }) - - ok, err = s2:open() - if ok then - ngx.say("unexpected open success") - else - ngx.say(err) - end - } - } ---- response_body eval -qr/^(skip: revocation not supported|unable to check session revocation)$/ - - - -=== TEST 12: fail open allows open when revocation check fails ---- config - location /t { - content_by_lua_block { - local plugin = require("apisix.plugins.openid-connect") - local session = require("resty.session") - local build = plugin._build_session_opts - - local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" - local cookie_name = "oidc_revocation_test" - - local function extract_cookie(raw) - if type(raw) == "table" then - for _, v in ipairs(raw) do - local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") - if m then - return m[1] - end - end - return "" - end - local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") - return m and m[1] or "" - end - - session.init({ - secret = secret, - cookie_name = cookie_name, - }) - local probe = session.new({ revocation_fail_mode = "open" }) - if probe.revocation_fail_mode == nil then - ngx.say("skip: revocation not supported") - return - end - - local cookies = {} - session.__set_ngx_header(cookies) - local s = session.new() - s:set("test_key", "test_data") - local ok, err = s:save() - if not ok then - ngx.say("save failed: ", err) - return - end - local session_cookie = extract_cookie(cookies["Set-Cookie"]) - s:close() - - local opts = build({ - secret = secret, - storage = "cookie", - redis = { - host = "127.0.0.1", - mode = "revocation", - }, - revocation_fail_mode = "open", - }) - - local s2 = session.new({ - secret = opts.secret, - cookie_name = cookie_name, - revocation_fail_mode = opts.revocation_fail_mode, - revocation = { - set = function() - return true - end, - get = function() - return nil, "connection refused" - end, - }, - }) - session.__set_ngx_var({ - ["cookie_" .. cookie_name] = session_cookie, - }) - - ok, err = s2:open() - if not ok then - ngx.say("open failed: ", err) - return - end - ngx.say("value=", s2:get("test_key")) - s2:close() - } - } ---- response_body eval -qr/^(skip: revocation not supported|value=test_data)$/ - - - -=== TEST 13: fail closed rejects destroy when revocation mark fails ---- config - location /t { - content_by_lua_block { - local plugin = require("apisix.plugins.openid-connect") - local session = require("resty.session") - local build = plugin._build_session_opts - - local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" - local cookie_name = "oidc_revocation_test" - - local function extract_cookie(raw) - if type(raw) == "table" then - for _, v in ipairs(raw) do - local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") - if m then - return m[1] - end - end - return "" - end - local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") - return m and m[1] or "" - end - - session.init({ - secret = secret, - cookie_name = cookie_name, - }) - local probe = session.new({ revocation_fail_mode = "open" }) - if probe.revocation_fail_mode == nil then - ngx.say("skip: revocation not supported") - return - end - - local cookies = {} - session.__set_ngx_header(cookies) - local s = session.new() - s:set("test_key", "test_data") - local ok, err = s:save() - if not ok then - ngx.say("save failed: ", err) - return - end - local session_cookie = extract_cookie(cookies["Set-Cookie"]) - s:close() - - local opts = build({ - secret = secret, - storage = "cookie", - redis = { - host = "127.0.0.1", - mode = "revocation", - }, - revocation_fail_mode = "closed", - }) - - local s2 = session.new({ - secret = opts.secret, - cookie_name = cookie_name, - }) - session.__set_ngx_var({ - ["cookie_" .. cookie_name] = session_cookie, + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "bogus", + }, + } }) - ok, err = s2:open() if not ok then - ngx.say("open failed: ", err) - return - end - - s2.revocation = { - set = function() - return nil, "connection refused" - end, - get = function() - return nil - end, - } - s2.revocation_fail_mode = opts.revocation_fail_mode - - session.__set_ngx_header(cookies) - ok, err = s2:destroy() - if ok then - ngx.say("unexpected destroy success") - else ngx.say(err) - end - } - } ---- response_body eval -qr/^(skip: revocation not supported|unable to mark session revoked)$/ - - - -=== TEST 14: fail open allows destroy when revocation mark fails ---- config - location /t { - content_by_lua_block { - local plugin = require("apisix.plugins.openid-connect") - local session = require("resty.session") - local build = plugin._build_session_opts - - local secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" - local cookie_name = "oidc_revocation_test" - - local function extract_cookie(raw) - if type(raw) == "table" then - for _, v in ipairs(raw) do - local m = ngx.re.match(v, cookie_name .. "=([\\w-]+);") - if m then - return m[1] - end - end - return "" - end - local m = ngx.re.match(raw, cookie_name .. "=([\\w-]+);") - return m and m[1] or "" - end - - session.init({ - secret = secret, - cookie_name = cookie_name, - }) - local probe = session.new({ revocation_fail_mode = "open" }) - if probe.revocation_fail_mode == nil then - ngx.say("skip: revocation not supported") - return - end - - local cookies = {} - session.__set_ngx_header(cookies) - local s = session.new() - s:set("test_key", "test_data") - local ok, err = s:save() - if not ok then - ngx.say("save failed: ", err) - return - end - local session_cookie = extract_cookie(cookies["Set-Cookie"]) - s:close() - - local opts = build({ - secret = secret, - storage = "cookie", - redis = { - host = "127.0.0.1", - mode = "revocation", - }, - revocation_fail_mode = "open", - }) - - local s2 = session.new({ - secret = opts.secret, - cookie_name = cookie_name, - }) - session.__set_ngx_var({ - ["cookie_" .. cookie_name] = session_cookie, - }) - ok, err = s2:open() - if not ok then - ngx.say("open failed: ", err) - return - end - - s2.revocation = { - set = function() - return nil, "connection refused" - end, - get = function() - return nil - end, - } - s2.revocation_fail_mode = opts.revocation_fail_mode - - session.__set_ngx_header(cookies) - ok, err = s2:destroy() - if not ok then - ngx.say("destroy failed: ", err) else - ngx.say("destroy ok") - end - } - } ---- response_body eval -qr/^(skip: revocation not supported|destroy ok)$/ - - - -=== TEST 15: build_session_opts forwards revocation_fail_mode to session.new ---- config - location /t { - content_by_lua_block { - local plugin = require("apisix.plugins.openid-connect") - local session = require("resty.session") - local build = plugin._build_session_opts - - local probe = session.new({ revocation_fail_mode = "open" }) - if probe.revocation_fail_mode == nil then - ngx.say("skip: revocation not supported") - return + ngx.say("done") end - - local opts = build({ - secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", - storage = "cookie", - redis = { - host = "127.0.0.1", - mode = "revocation", - }, - revocation_fail_mode = "closed", - }) - - local s = session.new(opts) - ngx.say("revocation_fail_mode=", s.revocation_fail_mode) - ngx.say("storage=", opts.storage) - ngx.say("has_revocation=", tostring(s.revocation ~= nil)) } } ---- response_body eval -qr/^(skip: revocation not supported|revocation_fail_mode=closed\nstorage=cookie\nhas_revocation=true)$/ +--- response_body_like +.*redis\.mode.* From 48bf5bace71a8f3a2557314994e696d0487b5cd7 Mon Sep 17 00:00:00 2001 From: Austin Hockenberry Date: Thu, 2 Jul 2026 21:53:22 -0400 Subject: [PATCH 6/6] test(openid-connect): consolidate revocation tests Cover normal configs first, then variants and edge cases, with validation last. --- t/plugin/openid-connect-revocation.t | 241 ++++++++++++++++++++++----- 1 file changed, 201 insertions(+), 40 deletions(-) diff --git a/t/plugin/openid-connect-revocation.t b/t/plugin/openid-connect-revocation.t index 4ba482dae1d5..9b51599a0eb7 100644 --- a/t/plugin/openid-connect-revocation.t +++ b/t/plugin/openid-connect-revocation.t @@ -38,39 +38,46 @@ run_tests(); __DATA__ -=== TEST 1: valid session with cookie storage and redis revocation denylist +=== TEST 1: typical cookie session with redis revocation config passes schema --- config location /t { content_by_lua_block { local plugin = require("apisix.plugins.openid-connect") - local ok, err = plugin.check_schema({ + local conf = { client_id = "a", client_secret = "b", discovery = "c", session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", storage = "cookie", + cookie_name = "oidc_session", + absolute_timeout = 3600, redis = { - host = "redis", - mode = "revocation", + host = "redis.internal", + port = 6379, + password = "secret", + database = 1, prefix = "oidc:session:", + mode = "revocation", + ssl = true, + connect_timeout = 1000, }, - revocation_fail_mode = "open", } - }) + } + local ok, err = plugin.check_schema(conf) if not ok then ngx.say(err) else - ngx.say("done") + ngx.say("revocation_fail_mode=", conf.session.revocation_fail_mode) end } } --- response_body -done +revocation_fail_mode=open -=== TEST 2: build_session_opts passes redis mode through for revocation +=== TEST 2: build_session_opts passes typical revocation config through unchanged --- config location /t { content_by_lua_block { @@ -79,28 +86,66 @@ done local opts = build({ secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", storage = "cookie", + cookie_name = "oidc_session", + absolute_timeout = 3600, redis = { - host = "redis", - mode = "revocation", + host = "redis.internal", + port = 6379, + password = "secret", + database = 1, prefix = "oidc:session:", + mode = "revocation", }, - revocation_fail_mode = "open", }) - ngx.say("storage=", tostring(opts.storage)) - ngx.say("redis.host=", opts.redis.host) - ngx.say("redis.mode=", tostring(opts.redis.mode)) - ngx.say("revocation_fail_mode=", opts.revocation_fail_mode) + ngx.say("cookie_name=", opts.cookie_name) + ngx.say("absolute_timeout=", opts.absolute_timeout) + ngx.say("redis.prefix=", opts.redis.prefix) + ngx.say("redis.mode=", opts.redis.mode) } } --- response_body -storage=cookie -redis.host=redis +cookie_name=oidc_session +absolute_timeout=3600 +redis.prefix=oidc:session: redis.mode=revocation -revocation_fail_mode=open -=== TEST 3: build_session_opts passes redis mode through for session storage +=== TEST 3: typical redis-backed session storage config passes schema +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "redis", + cookie_name = "oidc_session", + redis = { + host = "127.0.0.1", + port = 6379, + password = "secret", + prefix = "sessions", + mode = "storage", + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 4: build_session_opts passes redis mode through for session storage --- config location /t { content_by_lua_block { @@ -127,7 +172,7 @@ redis.mode=storage -=== TEST 4: session.revocation is rejected (use session.redis instead) +=== TEST 5: cookie session without redis block is valid --- config location /t { content_by_lua_block { @@ -138,9 +183,7 @@ redis.mode=storage discovery = "c", session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", - revocation = { - redis = { host = "127.0.0.1" }, - }, + storage = "cookie", } }) if not ok then @@ -150,12 +193,12 @@ redis.mode=storage end } } ---- response_body_like -.*additional properties forbidden.*revocation.* +--- response_body +done -=== TEST 5: invalid revocation_fail_mode value is rejected +=== TEST 6: valid schema with revocation_fail_mode closed --- config location /t { content_by_lua_block { @@ -171,7 +214,7 @@ redis.mode=storage host = "127.0.0.1", mode = "revocation", }, - revocation_fail_mode = "bogus", + revocation_fail_mode = "closed", } }) if not ok then @@ -181,12 +224,12 @@ redis.mode=storage end } } ---- response_body_like -.*revocation_fail_mode.* +--- response_body +done -=== TEST 6: build_session_opts passes revocation_fail_mode closed +=== TEST 7: build_session_opts passes revocation_fail_mode closed --- config location /t { content_by_lua_block { @@ -211,7 +254,7 @@ storage=cookie -=== TEST 7: valid schema with revocation_fail_mode closed +=== TEST 8: cookie storage with redis block omitting mode is valid --- config location /t { content_by_lua_block { @@ -225,9 +268,7 @@ storage=cookie storage = "cookie", redis = { host = "127.0.0.1", - mode = "revocation", }, - revocation_fail_mode = "closed", } }) if not ok then @@ -242,7 +283,65 @@ done -=== TEST 8: cookie storage with redis block omitting mode is valid +=== TEST 9: redis storage with redis block omitting mode is valid +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "redis", + redis = { + host = "127.0.0.1", + port = 6379, + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 10: build_session_opts passes redis block through when mode is omitted +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local build = plugin._build_session_opts + local opts = build({ + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + redis = { + host = "127.0.0.1", + }, + revocation_fail_mode = "closed", + }) + ngx.say("storage=", tostring(opts.storage)) + ngx.say("redis.host=", opts.redis.host) + ngx.say("redis.mode=", tostring(opts.redis.mode)) + ngx.say("revocation_fail_mode=", opts.revocation_fail_mode) + } + } +--- response_body +storage=cookie +redis.host=127.0.0.1 +redis.mode=nil +revocation_fail_mode=closed + + + +=== TEST 11: cookie storage with redis mode storage is valid --- config location /t { content_by_lua_block { @@ -256,6 +355,7 @@ done storage = "cookie", redis = { host = "127.0.0.1", + mode = "storage", }, } }) @@ -271,7 +371,34 @@ done -=== TEST 9: redis storage with redis block omitting mode is valid +=== TEST 12: revocation_fail_mode without redis block on cookie storage is valid +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + storage = "cookie", + revocation_fail_mode = "closed", + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body +done + + + +=== TEST 13: redis storage with redis mode revocation is valid schema --- config location /t { content_by_lua_block { @@ -286,6 +413,7 @@ done redis = { host = "127.0.0.1", port = 6379, + mode = "revocation", }, } }) @@ -301,7 +429,7 @@ done -=== TEST 10: cookie session without redis block is valid +=== TEST 14: invalid revocation_fail_mode value is rejected --- config location /t { content_by_lua_block { @@ -313,6 +441,11 @@ done session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", storage = "cookie", + redis = { + host = "127.0.0.1", + mode = "revocation", + }, + revocation_fail_mode = "bogus", } }) if not ok then @@ -322,12 +455,12 @@ done end } } ---- response_body -done +--- response_body_like +.*revocation_fail_mode.* -=== TEST 11: invalid redis.mode value is rejected +=== TEST 15: invalid redis.mode value is rejected --- config location /t { content_by_lua_block { @@ -354,3 +487,31 @@ done } --- response_body_like .*redis\.mode.* + + + +=== TEST 16: session.revocation is rejected (use session.redis instead) +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "a", + client_secret = "b", + discovery = "c", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + revocation = { + redis = { host = "127.0.0.1" }, + }, + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- response_body_like +.*additional properties forbidden.*revocation.*