blob: 9368b9e801fd264b78812a51406c1ddae03705dd [file]
#
# 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();
no_shuffle();
add_block_preprocessor(sub {
my ($block) = @_;
my $extra_init_by_lua = <<_EOC_;
local server = require("lib.server")
server.token = function()
local json = require("cjson")
local headers = ngx.req.get_headers()
ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ", json.encode(headers))
ngx.req.read_body()
local data = ngx.req.get_body_data()
ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
local payload = json.decode(data)
if payload.code ~= "passed" then
ngx.status = 400
ngx.say([[{"code": 20051, "error_description": "Unauthorized"}]])
return
end
ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
local resp_payload = [[
{
"code": 0,
"expires_in": 7200,
"access_token": "85b8b7665c4c3bc5bd91d8e6cb6594b7",
"token_type": "Bearer"
}
]]
ngx.say(resp_payload)
end
server.userinfo = function()
local json = require("cjson")
local headers = ngx.req.get_headers()
ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ", json.encode(headers))
local resp_payload = [[
{
"code": 0,
"data": {
"avatar_big": "https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4be",
"avatar_middle": "https://s1-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4",
"avatar_thumb": "https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba",
"avatar_url": "https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4g",
"en_name": "jack",
"name": "jack",
"open_id": "ou_8fc70d9ea27111749a71eb",
"tenant_key": "1224d18e8d",
"union_id": "on_c249ec29c9d6"
},
"msg": "success"
}
]]
ngx.say(resp_payload)
end
server.hello_echo = function()
-- echo back the X-Userinfo header value so tests can assert it was cleared
ngx.say(ngx.req.get_headers()["x-userinfo"] or "none")
end
_EOC_
$block->set_value("extra_init_by_lua", $extra_init_by_lua);
if (!$block->request) {
if (!$block->stream_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: enable feishu-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":{
"feishu-auth":{
"app_id": "123",
"app_secret": "456",
"secret": "my-secret",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"cookie_expires_in": 2,
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
if code <= 201 then
ngx.status = 200
end
ngx.say(body)
}
}
--- request
GET /t
--- response_body
passed
=== TEST 2: missing code
--- request
GET /hello
--- error_code: 302
--- response_headers
Location: /echo
=== TEST 3: invalid code
--- request
GET /hello?code=invalid
--- error_code: 401
--- response_body
{"message":"Invalid authorization code"}
=== TEST 4: valid code
--- request
GET /hello?code=passed
--- error_code: 200
--- response_body
hello world
=== TEST 5: X-Feishu-Code with invalid code
--- request
GET /hello
--- more_headers
X-Feishu-Code: invalid
--- error_code: 401
--- response_body
{"message":"Invalid authorization code"}
=== TEST 6: X-Feishu-Code header
--- request
GET /hello
--- more_headers
X-Feishu-Code: passed
--- error_code: 200
--- response_body
hello world
=== TEST 7: check cookie
--- 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, {
query = {
code = "passed",
},
method = "GET",
})
assert(res, "request failed: " .. (err or "unknown error"))
assert(res.status == 200, "unexpected res status: " .. res.status)
local cookie = res.headers["Set-Cookie"]
assert(cookie, "missing Set-Cookie header")
-- request with cookie
local res2, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Cookie"] = cookie,
},
})
assert(res2, "request failed: " .. (err or "unknown error"))
assert(res2.status == 200, "unexpected res2 status: " .. res2.status)
--- request without cookie
local res3, err = httpc:request_uri(uri, {
method = "GET",
})
assert(res3, "request failed: " .. (err or "unknown error"))
assert(res3.status == 302, "unexpected res3 status: " .. res3.status)
ngx.say("passed")
}
}
--- request
GET /t
--- response_body
passed
=== TEST 8: cookie expire
--- 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, {
query = {
code = "passed",
},
method = "GET",
})
assert(res, "request failed: " .. (err or "unknown error"))
assert(res.status == 200, "unexpected res status: " .. res.status)
local cookie = res.headers["Set-Cookie"]
assert(cookie, "missing Set-Cookie header")
-- request with cookie
local res2, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Cookie"] = cookie,
},
})
assert(res2, "request failed: " .. (err or "unknown error"))
assert(res2.status == 200, "unexpected res2 status: " .. res2.status)
ngx.sleep(3)
--- request without cookie
local res3, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Cookie"] = cookie,
},
})
assert(res3, "request failed: " .. (err or "unknown error"))
assert(res3.status == 302, "unexpected res3 status: " .. res3.status)
ngx.say("passed")
}
}
--- timeout: 5
--- request
GET /t
--- response_body
passed
=== TEST 9: specify header and query and redirect_uri
--- 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":{
"feishu-auth":{
"app_id": "123",
"app_secret": "456",
"secret": "my-secret",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"code_query": "custom_code",
"code_header": "Custom-feishu-Code",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
if code <= 201 then
ngx.status = 200
end
ngx.say(body)
}
}
--- request
GET /t
--- response_body
passed
=== TEST 10: specify query
--- pipelined_requests eval
["GET /hello?code=passed", "GET /hello?custom_code=passed"]
--- error_code eval
[302, 200]
=== TEST 11: specify header
--- pipelined_requests eval
["GET /hello", "GET /hello"]
--- more_headers eval
[
"X-Feishu-Code: passed",
"Custom-Feishu-Code: passed"
]
--- error_code eval
[302, 200]
=== TEST 12: secret_fallbacks allows session created with old secret after key rotation
--- config
location /t {
content_by_lua_block {
local http = require("resty.http")
local httpc = http.new()
local t = require("lib.test_admin").test
-- step 1: create route with secret-v1, no fallbacks
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"plugins": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "secret-v1",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
assert(code <= 201, "setup v1 failed: " .. tostring(code))
-- step 2: authenticate with secret-v1 and capture session cookie
local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
local res, err = httpc:request_uri(uri, {
method = "GET",
query = {code = "passed"},
})
assert(res, err)
assert(res.status == 200, "expected 200, got " .. res.status)
local old_cookie = res.headers["Set-Cookie"]
assert(old_cookie, "expected Set-Cookie from v1")
-- step 3: rotate to secret-v2 with secret-v1 in secret_fallbacks
local code2, body2 = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"plugins": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "secret-v2",
"secret_fallbacks": ["secret-v1"],
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
assert(code2 <= 201, "setup v2 failed: " .. tostring(code2))
-- step 4: old cookie should still work via fallback
local res2, err2 = httpc:request_uri(uri, {
method = "GET",
headers = {["Cookie"] = old_cookie},
})
assert(res2, err2)
assert(res2.status == 200,
"old cookie should be accepted via fallback, got " .. res2.status)
-- step 5: remove fallbacks; old cookie should no longer work
local code3, body3 = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"plugins": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "secret-v2",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
assert(code3 <= 201, "setup v2-no-fallback failed: " .. tostring(code3))
local res3, err3 = httpc:request_uri(uri, {
method = "GET",
headers = {["Cookie"] = old_cookie},
})
assert(res3, err3)
assert(res3.status == 302,
"old cookie should be rejected without fallback, got " .. res3.status)
ngx.say("passed")
}
}
--- request
GET /t
--- response_body
passed
=== TEST 13: forged X-Userinfo header does not bypass authentication
--- config
location /t {
content_by_lua_block {
local http = require("resty.http")
local httpc = http.new()
local t = require("lib.test_admin").test
-- restore route to a simple config
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"plugins": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "my-secret-xyz",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
assert(code <= 201, "setup failed: " .. tostring(code))
local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
-- forged X-Userinfo without a session cookie must be rejected
local res1, err1 = httpc:request_uri(uri, {
method = "GET",
headers = {
["X-Userinfo"] = ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
},
})
assert(res1, err1)
assert(res1.status == 302,
"forged X-Userinfo without cookie should be rejected, got " .. res1.status)
-- obtain a legitimate session cookie
local res2, err2 = httpc:request_uri(uri, {
method = "GET",
query = {code = "passed"},
})
assert(res2, err2)
assert(res2.status == 200, "expected 200 on auth, got " .. res2.status)
local cookie = res2.headers["Set-Cookie"]
assert(cookie, "expected Set-Cookie after auth")
-- valid cookie + forged X-Userinfo: request succeeds only due to the cookie
local res3, err3 = httpc:request_uri(uri, {
method = "GET",
headers = {
["Cookie"] = cookie,
["X-Userinfo"] = ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
},
})
assert(res3, err3)
assert(res3.status == 200,
"valid cookie should be accepted regardless of forged header, got " .. res3.status)
-- create a route with set_userinfo_header=false to verify the forged header
-- is cleared and not forwarded to upstream
local code2, body2 = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"plugins": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "my-secret-xyz",
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo",
"set_userinfo_header": false
}
},
"uri": "/hello-echo"
}]]
)
assert(code2 <= 201, "setup echo route failed: " .. tostring(code2))
local echo_uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello-echo"
local forged = ngx.encode_base64('{"open_id":"forged","name":"hacker"}')
-- with set_userinfo_header=false, upstream must not receive any X-Userinfo
local res4, err4 = httpc:request_uri(echo_uri, {
method = "GET",
headers = {
["Cookie"] = cookie,
["X-Userinfo"] = forged,
},
})
assert(res4, err4)
assert(res4.status == 200,
"expected 200 on echo route, got " .. res4.status)
assert(res4.body == "none\n",
"forged X-Userinfo must not reach upstream, got: " .. (res4.body or "nil"))
ngx.say("passed")
}
}
--- request
GET /t
--- response_body
passed
=== TEST 14: secret_fallbacks values are encrypted in etcd
--- yaml_config
apisix:
data_encryption:
enable_encrypt_fields: true
keyring:
- edd1c9f0985e76a2
--- config
location /t {
content_by_lua_block {
local json = require("toolkit.json")
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": {
"feishu-auth": {
"app_id": "123",
"app_secret": "456",
"secret": "my-secret-key-v2",
"secret_fallbacks": ["my-secret-key-v1"],
"auth_redirect_uri": "https://example.com",
"access_token_url": "http://127.0.0.1:1980/token",
"userinfo_url": "http://127.0.0.1:1980/userinfo",
"redirect_uri": "/echo"
}
},
"uri": "/hello"
}]]
)
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.sleep(0.1)
-- Admin API should return decrypted values
local code2, _, res2 = t('/apisix/admin/routes/1', ngx.HTTP_GET)
assert(code2 == 200, "admin get route failed: " .. tostring(code2))
res2 = json.decode(res2)
local conf = res2.value.plugins["feishu-auth"]
ngx.say("admin secret_fallbacks[1]: ", conf.secret_fallbacks[1])
-- etcd should store encrypted values
local etcd = require("apisix.core.etcd")
local etcd_res = assert(etcd.get('/routes/1'))
local etcd_conf = etcd_res.body.node.value.plugins["feishu-auth"]
assert(etcd_conf.secret_fallbacks and etcd_conf.secret_fallbacks[1],
"expected secret_fallbacks[1] in etcd payload")
ngx.say("etcd secret_fallbacks[1] encrypted: ",
etcd_conf.secret_fallbacks[1] ~= "my-secret-key-v1")
}
}
--- response_body
admin secret_fallbacks[1]: my-secret-key-v1
etcd secret_fallbacks[1] encrypted: true