This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 37c29f8b3 feat: add saml-auth plugin (#13346)
37c29f8b3 is described below
commit 37c29f8b38e7aa2961491280a817f08b35902db7
Author: AlinsRan <[email protected]>
AuthorDate: Mon Jun 1 14:43:58 2026 +0800
feat: add saml-auth plugin (#13346)
---
Makefile | 1 +
apisix-master-0.rockspec | 1 +
apisix/cli/config.lua | 1 +
apisix/plugins/saml-auth.lua | 119 +++++++
ci/init-plugin-test-service.sh | 15 +
ci/linux-install-openresty.sh | 4 +-
ci/pod/docker-compose.plugin.yml | 13 +
ci/pod/keycloak/kcadm_configure_saml.sh | 39 ++
ci/redhat-ci.sh | 2 +-
conf/config.yaml.example | 1 +
docker/debian-dev/Dockerfile | 7 +-
docs/en/latest/config.json | 3 +-
docs/en/latest/plugins/saml-auth.md | 153 ++++++++
docs/zh/latest/config.json | 3 +-
docs/zh/latest/plugins/saml-auth.md | 153 ++++++++
t/admin/plugins.t | 1 +
t/lib/keycloak_saml.lua | 469 ++++++++++++++++++++++++
t/plugin/saml-auth.t | 608 ++++++++++++++++++++++++++++++++
utils/install-dependencies.sh | 4 +-
utils/linux-install-luarocks.sh | 1 +
20 files changed, 1590 insertions(+), 8 deletions(-)
diff --git a/Makefile b/Makefile
index 0e5bcc719..24a160a2c 100644
--- a/Makefile
+++ b/Makefile
@@ -133,6 +133,7 @@ deps: install-runtime
$(eval ENV_LUAROCKS_VER := $(shell $(ENV_LUAROCKS) --version | grep -E
-o "luarocks [0-9]+."))
@if [ '$(ENV_LUAROCKS_VER)' = 'luarocks 3.' ]; then \
mkdir -p ~/.luarocks; \
+ $(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.OPENSSL_DIR $(ENV_OPENSSL_PREFIX); \
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.OPENSSL_LIBDIR $(addprefix $(ENV_OPENSSL_PREFIX), /lib); \
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.OPENSSL_INCDIR $(addprefix $(ENV_OPENSSL_PREFIX), /include); \
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.YAML_DIR $(ENV_LIBYAML_INSTALL_PREFIX); \
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 2beb608ba..283d4b961 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -51,6 +51,7 @@ dependencies = {
"lua-resty-radixtree = 2.9.2-0",
"lua-protobuf = 0.5.3-1",
"lua-resty-openidc = 1.8.0-1",
+ "lua-resty-saml = 0.2.5",
"luafilesystem = 1.8.0-1",
"nginx-lua-prometheus-api7 = 0.20250302-1",
"jsonschema = 0.9.13-0",
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index c4003636b..637ad84f6 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -214,6 +214,7 @@ local _M = {
"chaitin-waf",
"multi-auth",
"openid-connect",
+ "saml-auth",
"cas-auth",
"authz-casbin",
"authz-casdoor",
diff --git a/apisix/plugins/saml-auth.lua b/apisix/plugins/saml-auth.lua
new file mode 100644
index 000000000..c76e78625
--- /dev/null
+++ b/apisix/plugins/saml-auth.lua
@@ -0,0 +1,119 @@
+--
+-- 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 constants = require("apisix.constants")
+local resty_saml = require("resty.saml")
+
+local is_resty_saml_init = false
+
+local lrucache = core.lrucache.new({
+ ttl = 300, count = 512
+})
+
+local schema = {
+ type = "object",
+ properties = {
+ sp_issuer = { type = "string" },
+ idp_uri = { type = "string" },
+ idp_cert = { type = "string" },
+ login_callback_uri = { type = "string" },
+ logout_uri = { type = "string" },
+ logout_callback_uri = { type = "string" },
+ logout_redirect_uri = { type = "string" },
+ sp_cert = { type = "string" },
+ sp_private_key = { type = "string" },
+ auth_protocol_binding_method = {
+ type = "string",
+ default = "HTTP-Redirect",
+ enum = {"HTTP-Redirect", "HTTP-POST",},
+ description = "Binding method for authentication protocol, setting
to HTTP-POST " ..
+ "will set cookie samesite to None and cookie secure
to 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"
+ }
+ },
+ encrypt_fields = {"sp_private_key", "secret", "secret_fallbacks"},
+ required = {
+ "sp_issuer",
+ "idp_uri",
+ "idp_cert",
+ "login_callback_uri",
+ "logout_uri",
+ "logout_callback_uri",
+ "logout_redirect_uri",
+ "sp_cert",
+ "sp_private_key",
+ "secret",
+ }
+}
+
+local plugin_name = "saml-auth"
+
+local _M = {
+ version = 0.1,
+ priority = 2598,
+ name = plugin_name,
+ schema = schema,
+}
+
+
+function _M.check_schema(conf, _)
+ return core.schema.check(schema, conf)
+end
+
+function _M.rewrite(conf, ctx)
+ if not is_resty_saml_init then
+ local err = resty_saml.init({
+ debug = false,
+ data_dir = constants.apisix_lua_home ..
"/deps/share/lua/5.1/resty/saml"
+ })
+ if err then
+ core.log.error("saml init: ", err)
+ return 503, {message = "saml init failed"}
+ end
+ is_resty_saml_init = true
+ end
+
+ local saml = core.lrucache.plugin_ctx(lrucache, ctx, nil, resty_saml.new,
conf)
+ if not saml then
+ core.log.error("saml new failed")
+ return 500, {message = "create saml object failed"}
+ end
+
+ local data, err = saml:authenticate()
+ if err then
+ core.log.error("saml authenticate failed: ", err)
+ return 500, {message = "saml authentication failed"}
+ end
+
+ ctx.external_user = data
+end
+
+return _M
diff --git a/ci/init-plugin-test-service.sh b/ci/init-plugin-test-service.sh
index 201ae6f4f..6ef72f1df 100755
--- a/ci/init-plugin-test-service.sh
+++ b/ci/init-plugin-test-service.sh
@@ -71,6 +71,21 @@ after() {
docker exec apisix_keycloak bash /tmp/kcadm_configure_university.sh
docker exec apisix_keycloak bash /tmp/kcadm_configure_basic.sh
+ # wait for saml keycloak ready and configure it
+ for i in $(seq 1 60); do
+ if curl -sf localhost:8087 >/dev/null 2>&1; then
+ break
+ fi
+ if [ "$i" -eq 60 ]; then
+ echo "ERROR: keycloak (apisix_keycloak_saml) failed to become
ready"
+ docker logs apisix_keycloak_saml 2>&1 || true
+ exit 1
+ fi
+ sleep 3
+ done
+ docker cp jq apisix_keycloak_saml:/usr/bin/
+ docker exec apisix_keycloak_saml bash /tmp/kcadm_configure_saml.sh
+
# configure clickhouse
echo 'CREATE TABLE default.test (`host` String, `client_ip` String,
`route_id` String, `service_id` String, `@timestamp` String, PRIMARY
KEY(`@timestamp`)) ENGINE = MergeTree()' | curl 'http://localhost:8123/'
--data-binary @-
echo 'CREATE TABLE default.test (`host` String, `client_ip` String,
`route_id` String, `service_id` String, `@timestamp` String, PRIMARY
KEY(`@timestamp`)) ENGINE = MergeTree()' | curl 'http://localhost:8124/'
--data-binary @-
diff --git a/ci/linux-install-openresty.sh b/ci/linux-install-openresty.sh
index d94adbc4b..2c8fd81bb 100755
--- a/ci/linux-install-openresty.sh
+++ b/ci/linux-install-openresty.sh
@@ -40,7 +40,7 @@ if [ "$SSL_LIB_VERSION" == "tongsuo" ] || [ "$ENABLE_FIPS" ==
"true" ]; then
sudo add-apt-repository -y "deb
http://repos.apiseven.com/packages/${arch_path}debian bullseye main"
sudo apt-get update
- sudo apt-get install -y openresty-pcre-dev openresty-zlib-dev
build-essential gcc g++ cpanminus
+ sudo apt-get install -y openresty-pcre-dev openresty-zlib-dev
build-essential gcc g++ cpanminus libxml2-dev libxslt-dev
if [ "$SSL_LIB_VERSION" == "tongsuo" ]; then
export openssl_prefix=/usr/local/tongsuo
@@ -59,7 +59,7 @@ if [ "$SSL_LIB_VERSION" == "tongsuo" ] || [ "$ENABLE_FIPS" ==
"true" ]; then
fi
else
sudo apt-get -y update --fix-missing
- sudo apt-get install -y build-essential gcc g++ cpanminus
+ sudo apt-get install -y build-essential gcc g++ cpanminus libxml2-dev
libxslt-dev
if [ "$APISIX_RUNTIME" != "1.3.6" ]; then
echo "Please update the apisix-runtime-debug checksum for
APISIX_RUNTIME=$APISIX_RUNTIME" >&2
diff --git a/ci/pod/docker-compose.plugin.yml b/ci/pod/docker-compose.plugin.yml
index 0cecf593f..3f5803e50 100644
--- a/ci/pod/docker-compose.plugin.yml
+++ b/ci/pod/docker-compose.plugin.yml
@@ -40,6 +40,19 @@ services:
-
./ci/pod/keycloak/kcadm_configure_university.sh:/tmp/kcadm_configure_university.sh
-
./ci/pod/keycloak/kcadm_configure_basic.sh:/tmp/kcadm_configure_basic.sh
+ ## keycloak for saml-auth plugin tests (separate instance to avoid realm
conflicts)
+ apisix_keycloak_saml:
+ container_name: apisix_keycloak_saml
+ image: quay.io/keycloak/keycloak:18.0.2
+ network_mode: host
+ environment:
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ restart: unless-stopped
+ command: ["start-dev", "--http-port=8087"]
+ volumes:
+ - ./ci/pod/keycloak/kcadm_configure_saml.sh:/tmp/kcadm_configure_saml.sh
+
## kafka-cluster
zookeeper-server1:
image: bitnamilegacy/zookeeper:3.6.0
diff --git a/ci/pod/keycloak/kcadm_configure_saml.sh
b/ci/pod/keycloak/kcadm_configure_saml.sh
new file mode 100644
index 000000000..c9faab6c0
--- /dev/null
+++ b/ci/pod/keycloak/kcadm_configure_saml.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+
+export PATH=/opt/keycloak/bin:$PATH
+
+kcadm.sh config credentials --server http://localhost:8087 --realm master
--user admin --password admin
+
+kcadm.sh create realms -s realm=test -s enabled=true
+
+kcadm.sh create users -r test -s username=test -s enabled=true
+kcadm.sh set-password -r test --username test --new-password test
+
+sp_cert="MIIDgjCCAmqgAwIBAgIUOnf+MXKVU2zfIVaPz5dl0NTwPM4wDQYJKoZIhvcNAQENBQAwUTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRcwFQYDVQQKDA5sdWEtcmVzdHktc2FtbDEZMBcGA1UEAwwQc2VydmljZS1wcm92aWRlcjAgFw0xOTA1MDgwMTIyMDZaGA8yMTE4MDQxNDAxMjIwNlowUTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRcwFQYDVQQKDA5sdWEtcmVzdHktc2FtbDEZMBcGA1UEAwwQc2VydmljZS1wcm92aWRlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMLOj3YA5OGWqwV/GojID2AeuPfj3dTFOWFajXk4mc0vUBE10ovgkUfqdj2wye2Qu1ox1joFgMjaUcK/prXFBLFq+RLiR6lMUyi2PvCZ8td
[...]
+
+clients=("sp" "sp2")
+rootUrls=("http://127.0.0.1:1984" "http://127.0.0.2:1984")
+
+for i in ${!clients[@]}; do
+ kcadm.sh create clients -r test -s clientId=${clients[$i]} -s enabled=true
+
+ id=$(kcadm.sh get clients -r test --fields id,clientId 2>/dev/null | jq -r
'.[] | select(.clientId=='\"${clients[$i]}\"') | .id')
+
+ kcadm.sh update clients/${id} -r test -s protocol=saml -s
frontchannelLogout=true -s rootUrl=${rootUrls[$i]} -s 'redirectUris=["/acs"]'
-s 'attributes={"saml.server.signature":"true", "saml.authnstatement":"true",
"saml.signature.algorithm":"RSA_SHA256", "saml.client.signature":"true",
"saml_single_logout_service_url_redirect":"/sls",
"saml.signing.certificate":'\"${sp_cert}\"'}'
+done
diff --git a/ci/redhat-ci.sh b/ci/redhat-ci.sh
index 97492a6e6..fb1a822ad 100755
--- a/ci/redhat-ci.sh
+++ b/ci/redhat-ci.sh
@@ -25,7 +25,7 @@ install_dependencies() {
yum install -y --disablerepo=* --enablerepo=ubi-8-appstream-rpms
--enablerepo=ubi-8-baseos-rpms \
wget tar gcc gcc-c++ automake autoconf libtool make unzip git sudo
openldap-devel hostname patch \
which ca-certificates pcre pcre-devel pcre2 pcre2-devel xz \
- openssl-devel
+ openssl-devel libxml2-devel libxslt-devel
yum install -y libyaml-devel
yum install -y --disablerepo=* --enablerepo=ubi-8-appstream-rpms
--enablerepo=ubi-8-baseos-rpms cpanminus perl
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 6cac77e86..cf8e7da7e 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -500,6 +500,7 @@ plugins: # plugin list (sorted by
priority)
- chaitin-waf # priority: 2700
- multi-auth # priority: 2600
- openid-connect # priority: 2599
+ - saml-auth # priority: 2598
- cas-auth # priority: 2597
- authz-casbin # priority: 2560
- authz-casdoor # priority: 2559
diff --git a/docker/debian-dev/Dockerfile b/docker/debian-dev/Dockerfile
index acfe8eb14..7b3487e3d 100644
--- a/docker/debian-dev/Dockerfile
+++ b/docker/debian-dev/Dockerfile
@@ -40,6 +40,11 @@ RUN set -x \
gcc \
g++ \
libyaml-dev \
+ libxml2-dev \
+ libxslt1-dev \
+ pkg-config \
+ libssl-dev \
+ zlib1g-dev \
&& bash -c '. ./utils/install-rust-toolchain.sh && install_rust_toolchain'
\
&& ls -al \
&& make deps \
@@ -54,7 +59,7 @@ ARG INSTALL_BROTLI=./install-brotli.sh
# Install the runtime libyaml package
RUN apt-get -y update --fix-missing \
- && apt-get install -y libldap2-dev libyaml-0-2 \
+ && apt-get install -y libldap2-dev libyaml-0-2 libxml2 libxslt1.1 \
&& apt-get remove --purge --auto-remove -y \
&& mkdir -p /usr/local/apisix/ui
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 820a172d1..84d5fcb46 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -138,7 +138,8 @@
"plugins/ldap-auth",
"plugins/opa",
"plugins/forward-auth",
- "plugins/multi-auth"
+ "plugins/multi-auth",
+ "plugins/saml-auth"
]
},
{
diff --git a/docs/en/latest/plugins/saml-auth.md
b/docs/en/latest/plugins/saml-auth.md
new file mode 100644
index 000000000..861478efe
--- /dev/null
+++ b/docs/en/latest/plugins/saml-auth.md
@@ -0,0 +1,153 @@
+---
+title: saml-auth
+keywords:
+ - Apache APISIX
+ - API Gateway
+ - SAML
+ - SAML 2.0
+ - SSO
+ - Single Sign-On
+description: The saml-auth Plugin enables SAML 2.0 authentication for API
routes, integrating with external Identity Providers (IdP) such as Keycloak,
Okta, and Azure Active Directory.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/saml-auth" />
+</head>
+
+## Description
+
+The `saml-auth` Plugin enables [SAML
2.0](https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html)
(Security Assertion Markup Language) authentication for API routes. It acts as
a SAML Service Provider (SP) and integrates with external Identity Providers
(IdP) such as Keycloak, Okta, and Azure Active Directory to authenticate users
before allowing access to upstream resources.
+
+When a request arrives at a protected route, the Plugin checks for a valid
SAML session. If no session exists, it redirects the user to the IdP for
authentication. After the user authenticates at the IdP, the IdP posts a signed
SAML assertion back to the SP's Assertion Consumer Service (ACS) URL. The
Plugin validates the assertion and establishes a session for the user.
+
+The Plugin supports:
+
+- **HTTP-Redirect binding** (default) — SAML messages are transmitted as URL
query parameters.
+- **HTTP-POST binding** — SAML messages are transmitted as HTML form values.
+- **Single Logout (SLO)** — logout requests can be initiated by the SP or the
IdP.
+- **Session key rotation** via `secret_fallbacks`.
+
+Authenticated user data is stored in `ctx.external_user` and can be used by
downstream authorization plugins such as `acl`.
+
+## Attributes
+
+| Name | Type | Required | Encrypted | Default | Valid Values | Description |
+|------|------|----------|-----------|---------|--------------|-------------|
+| sp_issuer | string | True | | | | Service Provider (SP) entity ID/issuer
URI. Must match the SP entity ID registered with the IdP. |
+| idp_uri | string | True | | | | Identity Provider SSO endpoint URL. This is
the URL to which SAML authentication requests are sent. |
+| idp_cert | string | True | | | | IdP's X.509 certificate in PEM format, used
to verify signatures on SAML assertions. |
+| login_callback_uri | string | True | | | | SP's Assertion Consumer Service
(ACS) URL. The IdP posts SAML responses to this URL after authentication. Must
be registered with the IdP. |
+| logout_uri | string | True | | | | SP's Single Logout (SLO) endpoint.
Requests to this URI initiate the logout flow. |
+| logout_callback_uri | string | True | | | | SP's SLO callback URL. The IdP
sends logout responses to this URL. Must be registered with the IdP. |
+| logout_redirect_uri | string | True | | | | URL to redirect users to after a
successful logout. |
+| sp_cert | string | True | | | | SP's X.509 certificate in PEM format. Used
by the IdP to verify requests signed by the SP. |
+| sp_private_key | string | True | Yes | | | SP's private key in PEM format,
used to sign SAML requests. This field is encrypted at rest. |
+| auth_protocol_binding_method | string | False | | `HTTP-Redirect` |
`HTTP-Redirect`, `HTTP-POST` | SAML binding method for the authentication
request. When set to `HTTP-POST`, the session cookie `SameSite` attribute is
set to `None` and `Secure` is set to `true`. |
+| secret | string | True | Yes | | 8–32 characters | Secret used for session
key derivation. Must be identical on all APISIX nodes to ensure sessions are
readable across workers and after reloads. This field is encrypted at rest. |
+| secret_fallbacks | array[string] | False | Yes | | Each item: 8–32
characters | List of previous secrets used during key rotation. Allows sessions
encrypted with old secrets to remain valid. This field is encrypted at rest. |
+
+## Prerequisites
+
+Install `lua-resty-saml` on every APISIX node before enabling this Plugin:
+
+```shell
+luarocks install lua-resty-saml 0.2.5
+```
+
+`lua-resty-saml` builds native xmlsec bindings, so the build environment must
provide the OpenSSL, libxml2, and libxslt development files required by
LuaRocks.
+
+Before configuring the `saml-auth` Plugin, you need to register APISIX as a
Service Provider with your Identity Provider. The exact steps depend on your
IdP; the following example uses [Keycloak](https://www.keycloak.org/).
+
+### Set Up Keycloak
+
+1. Log in to the Keycloak Admin Console.
+2. Create or select a realm (for example, `myrealm`).
+3. Navigate to **Clients** and click **Create client**.
+4. Set **Client type** to `SAML`.
+5. Set **Client ID** to match the `sp_issuer` value you will use in the Plugin
configuration (for example, `https://sp.example.com`).
+6. Under **Client** > **Settings**:
+ - Set **Root URL** to `https://sp.example.com`.
+ - Set **Valid redirect URIs** to include the `login_callback_uri` (for
example, `https://sp.example.com/login/callback`).
+ - Set **Master SAML Processing URL** to
`https://sp.example.com/login/callback`.
+7. Under **Client** > **Keys**, upload the SP certificate (`sp_cert`) and
enable **Sign assertions**.
+8. Export the IdP metadata to obtain the `idp_uri` (SSO URL) and `idp_cert`
(signing certificate).
+9. Create users in Keycloak that will be allowed to authenticate.
+
+## Enable the Plugin
+
+The following example creates a route protected by the `saml-auth` Plugin
using a Keycloak IdP:
+
+:::note
+
+Replace the placeholder certificate and key values with your actual SP
certificate, SP private key, and IdP certificate.
+
+:::
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/1" \
+ -H "X-API-KEY: $ADMIN_API_KEY" \
+ -X PUT \
+ -d '{
+ "uri": "/*",
+ "plugins": {
+ "saml-auth": {
+ "sp_issuer": "https://sp.example.com",
+ "idp_uri": "https://keycloak.example.com/realms/myrealm/protocol/saml",
+ "idp_cert": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END
CERTIFICATE-----",
+ "login_callback_uri": "https://sp.example.com/login/callback",
+ "logout_uri": "https://sp.example.com/logout",
+ "logout_callback_uri": "https://sp.example.com/logout/callback",
+ "logout_redirect_uri": "https://sp.example.com/logout/done",
+ "sp_cert": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END
CERTIFICATE-----",
+ "sp_private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END
RSA PRIVATE KEY-----",
+ "auth_protocol_binding_method": "HTTP-Redirect",
+ "secret": "my-session-secret"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }'
+```
+
+## Disable the Plugin
+
+To disable the `saml-auth` Plugin, remove it from the route configuration:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/1" \
+ -H "X-API-KEY: $ADMIN_API_KEY" \
+ -X PUT \
+ -d '{
+ "uri": "/*",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }'
+```
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index b2fd498c6..ea8fc969a 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -126,7 +126,8 @@
"plugins/ldap-auth",
"plugins/opa",
"plugins/forward-auth",
- "plugins/multi-auth"
+ "plugins/multi-auth",
+ "plugins/saml-auth"
]
},
{
diff --git a/docs/zh/latest/plugins/saml-auth.md
b/docs/zh/latest/plugins/saml-auth.md
new file mode 100644
index 000000000..6582d02a1
--- /dev/null
+++ b/docs/zh/latest/plugins/saml-auth.md
@@ -0,0 +1,153 @@
+---
+title: saml-auth
+keywords:
+ - Apache APISIX
+ - API 网关
+ - SAML
+ - SAML 2.0
+ - SSO
+ - 单点登录
+description: saml-auth 插件为 API 路由提供 SAML 2.0 身份验证,可与 Keycloak、Okta、Azure
Active Directory 等外部身份提供商集成。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/saml-auth" />
+</head>
+
+## 描述
+
+`saml-auth` 插件为 API 路由提供 [SAML
2.0](https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html)(安全断言标记语言)身份验证。该插件充当
SAML 服务提供商(SP),并与 Keycloak、Okta、Azure Active Directory
等外部身份提供商(IdP)集成,在允许访问上游资源之前对用户进行身份验证。
+
+当请求到达受保护的路由时,插件会检查是否存在有效的 SAML 会话。若没有会话,则将用户重定向到 IdP 进行身份验证。用户在 IdP 完成认证后,IdP
会将签名的 SAML 断言以 POST 方式发送到 SP 的断言消费者服务(ACS)URL。插件验证断言后,为用户建立会话。
+
+该插件支持:
+
+- **HTTP-Redirect 绑定**(默认)— SAML 消息以 URL 查询参数形式传输。
+- **HTTP-POST 绑定** — SAML 消息以 HTML 表单值形式传输。
+- **单点注销(SLO)** — 注销请求可由 SP 或 IdP 发起。
+- 通过 `secret_fallbacks` 实现**会话密钥轮换**。
+
+经过身份验证的用户数据存储在 `ctx.external_user` 中,可供 `acl` 等下游授权插件使用。
+
+## 属性
+
+| 名称 | 类型 | 必填 | 加密 | 默认值 | 有效值 | 描述 |
+|------|------|------|------|--------|--------|------|
+| sp_issuer | string | 是 | | | | 服务提供商(SP)实体 ID/颁发者 URI,必须与在 IdP 中注册的 SP 实体 ID
一致。 |
+| idp_uri | string | 是 | | | | 身份提供商 SSO 端点 URL,SAML 认证请求将发送至此 URL。 |
+| idp_cert | string | 是 | | | | PEM 格式的 IdP X.509 证书,用于验证 SAML 断言上的签名。 |
+| login_callback_uri | string | 是 | | | | SP 的断言消费者服务(ACS)URL。IdP 在认证后将 SAML
响应 POST 到此 URL,必须在 IdP 中注册。 |
+| logout_uri | string | 是 | | | | SP 的单点注销(SLO)端点,请求此 URI 将触发注销流程。 |
+| logout_callback_uri | string | 是 | | | | SP 的 SLO 回调 URL,IdP 将注销响应发送至此
URL,必须在 IdP 中注册。 |
+| logout_redirect_uri | string | 是 | | | | 注销成功后重定向用户的 URL。 |
+| sp_cert | string | 是 | | | | PEM 格式的 SP X.509 证书,IdP 使用此证书验证 SP 签名的请求。 |
+| sp_private_key | string | 是 | 是 | | | PEM 格式的 SP 私钥,用于对 SAML
请求进行签名,该字段在存储时加密。 |
+| auth_protocol_binding_method | string | 否 | | `HTTP-Redirect` |
`HTTP-Redirect`、`HTTP-POST` | 认证请求的 SAML 绑定方式。设置为 `HTTP-POST` 时,会话 Cookie 的
`SameSite` 属性将设置为 `None`,`Secure` 设置为 `true`。 |
+| secret | string | 是 | 是 | | 8–32 个字符 | 用于会话密钥派生的密钥。所有 APISIX
节点必须配置相同的值,以确保会话可在多个 worker 进程之间及重启后正常读取。该字段在存储时加密。 |
+| secret_fallbacks | array[string] | 否 | 是 | | 每项:8–32 个字符 |
密钥轮换时使用的历史密钥列表,允许使用旧密钥加密的会话继续有效,该字段在存储时加密。 |
+
+## 前提条件
+
+在启用该插件前,请先在每个 APISIX 节点上安装 `lua-resty-saml`:
+
+```shell
+luarocks install lua-resty-saml 0.2.5
+```
+
+`lua-resty-saml` 会编译原生 xmlsec 绑定,因此构建环境需要提供 LuaRocks 所需的 OpenSSL、libxml2 和
libxslt 开发文件。
+
+在配置 `saml-auth` 插件之前,需要在身份提供商处将 APISIX 注册为服务提供商。具体步骤因 IdP 而异,以下示例使用
[Keycloak](https://www.keycloak.org/)。
+
+### 配置 Keycloak
+
+1. 登录 Keycloak 管理控制台。
+2. 创建或选择一个 Realm(例如 `myrealm`)。
+3. 进入 **Clients**,点击 **Create client**。
+4. 将 **Client type** 设置为 `SAML`。
+5. 将 **Client ID** 设置为与插件配置中 `sp_issuer` 一致的值(例如 `https://sp.example.com`)。
+6. 在 **Client** > **Settings** 中:
+ - 将 **Root URL** 设置为 `https://sp.example.com`。
+ - 将 **Valid redirect URIs** 设置为包含 `login_callback_uri`(例如
`https://sp.example.com/login/callback`)。
+ - 将 **Master SAML Processing URL** 设置为
`https://sp.example.com/login/callback`。
+7. 在 **Client** > **Keys** 中,上传 SP 证书(`sp_cert`)并启用 **Sign assertions**。
+8. 导出 IdP 元数据,获取 `idp_uri`(SSO URL)和 `idp_cert`(签名证书)。
+9. 在 Keycloak 中创建允许登录的用户。
+
+## 启用插件
+
+以下示例创建一个使用 Keycloak IdP 的 `saml-auth` 插件保护的路由:
+
+:::note
+
+请将证书和密钥占位符替换为实际的 SP 证书、SP 私钥和 IdP 证书。
+
+:::
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/1" \
+ -H "X-API-KEY: $ADMIN_API_KEY" \
+ -X PUT \
+ -d '{
+ "uri": "/*",
+ "plugins": {
+ "saml-auth": {
+ "sp_issuer": "https://sp.example.com",
+ "idp_uri": "https://keycloak.example.com/realms/myrealm/protocol/saml",
+ "idp_cert": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END
CERTIFICATE-----",
+ "login_callback_uri": "https://sp.example.com/login/callback",
+ "logout_uri": "https://sp.example.com/logout",
+ "logout_callback_uri": "https://sp.example.com/logout/callback",
+ "logout_redirect_uri": "https://sp.example.com/logout/done",
+ "sp_cert": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END
CERTIFICATE-----",
+ "sp_private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END
RSA PRIVATE KEY-----",
+ "auth_protocol_binding_method": "HTTP-Redirect",
+ "secret": "my-session-secret"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }'
+```
+
+## 禁用插件
+
+如需禁用 `saml-auth` 插件,从路由配置中移除即可:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/1" \
+ -H "X-API-KEY: $ADMIN_API_KEY" \
+ -X PUT \
+ -d '{
+ "uri": "/*",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index 783836567..b71b0711a 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -77,6 +77,7 @@ request-validation
chaitin-waf
multi-auth
openid-connect
+saml-auth
cas-auth
authz-casbin
authz-casdoor
diff --git a/t/lib/keycloak_saml.lua b/t/lib/keycloak_saml.lua
new file mode 100644
index 000000000..c15acaf05
--- /dev/null
+++ b/t/lib/keycloak_saml.lua
@@ -0,0 +1,469 @@
+--
+-- 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 http = require "resty.http"
+
+local _M = {}
+
+local function split(text, chunk_size)
+ local s = {}
+ for i=1, #text, chunk_size do
+ s[#s+1] = text:sub(i, i + chunk_size - 1)
+ end
+ return s
+end
+
+local function read_cert(str)
+ local t = split(str, 64)
+ table.insert(t, 1, "-----BEGIN CERTIFICATE-----")
+ table.insert(t, "-----END CERTIFICATE-----")
+ return string.format(table.concat(t, "\n"))
+end
+
+local sp_private_key = "-----BEGIN PRIVATE KEY-----\n" ..
+ "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCzo92AOThlqsF\n" ..
+ "fxqIyA9gHrj3493UxTlhWo15OJnNL1ARNdKL4JFH6nY9sMntkLtaMdY6BYDI2lHC\n" ..
+ "v6a1xQSxavkS4kepTFMotj7wmfLXWEY3mFbbITbGUmTQ0yQoJ4Lrii/nQ6Esv20z\n" ..
+ "V/mSTJzHLTdcH/lIuksZXKLPnEzue3zqGopvk4ZduvwyRzU0FzPoSYlCLqAEJcx6\n" ..
+ "bkulQcZcqSER/0bke/m9eCDt91evDJM1yOHzYuiDZH8trhFwzE+9ms/I/8Svt+tQ\n" ..
+ "kAB5EAzfI26VpUWB3oq4eJsoEPEC4UJBsKaZh4a1GA+wbm8ql8EgUr0EsgFZH1Hg\n" ..
+ "Gg2m97nLAgMBAAECggEBAJXT0sjadS7/97c5g8nxvMmbt32ItyOfMLusrqSuILSM\n" ..
+ "EBO8hpvoczSRorFd2GCr8Ty0meR0ORHBwCJ9zpV821gtQzX/7UfLmSX1zUC11u1D\n" ..
+ "SnYV56+PwxYTZtCpo+RyRyIrXR6MiFjnPfDAWAXqgKY8I5jqSotiJMJz2hC9UPoV\n" ..
+ "i56tHYXGCjtUAJrvG8FZM46TNL67nQ3ASWb5IH4cOqkgkKAJ/rZLrrMoL/HYpePr\n" ..
+ "n2MxlvT+TgdXebxo3rngu3pLRmLsfyV9eCLoOiP/oNAxTEA35EQQlnVfZOIEit8L\n" ..
+ "uvBYJYfYuXlxb96nQnOLqO/PrydwpXK9h1NtDvq3K2ECgYEA/i5ebOejoXORkFGx\n" ..
+ "DyYwkTczkh7QE328LSUVIiVGh4K1zFeYtj4mYYTeQMbzhlLAf9tGAZyZmvN52/ja\n" ..
+ "iFLnI5lObNBooIfAYe3RAzUHGYraY7R1XutdOMjlP9tqjQ55y/xij/tu9qHT4fEz\n" ..
+ "aQQPJ8D5sFbB5NgjxC8rlQ/WiLECgYEAxDNss4aMNhvL2+RTda72RMt99BS8PWEZ\n" ..
+ "/sdzzvu2zIJYFjBlCZ3Yd3vLhA/0MQXogMIcJofu4u2edZQVFSw4aHfnHFQCr45B\n" ..
+ "1QdDhZ8zoludEevgnLdSBzNakEJ63C8AQSkjIck4IaEmW+8G7fswpWGuVDBuHQZm\n" ..
+ "PBBcgz84CTsCgYBi8VvSWs0IYPtNyW757azEKk/J1nK605v3mtLCKu5se4YXGBYb\n" ..
+ "AtBf75+waYGMTRQf8RQsNnBYr+REq3ctz8+nvNqZYvsHWjCaLj/JVs//slxWqX1y\n" ..
+ "yH3OR+1tURUF+ZeRvxoC4CYOnWnkLscLXwgjOmw3p13snfI2QQJfEP460QKBgCzD\n" ..
+ "LsGmqMaPgOsiJIhs6nK3mnzdXjUCulOOXbWTaBkwg7hMQkD3ajOYYs42dZfZqTn3\n" ..
+ "D0UbLj1HySc6KbUy6YusD2Y/JH25DvvzNEyADd+01xkHn68hg+1wofDXugASGRTE\n" ..
+ "tec3aT8C7SV8WzBgZrDUoFlE01p740dA1Fp9SeORAoGBAIEa6LBIXuxb13xdOPDQ\n" ..
+ "FLaOQvmDCZeEwy2RAIOhG/1KGv+HYoCv0mMb4UXE1d65TOOE9QZLGUXksFfPc/ya\n" ..
+ "OP1vdjF/HN3DznxQ421GdPDYVIfp7edxZstNtGMYcR/SBwoIcvwaA5c2woMHbeju\n" ..
+ "+rbxDQL4gIT1lqn71w/8uoIJ\n" ..
+ "-----END PRIVATE KEY-----"
+
+local sp_cert = "-----BEGIN CERTIFICATE-----\n" ..
+ "MIIDgjCCAmqgAwIBAgIUOnf+MXKVU2zfIVaPz5dl0NTwPM4wDQYJKoZIhvcNAQEN\n" ..
+ "BQAwUTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRcwFQYDVQQKDA5sdWEt\n" ..
+ "cmVzdHktc2FtbDEZMBcGA1UEAwwQc2VydmljZS1wcm92aWRlcjAgFw0xOTA1MDgw\n" ..
+ "MTIyMDZaGA8yMTE4MDQxNDAxMjIwNlowUTELMAkGA1UEBhMCVVMxDjAMBgNVBAgM\n" ..
+ "BVRleGFzMRcwFQYDVQQKDA5sdWEtcmVzdHktc2FtbDEZMBcGA1UEAwwQc2Vydmlj\n" ..
+ "ZS1wcm92aWRlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMLOj3YA\n" ..
+ "5OGWqwV/GojID2AeuPfj3dTFOWFajXk4mc0vUBE10ovgkUfqdj2wye2Qu1ox1joF\n" ..
+ "gMjaUcK/prXFBLFq+RLiR6lMUyi2PvCZ8tdYRjeYVtshNsZSZNDTJCgnguuKL+dD\n" ..
+ "oSy/bTNX+ZJMnMctN1wf+Ui6Sxlcos+cTO57fOoaim+Thl26/DJHNTQXM+hJiUIu\n" ..
+ "oAQlzHpuS6VBxlypIRH/RuR7+b14IO33V68MkzXI4fNi6INkfy2uEXDMT72az8j/\n" ..
+ "xK+361CQAHkQDN8jbpWlRYHeirh4mygQ8QLhQkGwppmHhrUYD7BubyqXwSBSvQSy\n" ..
+ "AVkfUeAaDab3ucsCAwEAAaNQME4wHQYDVR0OBBYEFPbRiK9OxGCZeNUViinNQ4P5\n" ..
+ "ZOf0MB8GA1UdIwQYMBaAFPbRiK9OxGCZeNUViinNQ4P5ZOf0MAwGA1UdEwQFMAMB\n" ..
+ "Af8wDQYJKoZIhvcNAQENBQADggEBAD0MvA3mk+u3CBDFwPtT9tI8HPSaYXS0HZ3E\n" ..
+ "VXe4WcU3PYFpZzK0x6qr+a7mB3tbpHYXl49V7uxcIOD2aHLvKonKRRslyTiw4UvL\n" ..
+ "OhSSByrArUGleI0wyr1BXAJArippiIhqrTDybvPpFC45x45/KtrckeM92NOlttlQ\n" ..
+ "yd2yW0qSd9gAnqkDu2kvjLlGh9ZYnT+yHPjUuWcxDL66P3za6gc+GhVOtsOemdYN\n" ..
+ "AErhuxiGVNHrtq2dfSedqcxtCpavMYzyGhqzxr9Lt43fpQeXeS/7JVFoC2y9buyO\n" ..
+ "z9HIbQ6/02HIoenDoP3xfqvAY1emixgbV4iwm3SWzG8pSTxvwuM=\n" ..
+ "-----END CERTIFICATE-----"
+
+local idp_uri = "http://127.0.0.1:8087/realms/test/protocol/saml"
+
+local default_opts = {
+ idp_uri = idp_uri,
+ login_callback_uri = "/acs",
+ logout_uri = "/logout",
+ logout_callback_uri = "/sls",
+ logout_redirect_uri = "/logout_ok",
+ sp_cert = sp_cert,
+ sp_private_key = sp_private_key,
+ secret = "keycloak_saml_test_secret",
+}
+
+local function get_realm_cert()
+ local http = require "resty.http"
+ local httpc = http.new()
+ local uri = "http://127.0.0.1:8087/realms/test/protocol/saml/descriptor"
+ local res, err = httpc:request_uri(uri, { method = "GET" })
+ if err then
+ ngx.log(ngx.ERR, err)
+ ngx.exit(500)
+ end
+
+ local cert =
res.body:match("<ds:X509Certificate>(.-)</ds:X509Certificate>")
+ if not cert then
+ ngx.log(ngx.ERR, "failed to extract certificate from Keycloak SAML
metadata, body: ",
+ res.body)
+ ngx.exit(500)
+ end
+ return read_cert(cert)
+end
+
+function _M.get_default_opts()
+ if default_opts.idp_cert == nil then
+ default_opts.idp_cert = get_realm_cert()
+ end
+ return default_opts
+end
+
+-- Login keycloak and return the login original uri
+function _M.login_keycloak(uri, username, password)
+ local httpc = http.new()
+
+ local res, err = httpc:request_uri(uri, {method = "GET"})
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "login was not redirected to keycloak."
+ else
+ local cookies = res.headers['Set-Cookie']
+ local cookie_str = _M.concatenate_cookies(cookies)
+
+ res, err = httpc:request_uri(res.headers['Location'], {method = "GET"})
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 200 then
+ -- Unexpected response.
+ return nil, res.body
+ end
+
+ -- From the returned form, extract the submit URI and parameters.
+ local uri, params = res.body:match('.*action="(.*)%?(.*)"
method="post">')
+
+ -- Substitute escaped ampersand in parameters.
+ params = params:gsub("&", "&")
+
+ local auth_cookies = res.headers['Set-Cookie']
+
+ -- Concatenate cookies into one string as expected when sent in
request header.
+ local auth_cookie_str = _M.concatenate_cookies(auth_cookies)
+
+ -- Invoke the submit URI with parameters and cookies, adding username
+ -- and password in the body.
+ -- Note: Username and password are specific to the Keycloak Docker
image used.
+ res, err = httpc:request_uri(uri .. "?" .. params, {
+ method = "POST",
+ body = "username=" .. username .. "&password=" .. password,
+ headers = {
+ ["Content-Type"] = "application/x-www-form-urlencoded",
+ ["Cookie"] = auth_cookie_str
+ }
+ })
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ end
+
+ local keycloak_cookie_str =
_M.concatenate_cookies(res.headers['Set-Cookie'])
+ local redirect_uri
+
+ -- for HTTP-POST case:
+ if res.status == 200 then
+ ngx.log(ngx.INFO, "login callback req with http post")
+ local form_action = res.body:match('action="([^"]+)"')
+ local saml_response = res.body:match('name="SAMLResponse"
value="([^"]+)"')
+ local relay_state = res.body:match('name="RelayState"
value="([^"]+)"')
+
+ if not form_action or not saml_response then
+ return nil, "HTTP-POST response missing form data"
+ end
+
+ -- mock IDP sending respponse to service
+ res, err = httpc:request_uri(form_action, {
+ method = "POST",
+ body = "SAMLResponse=" .. ngx.escape_uri(saml_response) ..
+ (relay_state and ("&RelayState=" ..
ngx.escape_uri(relay_state)) or ""),
+ headers = {
+ ["Content-Type"] = "application/x-www-form-urlencoded",
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "ACS POST did not return redirect to original URI"
+ end
+
+ elseif res.status == 302 then
+ ngx.log(ngx.INFO, "login callback req with redirect")
+ redirect_uri = res.headers['Location']
+ res, err = httpc:request_uri(redirect_uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "login callback: " ..
+ "did not return redirect to original URI."
+ end
+ else
+ return nil, "Login form submission returned unexpected status: "
.. res.status
+ end
+
+ cookies = res.headers['Set-Cookie']
+ cookie_str = _M.concatenate_cookies(cookies)
+
+ return res, nil, cookie_str, keycloak_cookie_str
+ end
+end
+
+-- Login keycloak and return the login original uri
+function _M.login_keycloak_for_second_sp(uri, keycloak_cookie_str)
+ local httpc = http.new()
+
+ local res, err = httpc:request_uri(uri, {method = "GET"})
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "login was not redirected to keycloak."
+ end
+
+ local cookies = res.headers['Set-Cookie']
+ local cookie_str = _M.concatenate_cookies(cookies)
+
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = keycloak_cookie_str
+ }
+ })
+ ngx.log(ngx.INFO, keycloak_cookie_str)
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ end
+
+ if res.status == 200 then
+ ngx.log(ngx.INFO, "login callback req with http post")
+ local form_action = res.body:match('action="([^"]+)"')
+ local saml_response = res.body:match('name="SAMLResponse"
value="([^"]+)"')
+ local relay_state = res.body:match('name="RelayState" value="([^"]+)"')
+
+ if not form_action or not saml_response then
+ return nil, "HTTP-POST response missing form data"
+ end
+
+ res, err = httpc:request_uri(form_action, {
+ method = "POST",
+ body = "SAMLResponse=" .. ngx.escape_uri(saml_response) ..
+ (relay_state and ("&RelayState=" ..
ngx.escape_uri(relay_state)) or ""),
+ headers = {
+ ["Content-Type"] = "application/x-www-form-urlencoded",
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "ACS POST did not return redirect to original URI"
+ end
+ elseif res.status == 302 then
+ ngx.log(ngx.INFO, "login callback req with redirect")
+ -- login callback
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "login callback: " ..
+ "did not return redirect to original URI."
+ end
+ end
+
+ cookies = res.headers['Set-Cookie']
+ cookie_str = _M.concatenate_cookies(cookies)
+
+ return res, nil, cookie_str
+end
+
+-- Logout keycloak and return the logout_redirect_uri
+function _M.logout_keycloak(uri, cookie_str, keycloak_cookie_str)
+ local httpc = http.new()
+
+ local res, err = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "logout was not redirected to keycloak."
+ else
+ -- keycloak logout
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = keycloak_cookie_str
+ }
+ })
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "Logout did not return redirect to redirect URI."
+ end
+
+ -- logout callback
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "logout callback: " ..
+ "did not return redirect to logout redirect URI."
+ end
+
+ return res, nil
+ end
+end
+
+-- Logout keycloak and return the logout_redirect_uri
+function _M.single_logout(uri, cookie_str, cookie_str2, keycloak_cookie_str)
+ local httpc = http.new()
+
+ local res, err = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ return nil, err
+ elseif res.status ~= 302 then
+ return nil, "logout was not redirected to keycloak."
+ end
+
+ -- logout request from sp1 to keycloak
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = keycloak_cookie_str
+ }
+ })
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "Logout did not return redirect to redirect URI."
+ end
+
+ -- logout callback to sp2
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str2
+ }
+ })
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "logout callback: " ..
+ "did not return redirect to logout redirect URI."
+ end
+
+ -- logout response from sp2 to keycloak
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = keycloak_cookie_str
+ }
+ })
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "Logout did not return redirect to redirect URI."
+ end
+
+ -- logout response from keycloak to sp1
+ res, err = httpc:request_uri(res.headers['Location'], {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie_str
+ }
+ })
+
+ if not res then
+ -- No response, must be an error.
+ return nil, err
+ elseif res.status ~= 302 then
+ -- Not a redirect which we expect.
+ return nil, "logout callback: " ..
+ "did not return redirect to logout redirect URI."
+ end
+
+ return res, nil
+end
+
+-- Concatenate cookies into one string as expected when sent in request header.
+function _M.concatenate_cookies(cookies)
+ local cookie_str = ""
+ if type(cookies) == 'string' then
+ cookie_str = cookies:match('([^;]*); .*')
+ else
+ -- Must be a table.
+ local len = #cookies
+ if len > 0 then
+ cookie_str = cookies[1]:match('([^;]*); .*')
+ for i = 2, len do
+ cookie_str = cookie_str .. "; " .. cookies[i]:match('([^;]*);
.*')
+ end
+ end
+ end
+
+ return cookie_str, nil
+end
+
+
+return _M
diff --git a/t/plugin/saml-auth.t b/t/plugin/saml-auth.t
new file mode 100644
index 000000000..43119df4e
--- /dev/null
+++ b/t/plugin/saml-auth.t
@@ -0,0 +1,608 @@
+#
+# 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('info');
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $extra_yaml_config = $block->extra_yaml_config // '';
+ $extra_yaml_config .= <<_EOC_;
+plugins:
+ - saml-auth # priority: 2598
+_EOC_
+
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ if (!defined $block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if ((!defined $block->error_log) && (!defined $block->error_log_like) &&
(!defined $block->no_error_log)) {
+ $block->set_value("no_error_log", "[error]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: schema validation - valid config with all required fields
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: schema validation - missing required field sp_issuer
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body_like eval
+qr/failed: .*sp_issuer.*is required/
+
+
+
+=== TEST 3: schema validation - missing required field idp_uri
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body_like eval
+qr/failed: .*idp_uri.*is required/
+
+
+
+=== TEST 4: schema validation - invalid auth_protocol_binding_method
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ auth_protocol_binding_method = "HTTP-INVALID",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body_like eval
+qr/failed: .*auth_protocol_binding_method/
+
+
+
+=== TEST 5: schema validation - missing required field secret
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body_like eval
+qr/failed: .*secret.*is required/
+
+
+
+=== TEST 6: schema validation - secret too short (< 8 chars)
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "short",
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body_like eval
+qr/failed: .*secret/
+
+
+
+=== TEST 7: schema validation - valid config with optional fields
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.saml-auth")
+ local ok, err = plugin.check_schema({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ auth_protocol_binding_method = "HTTP-POST",
+ secret = "mysecret1",
+ secret_fallbacks = {"oldsecret1", "oldsecret2"},
+ })
+ if not ok then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 8: rewrite sets ctx.external_user when saml:authenticate succeeds
+--- config
+ location /t {
+ content_by_lua_block {
+ local old_plugin = package.loaded["apisix.plugins.saml-auth"]
+ local old_saml = package.loaded["resty.saml"]
+
+ package.loaded["apisix.plugins.saml-auth"] = nil
+ package.loaded["resty.saml"] = nil
+
+ local mock_user = "[email protected]"
+ local mock_saml_obj = {}
+ function mock_saml_obj:authenticate()
+ return mock_user
+ end
+ package.loaded["resty.saml"] = {
+ init = function(opts) return nil end,
+ new = function(conf) return mock_saml_obj end,
+ }
+
+ local plugin = require("apisix.plugins.saml-auth")
+ local ctx = {conf_type = "route", conf_id = "test-saml",
conf_version = 1}
+ plugin.rewrite({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ }, ctx)
+
+ package.loaded["apisix.plugins.saml-auth"] = old_plugin
+ package.loaded["resty.saml"] = old_saml
+
+ ngx.say(ctx.external_user)
+ }
+ }
+--- response_body
[email protected]
+
+
+
+=== TEST 9: rewrite returns 500 when saml:authenticate fails
+--- config
+ location /t {
+ content_by_lua_block {
+ local old_plugin = package.loaded["apisix.plugins.saml-auth"]
+ local old_saml = package.loaded["resty.saml"]
+
+ package.loaded["apisix.plugins.saml-auth"] = nil
+ package.loaded["resty.saml"] = nil
+
+ local mock_saml_obj = {}
+ function mock_saml_obj:authenticate()
+ return nil, "mock auth error"
+ end
+ package.loaded["resty.saml"] = {
+ init = function(opts) return nil end,
+ new = function(conf) return mock_saml_obj end,
+ }
+
+ local plugin = require("apisix.plugins.saml-auth")
+ local code, body = plugin.rewrite({
+ sp_issuer = "https://sp.example.com",
+ idp_uri = "https://idp.example.com/sso",
+ idp_cert = "MIIC...",
+ login_callback_uri = "https://sp.example.com/login/callback",
+ logout_uri = "https://sp.example.com/logout",
+ logout_callback_uri = "https://sp.example.com/logout/callback",
+ logout_redirect_uri = "https://sp.example.com/logout/done",
+ sp_cert = "MIIC...",
+ sp_private_key = "MIIE...",
+ secret = "mysecret1",
+ }, {conf_type = "route", conf_id = "test-saml", conf_version = 1})
+
+ package.loaded["apisix.plugins.saml-auth"] = old_plugin
+ package.loaded["resty.saml"] = old_saml
+
+ ngx.say(code)
+ ngx.say(body.message)
+ }
+ }
+--- response_body
+500
+saml authentication failed
+--- no_error_log
+[crit]
+--- error_log_like eval
+qr/saml authenticate failed: mock auth error/
+
+
+
+=== TEST 10: (integration) add route for sp1
+--- config
+ location /t {
+ content_by_lua_block {
+ local kc = require("lib.keycloak_saml")
+ local core = require("apisix.core")
+
+ local default_opts = kc.get_default_opts()
+ local opts = core.table.deepcopy(default_opts)
+ opts.sp_issuer = "sp"
+ local t = require("lib.test_admin").test
+
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "host" : "127.0.0.1",
+ "plugins": {
+ "saml-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 11: (integration) 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_saml"
+
+ local path = "/uri"
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port
+ local username = "test"
+ local password = "test"
+
+ local res, err, saml_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"] = saml_cookie
+ }
+ })
+ assert(res.status == 200)
+ ngx.say(res.body)
+
+ res, err = kc.logout_keycloak(uri .. "/logout", saml_cookie,
keycloak_cookie)
+ if err or res.headers['Location'] ~= "/logout_ok" then
+ ngx.log(ngx.ERR, err)
+ ngx.exit(500)
+ end
+ }
+ }
+--- response_body_like
+uri: /uri
+cookie: .*
+host: 127.0.0.1:1984
+user-agent: .*
+x-real-ip: 127.0.0.1
+--- error_log
+login callback req with redirect
+
+
+
+=== TEST 12: (integration) add route for sp2
+--- config
+ location /t {
+ content_by_lua_block {
+ local kc = require("lib.keycloak_saml")
+ local core = require("apisix.core")
+
+ local default_opts = kc.get_default_opts()
+ local opts = core.table.deepcopy(default_opts)
+ opts.sp_issuer = "sp2"
+ local t = require("lib.test_admin").test
+
+ local code, body = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "host" : "127.0.0.2",
+ "plugins": {
+ "saml-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 13: (integration) 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_saml"
+
+ 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, saml_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"] = saml_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, saml_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"] = saml_cookie2
+ }
+ })
+ assert(res.status == 200)
+
+ -- SLO (single logout)
+ res, err = kc.single_logout(uri .. "/logout", saml_cookie,
saml_cookie2, keycloak_cookie)
+ if err or res.headers['Location'] ~= "/logout_ok" then
+ ngx.log(ngx.ERR, err)
+ ngx.exit(500)
+ end
+
+ -- login to sp2, which would do normal login process at keycloak
side
+ local res, err, saml_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"] = saml_cookie2
+ }
+ })
+ assert(res.status == 200)
+
+ -- logout sp2
+ res, err = kc.logout_keycloak(uri2 .. "/logout", saml_cookie2,
keycloak_cookie)
+ if err or res.headers['Location'] ~= "/logout_ok" then
+ ngx.log(ngx.ERR, err)
+ ngx.exit(500)
+ end
+ }
+ }
+--- error_log
+login callback req with redirect
+
+
+
+=== TEST 14: (integration) add route for sp1 with wrong login_callback_uri
+--- config
+ location /t {
+ content_by_lua_block {
+ local kc = require("lib.keycloak_saml")
+ local core = require("apisix.core")
+
+ local default_opts = kc.get_default_opts()
+ local opts = core.table.deepcopy(default_opts)
+ opts.sp_issuer = "sp"
+ opts.login_callback_uri = "/wrong_url"
+ local t = require("lib.test_admin").test
+
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "host" : "127.0.0.1",
+ "plugins": {
+ "saml-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 15: (integration) login failed
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require "resty.http"
+ local httpc = http.new()
+ local kc = require "lib.keycloak_saml"
+
+ local path = "/uri"
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port
+ local username = "test"
+ local password = "test"
+
+ local res = kc.login_keycloak(uri .. path, username, password)
+ assert(res == nil)
+ }
+ }
diff --git a/utils/install-dependencies.sh b/utils/install-dependencies.sh
index 74c646b8f..a2e19802d 100755
--- a/utils/install-dependencies.sh
+++ b/utils/install-dependencies.sh
@@ -62,7 +62,7 @@ function install_dependencies_with_yum() {
sudo yum install -y \
gcc gcc-c++ curl wget unzip xz gnupg perl-ExtUtils-Embed cpanminus
patch libyaml-devel \
perl perl-devel pcre pcre-devel pcre2 pcre2-devel openldap-devel \
- openresty-zlib-devel openresty-pcre-devel
+ openresty-zlib-devel openresty-pcre-devel libxml2-devel libxslt-devel
zlib-devel
install_rust_toolchain
}
@@ -85,7 +85,7 @@ function install_dependencies_with_apt() {
sudo apt-get update
# install some compilation tools
- sudo apt-get install -y curl make gcc g++ cpanminus libpcre3 libpcre3-dev
libpcre2-dev libyaml-dev unzip openresty-zlib-dev openresty-pcre-dev
+ sudo apt-get install -y curl make gcc g++ cpanminus libpcre3 libpcre3-dev
libpcre2-dev libyaml-dev unzip openresty-zlib-dev openresty-pcre-dev
libxml2-dev libxslt-dev zlib1g-dev
install_rust_toolchain
}
diff --git a/utils/linux-install-luarocks.sh b/utils/linux-install-luarocks.sh
index 88623a83b..dc9f11020 100755
--- a/utils/linux-install-luarocks.sh
+++ b/utils/linux-install-luarocks.sh
@@ -63,6 +63,7 @@ if [[ "${FOUND_PATH}" == "" ]]; then
export PATH=$PATH:/usr/local/bin
fi
+luarocks config variables.OPENSSL_DIR ${OPENSSL_PREFIX}
luarocks config variables.OPENSSL_LIBDIR ${OPENSSL_PREFIX}/lib
luarocks config variables.OPENSSL_INCDIR ${OPENSSL_PREFIX}/include
luarocks config variables.YAML_DIR /usr