This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek 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 3d64360a6d feat(saml-auth): add lua-resty-saml 0.2.6 validation 
options (#13964)
3d64360a6d is described below

commit 3d64360a6db7fbcfbb8206e19b1639111176b4ac
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Sep 18 16:04:09 2026 +0800

    feat(saml-auth): add lua-resty-saml 0.2.6 validation options (#13964)
---
 apisix-master-0.rockspec            |   2 +-
 apisix/cli/config.lua               |   1 +
 apisix/cli/ngx_tpl.lua              |   4 +
 apisix/plugins/saml-auth.lua        |  51 ++-
 conf/config.yaml.example            |   1 +
 docs/en/latest/plugins/saml-auth.md |  82 ++++-
 docs/zh/latest/plugins/saml-auth.md |  82 ++++-
 t/APISIX.pm                         |   1 +
 t/cli/test_main.sh                  |   6 +
 t/lib/keycloak_saml.lua             |  40 +++
 t/plugin/saml-auth-options.t        | 615 ++++++++++++++++++++++++++++++++++++
 t/plugin/saml-auth.t                | 386 ++++++++++++++++++++++
 12 files changed, 1250 insertions(+), 21 deletions(-)

diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 9a6a9fa9d9..40b41a0222 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -52,7 +52,7 @@ dependencies = {
     "lua-resty-radixtree = 2.9.2-0",
     "lua-protobuf = 0.5.3-1",
     "lua-resty-openidc = 1.9.0-1",
-    "lua-resty-saml = 0.2.5",
+    "lua-resty-saml = 0.2.6",
     "luafilesystem = 1.8.0-1",
     "nginx-lua-prometheus-api7 = 1.0.0-1",
     "jsonschema = 0.9.13-0",
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 2063eb8d71..464f839eee 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -178,6 +178,7 @@ local _M = {
         ["plugin-limit-conn-redis-cluster-slot-lock"] = "1m",
         ["plugin-graphql-limit-count"] = "10m",
         ["plugin-graphql-limit-count-reset-header"] = "10m",
+        ["plugin-saml-auth-replay"] = "10m",
         ["plugin-ai-rate-limiting"] = "10m",
         ["plugin-ai-rate-limiting-reset-header"] = "10m",
         tracing_buffer = "32m",
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 8d6947fea0..567f288b15 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -460,6 +460,10 @@ http {
     lua_shared_dict redis_cluster_health 10m;
     {% end %}
 
+    {% if enabled_plugins["saml-auth"] then %}
+    lua_shared_dict plugin-saml-auth-replay {* 
http.lua_shared_dict["plugin-saml-auth-replay"] *};
+    {% end %}
+
     {% if enabled_plugins["graphql-limit-count"] then %}
     lua_shared_dict plugin-graphql-limit-count {* 
http.lua_shared_dict["plugin-graphql-limit-count"] *};
     lua_shared_dict plugin-graphql-limit-count-reset-header {* 
http.lua_shared_dict["plugin-graphql-limit-count-reset-header"] *};
diff --git a/apisix/plugins/saml-auth.lua b/apisix/plugins/saml-auth.lua
index c76e786253..88448b7428 100644
--- a/apisix/plugins/saml-auth.lua
+++ b/apisix/plugins/saml-auth.lua
@@ -17,6 +17,7 @@
 local core = require("apisix.core")
 local constants = require("apisix.constants")
 local resty_saml = require("resty.saml")
+local pcall = pcall
 
 local is_resty_saml_init = false
 
@@ -57,7 +58,37 @@ local schema = {
                 maxLength = 32,
             },
             description = "List of secrets for alternative secrets used when 
doing key rotation"
-        }
+        },
+        idp_issuers = {
+            type = "array",
+            items = { type = "string" },
+            description = "Accepted IdP issuers, unset accepts any, empty 
accepts none",
+        },
+        sp_acs_url = {
+            type = "string",
+            pattern = "^https?://",
+            description = "Absolute external ACS URL, unset derives it from 
the request",
+        },
+        sp_audiences = {
+            type = "array",
+            items = { type = "string" },
+            description = "Accepted assertion audiences, unset means 
sp_issuer",
+        },
+        clock_skew = {
+            type = "number",
+            minimum = 0,
+            description = "Tolerated clock difference with the IdP in seconds, 
unset means 60",
+        },
+        replay_dict = {
+            type = "string",
+            minLength = 1,
+            description = "lua_shared_dict name recording accepted assertions 
on this node",
+        },
+        replay_ttl = {
+            type = "number",
+            minimum = 1,
+            description = "Seconds to record an assertion without expiry, 
unset means 600",
+        },
     },
     encrypt_fields = {"sp_private_key", "secret", "secret_fallbacks"},
     required = {
@@ -88,6 +119,17 @@ function _M.check_schema(conf, _)
     return core.schema.check(schema, conf)
 end
 
+
+-- resty.saml keeps opts by reference, so it gets a copy of the plugin conf
+local function new_saml(conf)
+    local ok, saml = pcall(resty_saml.new, core.table.deepcopy(conf))
+    if not ok then
+        return nil, saml
+    end
+    return saml
+end
+
+
 function _M.rewrite(conf, ctx)
     if not is_resty_saml_init then
         local err = resty_saml.init({
@@ -101,13 +143,14 @@ function _M.rewrite(conf, ctx)
         is_resty_saml_init = true
     end
 
-    local saml = core.lrucache.plugin_ctx(lrucache, ctx, nil, resty_saml.new, 
conf)
+    local saml, err = core.lrucache.plugin_ctx(lrucache, ctx, nil, new_saml, 
conf)
     if not saml then
-        core.log.error("saml new failed")
+        core.log.error("saml new failed: ", err)
         return 500, {message = "create saml object failed"}
     end
 
-    local data, err = saml:authenticate()
+    local data
+    data, err = saml:authenticate()
     if err then
         core.log.error("saml authenticate failed: ", err)
         return 500, {message = "saml authentication failed"}
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 86510feb5c..6a1361aec4 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -365,6 +365,7 @@ nginx_config:                     # Config for render the 
template to generate n
       plugin-limit-conn-redis-cluster-slot-lock: 1m
       plugin-graphql-limit-count: 10m
       plugin-graphql-limit-count-reset-header: 10m
+      plugin-saml-auth-replay: 10m    # Assertions accepted by saml-auth when 
`replay_dict` names this dict
       tracing_buffer: 10m
       plugin-api-breaker: 10m
       etcd-cluster-health-check: 10m
diff --git a/docs/en/latest/plugins/saml-auth.md 
b/docs/en/latest/plugins/saml-auth.md
index 861478efe6..281f86b5fc 100644
--- a/docs/en/latest/plugins/saml-auth.md
+++ b/docs/en/latest/plugins/saml-auth.md
@@ -55,22 +55,28 @@ Authenticated user data is stored in `ctx.external_user` 
and can be used by down
 | 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. |
+| login_callback_uri | string | True | | | | Request path of the SP's 
Assertion Consumer Service (ACS), such as `/login/callback`. The Plugin handles 
the IdP's login response on requests whose path equals this value. It is a 
path, so the externally visible absolute ACS URL is configured in `sp_acs_url`. 
|
+| logout_uri | string | True | | | | Request path of the SP's Single Logout 
(SLO) endpoint, such as `/logout`. Requests to this path initiate the logout 
flow. |
+| logout_callback_uri | string | True | | | | Request path of the SP's SLO 
callback, such as `/logout/callback`. The IdP sends logout requests and 
responses to this path. 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. |
+| idp_issuers | array[string] | False | | | | Issuers accepted on a login 
response. Every assertion in the response must name one of them. When unset, 
any issuer signed with `idp_cert` is accepted. An empty array accepts no 
issuer, so every login is refused. See [Issuer and 
audience](#issuer-and-audience). |
+| sp_acs_url | string | False | | | Absolute `http://` or `https://` URL | 
Externally visible absolute URL of the SP's ACS, such as 
`https://sp.example.com/login/callback`. It is sent to the IdP in the 
authentication request, and the `Destination` and `Recipient` of the login 
response must equal it. When unset, it is built from the request's scheme, 
host, and `login_callback_uri`. See [ACS URL behind a 
proxy](#acs-url-behind-a-proxy). |
+| sp_audiences | array[string] | False | | `sp_issuer` | | Audiences this SP 
accepts. An assertion carrying an `AudienceRestriction` must name one of them. 
When unset, only `sp_issuer` is accepted. |
+| clock_skew | number | False | | `60` | >= 0 | Seconds of clock difference 
tolerated against the IdP when checking `NotBefore` and `NotOnOrAfter`. |
+| replay_dict | string | False | | | Non-empty | Name of a declared 
`lua_shared_dict` that records accepted assertions, so that one assertion 
cannot log in twice on the same APISIX node. When unset, accepted assertions 
are not recorded. See [Assertion replay 
protection](#assertion-replay-protection). |
+| replay_ttl | number | False | | `600` | >= 1 | Seconds to record an 
assertion whose acceptance has no expiry. Assertions with an expiry are 
recorded until they expire, plus `clock_skew`, for at most one day or 
`replay_ttl` when that is longer. Only used when `replay_dict` is set. |
 
 ## Prerequisites
 
 Install `lua-resty-saml` on every APISIX node before enabling this Plugin:
 
 ```shell
-luarocks install lua-resty-saml 0.2.5
+luarocks install lua-resty-saml 0.2.6
 ```
 
 `lua-resty-saml` builds native xmlsec bindings, so the build environment must 
provide the OpenSSL, libxml2, and libxslt development files required by 
LuaRocks.
@@ -86,7 +92,7 @@ Before configuring the `saml-auth` Plugin, you need to 
register APISIX as a Serv
 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 **Valid redirect URIs** to include the absolute ACS URL, which is the 
`sp_acs_url` (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).
@@ -113,10 +119,13 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1"; \
         "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";,
+        "login_callback_uri": "/login/callback",
+        "sp_acs_url": "https://sp.example.com/login/callback";,
+        "logout_uri": "/logout",
+        "logout_callback_uri": "/logout/callback",
         "logout_redirect_uri": "https://sp.example.com/logout/done";,
+        "idp_issuers": ["https://keycloak.example.com/realms/myrealm";],
+        "sp_audiences": ["https://sp.example.com";],
         "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",
@@ -132,6 +141,63 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1"; \
   }'
 ```
 
+## Response Validation
+
+The Plugin passes the options below to `lua-resty-saml`, which checks every 
login response against them. All of them are optional, and a configuration that 
omits them keeps its existing behavior.
+
+### Issuer and audience
+
+`idp_cert` proves that a response was signed with the IdP's key, but a key may 
sign for several issuers, such as several realms of one IdP deployment. Set 
`idp_issuers` to the issuer, or entity ID, of the IdP you expect. For Keycloak 
it is `https://<keycloak-host>/realms/<realm>`. A response carrying an 
assertion from any other issuer is refused with `401`. Leaving `idp_issuers` 
unset accepts any issuer, while an empty array accepts none.
+
+An IdP restricts each assertion to an audience, which is normally the SP's 
entity ID. The Plugin accepts an assertion whose `AudienceRestriction` names 
`sp_issuer`. Set `sp_audiences` when the IdP issues a different audience, and 
list every audience the SP accepts, since `sp_issuer` is no longer added once 
`sp_audiences` is set.
+
+`clock_skew` sets how many seconds of clock difference between the APISIX node 
and the IdP are tolerated when checking the assertion's validity window. Keep 
APISIX nodes synchronized with NTP so the default is sufficient.
+
+### ACS URL behind a proxy
+
+The IdP sends the login response to an absolute ACS URL, and the response 
names that URL in its `Destination` and `Recipient`. The response is refused 
with `401` when they differ from the ACS URL the Plugin expects.
+
+When `sp_acs_url` is unset, the Plugin builds the expected URL from the scheme 
and host of the request that reaches APISIX. That URL is wrong whenever APISIX 
sees a different scheme or host than the browser, for example when a load 
balancer terminates TLS and forwards plain HTTP, or rewrites the `Host` header. 
Every login is then refused. Set `sp_acs_url` to the URL the browser uses, 
which is the ACS URL registered with the IdP:
+
+```json
+{
+  "login_callback_uri": "/login/callback",
+  "sp_acs_url": "https://sp.example.com/login/callback";
+}
+```
+
+`login_callback_uri` stays the request path that APISIX matches, and 
`sp_acs_url` is the absolute URL the IdP and the browser use. The path of 
`sp_acs_url` should resolve to `login_callback_uri` once the request reaches 
APISIX.
+
+### Assertion replay protection
+
+Set `replay_dict` to record the assertions each APISIX node accepts, so that 
presenting the same login response again is refused with `401`.
+
+APISIX declares the shared dict `plugin-saml-auth-replay` for this purpose 
whenever the `saml-auth` Plugin is enabled. Reference it from the Plugin:
+
+```json
+{
+  "replay_dict": "plugin-saml-auth-replay",
+  "replay_ttl": 600
+}
+```
+
+Its size defaults to `10m`, which holds about 40,000 assertions. Change it in 
`conf/config.yaml` on every APISIX node:
+
+```yaml
+nginx_config:
+  http:
+    lua_shared_dict:
+      plugin-saml-auth-replay: 20m
+```
+
+To give routes separate records, declare more shared dicts under 
`nginx_config.http.custom_lua_shared_dict` and name them in `replay_dict`. A 
route naming a shared dict that is not declared answers every request with 
`500` and logs `no lua_shared_dict named <name>`.
+
+Consider the following when enabling replay protection:
+
+- **The record is local to one node.** A `lua_shared_dict` is shared by the 
worker processes of one APISIX node only. When several APISIX nodes serve the 
same route, a response accepted by one node is not known to the others. On 
every node, `lua-resty-saml` still binds a response to the login request stored 
in the user's session, provided the IdP sends `InResponseTo`, which mainstream 
IdPs do.
+- **Size the shared dict for the login rate.** Each accepted assertion holds 
an entry until it expires. For example, 100 logins per second with 10-minute 
assertions keep about 60,000 entries, which needs more than the default `10m`. 
When the shared dict is full, the assertion is accepted without being recorded 
and an error is logged.
+- **A repeated submission is refused.** A browser that submits the same login 
response again, for example after going back in history, receives `401`. 
Opening the protected URL again starts a new login.
+
 ## Disable the Plugin
 
 To disable the `saml-auth` Plugin, remove it from the route configuration:
diff --git a/docs/zh/latest/plugins/saml-auth.md 
b/docs/zh/latest/plugins/saml-auth.md
index 6582d02a1d..e327d39d0f 100644
--- a/docs/zh/latest/plugins/saml-auth.md
+++ b/docs/zh/latest/plugins/saml-auth.md
@@ -55,22 +55,28 @@ description: saml-auth 插件为 API 路由提供 SAML 2.0 身份验证,可与
 | 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 中注册。 |
+| login_callback_uri | string | 是 | | | | SP 断言消费者服务(ACS)的请求路径,例如 
`/login/callback`。插件在请求路径等于该值时处理 IdP 的登录响应。该字段是路径,对外可见的 ACS 绝对 URL 通过 
`sp_acs_url` 配置。 |
+| logout_uri | string | 是 | | | | SP 单点注销(SLO)端点的请求路径,例如 
`/logout`,请求此路径将触发注销流程。 |
+| logout_callback_uri | string | 是 | | | | SP 的 SLO 回调请求路径,例如 
`/logout/callback`,IdP 将注销请求和注销响应发送至此路径,必须在 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 个字符 | 
密钥轮换时使用的历史密钥列表,允许使用旧密钥加密的会话继续有效,该字段在存储时加密。 |
+| idp_issuers | array[string] | 否 | | | | 
登录响应中允许的颁发者列表,响应中的每个断言都必须属于其中之一。未设置时,接受 `idp_cert` 
签名的任意颁发者。空数组不接受任何颁发者,所有登录都会被拒绝。参见[颁发者与受众](#颁发者与受众)。 |
+| sp_acs_url | string | 否 | | | `http://` 或 `https://` 开头的绝对 URL | 对外可见的 SP 
ACS 绝对 URL,例如 `https://sp.example.com/login/callback`。该值会在认证请求中发送给 IdP,登录响应的 
`Destination` 和 `Recipient` 必须与之相等。未设置时,根据请求的协议、主机和 `login_callback_uri` 
生成。参见[代理后的 ACS URL](#代理后的-acs-url)。 |
+| sp_audiences | array[string] | 否 | | `sp_issuer` | | SP 接受的受众列表。带有 
`AudienceRestriction` 的断言必须指定其中之一。未设置时,仅接受 `sp_issuer`。 |
+| clock_skew | number | 否 | | `60` | >= 0 | 校验 `NotBefore` 和 `NotOnOrAfter` 
时允许与 IdP 之间存在的时钟偏差,单位为秒。 |
+| replay_dict | string | 否 | | | 非空 | 已声明的 `lua_shared_dict` 
名称,用于记录已接受的断言,使同一断言无法在同一 APISIX 节点上登录两次。未设置时,不记录已接受的断言。参见[断言重放防护](#断言重放防护)。 |
+| replay_ttl | number | 否 | | `600` | >= 1 | 
对没有过期时间的断言,记录的时长,单位为秒。有过期时间的断言会记录到过期后再加 `clock_skew`,最长一天,若 `replay_ttl` 
大于一天则以其为上限。仅在设置 `replay_dict` 时生效。 |
 
 ## 前提条件
 
 在启用该插件前,请先在每个 APISIX 节点上安装 `lua-resty-saml`:
 
 ```shell
-luarocks install lua-resty-saml 0.2.5
+luarocks install lua-resty-saml 0.2.6
 ```
 
 `lua-resty-saml` 会编译原生 xmlsec 绑定,因此构建环境需要提供 LuaRocks 所需的 OpenSSL、libxml2 和 
libxslt 开发文件。
@@ -86,7 +92,7 @@ luarocks install lua-resty-saml 0.2.5
 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`)。
+   - 将 **Valid redirect URIs** 设置为包含 ACS 绝对 URL,即 `sp_acs_url`(例如 
`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`(签名证书)。
@@ -113,10 +119,13 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1"; \
         "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";,
+        "login_callback_uri": "/login/callback",
+        "sp_acs_url": "https://sp.example.com/login/callback";,
+        "logout_uri": "/logout",
+        "logout_callback_uri": "/logout/callback",
         "logout_redirect_uri": "https://sp.example.com/logout/done";,
+        "idp_issuers": ["https://keycloak.example.com/realms/myrealm";],
+        "sp_audiences": ["https://sp.example.com";],
         "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",
@@ -132,6 +141,63 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1"; \
   }'
 ```
 
+## 响应校验
+
+插件将以下配置传递给 `lua-resty-saml`,由其校验每个登录响应。这些配置均为可选项,未设置它们的现有配置行为保持不变。
+
+### 颁发者与受众
+
+`idp_cert` 证明响应由 IdP 的密钥签名,但同一密钥可能为多个颁发者签名,例如同一 IdP 部署中的多个 Realm。将 
`idp_issuers` 设置为预期 IdP 的颁发者(实体 ID),Keycloak 的颁发者为 
`https://<keycloak-host>/realms/<realm>`。响应中若包含其他颁发者的断言,将以 `401` 拒绝。未设置 
`idp_issuers` 时接受任意颁发者,设置为空数组时不接受任何颁发者。
+
+IdP 会将每个断言限定给某个受众,通常为 SP 的实体 ID。插件接受 `AudienceRestriction` 中包含 `sp_issuer` 
的断言。当 IdP 使用其他受众时,请设置 `sp_audiences` 并列出 SP 接受的全部受众,因为设置 `sp_audiences` 
后不会再自动加入 `sp_issuer`。
+
+`clock_skew` 设置校验断言有效期时允许 APISIX 节点与 IdP 之间存在的时钟偏差秒数。请使用 NTP 同步 APISIX 
节点时间,使默认值足够使用。
+
+### 代理后的 ACS URL
+
+IdP 将登录响应发送到 ACS 绝对 URL,并在响应的 `Destination` 和 `Recipient` 中写入该 URL。当它们与插件预期的 
ACS URL 不同时,响应将以 `401` 拒绝。
+
+未设置 `sp_acs_url` 时,插件根据到达 APISIX 的请求的协议和主机生成预期 URL。当 APISIX 
看到的协议或主机与浏览器不同时,例如负载均衡器终止 TLS 后以 HTTP 转发,或改写了 `Host` 请求头,生成的 URL 
就是错误的,所有登录都会被拒绝。此时请将 `sp_acs_url` 设置为浏览器使用的 URL,即在 IdP 中注册的 ACS URL:
+
+```json
+{
+  "login_callback_uri": "/login/callback",
+  "sp_acs_url": "https://sp.example.com/login/callback";
+}
+```
+
+`login_callback_uri` 仍是 APISIX 匹配的请求路径,`sp_acs_url` 是 IdP 和浏览器使用的绝对 URL。请求到达 
APISIX 时,`sp_acs_url` 的路径应对应 `login_callback_uri`。
+
+### 断言重放防护
+
+设置 `replay_dict` 后,每个 APISIX 节点会记录其接受的断言,再次提交同一登录响应时将以 `401` 拒绝。
+
+启用 `saml-auth` 插件时,APISIX 会为此声明共享字典 `plugin-saml-auth-replay`。在插件中引用:
+
+```json
+{
+  "replay_dict": "plugin-saml-auth-replay",
+  "replay_ttl": 600
+}
+```
+
+该共享字典默认大小为 `10m`,约可保存 40,000 个断言。如需调整,请在每个 APISIX 节点的 `conf/config.yaml` 中修改:
+
+```yaml
+nginx_config:
+  http:
+    lua_shared_dict:
+      plugin-saml-auth-replay: 20m
+```
+
+如需为不同路由使用独立的记录,可在 `nginx_config.http.custom_lua_shared_dict` 中声明更多共享字典,并在 
`replay_dict` 中引用。若路由引用了未声明的共享字典,该路由的所有请求都将返回 `500`,并记录日志 `no lua_shared_dict 
named <name>`。
+
+启用重放防护时,请注意以下事项:
+
+- **记录仅在单个节点内有效。** `lua_shared_dict` 只在同一 APISIX 节点的 worker 进程之间共享。当多个 APISIX 
节点服务同一路由时,一个节点接受的响应不会被其他节点知晓。只要 IdP 发送 `InResponseTo`(主流 IdP 
均会发送),`lua-resty-saml` 在每个节点上仍会将响应绑定到用户会话中保存的登录请求。
+- **按登录速率设置共享字典大小。** 每个已接受的断言在过期前都会占用一个条目。例如,每秒 100 次登录、断言有效期 10 分钟时,约需保存 
60,000 个条目,默认的 `10m` 不足以容纳。共享字典已满时,断言会被接受但不会被记录,并记录错误日志。
+- **重复提交会被拒绝。** 浏览器再次提交同一登录响应(例如通过浏览历史返回)时会收到 `401`。重新打开受保护的 URL 即可发起新的登录。
+
 ## 禁用插件
 
 如需禁用 `saml-auth` 插件,从路由配置中移除即可:
diff --git a/t/APISIX.pm b/t/APISIX.pm
index dca50da5cb..db1727424a 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -657,6 +657,7 @@ _EOC_
     lua_shared_dict plugin-ai-rate-limiting-reset-header 10m;
     lua_shared_dict plugin-graphql-limit-count 10m;
     lua_shared_dict plugin-graphql-limit-count-reset-header 10m;
+    lua_shared_dict plugin-saml-auth-replay 10m;
     lua_shared_dict internal-status 10m;
     lua_shared_dict worker-events 10m;
     lua_shared_dict lrucache-lock 10m;
diff --git a/t/cli/test_main.sh b/t/cli/test_main.sh
index c6c00adf23..0ae7f2dba8 100755
--- a/t/cli/test_main.sh
+++ b/t/cli/test_main.sh
@@ -1001,6 +1001,7 @@ nginx_config:
       balancer-ewma-locks: 20m
       balancer-ewma-last-touched-at: 20m
       plugin-limit-count-redis-cluster-slot-lock: 2m
+      plugin-saml-auth-replay: 20m
       tracing_buffer: 20m
       plugin-api-breaker: 20m
       etcd-cluster-health-check: 20m
@@ -1072,6 +1073,11 @@ if ! grep "plugin-limit-count-redis-cluster-slot-lock 
2m;" conf/nginx.conf > /de
     exit 1
 fi
 
+if ! grep "plugin-saml-auth-replay 20m;" conf/nginx.conf > /dev/null; then
+    echo "failed: 'plugin-saml-auth-replay 20m;' not in nginx.conf"
+    exit 1
+fi
+
 if ! grep "plugin-api-breaker 20m;" conf/nginx.conf > /dev/null; then
     echo "failed: 'plugin-api-breaker 20m;' not in nginx.conf"
     exit 1
diff --git a/t/lib/keycloak_saml.lua b/t/lib/keycloak_saml.lua
index c15acaf05b..dd8ea2e175 100644
--- a/t/lib/keycloak_saml.lua
+++ b/t/lib/keycloak_saml.lua
@@ -232,6 +232,46 @@ function _M.login_keycloak(uri, username, password)
     end
 end
 
+-- Login keycloak with HTTP-Redirect binding and stop before the SP callback,
+-- returning the callback URL carrying SAMLResponse and the SP session cookie
+function _M.login_keycloak_until_acs(uri, username, password, headers)
+    local httpc = http.new()
+
+    local res, err = httpc:request_uri(uri, {method = "GET", headers = 
headers})
+    if not res then
+        return nil, err
+    elseif res.status ~= 302 then
+        return nil, "login was not redirected to keycloak."
+    end
+    local sp_cookie = _M.concatenate_cookies(res.headers['Set-Cookie'])
+
+    res, err = httpc:request_uri(res.headers['Location'], {method = "GET"})
+    if not res then
+        return nil, err
+    elseif res.status ~= 200 then
+        return nil, res.body
+    end
+
+    local action, params = res.body:match('.*action="(.*)%?(.*)" 
method="post">')
+    params = params:gsub("&amp;", "&")
+
+    res, err = httpc:request_uri(action .. "?" .. params, {
+        method = "POST",
+        body = "username=" .. username .. "&password=" .. password,
+        headers = {
+            ["Content-Type"] = "application/x-www-form-urlencoded",
+            ["Cookie"] = _M.concatenate_cookies(res.headers['Set-Cookie'])
+        }
+    })
+    if not res then
+        return nil, err
+    elseif res.status ~= 302 then
+        return nil, "keycloak did not redirect to the SP callback: " .. 
res.status
+    end
+
+    return res.headers['Location'], nil, sp_cookie
+end
+
 -- Login keycloak and return the login original uri
 function _M.login_keycloak_for_second_sp(uri, keycloak_cookie_str)
     local httpc = http.new()
diff --git a/t/plugin/saml-auth-options.t b/t/plugin/saml-auth-options.t
new file mode 100644
index 0000000000..dc15876a70
--- /dev/null
+++ b/t/plugin/saml-auth-options.t
@@ -0,0 +1,615 @@
+#
+# 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) = @_;
+
+    # setup default conf.yaml
+    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->no_error_log)) {
+        $block->set_value("no_error_log", "[error]");
+    }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: add route with issuer, audience, clock skew and ACS URL pinned
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.idp_issuers = {"http://127.0.0.1:8087/realms/test"}
+            opts.sp_audiences = {"sp"}
+            opts.clock_skew = 30
+            opts.sp_acs_url = "http://127.0.0.1:1984/acs";
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 2: login and logout ok with the pinned options
+--- 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 res, err, saml_cookie, keycloak_cookie = 
kc.login_keycloak(uri .. path, "test", "test")
+            if err or res.headers['Location'] ~= path then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+            res = httpc:request_uri(uri .. res.headers['Location'], {
+                method = "GET",
+                headers = {["Cookie"] = saml_cookie}
+            })
+            ngx.say(res.status)
+
+            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)
+                return ngx.exit(500)
+            end
+        }
+    }
+--- response_body
+200
+--- error_log
+login callback req with redirect
+
+
+
+=== TEST 3: add route accepting another issuer only
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.idp_issuers = {"http://127.0.0.1:8087/realms/other"}
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 4: login from an issuer outside idp_issuers is refused
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local res = http.new():request_uri(acs, {
+                method = "GET",
+                headers = {["Cookie"] = sp_cookie}
+            })
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+401
+--- error_log
+unexpected issuer in response from IdP: http://127.0.0.1:8087/realms/test
+
+
+
+=== TEST 5: add route with an empty idp_issuers
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.idp_issuers = core.json.decode("[]")
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 6: an empty idp_issuers accepts no issuer
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local res = http.new():request_uri(acs, {
+                method = "GET",
+                headers = {["Cookie"] = sp_cookie}
+            })
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+401
+--- error_log
+unexpected issuer in response from IdP: http://127.0.0.1:8087/realms/test
+
+
+
+=== TEST 7: add route answering another audience only
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.sp_audiences = {"other-sp"}
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 8: login restricted to an audience outside sp_audiences is refused
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local res = http.new():request_uri(acs, {
+                method = "GET",
+                headers = {["Cookie"] = sp_cookie}
+            })
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+401
+--- error_log
+is restricted to sp
+
+
+
+=== TEST 9: add route without sp_acs_url
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 10: without sp_acs_url, a callback reaching the gateway under another 
host is refused
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local res = http.new():request_uri(acs, {
+                method = "GET",
+                headers = {["Cookie"] = sp_cookie, ["Host"] = 
"gateway.internal"}
+            })
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+401
+--- error_log
+response from IdP is addressed to http://127.0.0.1:1984/acs
+
+
+
+=== TEST 11: add route with sp_acs_url naming the external ACS
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.sp_acs_url = "http://127.0.0.1:1984/acs";
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 12: with sp_acs_url, login works while the gateway sees another host
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+            local headers = {["Host"] = "gateway.internal"}
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test",
+                                                                   headers)
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+            ngx.say(acs:sub(1, #"http://127.0.0.1:1984/acs?";))
+
+            local httpc = http.new()
+            local res = httpc:request_uri(acs, {
+                method = "GET",
+                headers = {["Cookie"] = sp_cookie, ["Host"] = 
"gateway.internal"}
+            })
+            ngx.say(res.status, " ", res.headers["Location"])
+
+            res = httpc:request_uri(uri .. "/uri", {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = 
kc.concatenate_cookies(res.headers["Set-Cookie"]),
+                    ["Host"] = "gateway.internal",
+                }
+            })
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+http://127.0.0.1:1984/acs?
+302 /uri
+200
+
+
+
+=== TEST 13: without replay_dict, the same response is accepted twice
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local httpc = http.new()
+            for _ = 1, 2 do
+                local res = httpc:request_uri(acs, {
+                    method = "GET",
+                    headers = {["Cookie"] = sp_cookie}
+                })
+                ngx.say(res.status)
+            end
+        }
+    }
+--- response_body
+302
+302
+
+
+
+=== TEST 14: add route with replay_dict
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.replay_dict = "plugin-saml-auth-replay"
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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: with replay_dict, the same response is refused the second time
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local kc = require "lib.keycloak_saml"
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+            local acs, err, sp_cookie = kc.login_keycloak_until_acs(uri .. 
"/uri", "test", "test")
+            if not acs then
+                ngx.log(ngx.ERR, err)
+                return ngx.exit(500)
+            end
+
+            local httpc = http.new()
+            for _ = 1, 2 do
+                local res = httpc:request_uri(acs, {
+                    method = "GET",
+                    headers = {["Cookie"] = sp_cookie}
+                })
+                ngx.say(res.status)
+            end
+        }
+    }
+--- response_body
+302
+401
+--- error_log
+has been presented already
+
+
+
+=== TEST 16: add route naming a lua_shared_dict that is not declared
+--- config
+    location /t {
+        content_by_lua_block {
+            local kc = require("lib.keycloak_saml")
+            local core = require("apisix.core")
+
+            local opts = core.table.deepcopy(kc.get_default_opts())
+            opts.sp_issuer = "sp"
+            opts.replay_dict = "undeclared_replay"
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "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 17: a missing replay_dict fails the request
+--- request
+GET /uri
+--- error_code: 500
+--- response_body
+{"message":"create saml object failed"}
+--- error_log
+no lua_shared_dict named undeclared_replay
diff --git a/t/plugin/saml-auth.t b/t/plugin/saml-auth.t
index 43119df4e4..61922ad098 100644
--- a/t/plugin/saml-auth.t
+++ b/t/plugin/saml-auth.t
@@ -606,3 +606,389 @@ passed
             assert(res == nil)
         }
     }
+
+
+
+=== TEST 16: schema validation - valid config with every response validation 
option
+--- 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 = "/acs",
+                logout_uri = "/logout",
+                logout_callback_uri = "/sls",
+                logout_redirect_uri = "/logout_ok",
+                sp_cert = "MIIC...",
+                sp_private_key = "MIIE...",
+                secret = "mysecret1",
+                idp_issuers = {"https://idp.example.com/realms/test"},
+                sp_acs_url = "https://sp.example.com/acs";,
+                sp_audiences = {"https://sp.example.com";, "sp"},
+                clock_skew = 0,
+                replay_dict = "saml_replay",
+                replay_ttl = 1,
+            })
+            if not ok then
+                ngx.say("failed: ", err)
+                return
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 17: schema validation - invalid response validation options
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.saml-auth")
+            local base = {
+                sp_issuer = "https://sp.example.com";,
+                idp_uri = "https://idp.example.com/sso";,
+                idp_cert = "MIIC...",
+                login_callback_uri = "/acs",
+                logout_uri = "/logout",
+                logout_callback_uri = "/sls",
+                logout_redirect_uri = "/logout_ok",
+                sp_cert = "MIIC...",
+                sp_private_key = "MIIE...",
+                secret = "mysecret1",
+            }
+            local cases = {
+                {"idp_issuers", "https://idp.example.com"},
+                {"idp_issuers", {1}},
+                {"sp_audiences", "sp"},
+                {"sp_audiences", {true}},
+                {"clock_skew", -1},
+                {"clock_skew", "60"},
+                {"replay_dict", ""},
+                {"replay_dict", 1},
+                {"replay_ttl", 0},
+                {"replay_ttl", -1},
+                {"replay_ttl", "600"},
+                {"sp_acs_url", "/acs"},
+            }
+            for _, case in ipairs(cases) do
+                local conf = core.table.deepcopy(base)
+                conf[case[1]] = case[2]
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(case[1], ": ", ok and "passed" or err)
+            end
+        }
+    }
+--- response_body
+idp_issuers: property "idp_issuers" validation failed: wrong type: expected 
array, got string
+idp_issuers: property "idp_issuers" validation failed: failed to validate item 
1: wrong type: expected string, got number
+sp_audiences: property "sp_audiences" validation failed: wrong type: expected 
array, got string
+sp_audiences: property "sp_audiences" validation failed: failed to validate 
item 1: wrong type: expected string, got boolean
+clock_skew: property "clock_skew" validation failed: expected -1 to be at 
least 0
+clock_skew: property "clock_skew" validation failed: wrong type: expected 
number, got string
+replay_dict: property "replay_dict" validation failed: string too short, 
expected at least 1, got 0
+replay_dict: property "replay_dict" validation failed: wrong type: expected 
string, got number
+replay_ttl: property "replay_ttl" validation failed: expected 0 to be at least 
1
+replay_ttl: property "replay_ttl" validation failed: expected -1 to be at 
least 1
+replay_ttl: property "replay_ttl" validation failed: wrong type: expected 
number, got string
+sp_acs_url: property "sp_acs_url" validation failed: failed to match pattern 
"^https?://" with "/acs"
+
+
+
+=== TEST 18: schema validation - omitted options stay absent and an empty 
idp_issuers stays empty
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.saml-auth")
+            local conf = core.json.decode([[{
+                "sp_issuer": "sp",
+                "idp_uri": "https://idp.example.com/sso";,
+                "idp_cert": "MIIC...",
+                "login_callback_uri": "/acs",
+                "logout_uri": "/logout",
+                "logout_callback_uri": "/sls",
+                "logout_redirect_uri": "/logout_ok",
+                "sp_cert": "MIIC...",
+                "sp_private_key": "MIIE...",
+                "secret": "mysecret1"
+            }]])
+            local ok, err = plugin.check_schema(conf)
+            ngx.say("omitted: ", ok and "passed" or err)
+            for _, name in ipairs({"idp_issuers", "sp_acs_url", "sp_audiences",
+                                   "clock_skew", "replay_dict", "replay_ttl"}) 
do
+                if conf[name] ~= nil then
+                    ngx.say(name, " was filled in")
+                end
+            end
+
+            conf.idp_issuers = core.json.decode("[]")
+            ok, err = plugin.check_schema(conf)
+            ngx.say("empty idp_issuers: ", ok and "passed" or err)
+            ngx.say("idp_issuers: ", core.json.encode(conf.idp_issuers))
+        }
+    }
+--- response_body
+omitted: passed
+empty idp_issuers: passed
+idp_issuers: []
+
+
+
+=== TEST 19: constructor receives every option unchanged in a copy of the 
plugin conf
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local old_plugin = package.loaded["apisix.plugins.saml-auth"]
+            local old_saml = package.loaded["resty.saml"]
+            package.loaded["apisix.plugins.saml-auth"] = nil
+
+            local received = {}
+            package.loaded["resty.saml"] = {
+                init = function() return nil end,
+                new = function(opts)
+                    received[#received + 1] = opts
+                    return {authenticate = function() return "user" end}
+                end,
+            }
+
+            local plugin = require("apisix.plugins.saml-auth")
+            local conf = {
+                sp_issuer = "sp",
+                idp_uri = "https://idp.example.com/sso";,
+                idp_cert = "MIIC...",
+                login_callback_uri = "/acs",
+                logout_uri = "/logout",
+                logout_callback_uri = "/sls",
+                logout_redirect_uri = "/logout_ok",
+                sp_cert = "MIIC...",
+                sp_private_key = "MIIE...",
+                secret = "mysecret1",
+                idp_issuers = {"https://idp.example.com/realms/test"},
+                sp_acs_url = "https://sp.example.com/acs";,
+                sp_audiences = {"sp", "https://sp.example.com"},
+                clock_skew = 30,
+                replay_dict = "saml_replay",
+                replay_ttl = 120,
+            }
+            plugin.rewrite(conf, {conf_type = "route", conf_id = "copy", 
conf_version = 1})
+
+            package.loaded["apisix.plugins.saml-auth"] = old_plugin
+            package.loaded["resty.saml"] = old_saml
+
+            local opts = received[1]
+            ngx.say("new called: ", #received)
+            ngx.say("same table: ", opts == conf)
+            ngx.say("same idp_issuers table: ", opts.idp_issuers == 
conf.idp_issuers)
+            ngx.say("same sp_audiences table: ", opts.sp_audiences == 
conf.sp_audiences)
+            for _, name in ipairs({"idp_issuers", "sp_acs_url", "sp_audiences",
+                                   "clock_skew", "replay_dict", "replay_ttl"}) 
do
+                ngx.say(name, ": ", core.json.encode(opts[name]))
+            end
+        }
+    }
+--- response_body
+new called: 1
+same table: false
+same idp_issuers table: false
+same sp_audiences table: false
+idp_issuers: ["https://idp.example.com/realms/test";]
+sp_acs_url: "https://sp.example.com/acs";
+sp_audiences: ["sp","https://sp.example.com";]
+clock_skew: 30
+replay_dict: "saml_replay"
+replay_ttl: 120
+
+
+
+=== TEST 20: constructor leaves omitted options absent and keeps an empty 
idp_issuers
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local old_plugin = package.loaded["apisix.plugins.saml-auth"]
+            local old_saml = package.loaded["resty.saml"]
+            package.loaded["apisix.plugins.saml-auth"] = nil
+
+            local received = {}
+            package.loaded["resty.saml"] = {
+                init = function() return nil end,
+                new = function(opts)
+                    received[#received + 1] = opts
+                    return {authenticate = function() return "user" end}
+                end,
+            }
+
+            local plugin = require("apisix.plugins.saml-auth")
+            local conf = core.json.decode([[{
+                "sp_issuer": "sp",
+                "idp_uri": "https://idp.example.com/sso";,
+                "idp_cert": "MIIC...",
+                "login_callback_uri": "/acs",
+                "logout_uri": "/logout",
+                "logout_callback_uri": "/sls",
+                "logout_redirect_uri": "/logout_ok",
+                "sp_cert": "MIIC...",
+                "sp_private_key": "MIIE...",
+                "secret": "mysecret1"
+            }]])
+            assert(plugin.check_schema(conf))
+            plugin.rewrite(conf, {conf_type = "route", conf_id = "omitted", 
conf_version = 1})
+
+            conf.idp_issuers = core.json.decode("[]")
+            assert(plugin.check_schema(conf))
+            plugin.rewrite(conf, {conf_type = "route", conf_id = "empty", 
conf_version = 1})
+
+            package.loaded["apisix.plugins.saml-auth"] = old_plugin
+            package.loaded["resty.saml"] = old_saml
+
+            for _, name in ipairs({"idp_issuers", "sp_acs_url", "sp_audiences",
+                                   "clock_skew", "replay_dict", "replay_ttl"}) 
do
+                if received[1][name] ~= nil then
+                    ngx.say(name, " was filled in")
+                end
+            end
+            ngx.say("empty idp_issuers: ", 
core.json.encode(received[2].idp_issuers))
+        }
+    }
+--- response_body
+empty idp_issuers: []
+
+
+
+=== TEST 21: mutating the plugin conf does not reach the cached SAML object
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local old_plugin = package.loaded["apisix.plugins.saml-auth"]
+            local old_saml = package.loaded["resty.saml"]
+            package.loaded["apisix.plugins.saml-auth"] = nil
+
+            local created = 0
+            package.loaded["resty.saml"] = {
+                init = function() return nil end,
+                new = function(opts)
+                    created = created + 1
+                    local obj = {opts = opts}
+                    function obj.authenticate(self)
+                        return self.opts
+                    end
+                    return obj
+                end,
+            }
+
+            local plugin = require("apisix.plugins.saml-auth")
+            local conf = {
+                sp_issuer = "sp",
+                idp_uri = "https://idp.example.com/sso";,
+                idp_cert = "MIIC...",
+                login_callback_uri = "/acs",
+                logout_uri = "/logout",
+                logout_callback_uri = "/sls",
+                logout_redirect_uri = "/logout_ok",
+                sp_cert = "MIIC...",
+                sp_private_key = "MIIE...",
+                secret = "mysecret1",
+                idp_issuers = {"https://idp.example.com/realms/test"},
+                sp_audiences = {"sp"},
+                clock_skew = 30,
+            }
+            local ctx = {conf_type = "route", conf_id = "mutate", conf_version 
= 1}
+            plugin.rewrite(conf, ctx)
+
+            conf.idp_issuers[1] = "https://other.example.com";
+            conf.sp_audiences = nil
+            conf.clock_skew = 3600
+            conf.replay_dict = "other"
+
+            plugin.rewrite(conf, ctx)
+
+            package.loaded["apisix.plugins.saml-auth"] = old_plugin
+            package.loaded["resty.saml"] = old_saml
+
+            local opts = ctx.external_user
+            ngx.say("created: ", created)
+            ngx.say("idp_issuers: ", core.json.encode(opts.idp_issuers))
+            ngx.say("sp_audiences: ", core.json.encode(opts.sp_audiences))
+            ngx.say("clock_skew: ", opts.clock_skew)
+            ngx.say("replay_dict: ", tostring(opts.replay_dict))
+        }
+    }
+--- response_body
+created: 1
+idp_issuers: ["https://idp.example.com/realms/test";]
+sp_audiences: ["sp"]
+clock_skew: 30
+replay_dict: nil
+
+
+
+=== TEST 22: rewrite returns 500 when the SAML object cannot be created
+--- 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"] = {
+                init = function() return nil end,
+                new = function(opts)
+                    error("no lua_shared_dict named " .. opts.replay_dict)
+                end,
+            }
+
+            local plugin = require("apisix.plugins.saml-auth")
+            local code, body = plugin.rewrite({
+                sp_issuer = "sp",
+                idp_uri = "https://idp.example.com/sso";,
+                idp_cert = "MIIC...",
+                login_callback_uri = "/acs",
+                logout_uri = "/logout",
+                logout_callback_uri = "/sls",
+                logout_redirect_uri = "/logout_ok",
+                sp_cert = "MIIC...",
+                sp_private_key = "MIIE...",
+                secret = "mysecret1",
+                replay_dict = "missing_dict",
+            }, {conf_type = "route", conf_id = "new-fails", 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
+create saml object failed
+--- error_log
+saml new failed:
+no lua_shared_dict named missing_dict
+
+
+
+=== TEST 23: resty.saml is first loaded in the worker, so its uuid seed is per 
worker
+--- extra_init_by_lua
+    ngx.log(ngx.WARN, "resty.saml loaded in init: ", 
package.loaded["resty.saml"] ~= nil)
+--- extra_init_worker_by_lua
+    ngx.log(ngx.WARN, "resty.saml loaded in init_worker: ", 
package.loaded["resty.saml"] ~= nil)
+--- config
+    location /t {
+        content_by_lua_block {
+            ngx.say("ok")
+        }
+    }
+--- response_body
+ok
+--- error_log
+resty.saml loaded in init: false
+resty.saml loaded in init_worker: true

Reply via email to