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: