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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to