This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 132f5a258 fix(openid-connect): redirect instead of 500 on a stale 
authorization callback (#13712)
132f5a258 is described below

commit 132f5a2581e1fb89ce112965dfd09900fae7bfa3
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 22 11:55:17 2026 +0800

    fix(openid-connect): redirect instead of 500 on a stale authorization 
callback (#13712)
---
 apisix/plugins/openid-connect.lua        |  28 ++++-
 docs/en/latest/plugins/openid-connect.md |  10 ++
 docs/zh/latest/plugins/openid-connect.md |  10 ++
 t/plugin/openid-connect11.t              | 174 +++++++++++++++++++++++++++++++
 4 files changed, 220 insertions(+), 2 deletions(-)

diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index eb0d3b526..202cf7e2c 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -32,6 +32,12 @@ local ngx_encode_base64 = ngx.encode_base64
 
 local plugin_name       = "openid-connect"
 
+-- returned verbatim by resty.openidc when the state in the authorization
+-- callback does not match the one restored from the session; the string is
+-- identical in lua-resty-openidc 1.8.0 and 1.9.0
+local STATE_MISMATCH_ERR =
+    "state from argument does not match state restored from session"
+
 
 -- Session config is passed as-is to resty.session.start(); the only
 -- translation is the legacy session.cookie.lifetime alias from the
@@ -757,7 +763,7 @@ function _M.rewrite(plugin_conf, ctx)
         end
     end
 
-    local response, err, session, _
+    local response, err, session
 
     if conf.bearer_only or conf.introspection_endpoint or conf.public_key or 
conf.use_jwks then
         -- An introspection endpoint or a public key has been configured. Try 
to
@@ -860,7 +866,8 @@ function _M.rewrite(plugin_conf, ctx)
         -- provider's authorization endpoint to initiate the Relying Party 
flow.
         -- This code path also handles when the ID provider then redirects to
         -- the configured redirect URI after successful authentication.
-        response, err, _, session  = openidc.authenticate(conf, nil, 
unauth_action,
+        local target_url
+        response, err, target_url, session = openidc.authenticate(conf, nil, 
unauth_action,
                                                           
build_session_opts(conf.session))
 
         if err then
@@ -873,6 +880,23 @@ function _M.rewrite(plugin_conf, ctx)
                 end
                 return 401
             end
+
+            -- Stale authorization callback: the state in the callback does not
+            -- match the one in the session, e.g. the same browser started
+            -- another login flow in a second tab and overwrote the state, or 
an
+            -- already completed callback was replayed. The client is a browser
+            -- mid-navigation, so instead of a dead-end 500, send it back to 
the
+            -- original URL that resty.openidc returns alongside the error: a
+            -- fresh flow starts from there and completes without any user
+            -- interaction while the ID provider still holds an SSO session.
+            if err == STATE_MISMATCH_ERR and target_url
+               and ngx.req.get_method() == "GET" then
+                core.log.warn("OIDC state mismatch (concurrent login flows or 
",
+                              "replayed callback), restarting the 
authentication flow")
+                core.response.set_header("Location", target_url)
+                return 302
+            end
+
             core.log.error("OIDC authentication failed: ", err)
             return 500
         end
diff --git a/docs/en/latest/plugins/openid-connect.md 
b/docs/en/latest/plugins/openid-connect.md
index e196d6922..5bb74efd3 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -412,6 +412,16 @@ This section covers a few commonly seen issues when 
working with this Plugin to
 
 If APISIX fails to resolve or cannot connect to the OpenID provider, double 
check the DNS settings in your configuration file `config.yaml` and modify as 
needed.
 
+### State Mismatch in the Authorization Callback
+
+The session holds the state of a single login flow. If the same browser starts 
a second flow before the first one completes — for example by opening a 
protected page in another tab — the second flow overwrites the state, and the 
first tab's callback arrives with a state that no longer matches:
+
+```text
+state from argument does not match state restored from session
+```
+
+For a `GET` callback, the Plugin responds with `302` back to the URL the user 
was originally trying to reach, so a fresh authentication flow starts from 
there. When the OpenID provider still holds an SSO session, this completes 
without any user interaction. Callbacks using other request methods, and 
callbacks carrying no session at all, still fail with `500`.
+
 ### No Session State Found
 
 If you encounter a `500 internal server error` with the following message in 
the log when working with [authorization code flow](#authorization-code-flow), 
there could be a number of reasons.
diff --git a/docs/zh/latest/plugins/openid-connect.md 
b/docs/zh/latest/plugins/openid-connect.md
index 445d2a977..5671cfb8f 100644
--- a/docs/zh/latest/plugins/openid-connect.md
+++ b/docs/zh/latest/plugins/openid-connect.md
@@ -412,6 +412,16 @@ OpenID Connect (OIDC) 中的 UserInfo 端点在 [OpenID Connect Core 
1.0 第 5.3
 
 如果 APISIX 无法解析或连接到 OpenID 提供商,请检查配置文件 `config.yaml` 中的 DNS 设置并根据需要进行修改。
 
+### 授权回调中的 state 不匹配
+
+会话中只保存一次登录流程的 state。如果同一浏览器在前一次流程完成之前又发起了新的流程(例如在另一个标签页中打开受保护页面),新流程会覆盖 
state,此时第一个标签页的回调携带的 state 已不再匹配:
+
+```text
+state from argument does not match state restored from session
+```
+
+对于 `GET` 回调,插件会返回 `302` 重定向回用户最初访问的地址,从那里重新发起一次认证流程。当身份提供商仍保有 SSO 
会话时,整个过程对用户无感。使用其他请求方法的回调,以及完全不携带会话的回调,仍然返回 `500`。
+
 ### 未找到会话状态
 
 如果在使用[授权码流程](#授权码流程)时,日志中出现 `500 internal server error` 和以下消息,可能有多种原因。
diff --git a/t/plugin/openid-connect11.t b/t/plugin/openid-connect11.t
new file mode 100644
index 000000000..c834b2abe
--- /dev/null
+++ b/t/plugin/openid-connect11.t
@@ -0,0 +1,174 @@
+#
+# 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');
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+
+    # every block here drives resty.openidc into an error path on purpose,
+    # which logs at [error]; assert on the specific message instead
+    if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+        $block->set_value("no_error_log", "no such assertion");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: create a route protected by openid-connect
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/oidc11',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/oidc11/*",
+                        "plugins": {
+                            "openid-connect": {
+                                "client_id": "apisix",
+                                "client_secret": "secret",
+                                "discovery": 
"http://127.0.0.1:8080/realms/basic/.well-known/openid-configuration";,
+                                "redirect_uri": 
"http://127.0.0.1:1984/oidc11/callback";,
+                                "ssl_verify": false,
+                                "session": {
+                                    "secret": "6S8IO+A+6KJsdazbjNyG7g=="
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: a callback whose state was overwritten by a second tab redirects 
back
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local base = "http://127.0.0.1:"; .. ngx.var.server_port
+
+            local function cookie_of(res)
+                local c = res.headers["Set-Cookie"]
+                if type(c) == "table" then
+                    c = table.concat(c, "; ")
+                end
+                return c and c:match("^([^;]+)")
+            end
+
+            -- first tab: start a login flow and keep the session cookie
+            local res_a = http.new():request_uri(base .. "/oidc11/page?tab=A")
+            local state_a = res_a.headers["Location"]:match("state=([^&]+)")
+            local jar = cookie_of(res_a)
+
+            -- second tab in the same browser: overwrites the state in the 
session
+            local res_b = http.new():request_uri(base .. "/oidc11/page?tab=B", 
{
+                headers = {Cookie = jar}
+            })
+            jar = cookie_of(res_b)
+
+            -- the first tab's callback now carries a stale state
+            local res_c = http.new():request_uri(
+                base .. "/oidc11/callback?code=dummy&state=" .. state_a, {
+                    headers = {Cookie = jar}
+                })
+            ngx.say(res_c.status, " ", tostring(res_c.headers["Location"]))
+        }
+    }
+--- response_body
+302 /oidc11/page?tab=B
+--- error_log
+does not match state restored from session
+
+
+
+=== TEST 3: a non-GET callback with a stale state still fails with 500
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local base = "http://127.0.0.1:"; .. ngx.var.server_port
+
+            local function cookie_of(res)
+                local c = res.headers["Set-Cookie"]
+                if type(c) == "table" then
+                    c = table.concat(c, "; ")
+                end
+                return c and c:match("^([^;]+)")
+            end
+
+            local res_a = http.new():request_uri(base .. "/oidc11/page?tab=A")
+            local state_a = res_a.headers["Location"]:match("state=([^&]+)")
+            local jar = cookie_of(res_a)
+
+            local res_b = http.new():request_uri(base .. "/oidc11/page?tab=B", 
{
+                headers = {Cookie = jar}
+            })
+            jar = cookie_of(res_b)
+
+            local res_c = http.new():request_uri(
+                base .. "/oidc11/callback?code=dummy&state=" .. state_a, {
+                    method = "POST",
+                    body = "",
+                    headers = {Cookie = jar}
+                })
+            ngx.say(res_c.status)
+        }
+    }
+--- response_body
+500
+
+
+
+=== TEST 4: a callback without a session cookie still fails with 500
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local base = "http://127.0.0.1:"; .. ngx.var.server_port
+            local res = http.new():request_uri(
+                base .. "/oidc11/callback?code=dummy&state=deadbeef")
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+500

Reply via email to