You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by "AlinsRan (via GitHub)" <gi...@apache.org> on 2026/05/17 19:31:03 UTC

[PR] feat: add dingtalk-auth plugin [apisix]

AlinsRan opened a new pull request, #13381:
URL: https://github.com/apache/apisix/pull/13381

   ## Summary
   
   Add the `dingtalk-auth` plugin that integrates [DingTalk](https://www.dingtalk.com/) (Ding Talk / 钉钉) OAuth 2.0 authentication into APISIX routes.
   
   ## How it works
   
   1. When a request arrives without a valid session cookie, the plugin checks for a DingTalk authorization code in a configurable query parameter (default: `code`) or HTTP header (default: `X-DingTalk-Code`).
   2. If no code is found, the request is redirected (302) to the configured `redirect_uri` (typically the DingTalk OAuth login page).
   3. If a code is present, the plugin exchanges it for an access token via the DingTalk token API (`access_token_url`), then retrieves user information from the DingTalk user info API (`userinfo_url`).
   4. The access token is cached in an LRU cache (TTL: 7000 s) to avoid redundant requests.
   5. Verified user information is stored in an encrypted `lua-resty-session` v4 cookie session. Subsequent requests carrying the session cookie bypass all DingTalk API calls.
   6. When `set_userinfo_header` is `true` (default), the upstream receives the user information in the `X-Userinfo` header as a Base64-encoded JSON object.
   
   ## Key attributes
   
   | Attribute | Default | Notes |
   |-----------|---------|-------|
   | `app_key` | — | Required |
   | `app_secret` | — | Required; stored encrypted |
   | `secret` | — | Required; 8–32 chars; stored encrypted |
   | `redirect_uri` | — | Required |
   | `code_query` | `code` | Query param name for the auth code |
   | `code_header` | `X-DingTalk-Code` | Header name for the auth code |
   | `cookie_expires_in` | `86400` | Session cookie TTL in seconds |
   | `secret_fallbacks` | — | Supports zero-downtime key rotation |
   
   Plugin priority: **2430** (between `key-auth` 2500 and `consumer-restriction` 2400).
   
   ## Changes
   
   - `apisix/plugins/dingtalk-auth.lua` — plugin implementation
   - `t/plugin/dingtalk-auth.t` — test suite (13 test cases)
   - `docs/en/latest/plugins/dingtalk-auth.md` — English documentation
   - `conf/config.yaml.example` — register plugin in default list
   - `docs/en/latest/config.json` — add to sidebar navigation
   - `t/admin/plugins.t` — register plugin in admin test list


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: add dingtalk-auth plugin [apisix]

Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #13381:
URL: https://github.com/apache/apisix/pull/13381#discussion_r3269291359


##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            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)
+        local ok, save_err = sess:save()
+        if not ok then
+            core.log.error("failed to save session: ", save_err)
+            return 500, {message = "Failed to save session"}
+        end
+        core.log.info("verified dingtalk user, code: ", code,
+                        ", app_key: ", conf.app_key)
+    end

Review Comment:
   Not adopting. The plugin follows the same pattern as `cas-auth`: `redirect_uri` is fully operator-configured (typically the DingTalk OAuth login URL that already encodes the state on the DingTalk side). Unlike `authz-casdoor`, this plugin does not own a callback URL inside APISIX, so there is nowhere to round-trip a server-generated state value. CSRF protection is the responsibility of the operator when configuring the DingTalk app.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"

Review Comment:
   Not adopting. The two defaults are compatible in practice: the OAuth2 user token obtained from `api.dingtalk.com/v1.0/oauth2/accessToken` is accepted as the `access_token` query parameter by `oapi.dingtalk.com/topapi/v2/user/getuserinfo`. Both URLs are fully configurable so operators can override them for any DingTalk environment.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},

Review Comment:
   Acknowledged. APISIX's `encrypt_fields` does not traverse arrays, so encrypting individual items in `secret_fallbacks` is not supported by the framework. Added a comment in the schema to document this limitation and remind operators to rotate fallback secrets promptly. Encrypting array items would require a framework-level change and is out of scope for this PR.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})

Review Comment:
   Not adopting in this PR. APISIX's module-level `lrucache` does not support per-item TTL without a significant refactor. The 7000-second TTL is a conservative value safely under DingTalk's standard 7200-second token lifetime. Both endpoints are configurable for non-standard environments. Dynamic TTL from `expireIn` is a good future enhancement.



##########
t/plugin/dingtalk-auth.t:
##########
@@ -0,0 +1,369 @@
+#
+# 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();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $http_config = $block->http_config // <<_EOC_;
+    server {
+        listen 10421;
+
+        location /v1.0/oauth2/accessToken {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                ngx.status = 200
+                ngx.say(json.encode({
+                    accessToken = "test_access_token_12345",
+                    expireIn = 7200
+                }))
+            }
+        }
+
+        location /topapi/v2/user/getuserinfo {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                local body = ngx.req.get_body_data()
+                local data = json.decode(body)
+                if data.code ~= "valid_code" then
+                    ngx.status = 200
+                    ngx.say(json.encode({
+                        errcode = 403,
+                        errmsg = "Unauthorized"

Review Comment:
   Not adopting. The mock uses errcode `403` purely to produce a non-zero errcode to exercise the plugin's error-handling path. The test validates plugin behavior (correct HTTP 401 for any non-zero errcode), not DingTalk-specific error code semantics.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: add dingtalk-auth plugin [apisix]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #13381:
URL: https://github.com/apache/apisix/pull/13381#discussion_r3255991033


##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},

Review Comment:
   `secret_fallbacks` contains key-derivation secrets used for cookie session signing/encryption, but it is not listed in `encrypt_fields` (only `app_secret` and `secret` are). As a result, fallback secrets are stored in plaintext in etcd, while the primary `secret` is encrypted at rest. This is inconsistent and partly defeats the purpose of encrypting `secret`. Note that `encrypt_fields` uses dot-paths and does not natively traverse arrays of strings (see `apisix/plugin.lua:1157-1180`), so supporting this likely requires either restructuring `secret_fallbacks` (e.g. as object fields) or documenting the limitation clearly.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        redirect_uri = {type = "string"},
+        timeout = {type = "integer", default = 6000},

Review Comment:
   String fields `code_header`, `code_query`, `redirect_uri`, `userinfo_url`, and `access_token_url` have no `minLength` constraint, so an empty string would pass schema validation. An empty `code_query` would cause `uri_args[""]` lookups and an empty `redirect_uri` would produce a 302 to an empty Location. Consider adding `minLength = 1` to these string properties to fail fast on misconfiguration.



##########
t/plugin/dingtalk-auth.t:
##########
@@ -0,0 +1,369 @@
+#
+# 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();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $http_config = $block->http_config // <<_EOC_;
+    server {
+        listen 10421;
+
+        location /v1.0/oauth2/accessToken {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                ngx.status = 200
+                ngx.say(json.encode({
+                    accessToken = "test_access_token_12345",
+                    expireIn = 7200
+                }))
+            }
+        }
+
+        location /topapi/v2/user/getuserinfo {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                local body = ngx.req.get_body_data()
+                local data = json.decode(body)
+                if data.code ~= "valid_code" then
+                    ngx.status = 200
+                    ngx.say(json.encode({
+                        errcode = 403,
+                        errmsg = "Unauthorized"

Review Comment:
   The DingTalk `getuserinfo` mock returns errcode `403` for an invalid code, but in reality DingTalk's `topapi/v2/user/getuserinfo` returns specific error codes like `40078` ("not found user info"). Hard-coding 403 makes the test less representative of real behavior. Consider using a realistic DingTalk error code so the test better validates plugin handling of real responses.
   



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            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)
+        local ok, save_err = sess:save()
+        if not ok then
+            core.log.error("failed to save session: ", save_err)
+            return 500, {message = "Failed to save session"}
+        end
+        core.log.info("verified dingtalk user, code: ", code,
+                        ", app_key: ", conf.app_key)
+    end
+
+    if userinfo and conf.set_userinfo_header ~= false then
+        local raw_for_header, encode_err = core.json.encode(userinfo)
+        if raw_for_header then
+            core.request.set_header(ctx, "X-Userinfo", base64_encode(raw_for_header))
+        else
+            core.log.warn("failed to encode userinfo for X-Userinfo header: ", encode_err)
+        end

Review Comment:
   The userinfo is JSON-encoded twice on the auth path: once to store in the session at line 263, and again here at line 280 for the `X-Userinfo` header. You can reuse the `raw` value already encoded above (or set the header from `raw` directly before saving the session) to avoid the redundant encode on every authentication request.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})

Review Comment:
   The access token TTL is hardcoded to 7000 seconds and ignores the `expireIn` value returned by the DingTalk token endpoint. If DingTalk changes the token lifetime (or returns a shorter `expireIn` for any reason), cached tokens may become invalid before the cache evicts them, causing user-info calls to fail until the LRU TTL elapses. Consider honoring `data.expireIn` from the response (with a safety margin) when caching, or at least exposing the cache TTL as configuration. There is also no fallback to refetch the token if `fetch_userinfo` fails due to an expired token.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            return 401, {
+                message = "Invalid authorization code",

Review Comment:
   When `fetch_userinfo` fails because the upstream returned an `errcode != 0` (e.g., DingTalk rejected the code), the plugin always returns 401 "Invalid authorization code". But `fetch_userinfo` can also fail due to network errors, non-200 HTTP status, or decode errors — none of which mean the user's code is invalid. Misclassifying these as 401 will mislead operators and clients. Consider distinguishing transient upstream errors (502/503) from genuine authorization failures (401).
   



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            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)
+        local ok, save_err = sess:save()
+        if not ok then
+            core.log.error("failed to save session: ", save_err)
+            return 500, {message = "Failed to save session"}
+        end
+        core.log.info("verified dingtalk user, code: ", code,
+                        ", app_key: ", conf.app_key)
+    end
+
+    if userinfo and conf.set_userinfo_header ~= false then

Review Comment:
   The check `conf.set_userinfo_header ~= false` is misleading because the schema already defaults `set_userinfo_header` to `true`, so it will always be a boolean here. Use a direct truthiness check (`if userinfo and conf.set_userinfo_header then`) to make the intent clearer and consistent with the schema default.
   



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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"}

Review Comment:
   When `sess:get("userinfo")` returns an invalid JSON payload, the plugin destroys the session and returns 500. Since this can only happen with a tampered cookie or stale data after a code change, returning 500 will look like a server bug to the client. Consider treating it the same as a missing session (destroy and redirect to `redirect_uri`) rather than surfacing a 500.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: add dingtalk-auth plugin [apisix]

Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan merged PR #13381:
URL: https://github.com/apache/apisix/pull/13381


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: add dingtalk-auth plugin [apisix]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #13381:
URL: https://github.com/apache/apisix/pull/13381#discussion_r3255861523


##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            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)
+        local ok, save_err = sess:save()
+        if not ok then
+            core.log.error("failed to save session: ", save_err)
+            return 500, {message = "Failed to save session"}
+        end
+        core.log.info("verified dingtalk user, code: ", code,
+                        ", app_key: ", conf.app_key)
+    end

Review Comment:
   The OAuth flow lacks a `state` parameter for CSRF protection. The plugin redirects unauthenticated users to `redirect_uri` (a static string) and then accepts any code presented at the callback without verifying that the request originated from the same client/session. A typical OAuth integration should generate a random state value, persist it in the pre-auth session, include it in the authorize URL, and verify it on the callback (this is how `authz-casdoor.lua` handles `state`). Consider documenting that callers must perform state-based CSRF protection externally, or implementing it within the plugin.



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",

Review Comment:
   When `access_token_cache` fails to fetch a token (e.g., transient network error, DingTalk outage, or wrong credentials), the response is `500 Invalid configuration`. This message conflates configuration errors with runtime/network failures and will mislead operators when diagnosing issues — the actual error from `err` is only logged, not surfaced. Consider returning a more accurate message such as "Failed to obtain access token" so the response distinguishes upstream failures from misconfiguration.
   



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"

Review Comment:
   The default `userinfo_url` (`https://oapi.dingtalk.com/topapi/v2/user/getuserinfo`) is from DingTalk's legacy "old" OpenAPI, whereas the default `access_token_url` (`https://api.dingtalk.com/v1.0/oauth2/accessToken`) is the new OAuth2 API. The access token obtained from the new OAuth2 endpoint is generally not interchangeable with the legacy `topapi/*` endpoints (which expect a token from `/gettoken`). Please verify the defaults are actually compatible end-to-end against a real DingTalk app, or align them to a single API generation (both legacy or both new). This affects users who rely on the defaults.
   



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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"},
+    required = {"app_key", "app_secret", "secret", "redirect_uri"},
+}
+
+local _M = {
+    version = 0.1,
+    priority = 2430,
+    name = "dingtalk-auth",
+    schema = schema,
+}
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local body = {
+        appKey = conf.app_key,
+        appSecret = conf.app_secret
+    }
+
+    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 dingtalk token: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk access token response status: ",
+                                    res.status)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                                        res.status, ", body: ", res.body)
+        return nil, "unexpected response status: " .. res.status
+    end
+
+    local data, err = core.json.decode(res.body)
+    if not data then
+        core.log.error("failed to decode dingtalk token response: ", err)
+        return nil, "failed to decode response: " .. (err or "nil")
+    end
+
+    local access_token = data.accessToken
+    if not access_token then
+        core.log.error("dingtalk token response missing accessToken: ", res.body)
+        return nil, "dingtalk token response missing accessToken"
+    end
+    return access_token, nil
+end
+
+
+local function fetch_userinfo(conf, access_token, code)
+    local httpc = http.new()
+    httpc:set_timeout(conf.timeout)
+
+    local params = {
+        access_token = access_token,
+    }
+
+    local body = {
+        code = code
+    }
+
+    local res, err = httpc:request_uri(conf.userinfo_url, {
+        method = "POST",
+        query = params,
+        headers = {
+            ["Content-Type"] = "application/json"
+        },
+        body = core.json.encode(body),
+        ssl_verify = conf.ssl_verify
+    })
+
+    if not res then
+        core.log.error("failed to verify dingtalk user: ", err)
+        return nil, err
+    end
+
+    core.log.debug("request dingtalk userinfo response status: ", res.status, ", body: ", res.body)
+
+    if res.status ~= 200 then
+        core.log.error("unexpected http response status from dingtalk: ",
+                            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 dingtalk userinfo response: ", err)
+        return nil, "failed to decode response: " .. err
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil")
+    end
+
+    return data.result, 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
+
+    local sess, sess_err = session.open(
+        {
+            secret = conf.secret,
+            secret_fallbacks = conf.secret_fallbacks,
+            cookie_name = "dingtalk_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 key = core.table.concat({
+            conf.access_token_url,
+            conf.app_key,
+            conf.app_secret,
+        }, "#")
+        local access_token, err = access_token_cache(key, nil,
+                                        fetch_access_token, conf)
+        if not access_token then
+            core.log.error("failed to get dingtalk access token: ", err)
+            return 500, {
+                message = "Invalid configuration",
+            }
+        end
+
+        local new_userinfo, err = fetch_userinfo(conf, access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            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)
+        local ok, save_err = sess:save()
+        if not ok then
+            core.log.error("failed to save session: ", save_err)
+            return 500, {message = "Failed to save session"}
+        end
+        core.log.info("verified dingtalk user, code: ", code,
+                        ", app_key: ", conf.app_key)
+    end
+
+    if userinfo and conf.set_userinfo_header ~= false then
+        local raw_for_header, encode_err = core.json.encode(userinfo)
+        if raw_for_header then
+            core.request.set_header(ctx, "X-Userinfo", base64_encode(raw_for_header))
+        else
+            core.log.warn("failed to encode userinfo for X-Userinfo header: ", encode_err)
+        end
+    end
+    ctx.external_user = userinfo

Review Comment:
   `ctx.external_user = userinfo` is assigned but never read anywhere else in the codebase (no other consumer of this field exists). If the intent is to expose the verified DingTalk user to downstream plugins or logs, please follow an established convention (e.g., `ctx.consumer`/`ctx.consumer_name` or a documented `ctx` field used by other auth plugins) or remove the assignment.
   



##########
t/plugin/dingtalk-auth.t:
##########
@@ -0,0 +1,369 @@
+#
+# 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();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $http_config = $block->http_config // <<_EOC_;
+    server {
+        listen 10421;
+
+        location /v1.0/oauth2/accessToken {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                ngx.status = 200
+                ngx.say(json.encode({
+                    accessToken = "test_access_token_12345",
+                    expireIn = 7200
+                }))
+            }
+        }
+
+        location /topapi/v2/user/getuserinfo {
+            content_by_lua_block {
+                local json = require("toolkit.json")
+                ngx.req.read_body()
+                local body = ngx.req.get_body_data()
+                local data = json.decode(body)
+                if data.code ~= "valid_code" then
+                    ngx.status = 200
+                    ngx.say(json.encode({
+                        errcode = 403,
+                        errmsg = "Unauthorized"
+                    }))
+                    return
+                end
+                ngx.status = 200
+                ngx.say(json.encode({
+                    errcode = 0,
+                    errmsg = "ok",
+                    result = {
+                        userid = "user_001",
+                        name = "Test User",
+                        unionid = "union_abc123"
+                    }
+                }))
+            }
+        }
+    }
+_EOC_
+
+    $block->set_value("http_config", $http_config);
+
+    if (!$block->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: schema check - all required fields present
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.dingtalk-auth")
+            local ok, err = plugin.check_schema({
+                app_key = "appkey123",
+                app_secret = "appsecret456",
+                secret = "session-secret-key",
+                redirect_uri = "/login",
+            })
+            if not ok then
+                ngx.say(err)
+                return
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: schema check - missing required field app_key
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.dingtalk-auth")
+            local ok, err = plugin.check_schema({
+                app_secret = "appsecret456",
+                secret = "session-secret-key",
+                redirect_uri = "/login",
+            })
+            ngx.say(ok)
+        }
+    }
+--- response_body
+false
+
+
+
+=== TEST 3: schema check - secret too short
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.dingtalk-auth")
+            local ok, err = plugin.check_schema({
+                app_key = "appkey123",
+                app_secret = "appsecret456",
+                secret = "short",
+                redirect_uri = "/login",
+            })
+            ngx.say(ok)
+        }
+    }
+--- response_body
+false
+

Review Comment:
   Tests 2 and 3 only assert that `ok` is `false`, but do not verify the actual error message returned by `check_schema`. As a result, a regression where the schema rejects for the wrong reason (e.g., a different missing field, or a schema parse failure) would still pass these tests. Please also assert on `err` (e.g., that it mentions `app_key` or the `minLength` constraint).



##########
apisix/plugins/dingtalk-auth.lua:
##########
@@ -0,0 +1,291 @@
+--
+-- 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
+
+-- 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({
+    ttl = 7000,
+    invalid_stale = true,
+})
+
+local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
+local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+
+local schema = {
+    type = "object",
+    properties = {
+        app_key = {type = "string", minLength = 1},
+        app_secret = {type = "string", minLength = 1},
+        code_header = {
+            type = "string",
+            description = "HTTP header name to extract dingtalk authorization code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            description = "Query parameter name to extract dingtalk 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 dingtalk user information in request headers",
+            default = true
+        },
+        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,
+        },

Review Comment:
   `timeout` and `cookie_expires_in` are declared as `integer` without a `minimum`. Negative or zero values would be accepted by the schema but cause undefined behavior (immediately-expired cookies, instant HTTP timeouts). Please add `minimum = 1` (or an appropriate lower bound) to both fields.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org