diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index c42ecbdeec8f..8539479fea41 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -221,6 +221,7 @@ local _M = { "jwt-auth", "jwe-decrypt", "key-auth", + "dingtalk-auth", "consumer-restriction", "attach-consumer-label", "forward-auth", diff --git a/apisix/plugins/dingtalk-auth.lua b/apisix/plugins/dingtalk-auth.lua new file mode 100644 index 000000000000..d980b003cb86 --- /dev/null +++ b/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 +end + + +return _M diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 6023c83bc3dc..c23eb42f1c47 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -504,6 +504,7 @@ plugins: # plugin list (sorted by priority) - jwt-auth # priority: 2510 - jwe-decrypt # priority: 2509 - key-auth # priority: 2500 + - dingtalk-auth # priority: 2430 - consumer-restriction # priority: 2400 - attach-consumer-label # priority: 2399 - forward-auth # priority: 2002 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index 115448b95b0e..5b9c6b980cdd 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 000000000000..224f6421cacb --- /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. +--- + + + +## 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": "", + "app_secret": "", + "secret": "", + "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": "", + "app_secret": "", + "secret": "", + "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": "", + "app_secret": "", + "secret": "", + "secret_fallbacks": [""], + "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 eea7505ca94f..3d419f650d9a 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 consumer-restriction attach-consumer-label forward-auth diff --git a/t/plugin/dingtalk-auth.t b/t/plugin/dingtalk-auth.t new file mode 100644 index 000000000000..767fb222440f --- /dev/null +++ b/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 + + + +=== 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]