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

shreemaan-abhishek 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 0890d2de36 fix(feishu-auth, dingtalk-auth): bind the authorization 
code to the session that started the login (#13806)
0890d2de36 is described below

commit 0890d2de36958de3e35abf596cacd902b646df57
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Thu Sep 17 01:45:00 2026 +0800

    fix(feishu-auth, dingtalk-auth): bind the authorization code to the session 
that started the login (#13806)
---
 apisix/plugins/dingtalk-auth.lua        |  78 +++++++++++----
 apisix/plugins/feishu-auth.lua          |  79 +++++++++++----
 docs/en/latest/plugins/dingtalk-auth.md |   4 +-
 docs/en/latest/plugins/feishu-auth.md   |   9 +-
 docs/zh/latest/plugins/feishu-auth.md   |   9 +-
 t/lib/oauth_login.lua                   |  85 ++++++++++++++++
 t/plugin/dingtalk-auth.t                | 152 +++++++++++++++++++++++------
 t/plugin/feishu-auth.t                  | 168 ++++++++++++++++++++++++--------
 8 files changed, 465 insertions(+), 119 deletions(-)

diff --git a/apisix/plugins/dingtalk-auth.lua b/apisix/plugins/dingtalk-auth.lua
index 56152d2dfa..c61f388815 100644
--- a/apisix/plugins/dingtalk-auth.lua
+++ b/apisix/plugins/dingtalk-auth.lua
@@ -17,9 +17,13 @@
 local core = require("apisix.core")
 local http = require("resty.http")
 local session = require("resty.session")
+local resty_random = require("resty.random")
+local resty_string = require("resty.string")
 
 local base64_encode = ngx.encode_base64
 
+local STATE_BYTES = 16
+
 -- the access token from dingtalk has a TTL of 7200 seconds,
 -- we set the cache TTL to 7000 seconds to avoid edge cases of token 
expiration during use.
 local access_token_cache = core.lrucache.new({
@@ -201,14 +205,49 @@ local function fetch_userinfo(conf, access_token, code)
 end
 
 
+local function session_opts(conf)
+    return {
+        secret = conf.secret,
+        secret_fallbacks = conf.secret_fallbacks,
+        cookie_name = "dingtalk_session",
+        absolute_timeout = conf.cookie_expires_in,
+    }
+end
+
+
+-- returns the code and whether it came from the request header
 local function get_code(conf, ctx)
     local code = core.request.header(ctx, conf.code_header)
-    if not code then
-        local uri_args = core.request.get_uri_args(ctx) or {}
-        code = uri_args[conf.code_query]
+    if code then
+        return code, true
+    end
+
+    local uri_args = core.request.get_uri_args(ctx) or {}
+    return uri_args[conf.code_query], false
+end
+
+
+-- bind a fresh state to the session and carry it along to the login page,
+-- so the code that comes back can be tied to the browser that started the flow
+local function redirect_to_login(conf, opts)
+    local bytes = resty_random.bytes(STATE_BYTES, true)
+    if not bytes then
+        core.log.error("failed to get strong random bytes for the state")
+        return 500, {message = "Failed to generate state"}
+    end
+    local state = resty_string.to_hex(bytes)
+
+    local sess = session.start(opts)
+    sess:set("state", state)
+    local ok, err = sess:save()
+    if not ok then
+        core.log.error("failed to save session: ", err)
+        return 500, {message = "Failed to save session"}
     end
 
-    return code
+    local sep = core.string.find(conf.redirect_uri, "?") and "&" or "?"
+    core.response.set_header("Location", conf.redirect_uri .. sep .. "state=" 
.. state)
+    return 302
 end
 
 
@@ -218,14 +257,8 @@ function _M.rewrite(conf, ctx)
     -- clear any client-supplied X-Userinfo before authentication
     core.request.set_header(ctx, "X-Userinfo", nil)
 
-    local sess, sess_err = session.open(
-        {
-            secret = conf.secret,
-            secret_fallbacks = conf.secret_fallbacks,
-            cookie_name = "dingtalk_session",
-            absolute_timeout = conf.cookie_expires_in,
-        }
-    )
+    local opts = session_opts(conf)
+    local sess, sess_err = session.open(opts)
     if not sess then
         core.log.error("failed to open session: ", sess_err)
         return 500, {message = "Failed to open session"}
@@ -237,14 +270,25 @@ function _M.rewrite(conf, ctx)
         if not userinfo then
             sess:destroy()
             core.log.error("failed to decode userinfo in session: ", err)
-            core.response.set_header("Location", conf.redirect_uri)
-            return 302
+            return redirect_to_login(conf, opts)
         end
     else
-        local code = get_code(conf, ctx)
+        local code, from_header = get_code(conf, ctx)
         if not code then
-            core.response.set_header("Location", conf.redirect_uri)
-            return 302
+            return redirect_to_login(conf, opts)
+        end
+
+        -- a code in the query string comes back from the login redirect, so 
it must
+        -- carry the state bound to this session. a code in the header comes 
from a
+        -- non-browser client, which cannot be driven cross-site.
+        if not from_header then
+            local uri_args = core.request.get_uri_args(ctx) or {}
+            local state = sess:get("state")
+            if not state or uri_args.state ~= state then
+                core.log.warn("state does not match the one bound to the 
session")
+                return 401, {message = "Invalid state"}
+            end
+            sess:set("state", nil)
         end
 
         local key = core.table.concat({
diff --git a/apisix/plugins/feishu-auth.lua b/apisix/plugins/feishu-auth.lua
index 998ef1947d..8a5f376f8c 100644
--- a/apisix/plugins/feishu-auth.lua
+++ b/apisix/plugins/feishu-auth.lua
@@ -17,11 +17,15 @@
 local core = require("apisix.core")
 local http = require("resty.http")
 local session = require("resty.session")
+local resty_random = require("resty.random")
+local resty_string = require("resty.string")
 
 local base64_encode = ngx.encode_base64
 local ngx_time = ngx.time
 local type = type
 
+local STATE_BYTES = 16
+
 local DEFAULT_TOKEN_URL = 
"https://open.feishu.cn/open-apis/authen/v2/oauth/token";
 local DEFAULT_USERINFO_URL = 
"https://open.feishu.cn/open-apis/authen/v1/user_info";
 
@@ -193,14 +197,49 @@ local function fetch_userinfo(conf, access_token)
 end
 
 
+local function session_opts(conf)
+    return {
+        secret = conf.secret,
+        secret_fallbacks = conf.secret_fallbacks,
+        cookie_name = "feishu_session",
+        absolute_timeout = conf.cookie_expires_in,
+    }
+end
+
+
+-- returns the code and whether it came from the request header
 local function get_code(conf, ctx)
     local code = core.request.header(ctx, conf.code_header)
-    if not code then
-        local uri_args = core.request.get_uri_args(ctx) or {}
-        code = uri_args[conf.code_query]
+    if code then
+        return code, true
+    end
+
+    local uri_args = core.request.get_uri_args(ctx) or {}
+    return uri_args[conf.code_query], false
+end
+
+
+-- bind a fresh state to the session and carry it along to the login page,
+-- so the code that comes back can be tied to the browser that started the flow
+local function redirect_to_login(conf, opts)
+    local bytes = resty_random.bytes(STATE_BYTES, true)
+    if not bytes then
+        core.log.error("failed to get strong random bytes for the state")
+        return 500, {message = "Failed to generate state"}
+    end
+    local state = resty_string.to_hex(bytes)
+
+    local sess = session.start(opts)
+    sess:set("state", state)
+    local ok, err = sess:save()
+    if not ok then
+        core.log.error("failed to save session: ", err)
+        return 500, {message = "Failed to save session"}
     end
 
-    return code
+    local sep = core.string.find(conf.redirect_uri, "?") and "&" or "?"
+    core.response.set_header("Location", conf.redirect_uri .. sep .. "state=" 
.. state)
+    return 302
 end
 
 
@@ -210,14 +249,8 @@ function _M.rewrite(conf, ctx)
     -- clear any client-supplied X-Userinfo before authentication
     core.request.set_header(ctx, "X-Userinfo", nil)
 
-    local sess, sess_err = session.open(
-        {
-            secret = conf.secret,
-            secret_fallbacks = conf.secret_fallbacks,
-            cookie_name = "feishu_session",
-            absolute_timeout = conf.cookie_expires_in,
-        }
-    )
+    local opts = session_opts(conf)
+    local sess, sess_err = session.open(opts)
     if not sess then
         core.log.error("failed to open session: ", sess_err)
         return 500, {message = "Failed to open session"}
@@ -232,10 +265,22 @@ function _M.rewrite(conf, ctx)
             return 500, {message = "Invalid userinfo in session"}
         end
     else
-        local code = get_code(conf, ctx)
+        local code, from_header = get_code(conf, ctx)
         if not code then
-            core.response.set_header("Location", conf.redirect_uri)
-            return 302
+            return redirect_to_login(conf, opts)
+        end
+
+        -- a code in the query string comes back from the login redirect, so 
it must
+        -- carry the state bound to this session. a code in the header comes 
from a
+        -- non-browser client, which cannot be driven cross-site.
+        if not from_header then
+            local uri_args = core.request.get_uri_args(ctx) or {}
+            local state = sess:get("state")
+            if not state or uri_args.state ~= state then
+                core.log.warn("state does not match the one bound to the 
session")
+                return 401, {message = "Invalid state"}
+            end
+            sess:set("state", nil)
         end
 
         local refreshed = true
@@ -245,8 +290,8 @@ function _M.rewrite(conf, ctx)
             if expires_at and ngx_time() < expires_at then
                 refreshed = false
             else
-                sess:delete("access_token")
-                sess:delete("access_token_expires_at")
+                sess:set("access_token", nil)
+                sess:set("access_token_expires_at", nil)
             end
         end
 
diff --git a/docs/en/latest/plugins/dingtalk-auth.md 
b/docs/en/latest/plugins/dingtalk-auth.md
index 224f6421ca..b96dfff55f 100644
--- a/docs/en/latest/plugins/dingtalk-auth.md
+++ b/docs/en/latest/plugins/dingtalk-auth.md
@@ -119,8 +119,8 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 \
 
 Once you have enabled the Plugin, incoming requests to the Route are processed 
as follows:
 
-1. **No session and no code**: The user is redirected to `redirect_uri` 
(typically a DingTalk OAuth login page) with a `302` response.
-2. **Authorization code present** (in the `code` query parameter or 
`X-DingTalk-Code` header): The Plugin exchanges the code for an access token 
via `access_token_url`, then retrieves user information from `userinfo_url`. On 
success, the user information is stored in an encrypted cookie session and the 
original request proceeds.
+1. **No session and no code**: The Plugin generates a random `state`, stores 
it in the session cookie, and redirects the user to `redirect_uri` (typically a 
DingTalk OAuth login page) with a `302` response, appending `state` to the 
query string. Pass `state` through to DingTalk so that it is returned on the 
callback.
+2. **Authorization code present** (in the `code` query parameter or 
`X-DingTalk-Code` header): A code taken from the query parameter must arrive 
with the `state` bound to the session, otherwise the Plugin responds with 
`401`. This ties the code to the browser that started the flow. A code taken 
from the `X-DingTalk-Code` header is exempt, since such requests come from 
non-browser clients. The Plugin then exchanges the code for an access token via 
`access_token_url`, then retrieves user i [...]
 3. **Valid session cookie**: Subsequent requests carrying the session cookie 
bypass DingTalk API calls entirely and proceed directly to the upstream.
 
 When `set_userinfo_header` is `true` (the default), the upstream receives the 
DingTalk user information in the `X-Userinfo` header as a Base64-encoded JSON 
object.
diff --git a/docs/en/latest/plugins/feishu-auth.md 
b/docs/en/latest/plugins/feishu-auth.md
index 3b1f8d6b0e..39d49e81ef 100644
--- a/docs/en/latest/plugins/feishu-auth.md
+++ b/docs/en/latest/plugins/feishu-auth.md
@@ -102,11 +102,12 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 
"X-API-KEY: $admin_key" -X P
 The authentication flow proceeds as follows:
 
 1. A user visits a Route protected by `feishu-auth`.
-2. If no valid session cookie exists and no authorization `code` is present, 
the plugin redirects the user to `redirect_uri` with HTTP 302. Your application 
should then redirect the user to the Feishu OAuth authorization page.
+2. If no valid session cookie exists and no authorization `code` is present, 
the plugin generates a random `state`, stores it in the session cookie, and 
redirects the user to `redirect_uri` with HTTP 302, appending `state` to the 
query string. Your application should then redirect the user to the Feishu 
OAuth authorization page, passing `state` through so that Feishu returns it on 
the callback.
 3. After the user authorizes, Feishu redirects back to `auth_redirect_uri` 
with an authorization `code`. The plugin extracts the code either from the 
`code_query` query parameter or the `code_header` HTTP header.
-4. The plugin exchanges the code for an access token at `access_token_url`, 
then fetches user information from `userinfo_url`.
-5. User information is stored in an encrypted session cookie 
(`feishu_session`). Subsequent requests with a valid cookie bypass the OAuth 
flow.
-6. If `set_userinfo_header` is `true`, the plugin encodes the user information 
as Base64 JSON and sets it in the `X-Userinfo` request header before forwarding 
to the upstream.
+4. A code taken from the query string must arrive with the `state` bound to 
the session, otherwise the plugin responds with HTTP 401. This ties the code to 
the browser that started the flow. A code taken from the `code_header` HTTP 
header is exempt, since such requests come from non-browser clients.
+5. The plugin exchanges the code for an access token at `access_token_url`, 
then fetches user information from `userinfo_url`.
+6. User information is stored in an encrypted session cookie 
(`feishu_session`). Subsequent requests with a valid cookie bypass the OAuth 
flow.
+7. If `set_userinfo_header` is `true`, the plugin encodes the user information 
as Base64 JSON and sets it in the `X-Userinfo` request header before forwarding 
to the upstream.
 
 ## Delete Plugin
 
diff --git a/docs/zh/latest/plugins/feishu-auth.md 
b/docs/zh/latest/plugins/feishu-auth.md
index 7d8f83de9a..542d4a60eb 100644
--- a/docs/zh/latest/plugins/feishu-auth.md
+++ b/docs/zh/latest/plugins/feishu-auth.md
@@ -99,11 +99,12 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 
"X-API-KEY: $admin_key" -X P
 认证流程如下:
 
 1. 用户访问受 `feishu-auth` 插件保护的路由。
-2. 若不存在有效的 session Cookie 且请求中不含授权 `code`,插件将以 HTTP 302 重定向用户至 
`redirect_uri`。你的应用随后应将用户重定向到飞书 OAuth 授权页面。
+2. 若不存在有效的 session Cookie 且请求中不含授权 `code`,插件将生成随机 `state` 并存入 session 
Cookie,然后以 HTTP 302 重定向用户至 `redirect_uri`,并在其查询字符串中附加 `state`。你的应用随后应将用户重定向到飞书 
OAuth 授权页面,并透传 `state`,以便飞书在回调时将其返回。
 3. 用户授权后,飞书将携带授权 `code` 重定向回 `auth_redirect_uri`。插件从 `code_query` 查询参数或 
`code_header` 请求头中提取该授权码。
-4. 插件向 `access_token_url` 发起请求,使用授权码换取 access token,再从 `userinfo_url` 获取用户信息。
-5. 用户信息存储在加密的 session Cookie(`feishu_session`)中。后续携带有效 Cookie 的请求将跳过 OAuth 流程。
-6. 若 `set_userinfo_header` 为 `true`,插件将用户信息 Base64 编码后设置到 `X-Userinfo` 
请求头,随请求转发至上游服务。
+4. 从查询参数中获取的授权码必须携带与当前 session 绑定的 `state`,否则插件返回 HTTP 
401。该校验将授权码与发起流程的浏览器绑定。从 `code_header` 请求头中获取的授权码不做此校验,因为此类请求来自非浏览器客户端。
+5. 插件向 `access_token_url` 发起请求,使用授权码换取 access token,再从 `userinfo_url` 获取用户信息。
+6. 用户信息存储在加密的 session Cookie(`feishu_session`)中。后续携带有效 Cookie 的请求将跳过 OAuth 流程。
+7. 若 `set_userinfo_header` 为 `true`,插件将用户信息 Base64 编码后设置到 `X-Userinfo` 
请求头,随请求转发至上游服务。
 
 ## 删除插件
 
diff --git a/t/lib/oauth_login.lua b/t/lib/oauth_login.lua
new file mode 100644
index 0000000000..937cd278c4
--- /dev/null
+++ b/t/lib/oauth_login.lua
@@ -0,0 +1,85 @@
+--
+-- 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 http = require("resty.http")
+local str_match = string.match
+local table_concat = table.concat
+local type = type
+local ipairs = ipairs
+
+local _M = {}
+
+
+-- resty.http hands back a table when the response carries several Set-Cookie
+-- headers, so keep only each one's name=value and join them the way a Cookie
+-- request header expects
+local function to_cookie_header(set_cookie)
+    if type(set_cookie) ~= "table" then
+        return set_cookie
+    end
+
+    local parts = {}
+    for i, entry in ipairs(set_cookie) do
+        parts[i] = str_match(entry, "^[^;]+")
+    end
+
+    return table_concat(parts, "; ")
+end
+
+
+-- follow the redirect to the login page and return the session cookie
+-- together with the state bound to it
+function _M.begin(port, path)
+    local httpc = http.new()
+    local uri = "http://127.0.0.1:"; .. port .. path
+
+    local res, err = httpc:request_uri(uri, {method = "GET"})
+    if not res then
+        return nil, nil, err
+    end
+    if res.status ~= 302 then
+        return nil, nil, "expected 302 to the login page, got " .. res.status
+    end
+
+    local cookie = to_cookie_header(res.headers["Set-Cookie"])
+    local state = str_match(res.headers["Location"] or "", "state=([0-9a-f]+)")
+    if not cookie or not state then
+        return nil, nil, "redirect did not carry a session cookie and a state"
+    end
+
+    return cookie, state
+end
+
+
+-- drive a full login: pick up the state, then come back with the code
+function _M.login(port, path, code, code_query)
+    local cookie, state, err = _M.begin(port, path)
+    if not cookie then
+        return nil, err
+    end
+
+    local query = {state = state}
+    query[code_query or "code"] = code
+
+    return http.new():request_uri("http://127.0.0.1:"; .. port .. path, {
+        method = "GET",
+        query = query,
+        headers = {["Cookie"] = cookie},
+    })
+end
+
+
+return _M
diff --git a/t/plugin/dingtalk-auth.t b/t/plugin/dingtalk-auth.t
index 9908caf2fa..8859e090a3 100644
--- a/t/plugin/dingtalk-auth.t
+++ b/t/plugin/dingtalk-auth.t
@@ -196,34 +196,108 @@ passed
 
 
 
-=== TEST 5: no code provided - redirect to redirect_uri
+=== TEST 5: no code provided - redirect to redirect_uri with a state
 --- request
 GET /hello
 --- error_code: 302
---- response_headers
-Location: /login
+--- response_headers_like
+Location: /login\?state=[0-9a-f]{32}
 
 
 
-=== TEST 6: invalid code - returns 401
+=== TEST 6: query code without a state is rejected
 --- request
-GET /hello?code=invalid_code
+GET /hello?code=valid_code
 --- error_code: 401
 --- response_body
-{"message":"Invalid authorization code"}
+{"message":"Invalid state"}
 
 
 
-=== TEST 7: valid code via query param - returns 200
+=== TEST 7: query code with a state not bound to the session is rejected
 --- request
-GET /hello?code=valid_code
---- error_code: 200
+GET /hello?code=valid_code&state=deadbeefdeadbeefdeadbeefdeadbeef
+--- error_code: 401
+--- response_body
+{"message":"Invalid state"}
+
+
+
+=== TEST 8: invalid code with a valid state - returns 401
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"invalid_code")
+            assert(res, err)
+            assert(res.status == 401, "expected 401, got " .. res.status)
+            ngx.print(res.body)
+        }
+    }
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 9: valid code via query param - returns 200
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"valid_code")
+            assert(res, err)
+            assert(res.status == 200, "expected 200, got " .. res.status)
+            ngx.print(res.body)
+        }
+    }
 --- response_body
 hello world
 
 
 
-=== TEST 8: valid code via X-DingTalk-Code header - returns 200
+=== TEST 10: a state from one session cannot be used by another session
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local httpc = require("resty.http").new()
+            local port = ngx.var.server_port
+            local uri = "http://127.0.0.1:"; .. port .. "/hello"
+
+            local cookie_a, state_a, err_a = oauth.begin(port, "/hello")
+            assert(cookie_a, err_a)
+            local cookie_b, state_b, err_b = oauth.begin(port, "/hello")
+            assert(cookie_b, err_b)
+            assert(state_a ~= state_b, "states must not repeat across 
sessions")
+
+            -- session B presented with session A's state: this is the shape 
of an
+            -- injected code, and it must not authenticate
+            local res = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "valid_code", state = state_a},
+                headers = {["Cookie"] = cookie_b},
+            }))
+            assert(res.status == 401, "expected 401, got " .. res.status)
+            assert(not res.headers["Set-Cookie"],
+                   "invalid state must not clear the pending session")
+
+            -- session B with its own state still works
+            local res2 = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "valid_code", state = state_b},
+                headers = {["Cookie"] = cookie_b},
+            }))
+            assert(res2.status == 200, "expected 200, got " .. res2.status)
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 11: valid code via X-DingTalk-Code header - returns 200
 --- request
 GET /hello
 --- more_headers
@@ -234,7 +308,7 @@ hello world
 
 
 
-=== TEST 9: cookie session - subsequent requests reuse session
+=== TEST 12: cookie session - subsequent requests reuse session
 --- config
     location /t {
         content_by_lua_block {
@@ -243,10 +317,8 @@ hello world
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
 
             -- first request with valid code to obtain session cookie
-            local res, err = httpc:request_uri(uri, {
-                method = "GET",
-                query = { code = "valid_code" },
-            })
+            local res, err = require("lib.oauth_login")
+                                .login(ngx.var.server_port, "/hello", 
"valid_code")
             assert(res, "request failed: " .. (err or "nil"))
             assert(res.status == 200, "expected 200, got: " .. res.status)
 
@@ -274,7 +346,7 @@ passed
 
 
 
-=== TEST 10: cookie expires after cookie_expires_in seconds
+=== TEST 13: cookie expires after cookie_expires_in seconds
 --- config
     location /t {
         content_by_lua_block {
@@ -282,10 +354,8 @@ passed
             local httpc = http.new()
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
 
-            local res, err = httpc:request_uri(uri, {
-                method = "GET",
-                query = { code = "valid_code" },
-            })
+            local res, err = require("lib.oauth_login")
+                                .login(ngx.var.server_port, "/hello", 
"valid_code")
             assert(res, "request failed: " .. (err or "nil"))
             assert(res.status == 200, "expected 200, got: " .. res.status)
 
@@ -319,7 +389,7 @@ passed
 
 
 
-=== TEST 11: configure custom code_header and code_query
+=== TEST 14: configure custom code_header and code_query
 --- config
     location /t {
         content_by_lua_block {
@@ -360,15 +430,35 @@ passed
 
 
 
-=== TEST 12: custom code_query param works
---- pipelined_requests eval
-["GET /hello?code=valid_code", "GET /hello?dt_code=valid_code"]
---- error_code eval
-[302, 200]
+=== TEST 15: custom code_query param works
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local httpc = require("resty.http").new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
+
+            -- the default query name is not the configured one, so no code is 
seen
+            local res = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "valid_code"},
+            }))
+            assert(res.status == 302, "expected 302, got " .. res.status)
+
+            local res2, err = oauth.login(ngx.var.server_port, "/hello",
+                                          "valid_code", "dt_code")
+            assert(res2, err)
+            assert(res2.status == 200, "expected 200, got " .. res2.status)
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
 
 
 
-=== TEST 13: custom code_header works
+=== TEST 16: custom code_header works
 --- pipelined_requests eval
 ["GET /hello", "GET /hello"]
 --- more_headers eval
@@ -381,7 +471,7 @@ passed
 
 
 
-=== TEST 14: client-supplied X-Userinfo is not forwarded to upstream
+=== TEST 17: client-supplied X-Userinfo is not forwarded to upstream
 --- config
     location /t {
         content_by_lua_block {
@@ -415,10 +505,8 @@ passed
             assert(code <= 201, "setup route 1 failed: " .. tostring(code))
 
             local base = "http://127.0.0.1:"; .. ngx.var.server_port
-            local res, err = httpc:request_uri(base .. "/hello", {
-                method = "GET",
-                query = {code = "valid_code"},
-            })
+            local res, err = require("lib.oauth_login")
+                                .login(ngx.var.server_port, "/hello", 
"valid_code")
             assert(res, err)
             assert(res.status == 200, "expected 200 on auth, got " .. 
res.status)
             local cookie = res.headers["Set-Cookie"]
diff --git a/t/plugin/feishu-auth.t b/t/plugin/feishu-auth.t
index 9368b9e801..3e0dc02dfc 100644
--- a/t/plugin/feishu-auth.t
+++ b/t/plugin/feishu-auth.t
@@ -153,34 +153,108 @@ passed
 
 
 
-=== TEST 2: missing code
+=== TEST 2: missing code, redirect carries a state
 --- request
 GET /hello
 --- error_code: 302
---- response_headers
-Location: /echo
+--- response_headers_like
+Location: /echo\?state=[0-9a-f]{32}
 
 
 
-=== TEST 3: invalid code
+=== TEST 3: query code without a state is rejected
 --- request
-GET /hello?code=invalid
+GET /hello?code=passed
 --- error_code: 401
 --- response_body
-{"message":"Invalid authorization code"}
+{"message":"Invalid state"}
 
 
 
-=== TEST 4: valid code
+=== TEST 4: query code with a state not bound to the session is rejected
 --- request
-GET /hello?code=passed
---- error_code: 200
+GET /hello?code=passed&state=deadbeefdeadbeefdeadbeefdeadbeef
+--- error_code: 401
+--- response_body
+{"message":"Invalid state"}
+
+
+
+=== TEST 5: invalid code with a valid state
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"invalid")
+            assert(res, err)
+            assert(res.status == 401, "expected 401, got " .. res.status)
+            ngx.print(res.body)
+        }
+    }
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 6: valid code with the state bound to the session
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"passed")
+            assert(res, err)
+            assert(res.status == 200, "expected 200, got " .. res.status)
+            ngx.print(res.body)
+        }
+    }
 --- response_body
 hello world
 
 
 
-=== TEST 5: X-Feishu-Code with invalid code
+=== TEST 7: a state from one session cannot be used by another session
+--- config
+    location /t {
+        content_by_lua_block {
+            local oauth = require("lib.oauth_login")
+            local httpc = require("resty.http").new()
+            local port = ngx.var.server_port
+            local uri = "http://127.0.0.1:"; .. port .. "/hello"
+
+            local cookie_a, state_a, err_a = oauth.begin(port, "/hello")
+            assert(cookie_a, err_a)
+            local cookie_b, state_b, err_b = oauth.begin(port, "/hello")
+            assert(cookie_b, err_b)
+            assert(state_a ~= state_b, "states must not repeat across 
sessions")
+
+            -- session B presented with session A's state: this is the shape 
of an
+            -- injected code, and it must not authenticate
+            local res = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "passed", state = state_a},
+                headers = {["Cookie"] = cookie_b},
+            }))
+            assert(res.status == 401, "expected 401, got " .. res.status)
+            assert(not res.headers["Set-Cookie"],
+                   "invalid state must not clear the pending session")
+
+            -- session B with its own state still works
+            local res2 = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "passed", state = state_b},
+                headers = {["Cookie"] = cookie_b},
+            }))
+            assert(res2.status == 200, "expected 200, got " .. res2.status)
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: X-Feishu-Code with invalid code
 --- request
 GET /hello
 --- more_headers
@@ -191,7 +265,7 @@ X-Feishu-Code: invalid
 
 
 
-=== TEST 6: X-Feishu-Code header
+=== TEST 9: X-Feishu-Code header
 --- request
 GET /hello
 --- more_headers
@@ -202,7 +276,7 @@ hello world
 
 
 
-=== TEST 7: check cookie
+=== TEST 10: check cookie
 --- config
     location /t {
         content_by_lua_block {
@@ -210,12 +284,8 @@ hello world
             local httpc = http.new()
 
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
-            local res, err = httpc:request_uri(uri, {
-                query = {
-                    code = "passed",
-                },
-                method = "GET",
-            })
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"passed")
             assert(res, "request failed: " .. (err or "unknown error"))
             assert(res.status == 200, "unexpected res status: " .. res.status)
 
@@ -249,7 +319,7 @@ passed
 
 
 
-=== TEST 8: cookie expire
+=== TEST 11: cookie expire
 --- config
     location /t {
         content_by_lua_block {
@@ -257,12 +327,8 @@ passed
             local httpc = http.new()
 
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
-            local res, err = httpc:request_uri(uri, {
-                query = {
-                    code = "passed",
-                },
-                method = "GET",
-            })
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"passed")
             assert(res, "request failed: " .. (err or "unknown error"))
             assert(res.status == 200, "unexpected res status: " .. res.status)
 
@@ -302,7 +368,7 @@ passed
 
 
 
-=== TEST 9: specify header and query and redirect_uri
+=== TEST 12: specify header and query and redirect_uri
 --- config
     location /t {
         content_by_lua_block {
@@ -348,15 +414,35 @@ passed
 
 
 
-=== TEST 10: specify query
---- pipelined_requests eval
-["GET /hello?code=passed", "GET /hello?custom_code=passed"]
---- error_code eval
-[302, 200]
+=== TEST 13: specify query
+--- config
+    location /t {
+        content_by_lua_block {
+            local httpc = require("resty.http").new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
 
+            -- the default query name is not the configured one, so no code is 
seen
+            local res = assert(httpc:request_uri(uri, {
+                method = "GET",
+                query = {code = "passed"},
+            }))
+            assert(res.status == 302, "expected 302, got " .. res.status)
+
+            local oauth = require("lib.oauth_login")
+            local res2, err = oauth.login(ngx.var.server_port, "/hello",
+                                             "passed", "custom_code")
+            assert(res2, err)
+            assert(res2.status == 200, "expected 200, got " .. res2.status)
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
 
 
-=== TEST 11: specify header
+
+=== TEST 14: specify header
 --- pipelined_requests eval
 ["GET /hello", "GET /hello"]
 --- more_headers eval
@@ -369,7 +455,7 @@ passed
 
 
 
-=== TEST 12: secret_fallbacks allows session created with old secret after key 
rotation
+=== TEST 15: secret_fallbacks allows session created with old secret after key 
rotation
 --- config
     location /t {
         content_by_lua_block {
@@ -404,10 +490,8 @@ passed
 
             -- step 2: authenticate with secret-v1 and capture session cookie
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
-            local res, err = httpc:request_uri(uri, {
-                method = "GET",
-                query = {code = "passed"},
-            })
+            local oauth = require("lib.oauth_login")
+            local res, err = oauth.login(ngx.var.server_port, "/hello", 
"passed")
             assert(res, err)
             assert(res.status == 200, "expected 200, got " .. res.status)
             local old_cookie = res.headers["Set-Cookie"]
@@ -491,7 +575,7 @@ passed
 
 
 
-=== TEST 13: forged X-Userinfo header does not bypass authentication
+=== TEST 16: forged X-Userinfo header does not bypass authentication
 --- config
     location /t {
         content_by_lua_block {
@@ -538,10 +622,8 @@ passed
                 "forged X-Userinfo without cookie should be rejected, got " .. 
res1.status)
 
             -- obtain a legitimate session cookie
-            local res2, err2 = httpc:request_uri(uri, {
-                method = "GET",
-                query = {code = "passed"},
-            })
+            local oauth = require("lib.oauth_login")
+            local res2, err2 = oauth.login(ngx.var.server_port, "/hello", 
"passed")
             assert(res2, err2)
             assert(res2.status == 200, "expected 200 on auth, got " .. 
res2.status)
             local cookie = res2.headers["Set-Cookie"]
@@ -613,7 +695,7 @@ passed
 
 
 
-=== TEST 14: secret_fallbacks values are encrypted in etcd
+=== TEST 17: secret_fallbacks values are encrypted in etcd
 --- yaml_config
 apisix:
     data_encryption:

Reply via email to