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

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new cd32b7958 feat: add dingtalk-auth plugin (#13381)
cd32b7958 is described below

commit cd32b7958c6d955786674a01478d69a501420d93
Author: AlinsRan <[email protected]>
AuthorDate: Mon May 25 11:26:24 2026 +0800

    feat: add dingtalk-auth plugin (#13381)
---
 apisix/cli/config.lua                   |   1 +
 apisix/plugins/dingtalk-auth.lua        | 298 +++++++++++++++++++++++++
 conf/config.yaml.example                |   1 +
 docs/en/latest/config.json              |   1 +
 docs/en/latest/plugins/dingtalk-auth.md | 206 ++++++++++++++++++
 t/admin/plugins.t                       |   1 +
 t/plugin/dingtalk-auth.t                | 373 ++++++++++++++++++++++++++++++++
 7 files changed, 881 insertions(+)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 8ac1e567b..4a3fa3534 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -222,6 +222,7 @@ local _M = {
     "jwt-auth",
     "jwe-decrypt",
     "key-auth",
+    "dingtalk-auth",
     "acl",
     "consumer-restriction",
     "attach-consumer-label",
diff --git a/apisix/plugins/dingtalk-auth.lua b/apisix/plugins/dingtalk-auth.lua
new file mode 100644
index 000000000..d4c4b07b0
--- /dev/null
+++ b/apisix/plugins/dingtalk-auth.lua
@@ -0,0 +1,298 @@
+--
+-- 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",
+            minLength = 1,
+            description = "HTTP header name to extract dingtalk authorization 
code from.",
+            default = "X-DingTalk-Code"
+        },
+        code_query = {
+            type = "string",
+            minLength = 1,
+            description = "Query parameter name to extract dingtalk 
authorization code from.",
+            default = "code"
+        },
+        userinfo_url = {
+            type = "string",
+            minLength = 1,
+            default = DEFAULT_USERINFO_URL
+        },
+        access_token_url = {
+            type = "string",
+            minLength = 1,
+            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", minLength = 1},
+        timeout = {type = "integer", minimum = 1, 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",
+            minimum = 1,
+            description = "Valid duration (in seconds) for the authorization 
cookie."
+                        .. "This value defines how long the cookie remains 
valid after creation.",
+            default = 86400,
+        },
+    },
+    encrypt_fields = {"app_secret", "secret", "secret_fallbacks"},
+    required = {"app_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, false
+    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, false
+    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, false
+    end
+
+    if data.errcode ~= 0 then
+        return nil, "unexpected error code: " .. data.errcode
+                            .. ", errmsg: " .. (data.errmsg or "nil"), true
+    end
+
+    return data.result, nil, false
+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)
+            core.response.set_header("Location", conf.redirect_uri)
+            return 302
+        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 = "Failed to obtain access token",
+            }
+        end
+
+        local new_userinfo, err, is_auth_err = fetch_userinfo(conf, 
access_token, code)
+        if not new_userinfo then
+            core.log.warn("failed to get dingtalk userinfo: ", err)
+            if is_auth_err then
+                return 401, {message = "Invalid authorization code"}
+            end
+            return 503, {message = "Failed to obtain user info from DingTalk"}
+        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 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
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 482eb13be..e38435e64 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -508,6 +508,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - jwt-auth                       # priority: 2510
   - jwe-decrypt                    # priority: 2509
   - key-auth                       # priority: 2500
+  - dingtalk-auth                  # priority: 2430
   - acl                            # priority: 2410
   - consumer-restriction           # priority: 2400
   - attach-consumer-label          # priority: 2399
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index f44b65ee9..27c071ebd 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -130,6 +130,7 @@
             "plugins/wolf-rbac",
             "plugins/openid-connect",
             "plugins/cas-auth",
+            "plugins/dingtalk-auth",
             "plugins/hmac-auth",
             "plugins/authz-casbin",
             "plugins/ldap-auth",
diff --git a/docs/en/latest/plugins/dingtalk-auth.md 
b/docs/en/latest/plugins/dingtalk-auth.md
new file mode 100644
index 000000000..224f6421c
--- /dev/null
+++ b/docs/en/latest/plugins/dingtalk-auth.md
@@ -0,0 +1,206 @@
+---
+title: dingtalk-auth
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - DingTalk Auth
+  - dingtalk-auth
+description: This document contains information about the Apache APISIX 
dingtalk-auth Plugin.
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+## Description
+
+The `dingtalk-auth` Plugin integrates [DingTalk](https://www.dingtalk.com/) 
OAuth 2.0 authentication into APISIX routes. It validates a DingTalk 
authorization code, exchanges it for an access token, and retrieves user 
information from the DingTalk open platform. Verified user information is 
cached in a secure cookie session so that subsequent requests are not 
interrupted.
+
+## Attributes
+
+| Name               | Type     | Required | Default                           
                        | Description                                           
                                                                |
+|--------------------|----------|----------|-----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
+| `app_key`          | string   | True     |                                   
                        | DingTalk application App Key (client ID).             
                                                                |
+| `app_secret`       | string   | True     |                                   
                        | DingTalk application App Secret (client secret). This 
field is stored encrypted.                                      |
+| `secret`           | string   | True     |                                   
                        | Key used to sign and encrypt the cookie session (8–32 
characters). This field is stored encrypted.                    |
+| `redirect_uri`     | string   | True     |                                   
                        | URI to redirect the user to when no valid 
authorization code or session is present.                                   |
+| `code_header`      | string   | False    | `X-DingTalk-Code`                 
                        | HTTP request header name from which to read the 
DingTalk authorization code.                                         |
+| `code_query`       | string   | False    | `code`                            
                        | Query parameter name from which to read the DingTalk 
authorization code.                                             |
+| `access_token_url` | string   | False    | 
`https://api.dingtalk.com/v1.0/oauth2/accessToken`        | DingTalk endpoint 
used to obtain an access token.                                                 
                    |
+| `userinfo_url`     | string   | False    | 
`https://oapi.dingtalk.com/topapi/v2/user/getuserinfo`    | DingTalk endpoint 
used to retrieve user information.                                              
                    |
+| `set_userinfo_header` | boolean | False | `true`                             
                      | When `true`, the verified user information is 
Base64-encoded and forwarded to the upstream in the `X-Userinfo` header. |
+| `timeout`          | integer  | False    | `6000`                            
                        | Timeout in milliseconds for HTTP calls to DingTalk 
APIs.                                                              |
+| `ssl_verify`       | boolean  | False    | `true`                            
                        | Whether to verify the SSL certificate when calling 
DingTalk APIs.                                                     |
+| `cookie_expires_in` | integer | False   | `86400`                            
                       | Cookie session validity period in seconds.             
                                                               |
+| `secret_fallbacks` | array    | False    |                                   
                        | List of fallback secrets used during key rotation 
(each 8–32 characters).                                             |
+
+:::note
+
+`encrypt_fields = {"app_secret", "secret"}` is defined in the schema, which 
means both fields are stored encrypted in etcd. See [encrypted storage 
fields](../plugin-develop.md#encrypted-storage-fields).
+
+:::
+
+## Authentication flow
+
+```
+Client                     APISIX (dingtalk-auth)            DingTalk
+  │                               │                               │
+  │──── GET /resource ───────────►│                               │
+  │                               │  (no session, no code)        │
+  │◄─── 302 → redirect_uri ───────│                               │
+  │                               │                               │
+  │──── GET /resource?code=xxx ──►│                               │
+  │                               │──── POST /accessToken ───────►│
+  │                               │◄─── {"accessToken": "..."} ───│
+  │                               │──── POST /getuserinfo ────────►│
+  │                               │◄─── {"result": {...}} ─────────│
+  │                               │  (save userinfo in session)   │
+  │◄─── 200 + Set-Cookie ─────────│                               │
+  │                               │                               │
+  │──── GET /resource (Cookie) ──►│                               │
+  │                               │  (session valid, skip auth)   │
+  │◄─── 200 ──────────────────────│                               │
+```
+
+## Enable Plugin
+
+You can enable the Plugin on a specific Route as shown below:
+
+:::note
+You can fetch the `admin_key` from `config.yaml` and save to an environment 
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+  -H "X-API-KEY: $admin_key" \
+  -X PUT \
+  -d '{
+    "methods": ["GET"],
+    "uri": "/anything/*",
+    "plugins": {
+      "dingtalk-auth": {
+        "app_key": "<your-app-key>",
+        "app_secret": "<your-app-secret>",
+        "secret": "<session-secret-key>",
+        "redirect_uri": "https://login.dingtalk.com/oauth2/auth?...";
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+## Example usage
+
+Once you have enabled the Plugin, incoming requests to the Route are processed 
as follows:
+
+1. **No session and no code**: The user is redirected to `redirect_uri` 
(typically a DingTalk OAuth login page) with a `302` response.
+2. **Authorization code present** (in the `code` query parameter or 
`X-DingTalk-Code` header): The Plugin exchanges the code for an access token 
via `access_token_url`, then retrieves user information from `userinfo_url`. On 
success, the user information is stored in an encrypted cookie session and the 
original request proceeds.
+3. **Valid session cookie**: Subsequent requests carrying the session cookie 
bypass DingTalk API calls entirely and proceed directly to the upstream.
+
+When `set_userinfo_header` is `true` (the default), the upstream receives the 
DingTalk user information in the `X-Userinfo` header as a Base64-encoded JSON 
object.
+
+### Custom code extraction
+
+By default the Plugin reads the authorization code from the `code` query 
parameter or the `X-DingTalk-Code` header. You can customize both names:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+  -H "X-API-KEY: $admin_key" \
+  -X PUT \
+  -d '{
+    "methods": ["GET"],
+    "uri": "/anything/*",
+    "plugins": {
+      "dingtalk-auth": {
+        "app_key": "<your-app-key>",
+        "app_secret": "<your-app-secret>",
+        "secret": "<session-secret-key>",
+        "redirect_uri": "https://login.dingtalk.com/oauth2/auth?...";,
+        "code_query": "dt_code",
+        "code_header": "X-Custom-DT-Code"
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+### Key rotation
+
+Use `secret_fallbacks` to rotate the session signing key without invalidating 
existing sessions:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+  -H "X-API-KEY: $admin_key" \
+  -X PUT \
+  -d '{
+    "methods": ["GET"],
+    "uri": "/anything/*",
+    "plugins": {
+      "dingtalk-auth": {
+        "app_key": "<your-app-key>",
+        "app_secret": "<your-app-secret>",
+        "secret": "<new-secret-key>",
+        "secret_fallbacks": ["<old-secret-key>"],
+        "redirect_uri": "https://login.dingtalk.com/oauth2/auth?...";
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+## Delete Plugin
+
+To remove the `dingtalk-auth` Plugin, delete the corresponding JSON 
configuration from the Plugin configuration. APISIX will automatically reload 
and you do not have to restart for this to take effect.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+  -H "X-API-KEY: $admin_key" \
+  -X PUT \
+  -d '{
+    "methods": ["GET"],
+    "uri": "/anything/*",
+    "plugins": {},
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index e05926db0..8f13a99f3 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -87,6 +87,7 @@ basic-auth
 jwt-auth
 jwe-decrypt
 key-auth
+dingtalk-auth
 acl
 consumer-restriction
 attach-consumer-label
diff --git a/t/plugin/dingtalk-auth.t b/t/plugin/dingtalk-auth.t
new file mode 100644
index 000000000..50c5ddcab
--- /dev/null
+++ b/t/plugin/dingtalk-auth.t
@@ -0,0 +1,373 @@
+#
+# 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)
+            ngx.say(err)
+        }
+    }
+--- response_body
+false
+property "app_key" is required
+
+
+
+=== 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)
+            ngx.say(err)
+        }
+    }
+--- response_body
+false
+property "secret" validation failed: string too short, expected at least 8, 
got 5
+
+
+
+=== TEST 4: enable dingtalk-auth plugin
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "dingtalk-auth": {
+                            "app_key": "testappkey",
+                            "app_secret": "testappsecret",
+                            "secret": "my-session-secret",
+                            "access_token_url": 
"http://127.0.0.1:10421/v1.0/oauth2/accessToken";,
+                            "userinfo_url": 
"http://127.0.0.1:10421/topapi/v2/user/getuserinfo";,
+                            "cookie_expires_in": 2,
+                            "redirect_uri": "/login"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            if code <= 201 then
+                ngx.status = 200
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 5: no code provided - redirect to redirect_uri
+--- request
+GET /hello
+--- error_code: 302
+--- response_headers
+Location: /login
+
+
+
+=== TEST 6: invalid code - returns 401
+--- request
+GET /hello?code=invalid_code
+--- error_code: 401
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 7: valid code via query param - returns 200
+--- request
+GET /hello?code=valid_code
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 8: valid code via X-DingTalk-Code header - returns 200
+--- request
+GET /hello
+--- more_headers
+X-DingTalk-Code: valid_code
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 9: cookie session - subsequent requests reuse session
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
+
+            -- first request with valid code to obtain session cookie
+            local res, err = httpc:request_uri(uri, {
+                method = "GET",
+                query = { code = "valid_code" },
+            })
+            assert(res, "request failed: " .. (err or "nil"))
+            assert(res.status == 200, "expected 200, got: " .. res.status)
+
+            local cookie = res.headers["Set-Cookie"]
+            assert(cookie, "missing Set-Cookie header")
+
+            -- second request using the session cookie (no code needed)
+            local res2, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = { ["Cookie"] = cookie },
+            })
+            assert(res2, "request failed: " .. (err or "nil"))
+            assert(res2.status == 200, "expected 200, got: " .. res2.status)
+
+            -- request without cookie redirects again
+            local res3, err = httpc:request_uri(uri, { method = "GET" })
+            assert(res3, "request failed: " .. (err or "nil"))
+            assert(res3.status == 302, "expected 302, got: " .. res3.status)
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 10: cookie expires after cookie_expires_in seconds
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello"
+
+            local res, err = httpc:request_uri(uri, {
+                method = "GET",
+                query = { code = "valid_code" },
+            })
+            assert(res, "request failed: " .. (err or "nil"))
+            assert(res.status == 200, "expected 200, got: " .. res.status)
+
+            local cookie = res.headers["Set-Cookie"]
+            assert(cookie, "missing Set-Cookie header")
+
+            -- cookie still valid before expiry
+            local res2, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = { ["Cookie"] = cookie },
+            })
+            assert(res2, "request failed: " .. (err or "nil"))
+            assert(res2.status == 200, "expected 200 before expiry, got: " .. 
res2.status)
+
+            ngx.sleep(3)
+
+            -- cookie should be expired now
+            local res3, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = { ["Cookie"] = cookie },
+            })
+            assert(res3, "request failed: " .. (err or "nil"))
+            assert(res3.status == 302, "expected 302 after expiry, got: " .. 
res3.status)
+
+            ngx.say("passed")
+        }
+    }
+--- timeout: 5
+--- response_body
+passed
+
+
+
+=== TEST 11: configure custom code_header and code_query
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "methods": ["GET"],
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "plugins": {
+                        "dingtalk-auth": {
+                            "app_key": "testappkey",
+                            "app_secret": "testappsecret",
+                            "secret": "my-session-secret",
+                            "access_token_url": 
"http://127.0.0.1:10421/v1.0/oauth2/accessToken";,
+                            "userinfo_url": 
"http://127.0.0.1:10421/topapi/v2/user/getuserinfo";,
+                            "code_query": "dt_code",
+                            "code_header": "X-Custom-DT-Code",
+                            "redirect_uri": "/login"
+                        }
+                    },
+                    "uri": "/hello"
+                }]]
+            )
+            if code <= 201 then
+                ngx.status = 200
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 12: custom code_query param works
+--- pipelined_requests eval
+["GET /hello?code=valid_code", "GET /hello?dt_code=valid_code"]
+--- error_code eval
+[302, 200]
+
+
+
+=== TEST 13: custom code_header works
+--- pipelined_requests eval
+["GET /hello", "GET /hello"]
+--- more_headers eval
+[
+"X-DingTalk-Code: valid_code",
+"X-Custom-DT-Code: valid_code"
+]
+--- error_code eval
+[302, 200]


Reply via email to