feat: add dingtalk-auth plugin#13381
Open
AlinsRan wants to merge 3 commits into
Open
Conversation
Add the dingtalk-auth plugin that integrates DingTalk OAuth 2.0 authentication into APISIX routes. The plugin: - Validates a DingTalk authorization code from a configurable query parameter (default: "code") or request header (default: "X-DingTalk-Code") - Exchanges the code for an access token via the DingTalk token API and caches it with a 7000-second TTL to avoid repeated token fetches - Retrieves DingTalk user information and stores it in an encrypted cookie session (lua-resty-session v4) - Forwards user information to upstream in the X-Userinfo header (Base64-encoded JSON) when set_userinfo_header is true - Supports secret_fallbacks for zero-downtime session key rotation Priority: 2430 (between key-auth 2500 and consumer-restriction 2400) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nfo encoding error - Check return value of sess:save() and return 500 if it fails, rather than silently proceeding with an unsaved session - Handle encoding error for the X-Userinfo header gracefully instead of passing nil to base64_encode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add dingtalk-auth to apisix/cli/config.lua so the plugin is loaded by the APISIX runtime and recognized by the Admin API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a new dingtalk-auth plugin that integrates DingTalk OAuth 2.0 authentication into APISIX routes. The plugin extracts an authorization code from query/header, exchanges it for an access token (with an LRU cache), retrieves user info from DingTalk, and persists the verified user in an encrypted lua-resty-session v4 cookie. It also optionally forwards user info to the upstream via an X-Userinfo header.
Changes:
- New plugin implementation
apisix/plugins/dingtalk-auth.luawith priority 2430 and encryptedapp_secret/secretfields. - New test suite
t/plugin/dingtalk-auth.t(13 cases) and registration int/admin/plugins.t,apisix/cli/config.lua, andconf/config.yaml.example. - New English documentation
docs/en/latest/plugins/dingtalk-auth.mdand sidebar entry indocs/en/latest/config.json.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| apisix/plugins/dingtalk-auth.lua | New plugin implementing DingTalk OAuth2 code-to-userinfo flow with session cookie. |
| apisix/cli/config.lua | Registers dingtalk-auth in the default plugin list at priority 2430. |
| conf/config.yaml.example | Adds dingtalk-auth to the example plugin list. |
| t/plugin/dingtalk-auth.t | New test suite mocking DingTalk endpoints and exercising schema, redirect, code, session, and custom code-source paths. |
| t/admin/plugins.t | Adds plugin to expected admin plugin list. |
| docs/en/latest/plugins/dingtalk-auth.md | New English plugin documentation. |
| docs/en/latest/config.json | Adds doc page to the English sidebar. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+210
to
+277
| 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 not access_token then | ||
| core.log.error("failed to get dingtalk access token: ", err) | ||
| return 500, { | ||
| message = "Invalid configuration", |
| core.log.warn("failed to encode userinfo for X-Userinfo header: ", encode_err) | ||
| end | ||
| end | ||
| ctx.external_user = userinfo |
Comment on lines
+62
to
+84
| 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, | ||
| }, |
| }) | ||
|
|
||
| local DEFAULT_USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo" | ||
| local DEFAULT_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" |
Comment on lines
+111
to
+145
| === 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 | ||
|
|
Comment on lines
+70
to
+86
| 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"}, |
Comment on lines
+23
to
+28
| -- 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, | ||
| }) |
Comment on lines
+38
to
+62
| 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}, |
| ", app_key: ", conf.app_key) | ||
| end | ||
|
|
||
| if userinfo and conf.set_userinfo_header ~= false then |
Comment on lines
+279
to
+285
| 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 |
Comment on lines
+231
to
+232
| core.log.error("failed to decode userinfo in session: ", err) | ||
| return 500, {message = "Invalid userinfo in session"} |
Comment on lines
+258
to
+259
| return 401, { | ||
| message = "Invalid authorization code", |
Comment on lines
+53
to
+54
| errcode = 403, | ||
| errmsg = "Unauthorized" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add the
dingtalk-authplugin that integrates DingTalk (Ding Talk / 钉钉) OAuth 2.0 authentication into APISIX routes.How it works
code) or HTTP header (default:X-DingTalk-Code).redirect_uri(typically the DingTalk OAuth login page).access_token_url), then retrieves user information from the DingTalk user info API (userinfo_url).lua-resty-sessionv4 cookie session. Subsequent requests carrying the session cookie bypass all DingTalk API calls.set_userinfo_headeristrue(default), the upstream receives the user information in theX-Userinfoheader as a Base64-encoded JSON object.Key attributes
app_keyapp_secretsecretredirect_uricode_querycodecode_headerX-DingTalk-Codecookie_expires_in86400secret_fallbacksPlugin priority: 2430 (between
key-auth2500 andconsumer-restriction2400).Changes
apisix/plugins/dingtalk-auth.lua— plugin implementationt/plugin/dingtalk-auth.t— test suite (13 test cases)docs/en/latest/plugins/dingtalk-auth.md— English documentationconf/config.yaml.example— register plugin in default listdocs/en/latest/config.json— add to sidebar navigationt/admin/plugins.t— register plugin in admin test list