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 8cdb55217 feat: add feishu-auth plugin (#13382)
8cdb55217 is described below

commit 8cdb55217f1399c828e28800f849e6a216810d6d
Author: AlinsRan <[email protected]>
AuthorDate: Wed May 27 08:26:28 2026 +0800

    feat: add feishu-auth plugin (#13382)
---
 apisix/cli/config.lua                 |   1 +
 apisix/plugins/feishu-auth.lua        | 294 +++++++++++++++
 conf/config.yaml.example              |   1 +
 docs/en/latest/config.json            |   1 +
 docs/en/latest/plugins/feishu-auth.md | 128 +++++++
 docs/zh/latest/config.json            |   1 +
 docs/zh/latest/plugins/feishu-auth.md | 125 +++++++
 t/admin/plugins.t                     |   1 +
 t/plugin/feishu-auth.t                | 678 ++++++++++++++++++++++++++++++++++
 9 files changed, 1230 insertions(+)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index f7121e1d1..c4003636b 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -225,6 +225,7 @@ local _M = {
     "jwe-decrypt",
     "key-auth",
     "dingtalk-auth",
+    "feishu-auth",
     "acl",
     "consumer-restriction",
     "attach-consumer-label",
diff --git a/apisix/plugins/feishu-auth.lua b/apisix/plugins/feishu-auth.lua
new file mode 100644
index 000000000..998ef1947
--- /dev/null
+++ b/apisix/plugins/feishu-auth.lua
@@ -0,0 +1,294 @@
+--
+-- 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 core = require("apisix.core")
+local http = require("resty.http")
+local session = require("resty.session")
+
+local base64_encode = ngx.encode_base64
+local ngx_time = ngx.time
+local type = type
+
+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";
+
+local schema = {
+    type = "object",
+    properties = {
+        app_id = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "Header name to extract authorization code from.",
+            default = "X-Feishu-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract authorization code 
from.",
+            default = "code"
+        },
+        userinfo_url = {
+            type = "string",
+            default = DEFAULT_USERINFO_URL
+        },
+        access_token_url = {
+            type = "string",
+            default = DEFAULT_TOKEN_URL
+        },
+        set_userinfo_header = {
+            type = "boolean",
+            description = "Whether to set feishu user information in request 
headers",
+            default = true
+        },
+        auth_redirect_uri = {
+            type = "string",
+            description = "Redirect URI for initiating Feishu OAuth flow",
+        },
+        redirect_uri = {type = "string"},
+        timeout = {type = "integer", default = 6000},
+        ssl_verify = {type = "boolean", default = true},
+        secret = {
+            type = "string",
+            description = "Secret used for key derivation.",
+            minLength = 8,
+            maxLength = 32,
+        },
+        secret_fallbacks = {
+            type = "array",
+            items = {
+                type = "string",
+                minLength = 8,
+                maxLength = 32,
+            },
+            description = "List of secrets for alternative secrets used when 
doing key rotation"
+        },
+        cookie_expires_in = {
+            type = "integer",
+            description = "Valid duration (in seconds) for the authorization 
cookie."
+                        .. "This value defines how long the cookie remains 
valid after creation.",
+            default = 86400,
+        },
+
+    },
+    encrypt_fields = {"app_secret", "secret", "secret_fallbacks"},
+    required = {"app_id", "app_secret", "secret", "auth_redirect_uri", 
"redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2420,
+    name = "feishu-auth",
+    schema = schema,
+}
+
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        grant_type = "authorization_code",
+        client_id = conf.app_id,
+        client_secret = conf.app_secret,
+        redirect_uri = conf.auth_redirect_uri,
+        code = code,
+    }
+
+    local res, err = httpc:request_uri(conf.access_token_url, {
+        method = "POST",
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to get feishu token: ", err)
+        return nil, nil, err
+    end
+
+    core.log.debug("request feishu access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.warn("unexpected http response status from feishu: ",
+                                        res.status, ", body: ", res.body)
+        return nil, nil, "unexpected response status: " .. res.status
+                                            .. ", body: " .. res.body
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode feishu token response: ", err)
+        return nil, nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    if not data.access_token or type(data.expires_in) ~= "number" then
+        core.log.error("feishu token response missing access_token or 
expires_in: ", res.body)
+        return nil, nil, "missing access_token or expires_in in response"
+    end
+
+    return data.access_token, data.expires_in, nil
+end
+
+
+local function fetch_userinfo(conf, access_token)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "GET",
+        headers = {
+            ["Content-Type"] = "application/json",
+            ["Authorization"] = "Bearer " .. access_token,
+        },
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify feishu user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request feishu userinfo response status: ", res.status, ", 
body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from feishu: ",
+                            res.status, ", body: ", res.body)
+        return nil, "unexpected http response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode feishu userinfo response: ", err, ", 
body: ", res.body)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.code ~= 0 then
+        core.log.warn("failed to get feishu userinfo: ", res.body)
+        return nil, "unexpected error code: " .. data.code
+                            .. ", errmsg: " .. (data.msg or "nil")
+    end
+
+    return data.data, nil
+end
+
+
+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]
+    end
+
+    return code
+end
+
+
+function _M.rewrite(conf, ctx)
+    local userinfo, err
+
+    -- 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,
+        }
+    )
+    if not sess then
+        core.log.error("failed to open session: ", sess_err)
+        return 500, {message = "Failed to open session"}
+    end
+
+    local raw = sess:get("userinfo")
+    if raw then
+        userinfo, err = core.json.decode(raw)
+        if not userinfo then
+            sess:destroy()
+            core.log.error("failed to decode userinfo in session: ", err)
+            return 500, {message = "Invalid userinfo in session"}
+        end
+    else
+        local code = get_code(conf, ctx)
+        if not code then
+            core.response.set_header("Location", conf.redirect_uri)
+            return 302
+        end
+
+        local refreshed = true
+        local access_token = sess:get("access_token")
+        if access_token then
+            local expires_at = sess:get("access_token_expires_at")
+            if expires_at and ngx_time() < expires_at then
+                refreshed = false
+            else
+                sess:delete("access_token")
+                sess:delete("access_token_expires_at")
+            end
+        end
+
+        if refreshed then
+            local new_access_token, expires_in, err = fetch_access_token(conf, 
code)
+            if not new_access_token then
+                core.log.warn("failed to get feishu access token: ", err)
+                return 401, {
+                    message = "Invalid authorization code",
+                }
+            end
+            access_token = new_access_token
+            sess:set("access_token", access_token)
+            sess:set("access_token_expires_at", ngx_time() + expires_in - 60)
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token)
+        if not new_userinfo then
+            core.log.warn("failed to get feishu userinfo: ", err)
+            sess:destroy()
+            return 401, {
+                message = "Invalid authorization code",
+            }
+        end
+        userinfo = new_userinfo
+        local raw, err = core.json.encode(userinfo)
+        if not raw then
+            core.log.error("failed to encode userinfo: ", err)
+            return 500, {message = "Invalid userinfo"}
+        end
+
+        sess:set("userinfo", raw)
+        sess:save()
+        core.log.info("verified feishu user, code: ", code,
+                        ", app_id: ", conf.app_id)
+    end
+
+    if userinfo and conf.set_userinfo_header ~= false then
+        core.request.set_header(ctx, "X-Userinfo", 
base64_encode(core.json.encode(userinfo)))
+    end
+    ctx.external_user = userinfo
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 3bf6a73da..6cac77e86 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -511,6 +511,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - jwe-decrypt                    # priority: 2509
   - key-auth                       # priority: 2500
   - dingtalk-auth                  # priority: 2430
+  - feishu-auth                    # priority: 2420
   - acl                            # priority: 2410
   - consumer-restriction           # priority: 2400
   - attach-consumer-label          # priority: 2399
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index ad930b96a..ec3635413 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -132,6 +132,7 @@
             "plugins/openid-connect",
             "plugins/cas-auth",
             "plugins/dingtalk-auth",
+            "plugins/feishu-auth",
             "plugins/hmac-auth",
             "plugins/authz-casbin",
             "plugins/ldap-auth",
diff --git a/docs/en/latest/plugins/feishu-auth.md 
b/docs/en/latest/plugins/feishu-auth.md
new file mode 100644
index 000000000..3b1f8d6b0
--- /dev/null
+++ b/docs/en/latest/plugins/feishu-auth.md
@@ -0,0 +1,128 @@
+---
+title: feishu-auth
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - Feishu Auth
+  - feishu-auth
+description: This document contains information about the Apache APISIX 
feishu-auth Plugin.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+    <link rel="canonical" href="https://docs.api7.ai/hub/feishu-auth"; />
+</head>
+
+## Description
+
+The `feishu-auth` Plugin authenticates requests using the [Feishu (Lark) OAuth 
2.0](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/oauth-2.0-overview)
 authorization flow. Users are redirected to the Feishu login page when 
unauthenticated. After a successful login, Feishu user information is stored in 
an encrypted session cookie and optionally forwarded to upstream services via 
the `X-Userinfo` header.
+
+## Attributes
+
+| Name                   | Type     | Required | Default                       
                                    | Description                               
                                                                  |
+|------------------------|----------|----------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
+| `app_id`               | string   | True     |                               
                                    | Feishu application App ID.                
                                                                  |
+| `app_secret`           | string   | True     |                               
                                    | Feishu application App Secret.            
                                                                  |
+| `secret`               | string   | True     |                               
                                    | Secret used for signing the session 
cookie (8–32 characters). Must remain stable across restarts.          |
+| `auth_redirect_uri`    | string   | True     |                               
                                    | Redirect URI registered in the Feishu 
application for the OAuth flow.                                       |
+| `redirect_uri`         | string   | True     |                               
                                    | URI to redirect the user to when no 
authorization code is present (i.e. to start the OAuth flow).          |
+| `code_header`          | string   | False    | `"X-Feishu-Code"`             
                                    | HTTP header name to extract the Feishu 
authorization code from.                                             |
+| `code_query`           | string   | False    | `"code"`                      
                                    | Query parameter name to extract the 
Feishu authorization code from.                                         |
+| `access_token_url`     | string   | False    | 
`"https://open.feishu.cn/open-apis/authen/v2/oauth/token"`       | URL to 
exchange the authorization code for an access token.                            
                     |
+| `userinfo_url`         | string   | False    | 
`"https://open.feishu.cn/open-apis/authen/v1/user_info"`         | URL to 
retrieve user information using the access token.                               
                     |
+| `set_userinfo_header`  | boolean  | False    | `true`                        
                                    | When enabled, sets the `X-Userinfo` 
request header with Base64-encoded Feishu user information.            |
+| `cookie_expires_in`    | integer  | False    | `86400`                       
                                    | Validity duration (in seconds) for the 
session cookie.                                                      |
+| `secret_fallbacks`     | array    | False    |                               
                                    | List of fallback secrets used during key 
rotation.                                                          |
+| `timeout`              | integer  | False    | `6000`                        
                                    | Timeout (in milliseconds) for HTTP 
requests to Feishu endpoints.                                            |
+| `ssl_verify`           | boolean  | False    | `true`                        
                                    | When enabled, verifies the SSL 
certificate when connecting to Feishu endpoints.                             |
+
+:::note
+
+The fields `app_secret`, `secret`, and `secret_fallbacks` are encrypted and 
stored in etcd. See [encrypted storage 
fields](../plugin-develop.md#encrypted-storage-fields) for more information.
+
+:::
+
+## Enable Plugin
+
+You can enable the Plugin on a specific Route as shown below:
+
+:::note
+You can fetch the `admin_key` from `config.yaml` and save to an environment 
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+    "methods": ["GET"],
+    "uri": "/api/*",
+    "plugins": {
+        "feishu-auth": {
+            "app_id": "cli_xxxxxxxxxx",
+            "app_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+            "secret": "my-session-secret",
+            "auth_redirect_uri": "https://your-domain.com/api/callback";,
+            "redirect_uri": "https://your-domain.com/oauth/feishu";
+        }
+    },
+    "upstream": {
+        "type": "roundrobin",
+        "nodes": {
+            "127.0.0.1:8080": 1
+        }
+    }
+}'
+```
+
+## How It Works
+
+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.
+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.
+
+## Delete Plugin
+
+To remove the `feishu-auth` Plugin, delete the corresponding JSON 
configuration from the Plugin configuration. APISIX will automatically reload 
and you do not have to restart for this to take effect.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+    "methods": ["GET"],
+    "uri": "/api/*",
+    "plugins": {},
+    "upstream": {
+        "type": "roundrobin",
+        "nodes": {
+            "127.0.0.1:8080": 1
+        }
+    }
+}'
+```
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index f688efe97..b2fd498c6 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -121,6 +121,7 @@
             "plugins/basic-auth",
             "plugins/openid-connect",
             "plugins/hmac-auth",
+            "plugins/feishu-auth",
             "plugins/authz-casbin",
             "plugins/ldap-auth",
             "plugins/opa",
diff --git a/docs/zh/latest/plugins/feishu-auth.md 
b/docs/zh/latest/plugins/feishu-auth.md
new file mode 100644
index 000000000..7d8f83de9
--- /dev/null
+++ b/docs/zh/latest/plugins/feishu-auth.md
@@ -0,0 +1,125 @@
+---
+title: feishu-auth
+keywords:
+  - Apache APISIX
+  - API 网关
+  - Plugin
+  - Feishu Auth
+  - feishu-auth
+description: 本篇文档介绍了 Apache APISIX feishu-auth 插件的相关信息。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+    <link rel="canonical" href="https://docs.api7.ai/hub/feishu-auth"; />
+</head>
+
+## 描述
+
+`feishu-auth` 插件使用[飞书(Lark)OAuth 
2.0](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/oauth-2.0-overview)
 授权流程对请求进行认证。未认证的用户将被重定向到飞书登录页面,登录成功后,飞书用户信息将存储在加密的 session Cookie 中,并可通过 
`X-Userinfo` 请求头转发给上游服务。
+
+## 属性
+
+| 名称                  | 类型    | 必选项 | 默认值                                      
                       | 描述                                                     
                                    |
+|-----------------------|---------|--------|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
+| `app_id`              | string  | 是     |                                    
                                | 飞书应用的 App ID。                                 
                                         |
+| `app_secret`          | string  | 是     |                                    
                                | 飞书应用的 App Secret。                             
                                         |
+| `secret`              | string  | 是     |                                    
                                | 用于签名 session Cookie 的密钥(8-32 个字符),重启后需保持不变。   
                        |
+| `auth_redirect_uri`   | string  | 是     |                                    
                                | 在飞书应用中注册的 OAuth 重定向 URI。                      
                                  |
+| `redirect_uri`        | string  | 是     |                                    
                                | 当请求中不含授权码时,将用户重定向到此 URI 以发起 OAuth 授权流程。       
                   |
+| `code_header`         | string  | 否     | `"X-Feishu-Code"`                  
                                | 从 HTTP 请求头中提取飞书授权码所使用的请求头名称。                  
                         |
+| `code_query`          | string  | 否     | `"code"`                           
                                | 从 URL 查询参数中提取飞书授权码所使用的参数名称。                   
                         |
+| `access_token_url`    | string  | 否     | 
`"https://open.feishu.cn/open-apis/authen/v2/oauth/token"`        | 使用授权码换取 
access token 的接口地址。                                                     |
+| `userinfo_url`        | string  | 否     | 
`"https://open.feishu.cn/open-apis/authen/v1/user_info"`          | 使用 access 
token 获取用户信息的接口地址。                                                   |
+| `set_userinfo_header` | boolean | 否     | `true`                             
                                | 开启后,插件将飞书用户信息以 Base64 编码的 JSON 格式设置到 
`X-Userinfo` 请求头中。          |
+| `cookie_expires_in`   | integer | 否     | `86400`                            
                                | session Cookie 的有效时长(秒)。                      
                                      |
+| `secret_fallbacks`    | array   | 否     |                                    
                                | 密钥轮换时使用的备用密钥列表。                               
                                |
+| `timeout`             | integer | 否     | `6000`                             
                                | 请求飞书接口的超时时间(毫秒)。                              
                               |
+| `ssl_verify`          | boolean | 否     | `true`                             
                                | 开启后,连接飞书接口时会验证 SSL 证书。                        
                              |
+
+注意:schema 中定义了 `encrypt_fields = {"app_secret", "secret", 
"secret_fallbacks"}`,这意味着这些字段将会被加密存储在 etcd 
中。具体参考[加密存储字段](../plugin-develop.md#加密存储字段)。
+
+## 启用插件
+
+以下示例展示了如何在指定路由上启用 `feishu-auth` 插件:
+
+:::note
+
+你可以这样从 `config.yaml` 中获取 `admin_key` 并存入环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+    "methods": ["GET"],
+    "uri": "/api/*",
+    "plugins": {
+        "feishu-auth": {
+            "app_id": "cli_xxxxxxxxxx",
+            "app_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+            "secret": "my-session-secret",
+            "auth_redirect_uri": "https://your-domain.com/api/callback";,
+            "redirect_uri": "https://your-domain.com/oauth/feishu";
+        }
+    },
+    "upstream": {
+        "type": "roundrobin",
+        "nodes": {
+            "127.0.0.1:8080": 1
+        }
+    }
+}'
+```
+
+## 工作原理
+
+认证流程如下:
+
+1. 用户访问受 `feishu-auth` 插件保护的路由。
+2. 若不存在有效的 session Cookie 且请求中不含授权 `code`,插件将以 HTTP 302 重定向用户至 
`redirect_uri`。你的应用随后应将用户重定向到飞书 OAuth 授权页面。
+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` 
请求头,随请求转发至上游服务。
+
+## 删除插件
+
+如需禁用 `feishu-auth` 插件,可删除插件配置中对应的 JSON 配置。APISIX 将自动重新加载,无需重启。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+    "methods": ["GET"],
+    "uri": "/api/*",
+    "plugins": {},
+    "upstream": {
+        "type": "roundrobin",
+        "nodes": {
+            "127.0.0.1:8080": 1
+        }
+    }
+}'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index eecb0e897..783836567 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -88,6 +88,7 @@ jwt-auth
 jwe-decrypt
 key-auth
 dingtalk-auth
+feishu-auth
 acl
 consumer-restriction
 attach-consumer-label
diff --git a/t/plugin/feishu-auth.t b/t/plugin/feishu-auth.t
new file mode 100644
index 000000000..9368b9e80
--- /dev/null
+++ b/t/plugin/feishu-auth.t
@@ -0,0 +1,678 @@
+#
+# 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';
+
+repeat_each(1);
+log_level('debug');
+no_long_string();
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $extra_init_by_lua = <<_EOC_;
+    local server = require("lib.server")
+    server.token = function()
+        local json = require("cjson")
+        local headers = ngx.req.get_headers()
+        ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ", 
json.encode(headers))
+
+        ngx.req.read_body()
+        local data = ngx.req.get_body_data()
+        ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
+
+        local payload = json.decode(data)
+
+        if payload.code ~= "passed" then
+            ngx.status = 400
+            ngx.say([[{"code": 20051, "error_description": "Unauthorized"}]])
+            return
+        end
+
+        ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
+
+        local resp_payload = [[
+{
+    "code": 0,
+    "expires_in": 7200,
+    "access_token": "85b8b7665c4c3bc5bd91d8e6cb6594b7",
+    "token_type": "Bearer"
+}
+        ]]
+
+        ngx.say(resp_payload)
+    end
+
+    server.userinfo = function()
+        local json = require("cjson")
+        local headers = ngx.req.get_headers()
+        ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ", 
json.encode(headers))
+
+        local resp_payload = [[
+{
+  "code": 0,
+  "data": {
+    "avatar_big": 
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4be";,
+    "avatar_middle": 
"https://s1-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4";,
+    "avatar_thumb": 
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba";,
+    "avatar_url": 
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4g";,
+    "en_name": "jack",
+    "name": "jack",
+    "open_id": "ou_8fc70d9ea27111749a71eb",
+    "tenant_key": "1224d18e8d",
+    "union_id": "on_c249ec29c9d6"
+  },
+  "msg": "success"
+}
+        ]]
+
+        ngx.say(resp_payload)
+    end
+
+    server.hello_echo = function()
+        -- echo back the X-Userinfo header value so tests can assert it was 
cleared
+        ngx.say(ngx.req.get_headers()["x-userinfo"] or "none")
+    end
+_EOC_
+
+    $block->set_value("extra_init_by_lua", $extra_init_by_lua);
+
+
+    if (!$block->request) {
+        if (!$block->stream_request) {
+            $block->set_value("request", "GET /t");
+        }
+    }
+
+    if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+        $block->set_value("no_error_log", "[error]");
+    }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: enable feishu-auth plugin
+--- 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,
+                 [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "plugins":{
+                        "feishu-auth":{
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "my-secret",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "cookie_expires_in": 2,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+
+            if code <= 201 then
+                ngx.status = 200
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: missing code
+--- request
+GET /hello
+--- error_code: 302
+--- response_headers
+Location: /echo
+
+
+
+=== TEST 3: invalid code
+--- request
+GET /hello?code=invalid
+--- error_code: 401
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 4: valid code
+--- request
+GET /hello?code=passed
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 5: X-Feishu-Code with invalid code
+--- request
+GET /hello
+--- more_headers
+X-Feishu-Code: invalid
+--- error_code: 401
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 6: X-Feishu-Code header
+--- request
+GET /hello
+--- more_headers
+X-Feishu-Code: passed
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 7: check cookie
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            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",
+            })
+            assert(res, "request failed: " .. (err or "unknown error"))
+            assert(res.status == 200, "unexpected res status: " .. res.status)
+
+            local cookie = res.headers["Set-Cookie"]
+            assert(cookie, "missing Set-Cookie header")
+
+            -- request with cookie
+            local res2, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = cookie,
+                },
+            })
+            assert(res2, "request failed: " .. (err or "unknown error"))
+            assert(res2.status == 200, "unexpected res2 status: " .. 
res2.status)
+
+            --- request without cookie
+            local res3, err = httpc:request_uri(uri, {
+                method = "GET",
+            })
+            assert(res3, "request failed: " .. (err or "unknown error"))
+            assert(res3.status == 302, "unexpected res3 status: " .. 
res3.status)
+
+            ngx.say("passed")
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 8: cookie expire
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            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",
+            })
+            assert(res, "request failed: " .. (err or "unknown error"))
+            assert(res.status == 200, "unexpected res status: " .. res.status)
+
+            local cookie = res.headers["Set-Cookie"]
+            assert(cookie, "missing Set-Cookie header")
+
+            -- request with cookie
+            local res2, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = cookie,
+                },
+            })
+            assert(res2, "request failed: " .. (err or "unknown error"))
+            assert(res2.status == 200, "unexpected res2 status: " .. 
res2.status)
+
+            ngx.sleep(3)
+
+            --- request without cookie
+            local res3, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = cookie,
+                },
+            })
+            assert(res3, "request failed: " .. (err or "unknown error"))
+            assert(res3.status == 302, "unexpected res3 status: " .. 
res3.status)
+
+            ngx.say("passed")
+        }
+    }
+--- timeout: 5
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 9: specify header and query and redirect_uri
+--- 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,
+                 [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "plugins":{
+                        "feishu-auth":{
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "my-secret",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "code_query": "custom_code",
+                            "code_header": "Custom-feishu-Code",
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+
+            if code <= 201 then
+                ngx.status = 200
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 10: specify query
+--- pipelined_requests eval
+["GET /hello?code=passed", "GET /hello?custom_code=passed"]
+--- error_code eval
+[302, 200]
+
+
+
+=== TEST 11: specify header
+--- pipelined_requests eval
+["GET /hello", "GET /hello"]
+--- more_headers eval
+[
+"X-Feishu-Code: passed",
+"Custom-Feishu-Code: passed"
+]
+--- error_code eval
+[302, 200]
+
+
+
+=== TEST 12: secret_fallbacks allows session created with old secret after key 
rotation
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local httpc = http.new()
+            local t = require("lib.test_admin").test
+
+            -- step 1: create route with secret-v1, no fallbacks
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "secret-v1",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            assert(code <= 201, "setup v1 failed: " .. tostring(code))
+
+            -- 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"},
+            })
+            assert(res, err)
+            assert(res.status == 200, "expected 200, got " .. res.status)
+            local old_cookie = res.headers["Set-Cookie"]
+            assert(old_cookie, "expected Set-Cookie from v1")
+
+            -- step 3: rotate to secret-v2 with secret-v1 in secret_fallbacks
+            local code2, body2 = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "secret-v2",
+                            "secret_fallbacks": ["secret-v1"],
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            assert(code2 <= 201, "setup v2 failed: " .. tostring(code2))
+
+            -- step 4: old cookie should still work via fallback
+            local res2, err2 = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {["Cookie"] = old_cookie},
+            })
+            assert(res2, err2)
+            assert(res2.status == 200,
+                "old cookie should be accepted via fallback, got " .. 
res2.status)
+
+            -- step 5: remove fallbacks; old cookie should no longer work
+            local code3, body3 = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "secret-v2",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            assert(code3 <= 201, "setup v2-no-fallback failed: " .. 
tostring(code3))
+
+            local res3, err3 = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {["Cookie"] = old_cookie},
+            })
+            assert(res3, err3)
+            assert(res3.status == 302,
+                "old cookie should be rejected without fallback, got " .. 
res3.status)
+
+            ngx.say("passed")
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 13: forged X-Userinfo header does not bypass authentication
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local httpc = http.new()
+            local t = require("lib.test_admin").test
+
+            -- restore route to a simple config
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "my-secret-xyz",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            assert(code <= 201, "setup failed: " .. tostring(code))
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
+
+            -- forged X-Userinfo without a session cookie must be rejected
+            local res1, err1 = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["X-Userinfo"] = 
ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
+                },
+            })
+            assert(res1, err1)
+            assert(res1.status == 302,
+                "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"},
+            })
+            assert(res2, err2)
+            assert(res2.status == 200, "expected 200 on auth, got " .. 
res2.status)
+            local cookie = res2.headers["Set-Cookie"]
+            assert(cookie, "expected Set-Cookie after auth")
+
+            -- valid cookie + forged X-Userinfo: request succeeds only due to 
the cookie
+            local res3, err3 = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = cookie,
+                    ["X-Userinfo"] = 
ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
+                },
+            })
+            assert(res3, err3)
+            assert(res3.status == 200,
+                "valid cookie should be accepted regardless of forged header, 
got " .. res3.status)
+
+            -- create a route with set_userinfo_header=false to verify the 
forged header
+            -- is cleared and not forwarded to upstream
+            local code2, body2 = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "my-secret-xyz",
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo",
+                            "set_userinfo_header": false
+                        }
+                    },
+                    "uri": "/hello-echo"
+                }]]
+            )
+            assert(code2 <= 201, "setup echo route failed: " .. 
tostring(code2))
+
+            local echo_uri = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/hello-echo"
+            local forged = 
ngx.encode_base64('{"open_id":"forged","name":"hacker"}')
+
+            -- with set_userinfo_header=false, upstream must not receive any 
X-Userinfo
+            local res4, err4 = httpc:request_uri(echo_uri, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = cookie,
+                    ["X-Userinfo"] = forged,
+                },
+            })
+            assert(res4, err4)
+            assert(res4.status == 200,
+                "expected 200 on echo route, got " .. res4.status)
+            assert(res4.body == "none\n",
+                "forged X-Userinfo must not reach upstream, got: " .. 
(res4.body or "nil"))
+
+            ngx.say("passed")
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 14: secret_fallbacks values are encrypted in etcd
+--- yaml_config
+apisix:
+    data_encryption:
+        enable_encrypt_fields: true
+        keyring:
+            - edd1c9f0985e76a2
+--- config
+    location /t {
+        content_by_lua_block {
+            local json = require("toolkit.json")
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "feishu-auth": {
+                            "app_id": "123",
+                            "app_secret": "456",
+                            "secret": "my-secret-key-v2",
+                            "secret_fallbacks": ["my-secret-key-v1"],
+                            "auth_redirect_uri": "https://example.com";,
+                            "access_token_url": "http://127.0.0.1:1980/token";,
+                            "userinfo_url": "http://127.0.0.1:1980/userinfo";,
+                            "redirect_uri": "/echo"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            ngx.sleep(0.1)
+
+            -- Admin API should return decrypted values
+            local code2, _, res2 = t('/apisix/admin/routes/1', ngx.HTTP_GET)
+            assert(code2 == 200, "admin get route failed: " .. tostring(code2))
+            res2 = json.decode(res2)
+            local conf = res2.value.plugins["feishu-auth"]
+            ngx.say("admin secret_fallbacks[1]: ", conf.secret_fallbacks[1])
+
+            -- etcd should store encrypted values
+            local etcd = require("apisix.core.etcd")
+            local etcd_res = assert(etcd.get('/routes/1'))
+            local etcd_conf = etcd_res.body.node.value.plugins["feishu-auth"]
+            assert(etcd_conf.secret_fallbacks and 
etcd_conf.secret_fallbacks[1],
+                "expected secret_fallbacks[1] in etcd payload")
+            ngx.say("etcd secret_fallbacks[1] encrypted: ",
+                etcd_conf.secret_fallbacks[1] ~= "my-secret-key-v1")
+        }
+    }
+--- response_body
+admin secret_fallbacks[1]: my-secret-key-v1
+etcd secret_fallbacks[1] encrypted: true

Reply via email to