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]