From da27f0044b4acf26d8dd9d3db60eaea3727fc2ae Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 10:39:51 +0800 Subject: [PATCH 01/15] feat(openid-connect): add back-channel logout config schema --- apisix/cli/config.lua | 1 + apisix/cli/ngx_tpl.lua | 1 + apisix/plugins/openid-connect.lua | 156 ++++++++++++------- t/plugin/openid-connect-backchannel-logout.t | 128 +++++++++++++++ 4 files changed, 231 insertions(+), 55 deletions(-) create mode 100644 t/plugin/openid-connect-backchannel-logout.t diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 98c3ff581218..a236ca22e6de 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -183,6 +183,7 @@ local _M = { discovery = "1m", jwks = "1m", introspection = "10m", + bcl = "1m", ["access-tokens"] = "1m", ["ext-plugin"] = "1m", tars = "1m", diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 4fa56d57a4cc..3a904defd9b9 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -371,6 +371,7 @@ http { # for openid-connect plugin lua_shared_dict jwks {* http.lua_shared_dict["jwks"] *}; # cache for JWKs lua_shared_dict introspection {* http.lua_shared_dict["introspection"] *}; # cache for JWT verification results + lua_shared_dict bcl {* http.lua_shared_dict["bcl"] *}; # back-channel logout revocations {% end %} {% if enabled_plugins["cas-auth"] then %} diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 054cc6f1b1d5..4d496936e577 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -130,6 +130,67 @@ local function flatten_openidc_options(conf) end +-- Redis connection fields shaped after lua-resty-session's session.redis +-- options; shared by session storage and the back-channel logout store so +-- both expose an identical config surface. +local session_redis_schema = { + type = "object", + 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 bcl_redis_schema = core.table.deepcopy(session_redis_schema) +bcl_redis_schema.properties.prefix.default = "bcl" + local schema = { type = "object", properties = { @@ -231,60 +292,7 @@ local schema = { enum = {"cookie", "redis"}, default = "cookie", }, - redis = { - type = "object", - 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", - }, - } - } + redis = session_redis_schema, }, required = {"secret"}, ["if"] = { @@ -297,6 +305,30 @@ local schema = { }, additionalProperties = false, }, + backchannel_logout = { + type = "object", + description = "OIDC Back-Channel Logout 1.0 receiver: the identity " + .. "provider POSTs a logout_token to `path`, and the revoked " + .. "session is rejected from the next request on.", + properties = { + path = { + type = "string", + pattern = "^/", + description = "in-route path that receives the provider's " + .. "back-channel logout POST", + }, + storage = { + type = "string", + enum = {"shm", "redis"}, + default = "shm", + description = "where revocations are stored; shm is " + .. "per-instance, redis is shared across nodes", + }, + redis = bcl_redis_schema, + }, + required = {"path"}, + additionalProperties = false, + }, realm = { type = "string", default = "apisix", @@ -631,7 +663,8 @@ local schema = { } }, encrypt_fields = {"client_secret", "client_rsa_private_key", "dpop.private_key", - "session.secret", "session.redis.password"}, + "session.secret", "session.redis.password", + "backchannel_logout.redis.password"}, required = {"client_id", "discovery"} } @@ -914,6 +947,19 @@ function _M.check_schema(conf) return false, err end + if conf.backchannel_logout then + if conf.bearer_only then + return false, "backchannel_logout cannot be used with bearer_only" + end + if conf.backchannel_logout.storage == "redis" + and not conf.backchannel_logout.redis + and not (conf.session and conf.session.redis) then + return false, "backchannel_logout.redis is required when " .. + "backchannel_logout.storage is redis and " .. + "session.redis is not configured" + end + end + if conf.claim_schema and not secret.is_secret_ref(conf.claim_schema) then local ok, res = pcall(jsonschema.generate_validator, conf.claim_schema) if not ok then diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t new file mode 100644 index 000000000000..766dbcc77cfd --- /dev/null +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -0,0 +1,128 @@ +use t::APISIX 'no_plan'; + +log_level('debug'); +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: Minimal backchannel_logout config passes the schema check. +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "course_management", + client_secret = "secret", + discovery = "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + backchannel_logout = { + path = "/logout/backchannel" + } + }) + if not ok then + ngx.say(err) + return + end + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 2: backchannel_logout is rejected together with bearer_only. +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "course_management", + client_secret = "secret", + discovery = "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + bearer_only = true, + backchannel_logout = { + path = "/logout/backchannel" + } + }) + if ok then + ngx.say("unexpectedly passed") + return + end + ngx.say(err) + } + } +--- response_body +backchannel_logout cannot be used with bearer_only + + + +=== TEST 3: storage redis without a redis config and without session.redis is rejected. +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "course_management", + client_secret = "secret", + discovery = "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + backchannel_logout = { + path = "/logout/backchannel", + storage = "redis" + } + }) + if ok then + ngx.say("unexpectedly passed") + return + end + ngx.say(err) + } + } +--- response_body +backchannel_logout.redis is required when backchannel_logout.storage is redis and session.redis is not configured + + + +=== TEST 4: A path that does not start with a slash is rejected. +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "course_management", + client_secret = "secret", + discovery = "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + backchannel_logout = { + path = "logout" + } + }) + if ok then + ngx.say("unexpectedly passed") + return + end + ngx.say("rejected") + } + } +--- response_body +rejected From 9991a1762436790edd83ef05668e1a69678badfa Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 11:41:20 +0800 Subject: [PATCH 02/15] feat(openid-connect): add back-channel logout endpoint --- apisix/plugins/openid-connect.lua | 263 ++++++++++ t/APISIX.pm | 1 + t/plugin/openid-connect-backchannel-logout.t | 502 +++++++++++++++++++ 3 files changed, 766 insertions(+) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 4d496936e577..f7287f5b4157 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -22,6 +22,7 @@ local openidc = require("resty.openidc") local jsonschema = require('jsonschema') local pkey = require("resty.openssl.pkey") local dump_jwk = require("resty.openssl.auxiliary.jwk").dump_jwk +local redis = require("apisix.utils.redis") local string = string local ngx = ngx local ipairs = ipairs @@ -41,6 +42,19 @@ local plugin_name = "openid-connect" local STATE_MISMATCH_ERR = "state from argument does not match state restored from session" +-- OIDC Back-Channel Logout 1.0 +-- (https://openid.net/specs/openid-connect-backchannel-1_0.html) +local BCL_EVENT = "http://schemas.openid.net/event/backchannel-logout" +-- Acceptance window for the logout token's iat, mirroring +-- mod_auth_openidc's default OIDCIDTokenIatSlack. +local BCL_IAT_SLACK = 600 +-- A seen jti only needs to be remembered while a token whose iat is still +-- inside the acceptance window could arrive. +local BCL_JTI_TTL = 2 * BCL_IAT_SLACK + 10 +-- Fallback lifetime of a revocation entry when the session has no +-- absolute_timeout: an entry only needs to outlive the sessions it revokes. +local BCL_DENYLIST_TTL = 86400 + -- Session config is passed as-is to resty.session.start(); the only -- translation is the legacy session.cookie.lifetime alias from the @@ -1163,6 +1177,249 @@ local function validate_claims_in_oidcauth_response(resp, conf) end +local function bcl_redis_conf(conf) + return conf.backchannel_logout.redis + or (conf.session and conf.session.redis) +end + + +local function bcl_redis_connect(rconf) + local red, err = redis.new({ + redis_host = rconf.host, + redis_port = rconf.port, + redis_username = rconf.username, + redis_password = rconf.password, + redis_database = rconf.database, + redis_ssl = rconf.ssl, + redis_ssl_verify = rconf.ssl_verify, + redis_timeout = rconf.connect_timeout, + }) + if not red then + return nil, "failed to connect to redis: " .. err + end + return red +end + + +-- One revocation (or seen-jti) entry per key; the value is the unix time +-- the entry was written. +local function bcl_store_set(conf, key, ttl) + local now = ngx.time() + + if conf.backchannel_logout.storage == "redis" then + local rconf = bcl_redis_conf(conf) + local red, err = bcl_redis_connect(rconf) + if not red then + return false, err + end + local ok + ok, err = red:set(rconf.prefix .. ":" .. key, now, "EX", ttl) + if not ok then + return false, "failed to write to redis: " .. err + end + red:set_keepalive(rconf.keepalive_timeout, 100) + return true + end + + local dict = ngx.shared.bcl + if not dict then + return false, "shared dict \"bcl\" is missing" + end + -- safe_set: evicting an unexpired revocation to make room would silently + -- re-admit a revoked session, so a full dict must fail the write instead. + local ok, err = dict:safe_set(key, now, ttl) + if not ok then + return false, "failed to write to the shared dict: " .. err + end + return true +end + + +-- Returns the entry timestamp, nil when there is no entry, or nil plus an +-- error when the store cannot be reached (the caller treats that as +-- "no verdict", never as "clean"). +local function bcl_store_get(conf, key) + if conf.backchannel_logout.storage == "redis" then + local rconf = bcl_redis_conf(conf) + local red, err = bcl_redis_connect(rconf) + if not red then + return nil, err + end + local v + v, err = red:get(rconf.prefix .. ":" .. key) + if err then + return nil, "failed to read from redis: " .. err + end + red:set_keepalive(rconf.keepalive_timeout, 100) + if v == ngx.null then + return nil + end + return tonumber(v) + end + + local dict = ngx.shared.bcl + if not dict then + return nil, "shared dict \"bcl\" is missing" + end + return dict:get(key) +end + + +-- The issuer scopes sid/sub values (unique per issuer, Back-Channel Logout +-- 1.0 section 2.4); the client_id additionally scopes the entry to the +-- client the logout token's aud named, so two clients of the same realm +-- sharing a store cannot revoke each other's sessions. +local function bcl_denylist_key(kind, conf, issuer, value) + return "bcl:" .. kind .. ":" .. issuer .. "#" .. conf.client_id .. "#" .. value +end + + +-- Validates a logout token per Back-Channel Logout 1.0 section 2.6 and +-- returns its claims. Signature verification (JWKS fetch and cache via the +-- discovery document, kid rollover, "none"-alg rejection) and exp checking +-- (only when the claim is present; older providers omit it) are delegated +-- to resty.openidc's JWT machinery. +local function bcl_validate_logout_token(conf, discovery, logout_token) + conf.jwt_verification_cache_ignore = true + conf.token_signing_alg_values_expected = + discovery.id_token_signing_alg_values_supported + + local claims, err = openidc.jwt_verify(logout_token, conf) + if err then + return nil, "signature validation failed: " .. err + end + + if claims.iss ~= discovery.issuer then + return nil, "iss does not match the discovery issuer" + end + + local aud_ok = false + if type(claims.aud) == "string" then + aud_ok = claims.aud == conf.client_id + elseif type(claims.aud) == "table" then + for _, aud in ipairs(claims.aud) do + if aud == conf.client_id then + aud_ok = true + break + end + end + end + if not aud_ok then + return nil, "aud does not contain the client_id" + end + + if type(claims.iat) ~= "number" then + return nil, "iat claim is missing" + end + local now = ngx.time() + if claims.iat < now - BCL_IAT_SLACK or claims.iat > now + BCL_IAT_SLACK then + return nil, "iat is outside the acceptance window" + end + + if type(claims.events) ~= "table" + or type(claims.events[BCL_EVENT]) ~= "table" then + return nil, "events claim does not contain the back-channel logout event" + end + + if claims.nonce ~= nil then + return nil, "nonce claim is prohibited in a logout token" + end + + if type(claims.jti) ~= "string" or claims.jti == "" then + return nil, "jti claim is missing" + end + + if type(claims.sub) ~= "string" and type(claims.sid) ~= "string" then + return nil, "either a sub or a sid claim is required" + end + + return claims +end + + +local function bcl_error(description) + return 400, core.json.encode({ + error = "invalid_request", + error_description = description, + }) +end + + +-- The back-channel logout endpoint: an unauthenticated server-to-server +-- POST from the provider (Back-Channel Logout 1.0 section 2.5). Responses +-- per section 2.8: empty 200 on success, 400 with an RFC 6749-style JSON +-- error body otherwise, never cached. A store failure is a 400, not a 200: +-- claiming success while dropping the revocation would end the provider's +-- delivery attempts. +local function handle_backchannel_logout(conf) + core.response.set_header("Cache-Control", "no-store") + + if ngx.req.get_method() ~= "POST" then + return 405 + end + + ngx.req.read_body() + local args = ngx.req.get_post_args() + local logout_token = args and args.logout_token + if type(logout_token) ~= "string" or logout_token == "" then + return bcl_error("the logout_token parameter is missing") + end + + local discovery, discovery_err = openidc.get_discovery_doc(conf) + if discovery_err then + core.log.error("OIDC backchannel logout discovery failed: ", discovery_err) + return bcl_error("failed to load the discovery document") + end + + local claims, validate_err = + bcl_validate_logout_token(conf, discovery, logout_token) + if not claims then + core.log.warn("OIDC backchannel logout token rejected: ", validate_err) + return bcl_error(validate_err) + end + + local jti_key = "bcl:jti:" .. discovery.issuer .. "#" .. claims.jti + local seen, store_err = bcl_store_get(conf, jti_key) + if store_err then + core.log.error("OIDC backchannel logout store failed: ", store_err) + return bcl_error("the revocation store is unavailable") + end + if seen then + core.log.warn("OIDC backchannel logout token rejected: ", + "a logout token with this jti was already received") + return bcl_error("a logout token with this jti was already received") + end + local ok + ok, store_err = bcl_store_set(conf, jti_key, BCL_JTI_TTL) + if not ok then + core.log.error("OIDC backchannel logout store failed: ", store_err) + return bcl_error("the revocation store is unavailable") + end + + -- Section 2.4: a token with a sid revokes that one session; a sub-only + -- token revokes every session the user had when it was received + -- (section 2.7). + local key, logged_target + if claims.sid then + key = bcl_denylist_key("sid", conf, discovery.issuer, claims.sid) + logged_target = "sid " .. claims.sid + else + key = bcl_denylist_key("sub", conf, discovery.issuer, claims.sub) + logged_target = "sub " .. claims.sub + end + local ttl = (conf.session and conf.session.absolute_timeout) + or BCL_DENYLIST_TTL + ok, store_err = bcl_store_set(conf, key, ttl) + if not ok then + core.log.error("OIDC backchannel logout store failed: ", store_err) + return bcl_error("the revocation store is unavailable") + end + + core.log.warn("OIDC backchannel logout accepted for ", logged_target) + return 200 +end + + function _M.rewrite(plugin_conf, ctx) local conf = core.table.clone(plugin_conf) flatten_openidc_options(conf) @@ -1210,6 +1467,12 @@ function _M.rewrite(plugin_conf, ctx) conf.ssl_verify = "no" end + -- ctx.var.uri is deliberate: it is the path without the query string, + -- so a provider that appends query parameters still reaches the endpoint. + if conf.backchannel_logout and ctx.var.uri == conf.backchannel_logout.path then + return handle_backchannel_logout(conf) + end + if path == (conf.logout_path or "/logout") then local discovery, discovery_err = openidc.get_discovery_doc(conf) if discovery_err then diff --git a/t/APISIX.pm b/t/APISIX.pm index e0b86560b040..6db20cd00d8e 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -647,6 +647,7 @@ _EOC_ lua_shared_dict tracing_buffer 10m; # plugin skywalking lua_shared_dict access-tokens 1m; # plugin authz-keycloak lua_shared_dict discovery 1m; # plugin authz-keycloak + lua_shared_dict bcl 1m; # plugin openid-connect back-channel logout lua_shared_dict plugin-api-breaker 10m; lua_capture_error_log 1m; # plugin error-log-logger lua_shared_dict etcd-cluster-health-check 10m; # etcd health check diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 766dbcc77cfd..e0d70f6cabce 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -6,12 +6,45 @@ no_long_string(); no_root_location(); no_shuffle(); +my $stub_idp = <<_EOC_; + server { + listen 6724; + location = /.well-known/openid-configuration { + content_by_lua_block { + ngx.header.content_type = "application/json" + ngx.say([[{"issuer":"http://127.0.0.1:6724","authorization_endpoint":"http://127.0.0.1:6724/authorize","token_endpoint":"http://127.0.0.1:6724/token","jwks_uri":"http://127.0.0.1:6724/jwks","id_token_signing_alg_values_supported":["RS256"]}]]) + } + } + location = /jwks { + content_by_lua_block { + local pkey = require "resty.openssl.pkey" + local dump_jwk = require("resty.openssl.auxiliary.jwk").dump_jwk + local cjson = require "cjson.safe" + local t = require "lib.test_admin" + + local pub = pkey.new(t.read_file("t/certs/public.pem")) + local jwk = cjson.decode(dump_jwk(pub, false)) + jwk.kid = "bclkey" + jwk.alg = "RS256" + jwk.use = "sig" + + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({ keys = { jwk } })) + } + } + } +_EOC_ + add_block_preprocessor(sub { my ($block) = @_; if (!$block->request) { $block->set_value("request", "GET /t"); } + + my $http_config = $block->http_config // ''; + $http_config .= $stub_idp; + $block->set_value("http_config", $http_config); }); run_tests(); @@ -126,3 +159,472 @@ backchannel_logout.redis is required when backchannel_logout.storage is redis an } --- response_body rejected + + + +=== TEST 5: Set up a route against the stub IdP with the BCL endpoint enabled. +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/bcl" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 6: A valid logout token is accepted and lands in the denylist. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test6", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-6", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + ngx.say("cache-control: ", res.headers["Cache-Control"]) + + local entry = ngx.shared.bcl:get( + "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-6") + ngx.say("denylist entry: ", entry ~= nil) + } + } +--- response_body +cache-control: no-store +denylist entry: true +--- error_log +OIDC backchannel logout accepted for sid sess-6 + + + +=== TEST 7: A replayed jti is rejected on the second delivery. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test7", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-7", + } + }) + + local res1, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res1 then + ngx.status = 500 + ngx.say(err) + return + end + local res2 + res2, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res2 then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("first: ", res1.status) + ngx.say("second: ", res2.status) + } + } +--- response_body +first: 200 +second: 400 +--- error_log +a logout token with this jti was already received + + + +=== TEST 8: A tampered signature is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test8", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-8", + } + }) + token = token:sub(1, -3) .. "xx" + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +signature validation failed + + + +=== TEST 9: An unsigned token (alg none) is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + + local b64 = function(s) + return ngx.encode_base64(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "") + end + local cjson = require "cjson.safe" + local header = b64(cjson.encode({ alg = "none", typ = "logout+jwt" })) + local payload = b64(cjson.encode({ + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + jti = "jti-test9", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-9", + })) + local token = header .. "." .. payload .. "." + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +signature validation failed + + + +=== TEST 10: A token without the back-channel logout event is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test10", + sid = "sess-10", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +events claim does not contain the back-channel logout event + + + +=== TEST 11: A token carrying a nonce is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test11", + nonce = "forged", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-11", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +nonce claim is prohibited in a logout token + + + +=== TEST 12: A token with neither sub nor sid is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test12", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +either a sub or a sid claim is required + + + +=== TEST 13: A token without a jti is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-13", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +jti claim is missing + + + +=== TEST 14: A token with a stale iat is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time() - 1200, + exp = ngx.time() + 120, + jti = "jti-test14", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-14", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +iat is outside the acceptance window + + + +=== TEST 15: A token whose aud names another client is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "some-other-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test15", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-15", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +aud does not contain the client_id + + + +=== TEST 16: Transport-level failures: non-POST and missing logout_token. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + + local res1, err = t.req_self_with_http("/bcl", "GET") + if not res1 then + ngx.status = 500 + ngx.say(err) + return + end + local res2 + res2, err = t.req_self_with_http("/bcl", "POST", "foo=bar") + if not res2 then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("get: ", res1.status) + ngx.say("post without token: ", res2.status) + } + } +--- response_body +get: 405 +post without token: 400 From 5b31fbf1a69df9f43fa768e127f795e350a0a0b5 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 13:00:41 +0800 Subject: [PATCH 03/15] feat(openid-connect): enforce back-channel logout revocations on session requests --- apisix/plugins/openid-connect.lua | 91 ++++ t/lib/keycloak.lua | 123 ++++++ t/plugin/openid-connect-backchannel-logout.t | 436 ++++++++++++++++++- 3 files changed, 649 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index f7287f5b4157..3ac76886901a 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1420,6 +1420,49 @@ local function handle_backchannel_logout(conf) end +-- Checks the session's identity against the revocation store. Returns true +-- (revoked), false (clean), or nil plus an error when the store cannot be +-- reached - no verdict is not a verdict. +local function session_revoked_by_backchannel_logout(conf, response, session) + local id_token = response.id_token + if not id_token then + return false + end + + if id_token.sid then + local revoked_at, err = bcl_store_get(conf, + bcl_denylist_key("sid", conf, id_token.iss, id_token.sid)) + if err then + return nil, err + end + if revoked_at then + return true + end + end + + if id_token.sub then + local revoked_at, err = bcl_store_get(conf, + bcl_denylist_key("sub", conf, id_token.iss, id_token.sub)) + if err then + return nil, err + end + if revoked_at then + -- A sub entry means "log out every session this user had when + -- the logout was received": a session authenticated after it + -- stays valid. A missing timestamp kills the session, erring + -- toward revocation. + local auth_at = session:get("last_authenticated") + or id_token.auth_time or id_token.iat + if not auth_at or auth_at <= revoked_at then + return true + end + end + end + + return false +end + + function _M.rewrite(plugin_conf, ctx) local conf = core.table.clone(plugin_conf) flatten_openidc_options(conf) @@ -1636,6 +1679,54 @@ function _M.rewrite(plugin_conf, ctx) end if response then + if conf.backchannel_logout then + local revoked, bcl_err = + session_revoked_by_backchannel_logout(conf, response, session) + + if bcl_err then + -- No verdict: fail the request but keep the session, so + -- a store hiccup neither forwards a possibly revoked + -- token nor logs the whole user base out. + core.log.error("OIDC backchannel logout store failed: ", + bcl_err) + session:close() + return 503 + end + + if revoked then + core.log.warn("OIDC session revoked by backchannel logout") + + -- Drop the dead session. Every branch below returns, so + -- the tail close() is never reached. + session:clear_request_cookie() + session:destroy() + + if conf.unauth_action == "pass" then + return nil + end + + if conf.unauth_action == "deny" then + return 401 + end + + -- Start a fresh code flow; on success authenticate() + -- redirects and does not return. + local _, auth_err, _, new_session = + openidc.authenticate(conf, nil, "auth", + build_session_opts(conf.session)) + if new_session then + new_session:close() + end + + if auth_err then + core.log.error("OIDC re-authentication failed: ", auth_err) + return 500 + end + + return + end + end + local ok, err = validate_claims_in_oidcauth_response(response, conf) if not ok then core.log.error("OIDC claim validation failed: ", err) diff --git a/t/lib/keycloak.lua b/t/lib/keycloak.lua index 51d215504ef6..7070b157adb9 100644 --- a/t/lib/keycloak.lua +++ b/t/lib/keycloak.lua @@ -15,9 +15,132 @@ -- limitations under the License. -- local http = require "resty.http" +local cjson = require "cjson.safe" local _M = {} +-- Base URL and fixtures matching the CI Keycloak provisioned by +-- ci/pod/keycloak/kcadm_configure_university.sh. +local KEYCLOAK_BASE = "http://127.0.0.1:8080" +local REALM_BASE = KEYCLOAK_BASE .. "/admin/realms/University" + + +-- Fetch an admin access token from the master realm. +function _M.get_admin_token() + local httpc = http.new() + local res, err = httpc:request_uri( + KEYCLOAK_BASE .. "/realms/master/protocol/openid-connect/token", { + method = "POST", + body = "grant_type=password&client_id=admin-cli" .. + "&username=admin&password=admin", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + if not res or res.status ~= 200 then + return nil, "admin token request failed: " .. + (res and res.status or err) + end + return cjson.decode(res.body).access_token +end + + +-- Set the course_management client's backchannel logout URL and the +-- "session required" flag (whether logout tokens carry a sid claim). +function _M.set_backchannel_logout(token, url, session_required) + local httpc = http.new() + local res, err = httpc:request_uri( + REALM_BASE .. "/clients?clientId=course_management", { + headers = { ["Authorization"] = "Bearer " .. token } + }) + if not res or res.status ~= 200 then + return nil, "client lookup failed: " .. (res and res.status or err) + end + local client = cjson.decode(res.body)[1] + client.attributes = client.attributes or {} + client.attributes["backchannel.logout.url"] = url + client.attributes["backchannel.logout.session.required"] = + session_required and "true" or "false" + res, err = httpc:request_uri(REALM_BASE .. "/clients/" .. client.id, { + method = "PUT", + body = cjson.encode(client), + headers = { + ["Authorization"] = "Bearer " .. token, + ["Content-Type"] = "application/json" + } + }) + if not res or res.status >= 300 then + return nil, "client update failed: " .. (res and res.status or err) + end + return true +end + + +-- Make the caller's next admin logout deliver its back-channel logout token +-- deterministically, working around two observed Keycloak behaviors: +-- 1. A logout-all over several live sessions has been observed to deliver a +-- logout token for only one of them; purging the user's leftover sessions +-- first means the session the caller creates next is the only one, so its +-- token is the one delivered. +-- 2. Keycloak pools its connection to the logout URL and does not retry a +-- POST whose pooled socket died with a restarted gateway; a throwaway +-- login/logout of the student user absorbs any stale socket. +function _M.prime_backchannel_logout(uri) + local token, terr = _M.get_admin_token() + if not token then + return nil, terr + end + local ok, perr = _M.logout_user(token, "teacher@gmail.com") + if not ok then + return nil, perr + end + + local httpc = http.new() + local res, err = _M.login_keycloak(uri, "student@gmail.com", "123456") + if err then + return nil, "prime login failed: " .. err + end + local cookie_str = _M.concatenate_cookies(res.headers['Set-Cookie']) + local base = uri:match("^(https?://[^/]+)") + res, err = httpc:request_uri(base .. res.headers['Location'], { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res or res.status ~= 200 then + return nil, "prime code exchange failed: " .. + (res and res.status or err) + end + local lerr + ok, lerr = _M.logout_user(token, "student@gmail.com") + if not ok then + return nil, lerr + end + return true +end + + +-- Log the user out through the admin API; Keycloak then delivers +-- back-channel logout tokens to the registered clients. +function _M.logout_user(token, username) + local httpc = http.new() + local res, err = httpc:request_uri( + REALM_BASE .. "/users?username=" .. username .. "&exact=true", { + headers = { ["Authorization"] = "Bearer " .. token } + }) + if not res or res.status ~= 200 then + return nil, "user lookup failed: " .. (res and res.status or err) + end + local user = cjson.decode(res.body)[1] + res, err = httpc:request_uri(REALM_BASE .. "/users/" .. user.id .. "/logout", { + method = "POST", + headers = { ["Authorization"] = "Bearer " .. token } + }) + if not res or res.status >= 300 then + return nil, "admin logout failed: " .. (res and res.status or err) + end + return true +end + -- Request APISIX and redirect to keycloak, -- Login keycloak and return the res of APISIX diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index e0d70f6cabce..2d19251292ea 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -321,7 +321,10 @@ a logout token with this jti was already received sid = "sess-8", } }) - token = token:sub(1, -3) .. "xx" + -- replace the signature's last two characters with a pair that + -- is guaranteed to differ from the original + local tail = token:sub(-2) == "xx" and "yy" or "xx" + token = token:sub(1, -3) .. tail local res, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -628,3 +631,434 @@ aud does not contain the client_id --- response_body get: 405 post without token: 400 + + + +=== TEST 17: Set up the Keycloak route and register the BCL URL at the client. +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local keycloak = require "lib.keycloak" + + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/logout/backchannel" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + ngx.say(body) + + local token, err = keycloak.get_admin_token() + if not token then + ngx.status = 500 + ngx.say(err) + return + end + local url = "http://127.0.0.1:" .. ngx.var.server_port .. + "/logout/backchannel" + local ok + ok, err = keycloak.set_backchannel_logout(token, url, true) + if not ok then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("bcl configured") + } + } +--- response_body +passed +bcl configured + + + +=== TEST 18: The session is rejected after the IdP delivers a back-channel logout (sid). +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local keycloak = require "lib.keycloak" + + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + + -- Absorb any stale pooled connection at Keycloak before the + -- delivery this test asserts on. + local pok, perr = keycloak.prime_backchannel_logout(uri) + if not pok then + ngx.status = 500 + ngx.say(perr) + return + end + + local res, err = keycloak.login_keycloak(uri, + "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = keycloak.concatenate_cookies( + res.headers['Set-Cookie']) + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. + res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("authenticated: ", res.status) + + local token, terr = keycloak.get_admin_token() + if not token then + ngx.status = 500 + ngx.say(terr) + return + end + local ok, lerr = keycloak.logout_user(token, "teacher@gmail.com") + if not ok then + ngx.status = 500 + ngx.say(lerr) + return + end + ngx.say("logout: done") + -- Keycloak delivers the BCL POST while processing the logout; + -- the sleep absorbs scheduling jitter. + ngx.sleep(1) + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("after logout: ", res.status) + } + } +--- timeout: 15 +--- response_body +authenticated: 200 +logout: done +after logout: 302 +--- error_log +OIDC backchannel logout accepted for sid +OIDC session revoked by backchannel logout + + + +=== TEST 19: With unauth_action deny, a revoked session gets 401. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local t = require("lib.test_admin").test + local keycloak = require "lib.keycloak" + + local route_tpl = [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + %s + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/logout/backchannel" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + + -- Login must happen under the default auth action; deny would + -- answer 401 instead of driving the login redirect. + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, + string.format(route_tpl, "")) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + + -- Absorb any stale pooled connection at Keycloak before the + -- delivery this test asserts on. + local pok, perr = keycloak.prime_backchannel_logout(uri) + if not pok then + ngx.status = 500 + ngx.say(perr) + return + end + + local res, err = keycloak.login_keycloak(uri, + "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = keycloak.concatenate_cookies( + res.headers['Set-Cookie']) + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. + res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("authenticated: ", res.status) + + code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, + string.format(route_tpl, + '"unauth_action": "deny",')) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + ngx.sleep(0.5) + + local token, terr = keycloak.get_admin_token() + if not token then + ngx.status = 500 + ngx.say(terr) + return + end + local ok, lerr = keycloak.logout_user(token, "teacher@gmail.com") + if not ok then + ngx.status = 500 + ngx.say(lerr) + return + end + ngx.say("logout: done") + ngx.sleep(1) + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("after logout: ", res.status) + } + } +--- timeout: 15 +--- response_body +authenticated: 200 +logout: done +after logout: 401 +--- error_log +OIDC backchannel logout accepted for sid +OIDC session revoked by backchannel logout + + + +=== TEST 20: A sub-only logout kills the session; a later login survives. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local t = require("lib.test_admin").test + local keycloak = require "lib.keycloak" + + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/logout/backchannel" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local token, terr = keycloak.get_admin_token() + if not token then + ngx.status = 500 + ngx.say(terr) + return + end + local url = "http://127.0.0.1:" .. ngx.var.server_port .. + "/logout/backchannel" + -- session required off: the logout token carries only sub. + local ok, serr = keycloak.set_backchannel_logout(token, url, false) + if not ok then + ngx.status = 500 + ngx.say(serr) + return + end + + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + + -- Absorb any stale pooled connection at Keycloak before the + -- delivery this test asserts on. + local pok, perr = keycloak.prime_backchannel_logout(uri) + if not pok then + ngx.status = 500 + ngx.say(perr) + return + end + + local res, err = keycloak.login_keycloak(uri, + "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = keycloak.concatenate_cookies( + res.headers['Set-Cookie']) + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. + res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("authenticated: ", res.status) + + local lerr + ok, lerr = keycloak.logout_user(token, "teacher@gmail.com") + if not ok then + ngx.status = 500 + ngx.say(lerr) + return + end + ngx.say("logout: done") + ngx.sleep(1) + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("after logout: ", res.status) + + -- The sub rule kills sessions authenticated at or before the + -- revocation's second; move the new login past it. + ngx.sleep(1.5) + + res, err = keycloak.login_keycloak(uri, + "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + cookie_str = keycloak.concatenate_cookies(res.headers['Set-Cookie']) + redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. + res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("re-login: ", res.status) + } + } +--- timeout: 25 +--- response_body +authenticated: 200 +logout: done +after logout: 302 +re-login: 200 +--- error_log +OIDC backchannel logout accepted for sub +OIDC session revoked by backchannel logout From 02bf06ded9804e972c8a6b9c3af9b46cb46853b0 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 14:13:09 +0800 Subject: [PATCH 04/15] test(openid-connect): cover redis-backed back-channel logout storage --- t/plugin/openid-connect-backchannel-logout.t | 360 +++++++++++++++++++ 1 file changed, 360 insertions(+) diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 2d19251292ea..ead60f42ac85 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -1062,3 +1062,363 @@ re-login: 200 --- error_log OIDC backchannel logout accepted for sub OIDC session revoked by backchannel logout + + + +=== TEST 21: With storage redis, an accepted logout token lands in redis. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/bcl", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 6379 + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + jti = "jti-test21-" .. ngx.now(), + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-21", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("status: ", res.status) + + local resty_redis = require "resty.redis" + local red = resty_redis:new() + local ok, cerr = red:connect("127.0.0.1", 6379) + if not ok then + ngx.status = 500 + ngx.say(cerr) + return + end + local v = red:get("bcl:bcl:sid:http://127.0.0.1:6724#bcl-client#sess-21") + ngx.say("redis entry: ", v ~= ngx.null) + } + } +--- response_body +status: 200 +redis entry: true +--- error_log +OIDC backchannel logout accepted for sid sess-21 + + + +=== TEST 22: storage redis without an own redis block falls back to session.redis. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 6379 + } + }, + "backchannel_logout": { + "path": "/bcl", + "storage": "redis" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + jti = "jti-test22-" .. ngx.now(), + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-22", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("status: ", res.status) + + local resty_redis = require "resty.redis" + local red = resty_redis:new() + local ok, cerr = red:connect("127.0.0.1", 6379) + if not ok then + ngx.status = 500 + ngx.say(cerr) + return + end + local v = red:get( + "sessions:bcl:sid:http://127.0.0.1:6724#bcl-client#sess-22") + ngx.say("redis entry: ", v ~= ngx.null) + } + } +--- response_body +status: 200 +redis entry: true +--- error_log +OIDC backchannel logout accepted for sid sess-22 + + + +=== TEST 23: The endpoint answers 400 when the redis store is unreachable. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/bcl", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 1979 + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + jti = "jti-test23", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-23", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_code: 400 +--- error_log +OIDC backchannel logout store failed + + + +=== TEST 24: A request with the store down gets 503 and the session is kept. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local t = require("lib.test_admin").test + local keycloak = require "lib.keycloak" + + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/logout/backchannel", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 1979 + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local httpc = http.new() + 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_str = keycloak.concatenate_cookies( + res.headers['Set-Cookie']) + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("first request: ", res.status) + + local set_cookie = res.headers["Set-Cookie"] or "" + if type(set_cookie) == "table" then + set_cookie = table.concat(set_cookie, "; ") + end + ngx.say("session destroyed: ", + set_cookie:find("01 Jan 1970", 1, true) ~= nil) + + res, err = httpc:request_uri(uri, { + method = "GET", + headers = { ["Cookie"] = cookie_str } + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("second request: ", res.status) + } + } +--- timeout: 15 +--- response_body +first request: 503 +session destroyed: false +second request: 503 +--- error_log +OIDC backchannel logout store failed From c8ac54e401c9117752f13b1627b4af1b46cd1d9a Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 14:21:26 +0800 Subject: [PATCH 05/15] docs(openid-connect): document back-channel logout --- apisix/plugins/openid-connect.lua | 1 + docs/en/latest/plugins/openid-connect.md | 18 ++++++++++++++++++ docs/zh/latest/plugins/openid-connect.md | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 3ac76886901a..1a6768868f0a 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -28,6 +28,7 @@ local ngx = ngx local ipairs = ipairs local type = type local tostring = tostring +local tonumber = tonumber local pcall = pcall local concat = table.concat local unpack = unpack diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index e474083c439f..6c501e3bbb2f 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -103,6 +103,10 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | session.redis.send_timeout | integer | False | 1000 | | Send timeout in milliseconds. | | session.redis.read_timeout | integer | False | 1000 | | Read timeout in milliseconds. | | session.redis.keepalive_timeout | integer | False | 10000 | | Keepalive timeout in milliseconds. | +| backchannel_logout | object | False | | | OIDC Back-Channel Logout 1.0 receiver. When configured, the identity provider can POST a `logout_token` to `backchannel_logout.path`, and the revoked session is rejected from the next request on. Cannot be combined with `bearer_only`. | +| backchannel_logout.path | string | True | | | In-route path that receives the provider's back-channel logout POST. The route must cover this path, and the full public URL should be registered at the provider as the client's backchannel logout URL. | +| backchannel_logout.storage | string | False | shm | ["shm","redis"] | Where revocations are stored. `shm` is per-gateway-instance: in multi-node deployments the provider's POST only reaches one node, so use `redis`. When set to `redis` and `backchannel_logout.redis` is omitted, `session.redis` is reused. | +| backchannel_logout.redis | object | False | | | Redis connection for the revocation store. Same fields as `session.redis`, except `prefix` defaults to `bcl`. | | session_contents | object | False | | | Session content configurations. If unconfigured, all data will be stored in the session. | | session_contents.access_token | boolean | False | | | If true, store the access token in the session. | | session_contents.id_token | boolean | False | | | If true, store the ID token in the session. | @@ -460,6 +464,20 @@ The following diagram illustrates the interaction between different entities whe When `set_userinfo_header` is `true` (the default), the Plugin sets user info data in the `X-Userinfo` request header, which the Upstream can use for further processing. +### Back-Channel Logout + +When `backchannel_logout` is configured, the Plugin implements [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html): +the identity provider notifies the gateway when a user logs out elsewhere or an administrator revokes a session, and the corresponding session cookie stops being accepted immediately, instead of remaining valid until its stored token expiry. + +Register `https://` + `backchannel_logout.path` at the provider as the client's backchannel logout URL. +In Keycloak these are the client's **Backchannel logout URL** and **Backchannel logout session required** settings; enabling *session required* makes the provider send a `sid` claim so exactly one session is logged out, otherwise all of the user's sessions at this client are logged out. +Keycloak sends back-channel logout on user logout and on administrative session revocation, but not on session expiry; Okta, Microsoft Entra ID and Google do not send it at all. +Note that providers generally do not retry a delivery that fails in transit, so a logout arriving in the instant a gateway node restarts can be lost. + +A logout token with a `sid` claim revokes that one session; a token with only `sub` revokes every session the user had at the time: a login performed afterwards is unaffected. +Revocations are kept for `session.absolute_timeout` seconds (86400 when unset). +If the revocation store cannot be reached while checking a request, the request fails with `503` and the session is kept. + ## Troubleshooting This section covers a few commonly seen issues when working with this Plugin to help you troubleshoot. diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 672ec191d6ba..73a435f3ee9f 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -102,6 +102,10 @@ import TabItem from '@theme/TabItem'; | session.redis.send_timeout | integer | 否 | 1000 | | 发送超时时间,单位为毫秒。 | | session.redis.read_timeout | integer | 否 | 1000 | | 读取超时时间,单位为毫秒。 | | session.redis.keepalive_timeout | integer | 否 | 10000 | | 保活超时时间,单位为毫秒。 | +| backchannel_logout | object | 否 | | | OIDC Back-Channel Logout 1.0 接收端。配置后,身份提供商可以向 `backchannel_logout.path` POST 一个 `logout_token`,被撤销的会话从下一个请求起即被拒绝。不能与 `bearer_only` 同时使用。 | +| backchannel_logout.path | string | 是 | | | 接收身份提供商后端通道注销 POST 请求的路由内路径。路由必须覆盖该路径,且应在身份提供商处将完整的公开 URL 注册为客户端的 backchannel logout URL。 | +| backchannel_logout.storage | string | 否 | shm | ["shm","redis"] | 撤销记录的存储位置。`shm` 为每个网关实例独立:多节点部署中身份提供商的 POST 只会到达一个节点,因此应使用 `redis`。设置为 `redis` 且未配置 `backchannel_logout.redis` 时,复用 `session.redis`。 | +| backchannel_logout.redis | object | 否 | | | 撤销存储的 Redis 连接。字段与 `session.redis` 相同,但 `prefix` 默认为 `bcl`。 | | session_contents | object | 否 | | | 会话内容配置。如果未配置,所有数据将存储在会话中。 | | session_contents.access_token | boolean | 否 | | | 如果为 true,则在会话中存储访问令牌。 | | session_contents.id_token | boolean | 否 | | | 如果为 true,则在会话中存储 ID 令牌。 | @@ -459,6 +463,20 @@ OpenID Connect (OIDC) 中的 UserInfo 端点在 [OpenID Connect Core 1.0 第 5.3 当 `set_userinfo_header` 为 `true`(默认值)时,插件在 `X-Userinfo` 请求头中设置用户信息数据,上游服务可使用该数据进行进一步处理。 +### 后端通道注销(Back-Channel Logout) + +配置 `backchannel_logout` 后,插件实现 [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html): +当用户在其他地方注销或管理员撤销会话时,身份提供商会通知网关,对应的会话 Cookie 立即失效,而不是继续有效直至其存储的令牌过期。 + +将 `https://` + `backchannel_logout.path` 注册为身份提供商处该客户端的 backchannel logout URL。 +在 Keycloak 中对应客户端的 **Backchannel logout URL** 和 **Backchannel logout session required** 设置;启用 *session required* 后,身份提供商会在注销令牌中携带 `sid` 声明,从而只注销一个会话,否则该用户在此客户端的所有会话都会被注销。 +Keycloak 会在用户注销和管理员撤销会话时发送后端通道注销,但不会在会话过期时发送;Okta、Microsoft Entra ID 和 Google 则完全不发送。 +注意,身份提供商通常不会重试传输失败的投递,因此恰好在网关节点重启瞬间到达的注销可能会丢失。 + +携带 `sid` 声明的注销令牌只撤销对应的那一个会话;只携带 `sub` 的令牌会撤销该用户当时的所有会话:之后进行的登录不受影响。 +撤销记录保留 `session.absolute_timeout` 秒(未设置时为 86400 秒)。 +如果检查请求时无法访问撤销存储,该请求返回 `503`,会话保持不变。 + ## 故障排除 本节涵盖使用此插件时常见的一些问题,以帮助你进行故障排查。 From 2dc0e922e654cff42eee51bb1646e96d3a382d67 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:14:25 +0800 Subject: [PATCH 06/15] fix(openid-connect): reject alg:none logout tokens at the back-channel endpoint The back-channel logout endpoint is unauthenticated and attacker-postable. It reused the route's accept_none_alg/public_key options, which are meant for the browser-facing ID-token path, so a route that enabled them would also accept forged unsigned logout tokens. Force back-channel logout token verification to always require a real JWKS-verified signature. --- apisix/plugins/openid-connect.lua | 5 + t/plugin/openid-connect-backchannel-logout.t | 109 ++++++++++++++++--- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 1a6768868f0a..8d0806a68a5c 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1285,6 +1285,11 @@ local function bcl_validate_logout_token(conf, discovery, logout_token) conf.token_signing_alg_values_expected = discovery.id_token_signing_alg_values_supported + -- BCL endpoint is unauthenticated: always require a real JWKS-verified + -- signature, never alg:none or a static public_key. + conf.accept_none_alg = false + conf.public_key = nil + local claims, err = openidc.jwt_verify(logout_token, conf) if err then return nil, "signature validation failed: " .. err diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index ead60f42ac85..576eb8540048 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -381,7 +381,86 @@ signature validation failed -=== TEST 10: A token without the back-channel logout event is rejected. +=== TEST 10: A route with accept_none_alg true still rejects an unsigned logout token. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "accept_none_alg": true, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/bcl" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local b64 = function(s) + return ngx.encode_base64(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "") + end + local cjson = require "cjson.safe" + local header = b64(cjson.encode({ alg = "none", typ = "logout+jwt" })) + local payload = b64(cjson.encode({ + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + jti = "jti-test9a", + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-9a", + })) + local token = header .. "." .. payload .. "." + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("status: ", res.status) + + local entry = ngx.shared.bcl:get( + "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-9a") + ngx.say("denylist entry: ", entry ~= nil) + } + } +--- response_body +status: 400 +denylist entry: false +--- error_log +signature validation failed + + + +=== TEST 11: A token without the back-channel logout event is rejected. --- config location /t { content_by_lua_block { @@ -416,7 +495,7 @@ events claim does not contain the back-channel logout event -=== TEST 11: A token carrying a nonce is rejected. +=== TEST 12: A token carrying a nonce is rejected. --- config location /t { content_by_lua_block { @@ -455,7 +534,7 @@ nonce claim is prohibited in a logout token -=== TEST 12: A token with neither sub nor sid is rejected. +=== TEST 13: A token with neither sub nor sid is rejected. --- config location /t { content_by_lua_block { @@ -492,7 +571,7 @@ either a sub or a sid claim is required -=== TEST 13: A token without a jti is rejected. +=== TEST 14: A token without a jti is rejected. --- config location /t { content_by_lua_block { @@ -529,7 +608,7 @@ jti claim is missing -=== TEST 14: A token with a stale iat is rejected. +=== TEST 15: A token with a stale iat is rejected. --- config location /t { content_by_lua_block { @@ -567,7 +646,7 @@ iat is outside the acceptance window -=== TEST 15: A token whose aud names another client is rejected. +=== TEST 16: A token whose aud names another client is rejected. --- config location /t { content_by_lua_block { @@ -605,7 +684,7 @@ aud does not contain the client_id -=== TEST 16: Transport-level failures: non-POST and missing logout_token. +=== TEST 17: Transport-level failures: non-POST and missing logout_token. --- config location /t { content_by_lua_block { @@ -634,7 +713,7 @@ post without token: 400 -=== TEST 17: Set up the Keycloak route and register the BCL URL at the client. +=== TEST 18: Set up the Keycloak route and register the BCL URL at the client. --- config location /t { content_by_lua_block { @@ -701,7 +780,7 @@ bcl configured -=== TEST 18: The session is rejected after the IdP delivers a back-channel logout (sid). +=== TEST 19: The session is rejected after the IdP delivers a back-channel logout (sid). --- config location /t { content_by_lua_block { @@ -783,7 +862,7 @@ OIDC session revoked by backchannel logout -=== TEST 19: With unauth_action deny, a revoked session gets 401. +=== TEST 20: With unauth_action deny, a revoked session gets 401. --- config location /t { content_by_lua_block { @@ -912,7 +991,7 @@ OIDC session revoked by backchannel logout -=== TEST 20: A sub-only logout kills the session; a later login survives. +=== TEST 21: A sub-only logout kills the session; a later login survives. --- config location /t { content_by_lua_block { @@ -1065,7 +1144,7 @@ OIDC session revoked by backchannel logout -=== TEST 21: With storage redis, an accepted logout token lands in redis. +=== TEST 22: With storage redis, an accepted logout token lands in redis. --- config location /t { content_by_lua_block { @@ -1156,7 +1235,7 @@ OIDC backchannel logout accepted for sid sess-21 -=== TEST 22: storage redis without an own redis block falls back to session.redis. +=== TEST 23: storage redis without an own redis block falls back to session.redis. --- config location /t { content_by_lua_block { @@ -1249,7 +1328,7 @@ OIDC backchannel logout accepted for sid sess-22 -=== TEST 23: The endpoint answers 400 when the redis store is unreachable. +=== TEST 24: The endpoint answers 400 when the redis store is unreachable. --- config location /t { content_by_lua_block { @@ -1325,7 +1404,7 @@ OIDC backchannel logout store failed -=== TEST 24: A request with the store down gets 503 and the session is kept. +=== TEST 25: A request with the store down gets 503 and the session is kept. --- config location /t { content_by_lua_block { From 94613f781491078e85bd79de3584edccd110a02e Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:22:51 +0800 Subject: [PATCH 07/15] fix(openid-connect): require session_contents.id_token for back-channel logout Session revocation is keyed off the id_token's sid/sub claims. If an operator restricts session_contents without keeping id_token, lua-resty-openidc does not store those claims, so every request silently skips the denylist while the endpoint still returns 200 to the identity provider. Reject that combination at config time. --- apisix/plugins/openid-connect.lua | 7 ++ t/plugin/openid-connect-backchannel-logout.t | 73 ++++++++++++++------ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 8d0806a68a5c..adf5a4a170c4 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -973,6 +973,13 @@ function _M.check_schema(conf) "backchannel_logout.storage is redis and " .. "session.redis is not configured" end + -- revocation is keyed off the id_token's sid/sub claims, so a restricted + -- session_contents must still keep id_token, else requests silently skip the denylist. + if type(conf.session_contents) == "table" and not conf.session_contents.id_token then + return false, "backchannel_logout requires session_contents.id_token " .. + "to be true when session_contents is configured, because " .. + "session revocation is keyed off the id_token's sid/sub claims" + end end if conf.claim_schema and not secret.is_secret_ref(conf.claim_schema) then diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 576eb8540048..3ddb077a6677 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -162,7 +162,38 @@ rejected -=== TEST 5: Set up a route against the stub IdP with the BCL endpoint enabled. +=== TEST 5: backchannel_logout is rejected when session_contents omits id_token. +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({ + client_id = "course_management", + client_secret = "secret", + discovery = "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + session = { + secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + session_contents = { + access_token = true + }, + backchannel_logout = { + path = "/logout/backchannel" + } + }) + if ok then + ngx.say("unexpectedly passed") + return + end + ngx.say(err) + } + } +--- response_body +backchannel_logout requires session_contents.id_token to be true when session_contents is configured, because session revocation is keyed off the id_token's sid/sub claims + + + +=== TEST 6: Set up a route against the stub IdP with the BCL endpoint enabled. --- config location /t { content_by_lua_block { @@ -206,7 +237,7 @@ passed -=== TEST 6: A valid logout token is accepted and lands in the denylist. +=== TEST 7: A valid logout token is accepted and lands in the denylist. --- config location /t { content_by_lua_block { @@ -251,7 +282,7 @@ OIDC backchannel logout accepted for sid sess-6 -=== TEST 7: A replayed jti is rejected on the second delivery. +=== TEST 8: A replayed jti is rejected on the second delivery. --- config location /t { content_by_lua_block { @@ -300,7 +331,7 @@ a logout token with this jti was already received -=== TEST 8: A tampered signature is rejected. +=== TEST 9: A tampered signature is rejected. --- config location /t { content_by_lua_block { @@ -342,7 +373,7 @@ signature validation failed -=== TEST 9: An unsigned token (alg none) is rejected. +=== TEST 10: An unsigned token (alg none) is rejected. --- config location /t { content_by_lua_block { @@ -381,7 +412,7 @@ signature validation failed -=== TEST 10: A route with accept_none_alg true still rejects an unsigned logout token. +=== TEST 11: A route with accept_none_alg true still rejects an unsigned logout token. --- config location /t { content_by_lua_block { @@ -460,7 +491,7 @@ signature validation failed -=== TEST 11: A token without the back-channel logout event is rejected. +=== TEST 12: A token without the back-channel logout event is rejected. --- config location /t { content_by_lua_block { @@ -495,7 +526,7 @@ events claim does not contain the back-channel logout event -=== TEST 12: A token carrying a nonce is rejected. +=== TEST 13: A token carrying a nonce is rejected. --- config location /t { content_by_lua_block { @@ -534,7 +565,7 @@ nonce claim is prohibited in a logout token -=== TEST 13: A token with neither sub nor sid is rejected. +=== TEST 14: A token with neither sub nor sid is rejected. --- config location /t { content_by_lua_block { @@ -571,7 +602,7 @@ either a sub or a sid claim is required -=== TEST 14: A token without a jti is rejected. +=== TEST 15: A token without a jti is rejected. --- config location /t { content_by_lua_block { @@ -608,7 +639,7 @@ jti claim is missing -=== TEST 15: A token with a stale iat is rejected. +=== TEST 16: A token with a stale iat is rejected. --- config location /t { content_by_lua_block { @@ -646,7 +677,7 @@ iat is outside the acceptance window -=== TEST 16: A token whose aud names another client is rejected. +=== TEST 17: A token whose aud names another client is rejected. --- config location /t { content_by_lua_block { @@ -684,7 +715,7 @@ aud does not contain the client_id -=== TEST 17: Transport-level failures: non-POST and missing logout_token. +=== TEST 18: Transport-level failures: non-POST and missing logout_token. --- config location /t { content_by_lua_block { @@ -713,7 +744,7 @@ post without token: 400 -=== TEST 18: Set up the Keycloak route and register the BCL URL at the client. +=== TEST 19: Set up the Keycloak route and register the BCL URL at the client. --- config location /t { content_by_lua_block { @@ -780,7 +811,7 @@ bcl configured -=== TEST 19: The session is rejected after the IdP delivers a back-channel logout (sid). +=== TEST 20: The session is rejected after the IdP delivers a back-channel logout (sid). --- config location /t { content_by_lua_block { @@ -862,7 +893,7 @@ OIDC session revoked by backchannel logout -=== TEST 20: With unauth_action deny, a revoked session gets 401. +=== TEST 21: With unauth_action deny, a revoked session gets 401. --- config location /t { content_by_lua_block { @@ -991,7 +1022,7 @@ OIDC session revoked by backchannel logout -=== TEST 21: A sub-only logout kills the session; a later login survives. +=== TEST 22: A sub-only logout kills the session; a later login survives. --- config location /t { content_by_lua_block { @@ -1144,7 +1175,7 @@ OIDC session revoked by backchannel logout -=== TEST 22: With storage redis, an accepted logout token lands in redis. +=== TEST 23: With storage redis, an accepted logout token lands in redis. --- config location /t { content_by_lua_block { @@ -1235,7 +1266,7 @@ OIDC backchannel logout accepted for sid sess-21 -=== TEST 23: storage redis without an own redis block falls back to session.redis. +=== TEST 24: storage redis without an own redis block falls back to session.redis. --- config location /t { content_by_lua_block { @@ -1328,7 +1359,7 @@ OIDC backchannel logout accepted for sid sess-22 -=== TEST 24: The endpoint answers 400 when the redis store is unreachable. +=== TEST 25: The endpoint answers 400 when the redis store is unreachable. --- config location /t { content_by_lua_block { @@ -1404,7 +1435,7 @@ OIDC backchannel logout store failed -=== TEST 25: A request with the store down gets 503 and the session is kept. +=== TEST 26: A request with the store down gets 503 and the session is kept. --- config location /t { content_by_lua_block { From 64b5470e1af92d59ad9e6966a3ec603c0f196f10 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:24:08 +0800 Subject: [PATCH 08/15] test(openid-connect): add ASF license header to back-channel logout test file --- t/plugin/openid-connect-backchannel-logout.t | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 3ddb077a6677..e5d50f149e80 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -1,3 +1,19 @@ +# +# 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'; log_level('debug'); From d300ab14ac968aff95923963e20e03751802d34e Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:30:37 +0800 Subject: [PATCH 09/15] fix(openid-connect): guard back-channel logout denylist TTL against non-positive session timeout session.absolute_timeout of 0 (or negative) is lua-resty-session's "no absolute limit" sentinel. Because 0 is truthy in Lua, the previous `or` fallback passed it straight through as the denylist TTL, so redis SET ... EX 0 failed the write and every logout delivery returned 400. Fall back to the default TTL unless the timeout is a positive number. --- apisix/plugins/openid-connect.lua | 6 +- t/plugin/openid-connect-backchannel-logout.t | 95 ++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index adf5a4a170c4..ec33b25cbe9e 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1420,8 +1420,10 @@ local function handle_backchannel_logout(conf) key = bcl_denylist_key("sub", conf, discovery.issuer, claims.sub) logged_target = "sub " .. claims.sub end - local ttl = (conf.session and conf.session.absolute_timeout) - or BCL_DENYLIST_TTL + local ttl = conf.session and conf.session.absolute_timeout + if type(ttl) ~= "number" or ttl <= 0 then + ttl = BCL_DENYLIST_TTL + end ok, store_err = bcl_store_set(conf, key, ttl) if not ok then core.log.error("OIDC backchannel logout store failed: ", store_err) diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index e5d50f149e80..4588d4835946 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -1548,3 +1548,98 @@ session destroyed: false second request: 503 --- error_log OIDC backchannel logout store failed + + + +=== TEST 27: With storage redis, session.absolute_timeout 0 still falls back to a usable TTL. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK", + "absolute_timeout": 0 + }, + "backchannel_logout": { + "path": "/bcl", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 6379 + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + jti = "jti-test27-" .. ngx.now(), + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sid = "sess-27", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("status: ", res.status) + + local resty_redis = require "resty.redis" + local red = resty_redis:new() + local ok, cerr = red:connect("127.0.0.1", 6379) + if not ok then + ngx.status = 500 + ngx.say(cerr) + return + end + local v = red:get("bcl:bcl:sid:http://127.0.0.1:6724#bcl-client#sess-27") + ngx.say("redis entry: ", v ~= ngx.null) + local ttl = red:ttl("bcl:bcl:sid:http://127.0.0.1:6724#bcl-client#sess-27") + ngx.say("redis ttl positive: ", ttl ~= nil and ttl > 0) + } + } +--- response_body +status: 200 +redis entry: true +redis ttl positive: true +--- error_log +OIDC backchannel logout accepted for sid sess-27 From 7e5d7981cdad47480d3432df6f97646f39c877dd Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:41:38 +0800 Subject: [PATCH 10/15] fix(openid-connect): drop unsupported redis fields from back-channel logout schema The back-channel logout redis store connects through apisix/utils/redis.lua, which has no server_name (SNI) support and applies one timeout to connect, send and read. The schema still advertised server_name, send_timeout and read_timeout "same as session.redis", so those settings validated but silently did nothing. Remove them from the back-channel logout redis schema and make the docs honest; session.redis itself is unchanged. --- apisix/plugins/openid-connect.lua | 7 +++++++ docs/en/latest/plugins/openid-connect.md | 2 +- docs/zh/latest/plugins/openid-connect.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index ec33b25cbe9e..3b6154960748 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -205,6 +205,13 @@ local session_redis_schema = { local bcl_redis_schema = core.table.deepcopy(session_redis_schema) bcl_redis_schema.properties.prefix.default = "bcl" +-- apisix/utils/redis.lua has no server_name (SNI) support and applies a +-- single timeout to connect, send and read; drop the fields it can't honor. +bcl_redis_schema.properties.server_name = nil +bcl_redis_schema.properties.send_timeout = nil +bcl_redis_schema.properties.read_timeout = nil +bcl_redis_schema.properties.connect_timeout.description = + "connection timeout in milliseconds, applied to connect, send and read" local schema = { type = "object", diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index 6c501e3bbb2f..1194e261c225 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -106,7 +106,7 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | backchannel_logout | object | False | | | OIDC Back-Channel Logout 1.0 receiver. When configured, the identity provider can POST a `logout_token` to `backchannel_logout.path`, and the revoked session is rejected from the next request on. Cannot be combined with `bearer_only`. | | backchannel_logout.path | string | True | | | In-route path that receives the provider's back-channel logout POST. The route must cover this path, and the full public URL should be registered at the provider as the client's backchannel logout URL. | | backchannel_logout.storage | string | False | shm | ["shm","redis"] | Where revocations are stored. `shm` is per-gateway-instance: in multi-node deployments the provider's POST only reaches one node, so use `redis`. When set to `redis` and `backchannel_logout.redis` is omitted, `session.redis` is reused. | -| backchannel_logout.redis | object | False | | | Redis connection for the revocation store. Same fields as `session.redis`, except `prefix` defaults to `bcl`. | +| backchannel_logout.redis | object | False | | | Redis connection for the revocation store. Accepts the same connection fields as `session.redis` except `server_name`, `send_timeout` and `read_timeout`; the store uses `connect_timeout` as a single timeout for connect, send and read. `prefix` defaults to `bcl`. | | session_contents | object | False | | | Session content configurations. If unconfigured, all data will be stored in the session. | | session_contents.access_token | boolean | False | | | If true, store the access token in the session. | | session_contents.id_token | boolean | False | | | If true, store the ID token in the session. | diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 73a435f3ee9f..d764b400deed 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -105,7 +105,7 @@ import TabItem from '@theme/TabItem'; | backchannel_logout | object | 否 | | | OIDC Back-Channel Logout 1.0 接收端。配置后,身份提供商可以向 `backchannel_logout.path` POST 一个 `logout_token`,被撤销的会话从下一个请求起即被拒绝。不能与 `bearer_only` 同时使用。 | | backchannel_logout.path | string | 是 | | | 接收身份提供商后端通道注销 POST 请求的路由内路径。路由必须覆盖该路径,且应在身份提供商处将完整的公开 URL 注册为客户端的 backchannel logout URL。 | | backchannel_logout.storage | string | 否 | shm | ["shm","redis"] | 撤销记录的存储位置。`shm` 为每个网关实例独立:多节点部署中身份提供商的 POST 只会到达一个节点,因此应使用 `redis`。设置为 `redis` 且未配置 `backchannel_logout.redis` 时,复用 `session.redis`。 | -| backchannel_logout.redis | object | 否 | | | 撤销存储的 Redis 连接。字段与 `session.redis` 相同,但 `prefix` 默认为 `bcl`。 | +| backchannel_logout.redis | object | 否 | | | 撤销存储的 Redis 连接。接受与 `session.redis` 相同的连接字段,但不包括 `server_name`、`send_timeout` 和 `read_timeout`;该存储使用 `connect_timeout` 作为连接、发送和读取共用的单一超时时间。`prefix` 默认为 `bcl`。 | | session_contents | object | 否 | | | 会话内容配置。如果未配置,所有数据将存储在会话中。 | | session_contents.access_token | boolean | 否 | | | 如果为 true,则在会话中存储访问令牌。 | | session_contents.id_token | boolean | 否 | | | 如果为 true,则在会话中存储 ID 令牌。 | From dd1f1c1c4c62af14ba774b862a6f8aa6fac82e9b Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 15:59:29 +0800 Subject: [PATCH 11/15] fix(openid-connect): make back-channel logout jti guard atomic and store the token iat Replace the check-then-set jti replay guard with an atomic add-if-absent (SET NX / safe_add) so two concurrent deliveries of the same logout token cannot both pass. Store the logout token's iat as the denylist value instead of the receipt time, so the revocation cutoff is stable and a late duplicate cannot advance it and revoke sessions created after the logout. Also close the redis connection on error paths instead of returning it to the pool, matching the ai-cache convention. --- apisix/plugins/openid-connect.lua | 67 ++++++++++---- t/plugin/openid-connect-backchannel-logout.t | 91 ++++++++++++++++++++ 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 3b6154960748..6363296f62aa 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1216,11 +1216,10 @@ local function bcl_redis_connect(rconf) end --- One revocation (or seen-jti) entry per key; the value is the unix time --- the entry was written. -local function bcl_store_set(conf, key, ttl) - local now = ngx.time() - +-- One revocation (or seen-jti) entry per key, holding the caller-supplied +-- value (the logout token's iat, for entries the request path compares +-- against). +local function bcl_store_set(conf, key, value, ttl) if conf.backchannel_logout.storage == "redis" then local rconf = bcl_redis_conf(conf) local red, err = bcl_redis_connect(rconf) @@ -1228,8 +1227,9 @@ local function bcl_store_set(conf, key, ttl) return false, err end local ok - ok, err = red:set(rconf.prefix .. ":" .. key, now, "EX", ttl) + ok, err = red:set(rconf.prefix .. ":" .. key, value, "EX", ttl) if not ok then + red:close() return false, "failed to write to redis: " .. err end red:set_keepalive(rconf.keepalive_timeout, 100) @@ -1242,7 +1242,7 @@ local function bcl_store_set(conf, key, ttl) end -- safe_set: evicting an unexpired revocation to make room would silently -- re-admit a revoked session, so a full dict must fail the write instead. - local ok, err = dict:safe_set(key, now, ttl) + local ok, err = dict:safe_set(key, value, ttl) if not ok then return false, "failed to write to the shared dict: " .. err end @@ -1250,6 +1250,44 @@ local function bcl_store_set(conf, key, ttl) end +-- Atomic add-if-absent: writes value/ttl only when key has no entry yet. +-- Returns (written, existed, err) - existed true means a replay. +local function bcl_store_add(conf, key, value, ttl) + if conf.backchannel_logout.storage == "redis" then + local rconf = bcl_redis_conf(conf) + local red, err = bcl_redis_connect(rconf) + if not red then + return nil, nil, err + end + local res + res, err = red:set(rconf.prefix .. ":" .. key, value, "EX", ttl, "NX") + if res == "OK" or res == ngx.null then + red:set_keepalive(rconf.keepalive_timeout, 100) + end + if res == "OK" then + return true, false, nil + elseif res == ngx.null then + return false, true, nil + end + red:close() + return nil, nil, "failed to write to redis: " .. (err or "unknown") + end + + local dict = ngx.shared.bcl + if not dict then + return nil, nil, "shared dict \"bcl\" is missing" + end + -- safe_add: never evicts, matching safe_set's rationale above. + local ok, err = dict:safe_add(key, value, ttl) + if ok then + return true, false, nil + elseif err == "exists" then + return false, true, nil + end + return nil, nil, "failed to write to the shared dict: " .. (err or "unknown") +end + + -- Returns the entry timestamp, nil when there is no entry, or nil plus an -- error when the store cannot be reached (the caller treats that as -- "no verdict", never as "clean"). @@ -1263,6 +1301,7 @@ local function bcl_store_get(conf, key) local v v, err = red:get(rconf.prefix .. ":" .. key) if err then + red:close() return nil, "failed to read from redis: " .. err end red:set_keepalive(rconf.keepalive_timeout, 100) @@ -1399,22 +1438,17 @@ local function handle_backchannel_logout(conf) end local jti_key = "bcl:jti:" .. discovery.issuer .. "#" .. claims.jti - local seen, store_err = bcl_store_get(conf, jti_key) + local _, existed, store_err = bcl_store_add(conf, jti_key, claims.iat, BCL_JTI_TTL) if store_err then core.log.error("OIDC backchannel logout store failed: ", store_err) return bcl_error("the revocation store is unavailable") end - if seen then + if existed then core.log.warn("OIDC backchannel logout token rejected: ", "a logout token with this jti was already received") return bcl_error("a logout token with this jti was already received") end - local ok - ok, store_err = bcl_store_set(conf, jti_key, BCL_JTI_TTL) - if not ok then - core.log.error("OIDC backchannel logout store failed: ", store_err) - return bcl_error("the revocation store is unavailable") - end + -- not existed: written, fall through to the denylist write -- Section 2.4: a token with a sid revokes that one session; a sub-only -- token revokes every session the user had when it was received @@ -1431,7 +1465,8 @@ local function handle_backchannel_logout(conf) if type(ttl) ~= "number" or ttl <= 0 then ttl = BCL_DENYLIST_TTL end - ok, store_err = bcl_store_set(conf, key, ttl) + local ok + ok, store_err = bcl_store_set(conf, key, claims.iat, ttl) if not ok then core.log.error("OIDC backchannel logout store failed: ", store_err) return bcl_error("the revocation store is unavailable") diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 4588d4835946..eb9184af4079 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -1643,3 +1643,94 @@ redis entry: true redis ttl positive: true --- error_log OIDC backchannel logout accepted for sid sess-27 + + + +=== TEST 28: With storage redis, the denylist entry stores the token's iat, not receipt time. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local r_jwt = require "resty.jwt" + + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:6724/.well-known/openid-configuration", + "client_id": "bcl-client", + "client_secret": "dummy-not-used-by-the-endpoint", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + }, + "backchannel_logout": { + "path": "/bcl", + "storage": "redis", + "redis": { + "host": "127.0.0.1", + "port": 6379 + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + -- Clearly earlier than receipt, but inside the iat acceptance slack. + local past = ngx.time() - 60 + local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = past, + exp = ngx.time() + 120, + jti = "jti-test28-" .. ngx.now(), + events = { + ["http://schemas.openid.net/event/backchannel-logout"] = {} + }, + sub = "sub-28", + } + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.say("status: ", res.status) + + local resty_redis = require "resty.redis" + local red = resty_redis:new() + local ok, cerr = red:connect("127.0.0.1", 6379) + if not ok then + ngx.status = 500 + ngx.say(cerr) + return + end + local v = red:get("bcl:bcl:sub:http://127.0.0.1:6724#bcl-client#sub-28") + ngx.say("stored value equals token iat: ", tonumber(v) == past) + } + } +--- response_body +status: 200 +stored value equals token iat: true +--- error_log +OIDC backchannel logout accepted for sub sub-28 From 0e7eec57750dbd392c5f25a63537554510761664 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 16:29:46 +0800 Subject: [PATCH 12/15] fix(openid-connect): send an Allow header with the 405 on non-POST back-channel logout RFC 9110 requires a 405 response to advertise the supported methods. The back-channel logout endpoint only accepts POST, so return Allow: POST alongside the 405. --- apisix/plugins/openid-connect.lua | 1 + t/plugin/openid-connect-backchannel-logout.t | 29 +++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 6363296f62aa..6b8137150e0c 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -1414,6 +1414,7 @@ local function handle_backchannel_logout(conf) core.response.set_header("Cache-Control", "no-store") if ngx.req.get_method() ~= "POST" then + core.response.set_header("Allow", "POST") return 405 end diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index eb9184af4079..31f5d4c76d81 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -760,7 +760,16 @@ post without token: 400 -=== TEST 19: Set up the Keycloak route and register the BCL URL at the client. +=== TEST 19: A non-POST request to the back-channel logout endpoint returns 405 with an Allow header. +--- request +GET /bcl +--- error_code: 405 +--- response_headers +Allow: POST + + + +=== TEST 20: Set up the Keycloak route and register the BCL URL at the client. --- config location /t { content_by_lua_block { @@ -827,7 +836,7 @@ bcl configured -=== TEST 20: The session is rejected after the IdP delivers a back-channel logout (sid). +=== TEST 21: The session is rejected after the IdP delivers a back-channel logout (sid). --- config location /t { content_by_lua_block { @@ -909,7 +918,7 @@ OIDC session revoked by backchannel logout -=== TEST 21: With unauth_action deny, a revoked session gets 401. +=== TEST 22: With unauth_action deny, a revoked session gets 401. --- config location /t { content_by_lua_block { @@ -1038,7 +1047,7 @@ OIDC session revoked by backchannel logout -=== TEST 22: A sub-only logout kills the session; a later login survives. +=== TEST 23: A sub-only logout kills the session; a later login survives. --- config location /t { content_by_lua_block { @@ -1191,7 +1200,7 @@ OIDC session revoked by backchannel logout -=== TEST 23: With storage redis, an accepted logout token lands in redis. +=== TEST 24: With storage redis, an accepted logout token lands in redis. --- config location /t { content_by_lua_block { @@ -1282,7 +1291,7 @@ OIDC backchannel logout accepted for sid sess-21 -=== TEST 24: storage redis without an own redis block falls back to session.redis. +=== TEST 25: storage redis without an own redis block falls back to session.redis. --- config location /t { content_by_lua_block { @@ -1375,7 +1384,7 @@ OIDC backchannel logout accepted for sid sess-22 -=== TEST 25: The endpoint answers 400 when the redis store is unreachable. +=== TEST 26: The endpoint answers 400 when the redis store is unreachable. --- config location /t { content_by_lua_block { @@ -1451,7 +1460,7 @@ OIDC backchannel logout store failed -=== TEST 26: A request with the store down gets 503 and the session is kept. +=== TEST 27: A request with the store down gets 503 and the session is kept. --- config location /t { content_by_lua_block { @@ -1551,7 +1560,7 @@ OIDC backchannel logout store failed -=== TEST 27: With storage redis, session.absolute_timeout 0 still falls back to a usable TTL. +=== TEST 28: With storage redis, session.absolute_timeout 0 still falls back to a usable TTL. --- config location /t { content_by_lua_block { @@ -1646,7 +1655,7 @@ OIDC backchannel logout accepted for sid sess-27 -=== TEST 28: With storage redis, the denylist entry stores the token's iat, not receipt time. +=== TEST 29: With storage redis, the denylist entry stores the token's iat, not receipt time. --- config location /t { content_by_lua_block { From 6e65bebc5f65e3f0fefbd5729713414747e6d403 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 16:39:34 +0800 Subject: [PATCH 13/15] test(openid-connect): align back-channel logout tests with house idioms Add the no_error_log default to the block preprocessor so blocks that do not expect an error fail on a stray [error] log, matching the sibling openid-connect tests. Assert HTTP status via ngx.status/--- error_code instead of printing it into the response body, assert the 405 Allow and the Cache-Control headers via --- response_headers, and turn the missing-token check into a direct request. --- t/plugin/openid-connect-backchannel-logout.t | 58 +++++++------------- 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 31f5d4c76d81..bd61d8a7322a 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -54,6 +54,10 @@ _EOC_ add_block_preprocessor(sub { my ($block) = @_; + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + if (!$block->request) { $block->set_value("request", "GET /t"); } @@ -283,7 +287,7 @@ passed return end ngx.status = res.status - ngx.say("cache-control: ", res.headers["Cache-Control"]) + ngx.header["Cache-Control"] = res.headers["Cache-Control"] local entry = ngx.shared.bcl:get( "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-6") @@ -291,8 +295,9 @@ passed } } --- response_body -cache-control: no-store denylist entry: true +--- response_headers +Cache-Control: no-store --- error_log OIDC backchannel logout accepted for sid sess-6 @@ -492,7 +497,7 @@ signature validation failed ngx.say(err) return end - ngx.say("status: ", res.status) + ngx.status = res.status local entry = ngx.shared.bcl:get( "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-9a") @@ -500,8 +505,8 @@ signature validation failed } } --- response_body -status: 400 denylist entry: false +--- error_code: 400 --- error_log signature validation failed @@ -731,32 +736,13 @@ aud does not contain the client_id -=== TEST 18: Transport-level failures: non-POST and missing logout_token. ---- config - location /t { - content_by_lua_block { - local t = require "lib.test_admin" - - local res1, err = t.req_self_with_http("/bcl", "GET") - if not res1 then - ngx.status = 500 - ngx.say(err) - return - end - local res2 - res2, err = t.req_self_with_http("/bcl", "POST", "foo=bar") - if not res2 then - ngx.status = 500 - ngx.say(err) - return - end - ngx.say("get: ", res1.status) - ngx.say("post without token: ", res2.status) - } - } ---- response_body -get: 405 -post without token: 400 +=== TEST 18: A POST without a logout_token is rejected. +--- request +POST /bcl +foo=bar +--- more_headers +Content-Type: application/x-www-form-urlencoded +--- error_code: 400 @@ -1269,7 +1255,7 @@ OIDC session revoked by backchannel logout ngx.say(err) return end - ngx.say("status: ", res.status) + ngx.status = res.status local resty_redis = require "resty.redis" local red = resty_redis:new() @@ -1284,7 +1270,6 @@ OIDC session revoked by backchannel logout } } --- response_body -status: 200 redis entry: true --- error_log OIDC backchannel logout accepted for sid sess-21 @@ -1361,7 +1346,7 @@ OIDC backchannel logout accepted for sid sess-21 ngx.say(err) return end - ngx.say("status: ", res.status) + ngx.status = res.status local resty_redis = require "resty.redis" local red = resty_redis:new() @@ -1377,7 +1362,6 @@ OIDC backchannel logout accepted for sid sess-21 } } --- response_body -status: 200 redis entry: true --- error_log OIDC backchannel logout accepted for sid sess-22 @@ -1630,7 +1614,7 @@ OIDC backchannel logout store failed ngx.say(err) return end - ngx.say("status: ", res.status) + ngx.status = res.status local resty_redis = require "resty.redis" local red = resty_redis:new() @@ -1647,7 +1631,6 @@ OIDC backchannel logout store failed } } --- response_body -status: 200 redis entry: true redis ttl positive: true --- error_log @@ -1724,7 +1707,7 @@ OIDC backchannel logout accepted for sid sess-27 ngx.say(err) return end - ngx.say("status: ", res.status) + ngx.status = res.status local resty_redis = require "resty.redis" local red = resty_redis:new() @@ -1739,7 +1722,6 @@ OIDC backchannel logout accepted for sid sess-27 } } --- response_body -status: 200 stored value equals token iat: true --- error_log OIDC backchannel logout accepted for sub sub-28 From 4909df17dcb9691fc0b1a9b34d651f54eccc2cb9 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 16:49:59 +0800 Subject: [PATCH 14/15] test(openid-connect): extract a shared back-channel logout token signer Factor the repeated RS256 logout-token signing into t/lib/backchannel_logout.lua with defaults for iss/aud/iat/exp/events and an OMIT sentinel, so each test declares only the claims it varies instead of duplicating the whole sign call. --- t/lib/backchannel_logout.lua | 53 ++++ t/plugin/openid-connect-backchannel-logout.t | 277 +++++-------------- 2 files changed, 117 insertions(+), 213 deletions(-) create mode 100644 t/lib/backchannel_logout.lua diff --git a/t/lib/backchannel_logout.lua b/t/lib/backchannel_logout.lua new file mode 100644 index 000000000000..a9ef5d74791c --- /dev/null +++ b/t/lib/backchannel_logout.lua @@ -0,0 +1,53 @@ +-- +-- 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. +-- +local r_jwt = require "resty.jwt" +local test_admin = require "lib.test_admin" + +local _M = {} + +-- Sentinel: set a defaulted claim to _M.OMIT to drop it from the payload. +_M.OMIT = {} + +local BCL_EVENT = "http://schemas.openid.net/event/backchannel-logout" + +-- Signs an RS256 back-channel logout token against t/certs/private.pem. +-- Defaults are filled for iss/aud/iat/exp/events; pass a claim to override, +-- or _M.OMIT to drop a defaulted claim. jti and sid/sub are NOT defaulted -- +-- pass whatever the test needs (omit by simply not passing). +function _M.sign(claims) + local payload = { + iss = "http://127.0.0.1:6724", + aud = "bcl-client", + iat = ngx.time(), + exp = ngx.time() + 120, + events = { [BCL_EVENT] = {} }, + } + for k, v in pairs(claims or {}) do + payload[k] = v + end + for k, v in pairs(payload) do + if v == _M.OMIT then + payload[k] = nil + end + end + return r_jwt:sign(test_admin.read_file("t/certs/private.pem"), { + header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, + payload = payload, + }) +end + +return _M diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index bd61d8a7322a..1d0abbccf377 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -262,22 +262,9 @@ passed location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test6", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-6", - } - }) + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ jti = "jti-test6", sid = "sess-6" }) local res, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -308,22 +295,9 @@ OIDC backchannel logout accepted for sid sess-6 location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test7", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-7", - } - }) + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ jti = "jti-test7", sid = "sess-7" }) local res1, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -357,22 +331,9 @@ a logout token with this jti was already received location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test8", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-8", - } - }) + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ jti = "jti-test8", sid = "sess-8" }) -- replace the signature's last two characters with a pair that -- is guaranteed to differ from the original local tail = token:sub(-2) == "xx" and "yy" or "xx" @@ -517,18 +478,12 @@ signature validation failed location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test10", - sid = "sess-10", - } + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + jti = "jti-test10", + sid = "sess-10", + events = bcl.OMIT, }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -552,22 +507,12 @@ events claim does not contain the back-channel logout event location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test11", - nonce = "forged", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-11", - } + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + jti = "jti-test11", + nonce = "forged", + sid = "sess-11", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -591,21 +536,9 @@ nonce claim is prohibited in a logout token location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test12", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - } - }) + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ jti = "jti-test12" }) local res, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -628,21 +561,9 @@ either a sub or a sid claim is required location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-13", - } - }) + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ sid = "sess-13" }) local res, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -665,21 +586,12 @@ jti claim is missing location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time() - 1200, - exp = ngx.time() + 120, - jti = "jti-test14", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-14", - } + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + iat = ngx.time() - 1200, + jti = "jti-test14", + sid = "sess-14", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -703,21 +615,12 @@ iat is outside the acceptance window location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" - - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "some-other-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test15", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-15", - } + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + aud = "some-other-client", + jti = "jti-test15", + sid = "sess-15", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -1191,7 +1094,7 @@ OIDC session revoked by backchannel logout location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" + local bcl = require "lib.backchannel_logout" local code, body = t.test('/apisix/admin/routes/1', ngx.HTTP_PUT, @@ -1231,21 +1134,11 @@ OIDC session revoked by backchannel logout return end - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - -- unique per run: the jti replay guard lives in redis, - -- which outlives the test nginx instances - jti = "jti-test21-" .. ngx.now(), - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-21", - } + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + local token = bcl.sign({ + jti = "jti-test21-" .. ngx.now(), + sid = "sess-21", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -1281,7 +1174,7 @@ OIDC backchannel logout accepted for sid sess-21 location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" + local bcl = require "lib.backchannel_logout" local code, body = t.test('/apisix/admin/routes/1', ngx.HTTP_PUT, @@ -1322,21 +1215,11 @@ OIDC backchannel logout accepted for sid sess-21 return end - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - -- unique per run: the jti replay guard lives in redis, - -- which outlives the test nginx instances - jti = "jti-test22-" .. ngx.now(), - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-22", - } + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + local token = bcl.sign({ + jti = "jti-test22-" .. ngx.now(), + sid = "sess-22", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -1373,7 +1256,7 @@ OIDC backchannel logout accepted for sid sess-22 location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" + local bcl = require "lib.backchannel_logout" local code, body = t.test('/apisix/admin/routes/1', ngx.HTTP_PUT, @@ -1413,20 +1296,7 @@ OIDC backchannel logout accepted for sid sess-22 return end - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - jti = "jti-test23", - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-23", - } - }) + local token = bcl.sign({ jti = "jti-test23", sid = "sess-23" }) local res, err = t.req_self_with_http("/bcl", "POST", "logout_token=" .. token) @@ -1549,7 +1419,7 @@ OIDC backchannel logout store failed location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" + local bcl = require "lib.backchannel_logout" local code, body = t.test('/apisix/admin/routes/1', ngx.HTTP_PUT, @@ -1590,21 +1460,11 @@ OIDC backchannel logout store failed return end - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = ngx.time(), - exp = ngx.time() + 120, - -- unique per run: the jti replay guard lives in redis, - -- which outlives the test nginx instances - jti = "jti-test27-" .. ngx.now(), - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sid = "sess-27", - } + -- unique per run: the jti replay guard lives in redis, + -- which outlives the test nginx instances + local token = bcl.sign({ + jti = "jti-test27-" .. ngx.now(), + sid = "sess-27", }) local res, err = t.req_self_with_http("/bcl", "POST", @@ -1643,7 +1503,7 @@ OIDC backchannel logout accepted for sid sess-27 location /t { content_by_lua_block { local t = require "lib.test_admin" - local r_jwt = require "resty.jwt" + local bcl = require "lib.backchannel_logout" local code, body = t.test('/apisix/admin/routes/1', ngx.HTTP_PUT, @@ -1685,19 +1545,10 @@ OIDC backchannel logout accepted for sid sess-27 -- Clearly earlier than receipt, but inside the iat acceptance slack. local past = ngx.time() - 60 - local token = r_jwt:sign(t.read_file("t/certs/private.pem"), { - header = { typ = "JWT", alg = "RS256", kid = "bclkey" }, - payload = { - iss = "http://127.0.0.1:6724", - aud = "bcl-client", - iat = past, - exp = ngx.time() + 120, - jti = "jti-test28-" .. ngx.now(), - events = { - ["http://schemas.openid.net/event/backchannel-logout"] = {} - }, - sub = "sub-28", - } + local token = bcl.sign({ + iat = past, + jti = "jti-test28-" .. ngx.now(), + sub = "sub-28", }) local res, err = t.req_self_with_http("/bcl", "POST", From 98d817a4dfd653c193609611974bac0d40433114 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 12 Aug 2026 17:03:24 +0800 Subject: [PATCH 15/15] test(openid-connect): cover iss mismatch, future iat, and array aud in back-channel logout Fill the logout-token validation matrix: reject a token whose iss does not match the discovery issuer and one whose iat is too far in the future, and accept a token whose aud is an array that contains the client_id. --- t/plugin/openid-connect-backchannel-logout.t | 125 +++++++++++++++++-- 1 file changed, 115 insertions(+), 10 deletions(-) diff --git a/t/plugin/openid-connect-backchannel-logout.t b/t/plugin/openid-connect-backchannel-logout.t index 1d0abbccf377..82507c169d9a 100644 --- a/t/plugin/openid-connect-backchannel-logout.t +++ b/t/plugin/openid-connect-backchannel-logout.t @@ -658,7 +658,112 @@ Allow: POST -=== TEST 20: Set up the Keycloak route and register the BCL URL at the client. +=== TEST 20: A logout token whose iss does not match the discovery issuer is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + iss = "http://127.0.0.1:6724/wrong", + jti = "jti-bcl-iss-mismatch", + sid = "sess-iss-mismatch", + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + + local entry = ngx.shared.bcl:get( + "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-iss-mismatch") + ngx.say("denylist entry: ", entry ~= nil) + } + } +--- response_body +denylist entry: false +--- error_code: 400 +--- error_log +iss does not match the discovery issuer + + + +=== TEST 21: A logout token with an iat far in the future is rejected. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + iat = ngx.time() + 1200, + exp = ngx.time() + 1320, + jti = "jti-bcl-future-iat", + sid = "sess-future-iat", + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + + local entry = ngx.shared.bcl:get( + "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-future-iat") + ngx.say("denylist entry: ", entry ~= nil) + } + } +--- response_body +denylist entry: false +--- error_code: 400 +--- error_log +iat is outside the acceptance window + + + +=== TEST 22: A logout token whose aud is an array containing the client_id is accepted. +--- config + location /t { + content_by_lua_block { + local t = require "lib.test_admin" + local bcl = require "lib.backchannel_logout" + + local token = bcl.sign({ + aud = { "bcl-client", "another-client" }, + jti = "jti-bcl-array-aud", + sid = "sess-arrayaud", + }) + + local res, err = t.req_self_with_http("/bcl", "POST", + "logout_token=" .. token) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + + local entry = ngx.shared.bcl:get( + "bcl:sid:http://127.0.0.1:6724#bcl-client#sess-arrayaud") + ngx.say("denylist entry: ", entry ~= nil) + } + } +--- response_body +denylist entry: true +--- error_log +OIDC backchannel logout accepted for sid sess-arrayaud + + + +=== TEST 23: Set up the Keycloak route and register the BCL URL at the client. --- config location /t { content_by_lua_block { @@ -725,7 +830,7 @@ bcl configured -=== TEST 21: The session is rejected after the IdP delivers a back-channel logout (sid). +=== TEST 24: The session is rejected after the IdP delivers a back-channel logout (sid). --- config location /t { content_by_lua_block { @@ -807,7 +912,7 @@ OIDC session revoked by backchannel logout -=== TEST 22: With unauth_action deny, a revoked session gets 401. +=== TEST 25: With unauth_action deny, a revoked session gets 401. --- config location /t { content_by_lua_block { @@ -936,7 +1041,7 @@ OIDC session revoked by backchannel logout -=== TEST 23: A sub-only logout kills the session; a later login survives. +=== TEST 26: A sub-only logout kills the session; a later login survives. --- config location /t { content_by_lua_block { @@ -1089,7 +1194,7 @@ OIDC session revoked by backchannel logout -=== TEST 24: With storage redis, an accepted logout token lands in redis. +=== TEST 27: With storage redis, an accepted logout token lands in redis. --- config location /t { content_by_lua_block { @@ -1169,7 +1274,7 @@ OIDC backchannel logout accepted for sid sess-21 -=== TEST 25: storage redis without an own redis block falls back to session.redis. +=== TEST 28: storage redis without an own redis block falls back to session.redis. --- config location /t { content_by_lua_block { @@ -1251,7 +1356,7 @@ OIDC backchannel logout accepted for sid sess-22 -=== TEST 26: The endpoint answers 400 when the redis store is unreachable. +=== TEST 29: The endpoint answers 400 when the redis store is unreachable. --- config location /t { content_by_lua_block { @@ -1314,7 +1419,7 @@ OIDC backchannel logout store failed -=== TEST 27: A request with the store down gets 503 and the session is kept. +=== TEST 30: A request with the store down gets 503 and the session is kept. --- config location /t { content_by_lua_block { @@ -1414,7 +1519,7 @@ OIDC backchannel logout store failed -=== TEST 28: With storage redis, session.absolute_timeout 0 still falls back to a usable TTL. +=== TEST 31: With storage redis, session.absolute_timeout 0 still falls back to a usable TTL. --- config location /t { content_by_lua_block { @@ -1498,7 +1603,7 @@ OIDC backchannel logout accepted for sid sess-27 -=== TEST 29: With storage redis, the denylist entry stores the token's iat, not receipt time. +=== TEST 32: With storage redis, the denylist entry stores the token's iat, not receipt time. --- config location /t { content_by_lua_block {