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("&amp;", "&")
+
+        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

Reply via email to