Copilot commented on code in PR #13382: URL: https://github.com/apache/apisix/pull/13382#discussion_r3255995791
########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +local function fetch_access_token(conf, code) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local body = { + grant_type = "authorization_code", + client_id = conf.app_id, + client_secret = conf.app_secret, + redirect_uri = conf.auth_redirect_uri, + code = code, + } + + 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 feishu token: ", err) + return nil, nil, err + end + + core.log.debug("request feishu access token response status: ", + res.status) + + if res.status ~= 200 then + core.log.warn("unexpected http response status from feishu: ", + res.status, ", body: ", res.body) + return nil, nil, "unexpected response status: " .. res.status + .. ", body: " .. res.body + end + + local data, err = core.json.decode(res.body) + if not data then + core.log.error("failed to decode feishu token response: ", err) + return nil, nil, "failed to decode response: " .. (err or "nil") + end + + if not data.access_token or type(data.expires_in) ~= "number" then + core.log.error("feishu token response missing access_token or expires_in: ", res.body) + return nil, nil, "missing access_token or expires_in in response" + end + + return data.access_token, data.expires_in, nil +end + + +local function fetch_userinfo(conf, access_token) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(conf.userinfo_url, { + method = "GET", + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = conf.ssl_verify + }) + + if not res then + core.log.error("failed to verify feishu user: ", err) + return nil, err + end + + core.log.debug("request feishu userinfo response status: ", res.status, ", body: ", res.body) + + if res.status ~= 200 then + core.log.error("unexpected http response status from feishu: ", + 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 feishu userinfo response: ", err, ", body: ", res.body) + return nil, "failed to decode response: " .. err + end + + if data.code ~= 0 then + core.log.warn("failed to get feishu userinfo: ", res.body) + return nil, "unexpected error code: " .. data.code + .. ", errmsg: " .. (data.msg or "nil") + end + + return data.data, 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 + Review Comment: The authorization code is read from the client request but never removed from either the query string or request header before proxying. After a successful exchange, the upstream can receive and log the OAuth code via `code_query`/`code_header`; clear these credentials before forwarding the request. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +local function fetch_access_token(conf, code) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local body = { + grant_type = "authorization_code", + client_id = conf.app_id, + client_secret = conf.app_secret, + redirect_uri = conf.auth_redirect_uri, + code = code, + } + + 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 feishu token: ", err) + return nil, nil, err + end + + core.log.debug("request feishu access token response status: ", + res.status) + + if res.status ~= 200 then + core.log.warn("unexpected http response status from feishu: ", + res.status, ", body: ", res.body) + return nil, nil, "unexpected response status: " .. res.status + .. ", body: " .. res.body + end + + local data, err = core.json.decode(res.body) + if not data then + core.log.error("failed to decode feishu token response: ", err) + return nil, nil, "failed to decode response: " .. (err or "nil") + end + + if not data.access_token or type(data.expires_in) ~= "number" then + core.log.error("feishu token response missing access_token or expires_in: ", res.body) + return nil, nil, "missing access_token or expires_in in response" + end + + return data.access_token, data.expires_in, nil Review Comment: The Feishu OAuth token API returns `access_token` and `expires_in` inside the response `data` object for `/authen/v2/oauth/token`, but this code checks for those fields at the top level. Real successful Feishu responses will be rejected as missing the token, so authentication will fail outside the mock test server. ########## t/plugin/feishu-auth.t: ########## @@ -0,0 +1,299 @@ +# +# 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('warn'); +no_long_string(); +no_root_location(); Review Comment: These tests depend on state created by earlier blocks (for example, TEST 2 uses the route configured in TEST 1 and TEST 10/11 depend on TEST 9), but the file does not call `no_shuffle()`. If Test::Nginx shuffles blocks, the suite can become order-dependent and fail or test the wrong route configuration; most stateful plugin suites in `t/plugin` disable shuffling. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +local function fetch_access_token(conf, code) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local body = { + grant_type = "authorization_code", + client_id = conf.app_id, + client_secret = conf.app_secret, + redirect_uri = conf.auth_redirect_uri, + code = code, + } + + 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 feishu token: ", err) + return nil, nil, err + end + + core.log.debug("request feishu access token response status: ", + res.status) + + if res.status ~= 200 then + core.log.warn("unexpected http response status from feishu: ", + res.status, ", body: ", res.body) + return nil, nil, "unexpected response status: " .. res.status + .. ", body: " .. res.body + end + + local data, err = core.json.decode(res.body) + if not data then + core.log.error("failed to decode feishu token response: ", err) + return nil, nil, "failed to decode response: " .. (err or "nil") + end + + if not data.access_token or type(data.expires_in) ~= "number" then + core.log.error("feishu token response missing access_token or expires_in: ", res.body) + return nil, nil, "missing access_token or expires_in in response" + end + + return data.access_token, data.expires_in, nil +end + + +local function fetch_userinfo(conf, access_token) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(conf.userinfo_url, { + method = "GET", + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = conf.ssl_verify + }) + + if not res then + core.log.error("failed to verify feishu user: ", err) + return nil, err + end + + core.log.debug("request feishu userinfo response status: ", res.status, ", body: ", res.body) + + if res.status ~= 200 then + core.log.error("unexpected http response status from feishu: ", + 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 feishu userinfo response: ", err, ", body: ", res.body) + return nil, "failed to decode response: " .. err + end + + if data.code ~= 0 then + core.log.warn("failed to get feishu userinfo: ", res.body) + return nil, "unexpected error code: " .. data.code + .. ", errmsg: " .. (data.msg or "nil") + end + + return data.data, 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 = "feishu_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 refreshed = true + local access_token = sess:get("access_token") + if access_token then + local expires_at = sess:get("access_token_expires_at") + if expires_at and ngx_time() < expires_at then + refreshed = false + else + sess:delete("access_token") + sess:delete("access_token_expires_at") + end + end + + if refreshed then + local new_access_token, expires_in, err = fetch_access_token(conf, code) + if not new_access_token then + core.log.warn("failed to get feishu access token: ", err) + return 401, { + message = "Invalid authorization code", + } + end + access_token = new_access_token + sess:set("access_token", access_token) + sess:set("access_token_expires_at", ngx_time() + expires_in - 60) + end + + local new_userinfo, err = fetch_userinfo(conf, access_token) + if not new_userinfo then + core.log.warn("failed to get feishu userinfo: ", err) + sess:destroy() + 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) + sess:save() + core.log.info("verified feishu user, code: ", code, + ", app_id: ", conf.app_id) Review Comment: This logs the raw OAuth authorization code at info level. Authorization codes are bearer credentials during their validity window and should not be written to access/error logs; log only non-sensitive identifiers or a redacted value. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +local function fetch_access_token(conf, code) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local body = { + grant_type = "authorization_code", + client_id = conf.app_id, + client_secret = conf.app_secret, + redirect_uri = conf.auth_redirect_uri, + code = code, + } + + 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 feishu token: ", err) + return nil, nil, err + end + + core.log.debug("request feishu access token response status: ", + res.status) + + if res.status ~= 200 then + core.log.warn("unexpected http response status from feishu: ", + res.status, ", body: ", res.body) + return nil, nil, "unexpected response status: " .. res.status + .. ", body: " .. res.body + end + + local data, err = core.json.decode(res.body) + if not data then + core.log.error("failed to decode feishu token response: ", err) + return nil, nil, "failed to decode response: " .. (err or "nil") + end + + if not data.access_token or type(data.expires_in) ~= "number" then + core.log.error("feishu token response missing access_token or expires_in: ", res.body) + return nil, nil, "missing access_token or expires_in in response" + end + + return data.access_token, data.expires_in, nil +end + + +local function fetch_userinfo(conf, access_token) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(conf.userinfo_url, { + method = "GET", + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = conf.ssl_verify + }) + + if not res then + core.log.error("failed to verify feishu user: ", err) + return nil, err + end + + core.log.debug("request feishu userinfo response status: ", res.status, ", body: ", res.body) + + if res.status ~= 200 then + core.log.error("unexpected http response status from feishu: ", + 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 feishu userinfo response: ", err, ", body: ", res.body) + return nil, "failed to decode response: " .. err + end + + if data.code ~= 0 then + core.log.warn("failed to get feishu userinfo: ", res.body) + return nil, "unexpected error code: " .. data.code + .. ", errmsg: " .. (data.msg or "nil") + end + + return data.data, 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 = "feishu_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 Review Comment: This OAuth callback path accepts any authorization code without binding it to a per-session `state` value. A browser-based OAuth flow needs state generation and validation to prevent login CSRF/session fixation; otherwise an attacker can cause a victim to complete the callback with a code from the attacker's authorization flow. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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"}, Review Comment: `secret_fallbacks` contains the same kind of session-signing secrets as `secret`, but it is not included in `encrypt_fields`. When data encryption is enabled, these fallback keys would remain plaintext in etcd while the primary secret is encrypted. ########## docs/en/latest/plugins/feishu-auth.md: ########## @@ -0,0 +1,118 @@ +--- +title: feishu-auth +keywords: + - Apache APISIX + - API Gateway + - Plugin + - Feishu Auth + - feishu-auth +description: This document contains information about the Apache APISIX feishu-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. +# +--> + Review Comment: New plugin docs should include the canonical `<head>` link used by the existing plugin documentation (for example, `docs/en/latest/plugins/key-auth.md:31-33` and `docs/en/latest/plugins/cors.md:29-31`). This page currently starts the content directly after the license block, so the canonical URL is missing. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +local function fetch_access_token(conf, code) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local body = { + grant_type = "authorization_code", + client_id = conf.app_id, + client_secret = conf.app_secret, + redirect_uri = conf.auth_redirect_uri, + code = code, + } + + 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 feishu token: ", err) + return nil, nil, err + end + + core.log.debug("request feishu access token response status: ", + res.status) + + if res.status ~= 200 then + core.log.warn("unexpected http response status from feishu: ", + res.status, ", body: ", res.body) + return nil, nil, "unexpected response status: " .. res.status + .. ", body: " .. res.body + end + + local data, err = core.json.decode(res.body) + if not data then + core.log.error("failed to decode feishu token response: ", err) + return nil, nil, "failed to decode response: " .. (err or "nil") + end + + if not data.access_token or type(data.expires_in) ~= "number" then + core.log.error("feishu token response missing access_token or expires_in: ", res.body) + return nil, nil, "missing access_token or expires_in in response" + end + + return data.access_token, data.expires_in, nil +end + + +local function fetch_userinfo(conf, access_token) + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(conf.userinfo_url, { + method = "GET", + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = conf.ssl_verify + }) + + if not res then + core.log.error("failed to verify feishu user: ", err) + return nil, err + end + + core.log.debug("request feishu userinfo response status: ", res.status, ", body: ", res.body) + + if res.status ~= 200 then + core.log.error("unexpected http response status from feishu: ", + 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 feishu userinfo response: ", err, ", body: ", res.body) + return nil, "failed to decode response: " .. err + end + + if data.code ~= 0 then + core.log.warn("failed to get feishu userinfo: ", res.body) + return nil, "unexpected error code: " .. data.code + .. ", errmsg: " .. (data.msg or "nil") + end + + return data.data, 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 = "feishu_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 refreshed = true + local access_token = sess:get("access_token") + if access_token then + local expires_at = sess:get("access_token_expires_at") + if expires_at and ngx_time() < expires_at then + refreshed = false + else + sess:delete("access_token") + sess:delete("access_token_expires_at") + end + end + + if refreshed then + local new_access_token, expires_in, err = fetch_access_token(conf, code) + if not new_access_token then + core.log.warn("failed to get feishu access token: ", err) + return 401, { + message = "Invalid authorization code", + } + end + access_token = new_access_token + sess:set("access_token", access_token) + sess:set("access_token_expires_at", ngx_time() + expires_in - 60) + end + + local new_userinfo, err = fetch_userinfo(conf, access_token) + if not new_userinfo then + core.log.warn("failed to get feishu userinfo: ", err) + sess:destroy() + 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) + sess:save() + core.log.info("verified feishu user, code: ", code, + ", app_id: ", conf.app_id) + end + + if userinfo and conf.set_userinfo_header ~= false then + core.request.set_header(ctx, "X-Userinfo", base64_encode(core.json.encode(userinfo))) Review Comment: When `set_userinfo_header` is disabled, a client-supplied `X-Userinfo` header is left untouched and can reach the upstream. Since this plugin advertises `X-Userinfo` as its authenticated user output, clear the header before authentication (as `openid-connect` does) and only set it from verified session data when enabled. ########## docs/zh/latest/plugins/feishu-auth.md: ########## @@ -0,0 +1,121 @@ +--- +title: feishu-auth +keywords: + - Apache APISIX + - API 网关 + - Plugin + - Feishu Auth + - feishu-auth +description: 本篇文档介绍了 Apache APISIX feishu-auth 插件的相关信息。 +--- + +<!-- +# +# 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. +# +--> + Review Comment: New plugin docs should include the canonical `<head>` link used by the existing plugin documentation (for example, `docs/zh/latest/plugins/key-auth.md:31-33`). This page currently starts the content directly after the license block, so the canonical URL is missing. ########## apisix/plugins/feishu-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 +local ngx_time = ngx.time +local type = type + +local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + +local schema = { + type = "object", + properties = { + app_id = {type = "string", minLength = 1}, + app_secret = {type = "string", minLength = 1}, + code_header = { + type = "string", + description = "Header name to extract authorization code from.", + default = "X-Feishu-Code" + }, + code_query = { + type = "string", + description = "Query parameter name to extract 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 feishu user information in request headers", + default = true + }, + auth_redirect_uri = { + type = "string", + description = "Redirect URI for initiating Feishu OAuth flow", + }, + 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_id", "app_secret", "secret", "auth_redirect_uri", "redirect_uri"}, +} + +local _M = { + version = 0.1, + priority = 2420, + name = "feishu-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) Review Comment: `check_schema` does not call the HTTPS/TLS warning helpers for `auth_redirect_uri`, `redirect_uri`, `access_token_url`, or `userinfo_url`. Other auth integrations such as `authz-casdoor` and `openid-connect` call `core.utils.check_https` (and `check_tls_bool` for `ssl_verify`) so operators are warned before sending OAuth codes, client secrets, or access tokens over non-HTTPS endpoints. ########## docs/en/latest/plugins/feishu-auth.md: ########## @@ -0,0 +1,118 @@ +--- +title: feishu-auth +keywords: + - Apache APISIX + - API Gateway + - Plugin + - Feishu Auth + - feishu-auth +description: This document contains information about the Apache APISIX feishu-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 `feishu-auth` Plugin authenticates requests using the [Feishu (Lark) OAuth 2.0](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/oauth-2.0-overview) authorization flow. Users are redirected to the Feishu login page when unauthenticated. After a successful login, Feishu user information is stored in an encrypted session cookie and optionally forwarded to upstream services via the `X-Userinfo` header. + +## Attributes + +| Name | Type | Required | Default | Description | +|------------------------|----------|----------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| `app_id` | string | True | | Feishu application App ID. | +| `app_secret` | string | True | | Feishu application App Secret. | +| `secret` | string | True | | Secret used for signing the session cookie (8–32 characters). Must remain stable across restarts. | +| `auth_redirect_uri` | string | True | | Redirect URI registered in the Feishu application for the OAuth flow. | +| `redirect_uri` | string | True | | URI to redirect the user to when no authorization code is present (i.e. to start the OAuth flow). | +| `code_header` | string | False | `"X-Feishu-Code"` | HTTP header name to extract the Feishu authorization code from. | +| `code_query` | string | False | `"code"` | Query parameter name to extract the Feishu authorization code from. | +| `access_token_url` | string | False | `"https://open.feishu.cn/open-apis/authen/v2/oauth/token"` | URL to exchange the authorization code for an access token. | +| `userinfo_url` | string | False | `"https://open.feishu.cn/open-apis/authen/v1/user_info"` | URL to retrieve user information using the access token. | +| `set_userinfo_header` | boolean | False | `true` | When enabled, sets the `X-Userinfo` request header with Base64-encoded Feishu user information. | +| `cookie_expires_in` | integer | False | `86400` | Validity duration (in seconds) for the session cookie. | +| `secret_fallbacks` | array | False | | List of fallback secrets used during key rotation. | +| `timeout` | integer | False | `6000` | Timeout (in milliseconds) for HTTP requests to Feishu endpoints. | +| `ssl_verify` | boolean | False | `true` | When enabled, verifies the SSL certificate when connecting to Feishu endpoints. | + Review Comment: The English plugin docs omit the note that sensitive fields are encrypted with `encrypt_fields`, while the schema encrypts `app_secret` and `secret` and the Chinese doc includes this note. Add the same encryption-storage note here so English readers know how these credentials are stored. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
