blob: de895c463c3d71ff7f8a0a0d8308b9f25455d7d7 [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';
log_level('warn');
repeat_each(1);
no_long_string();
no_root_location();
no_shuffle();
add_block_preprocessor(sub {
my ($block) = @_;
if (!defined $block->request) {
$block->set_value("request", "GET /t");
}
});
run_tests;
__DATA__
=== TEST 1: Add route for sp1
--- config
location /t {
content_by_lua_block {
local kc = require("lib.keycloak_cas")
local core = require("apisix.core")
local default_opts = kc.get_default_opts()
local opts = core.table.deepcopy(default_opts)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/cas1',
ngx.HTTP_PUT,
[[{
"methods": ["GET", "POST"],
"host" : "127.0.0.1",
"plugins": {
"cas-auth": ]] .. core.json.encode(opts) .. [[
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/*"
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 2: login and logout ok
--- config
location /t {
content_by_lua_block {
local http = require "resty.http"
local httpc = http.new()
local kc = require "lib.keycloak_cas"
local path = "/uri"
local uri = "http://127.0.0.1:" .. ngx.var.server_port
local username = "test"
local password = "test"
local res, err, cas_cookie, keycloak_cookie = kc.login_keycloak(uri .. path, username, password)
if err or res.headers['Location'] ~= path then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
res, err = httpc:request_uri(uri .. res.headers['Location'], {
method = "GET",
headers = {
["Cookie"] = cas_cookie
}
})
assert(res.status == 200)
ngx.say(res.body)
res, err = kc.logout_keycloak(uri .. "/logout", cas_cookie, keycloak_cookie)
assert(res.status == 200)
}
}
--- response_body_like
uri: /uri
cookie: .*
host: 127.0.0.1:1984
user-agent: .*
x-real-ip: 127.0.0.1
=== TEST 3: Add route for sp2
--- config
location /t {
content_by_lua_block {
local kc = require("lib.keycloak_cas")
local core = require("apisix.core")
local default_opts = kc.get_default_opts()
local opts = core.table.deepcopy(default_opts)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/cas2',
ngx.HTTP_PUT,
[[{
"methods": ["GET", "POST"],
"host" : "127.0.0.2",
"plugins": {
"cas-auth": ]] .. core.json.encode(opts) .. [[
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/*"
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 4: login sp1 and sp2, then do single logout
--- config
location /t {
content_by_lua_block {
local http = require "resty.http"
local httpc = http.new()
local kc = require "lib.keycloak_cas"
local path = "/uri"
-- login to sp1
local uri = "http://127.0.0.1:" .. ngx.var.server_port
local username = "test"
local password = "test"
local res, err, cas_cookie, keycloak_cookie = kc.login_keycloak(uri .. path, username, password)
if err or res.headers['Location'] ~= path then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
res, err = httpc:request_uri(uri .. res.headers['Location'], {
method = "GET",
headers = {
["Cookie"] = cas_cookie
}
})
assert(res.status == 200)
-- login to sp2, which would skip login at keycloak side
local uri2 = "http://127.0.0.2:" .. ngx.var.server_port
local res, err, cas_cookie2 = kc.login_keycloak_for_second_sp(uri2 .. path, keycloak_cookie)
if err or res.headers['Location'] ~= path then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
res, err = httpc:request_uri(uri2 .. res.headers['Location'], {
method = "GET",
headers = {
["Cookie"] = cas_cookie2
}
})
assert(res.status == 200)
-- SLO (single logout)
res, err = kc.logout_keycloak(uri .. "/logout", cas_cookie, keycloak_cookie)
assert(res.status == 200)
-- login to sp2, which would do normal login process at keycloak side
local res, err, cas_cookie2, keycloak_cookie = kc.login_keycloak(uri2 .. path, username, password)
if err or res.headers['Location'] ~= path then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
res, err = httpc:request_uri(uri .. res.headers['Location'], {
method = "GET",
headers = {
["Cookie"] = cas_cookie2
}
})
assert(res.status == 200)
-- logout sp2
res, err = kc.logout_keycloak(uri2 .. "/logout", cas_cookie2, keycloak_cookie)
assert(res.status == 200)
}
}
=== TEST 5: schema rejects missing cookie.secret
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local ok, err = plugin.check_schema({
idp_uri = "http://127.0.0.1:8080",
cas_callback_uri = "/cas_callback",
logout_uri = "/logout",
cookie = {},
})
ngx.say(ok and "passed" or err)
}
}
--- response_body_like
.*property "secret" is required.*
=== TEST 6: schema rejects cookie.secret shorter than 32 chars
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local ok, err = plugin.check_schema({
idp_uri = "http://127.0.0.1:8080",
cas_callback_uri = "/cas_callback",
logout_uri = "/logout",
cookie = { secret = "tooshort" },
})
ngx.say(ok and "passed" or err)
}
}
--- response_body_like
.*string too short.*
=== TEST 7: schema rejects cookie.samesite=Strict
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local ok, err = plugin.check_schema({
idp_uri = "http://127.0.0.1:8080",
cas_callback_uri = "/cas_callback",
logout_uri = "/logout",
cookie = {
secret = "0123456789abcdef0123456789abcdef",
samesite = "Strict",
},
})
ngx.say(ok and "passed" or err)
}
}
--- response_body_like
.*samesite.*
=== TEST 8: schema rejects samesite=None with secure=false
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local ok, err = plugin.check_schema({
idp_uri = "http://127.0.0.1:8080",
cas_callback_uri = "/cas_callback",
logout_uri = "/logout",
cookie = {
secret = "0123456789abcdef0123456789abcdef",
samesite = "None",
secure = false,
},
})
ngx.say(ok and "passed" or err)
}
}
--- response_body_like
.*cookie.secure must be true when cookie.samesite is "None".*
=== TEST 9: is_safe_redirect rejects external and protocol-relative URLs
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local h = plugin._test_helpers
local cases = {
{"/foo", true},
{"/foo?bar=baz", true},
{"https://evil.com/x", false},
{"//evil.com/x", false},
{"\\\\evil.com", false},
{"/foo\r\nLocation: x", false},
{"", false},
{nil, false},
}
for _, c in ipairs(cases) do
local got = h.is_safe_redirect(c[1])
if got ~= c[2] then
ngx.say("FAIL ", tostring(c[1]), " expected ", tostring(c[2]),
" got ", tostring(got))
return
end
end
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 10: sign and verify roundtrip + tamper detection
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local h = plugin._test_helpers
local secret = "0123456789abcdef0123456789abcdef"
local signed = h.sign_value(secret, "/foo?bar=baz")
assert(signed, "sign_value returned nil")
local got = h.verify_value(secret, signed)
if got ~= "/foo?bar=baz" then
ngx.say("FAIL roundtrip got=", tostring(got))
return
end
-- flip last char of the signature segment
local tampered = signed:sub(1, -2) ..
(signed:sub(-1) == "A" and "B" or "A")
if h.verify_value(secret, tampered) ~= nil then
ngx.say("FAIL tampered signature accepted")
return
end
-- a different secret must not validate
if h.verify_value("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", signed) ~= nil then
ngx.say("FAIL wrong secret accepted")
return
end
-- nil and malformed inputs
if h.verify_value(secret, nil) ~= nil
or h.verify_value(secret, "no-dot-here") ~= nil
or h.verify_value(secret, "abc.def") ~= nil then
ngx.say("FAIL malformed cookie accepted")
return
end
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 11: callback_path derives path from relative and absolute cas_callback_uri
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.cas-auth")
local h = plugin._test_helpers
local cases = {
{"/cas_callback", "/cas_callback"},
{"https://app.example.com/cas_callback", "/cas_callback"},
{"http://app.example.com:8443/cb", "/cb"},
{"https://app.example.com", "/"},
{"https://app.example.com/cb?from=cas", "/cb"},
{"https://app.example.com/cb#frag", "/cb"},
}
for _, c in ipairs(cases) do
local got = h.callback_path(c[1])
if got ~= c[2] then
ngx.say("FAIL ", tostring(c[1]), " expected ", tostring(c[2]),
" got ", tostring(got))
return
end
end
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 12: add route with an absolute cas_callback_uri
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/cas-abs',
ngx.HTTP_PUT,
[[{
"methods": ["GET", "POST"],
"plugins": {
"cas-auth": {
"idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas",
"cas_callback_uri": "https://app.example.com/cas_callback",
"logout_uri": "/logout",
"cookie": {
"secret": "0123456789abcdef0123456789abcdef"
}
}
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/*"
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 13: absolute cas_callback_uri keeps service URL fixed despite forged Host
--- 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 .. "/uri"
local res, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Host"] = "attacker.example.net",
}
})
if not res then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
ngx.say(res.status)
ngx.say(res.headers['Location'])
}
}
--- response_body_like
^302
.*service=https%3A%2F%2Fapp\.example\.com%2Fcas_callback.*$
=== TEST 14: add route for callback initiation-cookie gate
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/cas-gate',
ngx.HTTP_PUT,
[[{
"methods": ["GET", "POST"],
"host": "127.0.0.3",
"plugins": {
"cas-auth": {
"idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas",
"cas_callback_uri": "/cas_callback",
"logout_uri": "/logout",
"cookie": {
"secret": "0123456789abcdef0123456789abcdef",
"secure": false
}
}
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/*"
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 15: callback without initiation cookie returns 401 and creates no 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
.. "/cas_callback?ticket=ST-test"
local res, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Host"] = "127.0.0.3",
}
})
if not res then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
ngx.say(res.status)
local set_cookie = res.headers['Set-Cookie']
local has_session = false
if type(set_cookie) == "string" then
if set_cookie:find("^CAS_SESSION_") then
has_session = true
end
elseif type(set_cookie) == "table" then
for _, c in ipairs(set_cookie) do
if c:find("^CAS_SESSION_") then
has_session = true
break
end
end
end
ngx.say("session_cookie_set=", tostring(has_session))
-- No shared-dict entry should have been written for ST-test
-- under any configuration's fingerprint namespace.
local in_store = false
for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do
if k:find(":ST-test", 1, true) then
in_store = true
break
end
end
ngx.say("session_in_store=", tostring(in_store))
}
}
--- response_body
401
session_cookie_set=false
session_in_store=false
=== TEST 16: callback with invalid initiation cookie returns 401 and creates no 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
.. "/cas_callback?ticket=ST-test"
local res, err = httpc:request_uri(uri, {
method = "GET",
headers = {
["Host"] = "127.0.0.3",
["Cookie"] = "CAS_REQUEST_URI=not-a-valid-signed-value",
}
})
if not res then
ngx.log(ngx.ERR, err)
ngx.exit(500)
end
ngx.say(res.status)
local set_cookie = res.headers['Set-Cookie']
local has_session = false
if type(set_cookie) == "string" then
if set_cookie:find("^CAS_SESSION_") then
has_session = true
end
elseif type(set_cookie) == "table" then
for _, c in ipairs(set_cookie) do
if c:find("^CAS_SESSION_") then
has_session = true
break
end
end
end
ngx.say("session_cookie_set=", tostring(has_session))
-- No shared-dict entry should have been written for ST-test
-- under any configuration's fingerprint namespace.
local in_store = false
for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do
if k:find(":ST-test", 1, true) then
in_store = true
break
end
end
ngx.say("session_in_store=", tostring(in_store))
}
}
--- response_body
401
session_cookie_set=false
session_in_store=false
=== TEST 17: Add dedicated routes for the per-config scoping test
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
-- Use priority=10 so these routes win over the no-host catch-all
-- registered in earlier tests (cas-abs), and unique hosts so they
-- don't collide with cas1/cas2.
local function put(id, host, cb)
local code, body = t('/apisix/admin/routes/' .. id,
ngx.HTTP_PUT,
string.format([[{
"methods": ["GET", "POST"],
"host": %q,
"priority": 10,
"plugins": {
"cas-auth": {
"idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas",
"cas_callback_uri": %q,
"logout_uri": "/logout",
"cookie": {
"secret": "0123456789abcdef0123456789abcdef",
"secure": false
}
}
},
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"uri": "/*"
}]], host, cb))
if code >= 300 then
ngx.status = code
ngx.say(body)
return false
end
return true
end
if not put("cas-scope-a", "127.0.0.10", "/cas_callback") then return end
if not put("cas-scope-b", "127.0.0.11", "/cas_callback_alt") then return end
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 18: sessions from one CAS configuration are not honoured under another
--- config
location /t {
content_by_lua_block {
local http = require "resty.http"
local resty_sha256 = require("resty.sha256")
local str = require("resty.string")
-- Recompute the per-config fingerprint here rather than exposing
-- the plugin's session_opts helper. Algorithm matches the plugin.
local function fingerprint(idp, cb)
local s = resty_sha256:new()
s:update(idp .. "|" .. cb)
return str.to_hex(s:final())
end
local idp = "http://127.0.0.1:8080/realms/test/protocol/cas"
local fp_a = fingerprint(idp, "/cas_callback")
local fp_b = fingerprint(idp, "/cas_callback_alt")
assert(fp_a ~= fp_b, "two configs must yield different fingerprints")
-- Plant a session as the plugin would: store key namespaced by the
-- fingerprint, value of "<fp>|<user>". This exercises the plugin's
-- session-read path (with_session_id -> store:get -> unpack_entry
-- -> fingerprint check) on scope-a.
local ticket = "ST-scope-test-" .. tostring(ngx.now())
local key_a = fp_a .. ":" .. ticket
local ok, err = ngx.shared.cas_sessions:set(key_a, fp_a .. "|alice", 60)
assert(ok, "plant failed: " .. tostring(err))
local httpc = http.new()
local base = "http://127.0.0.1:" .. ngx.var.server_port
-- Route scope-a (host 127.0.0.10) honours its own session.
local res, err2 = httpc:request_uri(base .. "/uri", {
method = "GET",
headers = {
["Host"] = "127.0.0.10",
["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket,
},
})
assert(res, "scope-a request failed: " .. tostring(err2))
assert(res.status == 200,
"scope-a should honour its own session, got status " .. res.status)
-- Same cookie sent to scope-b (different cas_callback_uri, different
-- fingerprint): scope-b looks for CAS_SESSION_<fp_b>, doesn't find
-- it, redirects to its own IdP.
res, err2 = httpc:request_uri(base .. "/uri", {
method = "GET",
headers = {
["Host"] = "127.0.0.11",
["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket,
},
})
assert(res, "scope-b request failed: " .. tostring(err2))
assert(res.status == 302,
"scope-b must not honour foreign cookie name, got "
.. res.status)
-- A forged cookie under scope-b's own name pointing at scope-a's
-- ticket: the namespaced store key under fp_b doesn't exist,
-- so the request still falls through to first_access.
res, err2 = httpc:request_uri(base .. "/uri", {
method = "GET",
headers = {
["Host"] = "127.0.0.11",
["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket,
},
})
assert(res, "scope-b forged-cookie request failed: " .. tostring(err2))
assert(res.status == 302,
"scope-b must not honour foreign session payload, got "
.. res.status)
-- Plant an entry under scope-b's namespaced key but with scope-a's
-- fingerprint inside the stored value. This is the only path that
-- reaches the in-value fingerprint check in with_session_id:
-- store:get finds the entry, but unpack_entry returns fp_a while
-- the route's opts.fingerprint is fp_b -> first_access (302).
local key_b_forged = fp_b .. ":" .. ticket
local ok2, err3 = ngx.shared.cas_sessions:set(key_b_forged,
fp_a .. "|alice", 60)
assert(ok2, "forged plant failed: " .. tostring(err3))
res, err2 = httpc:request_uri(base .. "/uri", {
method = "GET",
headers = {
["Host"] = "127.0.0.11",
["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket,
},
})
assert(res, "scope-b fingerprint-mismatch request failed: " .. tostring(err2))
assert(res.status == 302,
"scope-b must reject a stored entry whose fingerprint does not match, got "
.. res.status)
ngx.shared.cas_sessions:delete(key_a)
ngx.shared.cas_sessions:delete(key_b_forged)
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 19: add route for empty-body SLO callback test
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/cas-slo',
ngx.HTTP_PUT,
[[{
"methods": ["GET", "POST"],
"host": "127.0.0.20",
"priority": 10,
"plugins": {
"cas-auth": {
"idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas",
"cas_callback_uri": "/cas_callback",
"logout_uri": "/logout",
"cookie": {
"secret": "0123456789abcdef0123456789abcdef",
"secure": false
}
}
},
"upstream": {
"nodes": {"127.0.0.1:1980": 1},
"type": "roundrobin"
},
"uri": "/*"
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 20: malformed SLO POST to callback returns 400, not 500
--- config
location /t {
content_by_lua_block {
local http = require "resty.http"
local httpc = http.new()
local base = "http://127.0.0.1:" .. ngx.var.server_port
-- (1) no body at all: get_body() returns nil, which must not be
-- indexed (500) but fall through to a clean 400.
local res, err = httpc:request_uri(base .. "/cas_callback", {
method = "POST",
headers = { ["Host"] = "127.0.0.20" },
})
assert(res, "request failed: " .. tostring(err))
assert(res.status == 400,
"expected 400 for empty-body SLO POST, got " .. res.status)
assert(res.body and res.body:find("no ticket", 1, true),
"expected 'no ticket' message, got: " .. tostring(res.body))
-- (2) body present but SessionIndex empty: still a malformed
-- logout, must be 400 rather than passing an empty ticket through.
res, err = httpc:request_uri(base .. "/cas_callback", {
method = "POST",
headers = { ["Host"] = "127.0.0.20" },
body = "<samlp:SessionIndex></samlp:SessionIndex>",
})
assert(res, "request failed: " .. tostring(err))
assert(res.status == 400,
"expected 400 for empty SessionIndex, got " .. res.status)
ngx.say("passed")
}
}
--- response_body
passed