Skip to content

feat(openid-connect): support OIDC Back-Channel Logout 1.0 - #13808

Open
janiussyafiq wants to merge 15 commits into
apache:masterfrom
janiussyafiq:feat-oidc-backchannel-logout
Open

feat(openid-connect): support OIDC Back-Channel Logout 1.0#13808
janiussyafiq wants to merge 15 commits into
apache:masterfrom
janiussyafiq:feat-oidc-backchannel-logout

Conversation

@janiussyafiq

Copy link
Copy Markdown
Contributor

Description

Add an OIDC Back-Channel Logout 1.0 receiver to the openid-connect plugin, following the direction from the prior-art discussion in #13792: when the identity provider notifies the gateway that a user logged out elsewhere or an administrator revoked a session, the corresponding session cookie stops being accepted immediately, instead of remaining valid until its stored token expiry.

  • New optional backchannel_logout config block: the provider POSTs a logout_token to backchannel_logout.path; after validation (JWKS signature, iss/aud/iat/events/jti claim checks per spec section 2.6, jti replay guard) the revoked sid/sub is written to a denylist, stored in shm (per-instance) or redis (shared across nodes, reusing session.redis when configured).
  • Every session request is checked against the denylist: a revoked session is destroyed and the request then follows unauth_action; if the store is unreachable, the request fails with 503 and the session is kept.
  • Covered by Keycloak E2E tests (sid and sub-only logout tokens, unauth_action variants, store-down behavior) plus a stub-IdP negative validation matrix; docs updated (en/zh).

Which issue(s) this PR fixes:

Fixes #13750

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (the feature is off unless backchannel_logout is configured)

@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. enhancement New feature or request labels Aug 12, 2026
@juzhiyuan

Copy link
Copy Markdown
Member

Just approved the CI to run, thanks

…l 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.
…el 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.
…on-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.
…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.
…ore 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.
…ck-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.
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.
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.
…n 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.
Comment on lines +1201 to +1250
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, 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)
if not red then
return false, err
end
local ok
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)
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, value, ttl)
if not ok then
return false, "failed to write to the shared dict: " .. err
end
return true
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These codes that directly manipulate Redis data break the encapsulation of the OIDC and session libraries. We should not do this and need to find or add relevant features in the upstream libraries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: openid-connect plugin does not refresh access token when end server responds with 401

3 participants