diff --git a/README.md b/README.md
index d3e5bc0..86d8484 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,8 @@ local saml = resty_saml.new(opts)
| `sp_acs_url` | string | built from the request | Absolute URL of this SP's assertion consumer service. It is announced to the IdP, every `SubjectConfirmationData/@Recipient` has to name it, and a `Destination` has to name it on a response carrying one. Unset, it is assembled from the request's scheme and host, which is only as trustworthy as whatever sits in front: set it wherever the ingress does not normalise `Forwarded` and `X-Forwarded-*`, or terminates TLS without setting `X-Forwarded-Proto`. |
| `sp_audiences` | array of strings | `{ sp_issuer }` | Audiences this SP answers to. An assertion carrying an `AudienceRestriction` has to name one of them; an assertion carrying none is unrestricted. |
| `clock_skew` | number | `60` | Seconds of clock difference tolerated against the IdP when weighing `NotBefore` and `NotOnOrAfter`. |
+| `replay_dict` | string | None | Name of an `lua_shared_dict` in which to remember the assertions this instance has already accepted, so it accepts none of them twice. Unset leaves them untracked. See [Remembering assertions](#remembering-assertions) for what the zone has to hold and how far the guarantee reaches. |
+| `replay_ttl` | number | `600` | Seconds to remember an assertion when nothing bounds its acceptance: no `NotOnOrAfter` on its `Conditions` and none on a satisfiable subject confirmation. A bounded one is remembered until acceptance ends, plus `clock_skew`, capped at a day. |
#### Binding a response to the request
@@ -106,6 +108,46 @@ One note for upgrading. A session minted before this SP kept the ID has nothing
the assertion to name, so the login is started again rather than refused. The window
lasts as long as an `AuthnRequest` is outstanding across the upgrade.
+#### Remembering assertions
+
+Set `replay_dict` and every assertion this instance accepts is remembered for as
+long as it could still be used, within the bounds below, and presenting one that is
+remembered is refused. Leave it unset and assertions go untracked, which is what
+happened before the option existed.
+
+**The guarantee is per instance.** An `lua_shared_dict` is shared between the workers
+of one gateway and nowhere else, so a captured assertion replayed through a load
+balancer lands on a replica that has never seen it and is accepted. Across replicas
+the binding in [Binding a response to the request](#binding-a-response-to-the-request)
+is what carries the weight, since it travels in the user's own session, and this
+option is the defence for the deployments that binding leaves uncovered: the ones
+whose IdP sends no `InResponseTo`.
+
+**Size the zone for what it holds.** One entry per assertion accepted, held for as
+long as that assertion could still be used. A response normally carries one, so an SP
+taking ten logins a second against an IdP issuing ten-minute assertions holds around
+six thousand entries at once: `1m` is too small for that and a busy deployment wants
+more. A zone with no room leaves that assertion untracked and logs an error naming
+the assertion and the zone, rather than evicting an entry that is still protecting
+somebody else. A response carrying several assertions can end up partly tracked,
+which is the safe direction: a later replay still collides on whichever of them was
+recorded.
+
+**The record is bounded even where acceptance is not.** An assertion with no usable
+expiry is remembered for `replay_ttl` and accepted for good, so it is refusable only
+inside that window; one the IdP made valid beyond a day is remembered for the day
+and accepted again past it. Both need an IdP far outside shipped defaults, where
+the delivery window is minutes and the assertion window at most an hour, and the
+alternative is a record nothing reclaims. The limit an operator can move is
+`replay_ttl`; the day cap is fixed.
+
+**Two things it deliberately does not do.** An assertion carrying ``
+is still refused outright, so an IdP asking for exactly this protection cannot log in
+even with the option on; that is tracked separately and the two do not meet yet. And
+re-submitting a response that already logged in is refused, which is what a browser
+does when it loses the redirect that ends a login. Returning to the application starts
+a fresh login, and the IdP will not ask for a password again.
+
#### Seeding the worker
Request IDs and `RelayState` both come from `resty.jit-uuid`, which is seeded when
diff --git a/lua/resty/saml.lua b/lua/resty/saml.lua
index a7f92d8..8db5b6f 100644
--- a/lua/resty/saml.lua
+++ b/lua/resty/saml.lua
@@ -320,6 +320,14 @@ end
-- what stops an assertion minted for another SP in the same federation.
local DEFAULT_CLOCK_SKEW = 60
+-- how long an assertion that sets no expiry of its own is remembered
+local DEFAULT_REPLAY_TTL = 600
+
+-- and how long any assertion is remembered at most, whatever it claims. An
+-- assertion valid for years would pin a slot the dict never reclaims, and
+-- nobody is still trying to complete that login a day later.
+local MAX_REPLAY_TTL = 86400
+
local function time_bounds_ok(not_before, not_on_or_after, now, skew)
local opens, closes, err
@@ -501,6 +509,118 @@ local function issuers_allowed(allowed, issuers)
return true
end
+-- The last moment the checks above would still admit the assertion. They
+-- combine as an AND: the Conditions window has to hold, and one confirmation
+-- has to be satisfiable, so acceptance ends at whichever gives out first, the
+-- Conditions close or the last confirmation still standing. Profile 4.1.4.2
+-- puts a bearer assertion's expiry on its confirmation, so a Conditions
+-- carrying nothing but an audience is the profile-minimal shape rather than an
+-- odd one. Nil when nothing bounds acceptance, which replay_ttl stands in for.
+--
+-- Only confirmations that could ever confirm at this SP have a say, the same
+-- ones confirmation_ok weighs, minus the clock: one naming another Recipient
+-- or another request can never keep the assertion alive here, and one whose
+-- close this parser will not take, a legal xs:dateTime carrying an offset
+-- rather than Z, is unsatisfiable in the same way. Reading those as
+-- contributing nothing rather than as unbounded matters in both directions,
+-- since a confirmation naming no close never gives out: one satisfiable such
+-- confirmation means the confirmations impose no limit at all, where the
+-- earlier reading let a shorter sibling shrink the record below what an
+-- absent sibling would have left it.
+local function last_moment_usable(assertion, expected)
+ local notes_close
+ local unbounded = #assertion.subject_confirmations == 0
+ for _, confirmation in ipairs(assertion.subject_confirmations) do
+ local confirms_here = confirmation.recipient == expected.acs_url and
+ (confirmation.in_response_to == nil or
+ confirmation.in_response_to == expected.request_id)
+ if confirms_here then
+ if confirmation.not_on_or_after == nil then
+ unbounded = true
+ else
+ local at = parse_iso8601_utc_time(confirmation.not_on_or_after)
+ if at and (notes_close == nil or at > notes_close) then
+ notes_close = at
+ end
+ end
+ end
+ end
+ if unbounded then
+ notes_close = nil
+ end
+
+ local conditions_close
+ if assertion.not_on_or_after then
+ conditions_close = parse_iso8601_utc_time(assertion.not_on_or_after)
+ end
+
+ if conditions_close and notes_close then
+ return math.min(conditions_close, notes_close)
+ end
+ return conditions_close or notes_close
+end
+
+
+-- An ID is unique only within the IdP that minted it, and idp_issuers takes a
+-- list, so the two travel together. The SP name keeps instances sharing one
+-- dict apart.
+local function replay_key(opts, assertion)
+ return opts.sp_issuer .. "|" .. (assertion.issuer or "") .. "|" .. assertion.id
+end
+
+
+-- A bearer assertion is good for one login. Nothing above stops the same one
+-- being presented again inside its validity window, so its ID is remembered for
+-- as long as it could still be used and a second presentation is refused.
+--
+-- Called at the last gate rather than beside the checks, so a login the rest of
+-- the callback still refuses leaves the assertion unspent. A dict with no room
+-- leaves this assertion untracked rather than evicting one that is still
+-- protecting somebody else's login, which is what add would do on its own: the
+-- entry it takes belongs to another user, the login it stops protecting is
+-- theirs, and the warning is reported against whoever needed the space.
+local function spend_assertions(dict, opts, assertions, expected, now)
+ local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW
+ local spent = {}
+
+ for _, assertion in ipairs(assertions) do
+ if not assertion.id then
+ return false, "an assertion without an ID cannot be tracked"
+ end
+
+ local ttl = opts.replay_ttl or DEFAULT_REPLAY_TTL
+ local usable_until = last_moment_usable(assertion, expected)
+ if usable_until then
+ ttl = usable_until + skew - now
+ end
+ if ttl < 1 then
+ ttl = 1
+ elseif ttl > MAX_REPLAY_TTL then
+ ttl = MAX_REPLAY_TTL
+ end
+
+ local key = replay_key(opts, assertion)
+ local added, add_err = dict:safe_add(key, true, ttl)
+ if added then
+ spent[#spent + 1] = key
+ elseif add_err == "exists" then
+ -- this response authenticates nobody, so the assertions already
+ -- taken from it are handed back rather than left spent
+ for _, taken in ipairs(spent) do
+ dict:delete(taken)
+ end
+ return false, "assertion " .. assertion.id .. " has been presented already"
+ else
+ ngx.log(ngx.ERR, "could not remember assertion ", loggable(assertion.id), " in ",
+ opts.replay_dict, ": ", add_err,
+ ", this login is not covered by replay tracking")
+ end
+ end
+
+ return true
+end
+
+
local function login_callback(self, opts)
local sess = session.start(self.session_config)
@@ -584,7 +704,8 @@ local function login_callback(self, opts)
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
- local acceptable, reason = assertions_acceptable(opts, assertions, expected, ngx.time())
+ local now = ngx.time()
+ local acceptable, reason = assertions_acceptable(opts, assertions, expected, now)
if not acceptable then
ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason))
ngx.exit(ngx.HTTP_UNAUTHORIZED)
@@ -624,6 +745,17 @@ local function login_callback(self, opts)
end
+ -- the last gate: everything that can still refuse this login has run, so
+ -- the assertion is spent only where it actually authenticates somebody
+ if self.replay_dict then
+ local unused, used_reason = spend_assertions(self.replay_dict, opts, assertions,
+ expected, now)
+ if not unused then
+ ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(used_reason))
+ ngx.exit(ngx.HTTP_UNAUTHORIZED)
+ end
+ end
+
sess:set("authenticated", true)
sess:set("name_id", name_id)
sess:set("session_index", session_index)
@@ -800,6 +932,30 @@ function _M.new(opts)
obj.idp_cert_func = function(doc) return idp_cert end
obj.auth_protocol_binding_method = opts.auth_protocol_binding_method
obj.idp_issuers = issuer_set(opts.idp_issuers)
+ -- read once, and raised rather than returned so a mistyped name names
+ -- itself. A message built as an argument to assert is built on every
+ -- successful call too, and a non-string one fails on the concatenation
+ -- rather than on the option.
+ if opts.replay_dict ~= nil then
+ if type(opts.replay_dict) ~= "string" then
+ error("replay_dict must be the name of a lua_shared_dict", 2)
+ end
+ obj.replay_dict = ngx.shared[opts.replay_dict]
+ if obj.replay_dict == nil then
+ error("no lua_shared_dict named " .. opts.replay_dict, 2)
+ end
+ -- it is half the key, and tostring would turn a missing one into the
+ -- literal nil that two deployments would then share
+ if type(opts.sp_issuer) ~= "string" then
+ error("sp_issuer must be a string to track assertions", 2)
+ end
+ -- zero means never expire to lua_shared_dict, and a number arriving
+ -- from YAML or the environment as text compares against nothing
+ if opts.replay_ttl ~= nil and
+ (type(opts.replay_ttl) ~= "number" or opts.replay_ttl < 1) then
+ error("replay_ttl must be a positive number of seconds", 2)
+ end
+ end
local cookie_secure, cookie_same_site
if opts.auth_protocol_binding_method == "HTTP-POST" then
cookie_secure = true
diff --git a/src/lua_saml.c b/src/lua_saml.c
index 05f56fb..f0a7d2f 100644
--- a/src/lua_saml.c
+++ b/src/lua_saml.c
@@ -696,6 +696,7 @@ static int doc_assertions(lua_State* L) {
lua_pushinteger(L, i + 1);
lua_newtable(L);
set_str_field(L, "id", a->id);
+ set_str_field(L, "issuer", a->issuer);
set_bool_field(L, "has_conditions", a->has_conditions);
set_str_field(L, "not_before", a->not_before);
set_str_field(L, "not_on_or_after", a->not_on_or_after);
diff --git a/src/saml.h b/src/saml.h
index ac54afc..5d7592e 100644
--- a/src/saml.h
+++ b/src/saml.h
@@ -57,6 +57,7 @@ typedef struct {
typedef struct {
xmlChar* id;
+ xmlChar* issuer;
int has_conditions;
xmlChar* not_before;
xmlChar* not_on_or_after;
diff --git a/src/xml.c b/src/xml.c
index 6858757..61ba856 100644
--- a/src/xml.c
+++ b/src/xml.c
@@ -497,6 +497,10 @@ static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) {
return -1;
}
+ // An ID is unique only within the IdP that minted it, so the caller keeps the
+ // two together. Absent and empty read alike here, as they do for doc_issuers.
+ a->issuer = issuer_of(doc, node);
+
xmlNode* conditions = assertion_child(node, "Conditions");
if (conditions != NULL) {
a->has_conditions = 1;
@@ -608,6 +612,7 @@ void saml_assertions_free(saml_assertion_t* assertions, size_t assertions_len) {
for (size_t i = 0; i < assertions_len; i++) {
saml_assertion_t* a = assertions + i;
xmlFree(a->id);
+ xmlFree(a->issuer);
xmlFree(a->not_before);
xmlFree(a->not_on_or_after);
xmlFree(a->unknown_condition);
diff --git a/t/assertion-conditions.t b/t/assertion-conditions.t
index d730b32..2bb6dde 100644
--- a/t/assertion-conditions.t
+++ b/t/assertion-conditions.t
@@ -35,6 +35,12 @@ _EOC_
lua_package_path '$pwd/lua/?.lua;$pwd/deps/share/lua/5.1/?.lua;$pwd/t/?.lua;;';
lua_package_cpath '$pwd/?.so;$pwd/deps/lib/lua/5.1/?.so;;';
+ # a zone of the same name and size is reused across a reload, so entries
+ # outlive the block that made them under TEST_NGINX_USE_HUP=1. Blocks name
+ # their own assertions to stay apart, and flush as well
+ lua_shared_dict saml_replay 1m;
+ lua_shared_dict saml_replay_full 32k;
+
init_by_lua_block {
saml = require "saml"
local err = saml.init({ debug = true, data_dir = os.getenv("SAML_DATA_DIR") })
@@ -100,6 +106,13 @@ GnHKA3uj9HpsS6fAxHNPPvWxRjO67Xj8Yw==
skew = { clock_skew = 300 },
audiences = { sp_audiences = { "https://sp.example.com/metadata" } },
acs = { sp_acs_url = "http://127.0.0.1:1984/acs" },
+ replay = { replay_dict = "saml_replay" },
+ replay_short = { replay_dict = "saml_replay", replay_ttl = 90 },
+ replay_full = { replay_dict = "saml_replay_full" },
+ replay_pinned = {
+ replay_dict = "saml_replay",
+ idp_issuers = { "https://elsewhere.example.com" },
+ },
}
SPS = {}
@@ -190,7 +203,7 @@ GnHKA3uj9HpsS6fAxHNPPvWxRjO67Xj8Yw==
'ID="%s" Version="2.0" IssueInstant="2026-07-21T00:00:00Z">' ..
'%s' ..
'%s%s%s%s',
- spec.id or "a1", IDP, spec.name_id or "signed\@example.com",
+ spec.id or "a1", spec.issuer or IDP, spec.name_id or "signed\@example.com",
spec.confirmations or "", spec.conditions or "",
authn_statement(spec.session_expires))
end
@@ -222,6 +235,12 @@ GnHKA3uj9HpsS6fAxHNPPvWxRjO67Xj8Yw==
return saml.doc_id(doc)
end
+ -- the module owns this layout; naming it once here keeps a change to
+ -- the scheme from surfacing as a comparison against nil
+ function replay_key(id, issuer)
+ return "sp|" .. (issuer or IDP) .. "|" .. id
+ end
+
function callback_headers(name, cookie, extra)
local headers = {
["X-Test-SP"] = name,
@@ -986,3 +1005,384 @@ offers no subject confirmation this SP can satisfy
302 http://127.0.0.1:1984/idp
--- error_log
session carries no request id, starting the login again
+
+
+=== TEST 32: an assertion is good for one login
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ local xml = saml_response({
+ id = "once", conditions = conditions({ not_on_or_after = at(600) }),
+ })
+ ngx.say(login_with("replay", xml))
+ ngx.say(login_with("replay", xml))
+ }
+ }
+--- response_body
+302 /
+401 nil
+--- error_log
+assertion once has been presented already
+
+
+=== TEST 33: a second assertion of its own is accepted
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ ngx.say(login_with("replay", saml_response({ id = "first" })))
+ ngx.say(login_with("replay", saml_response({ id = "second" })))
+ }
+ }
+--- response_body
+302 /
+302 /
+
+
+=== TEST 34: an assertion is remembered for as long as it is usable
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ ngx.say(login_with("replay", saml_response({
+ id = "bounded", conditions = conditions({ not_on_or_after = at(600) }),
+ })))
+ -- the window plus the skew allowance, which is when it stops being
+ -- accepted and so stops being worth remembering
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("bounded"))
+ ngx.say("tracked: ", ttl > 600 and ttl <= 660)
+ }
+ }
+--- response_body
+302 /
+tracked: true
+
+
+=== TEST 35: two IdPs may mint the same assertion ID
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- an ID is unique only within the IdP that issued it, so sharing
+ -- one is not a replay
+ ngx.say(login_with("replay", saml_response({ id = "shared" })))
+ ngx.say(login_with("replay", saml_response({
+ id = "shared", issuer = "https://second-idp.example.com",
+ })))
+ }
+ }
+--- response_body
+302 /
+302 /
+
+
+=== TEST 36: an expiry the IdP puts on the confirmation decides it too
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- profile 4.1.4.2 puts a bearer assertion's expiry here, so a
+ -- Conditions naming only an audience is the ordinary shape. Reading
+ -- only Conditions forgot the assertion while it was still accepted.
+ ngx.say(login_with("replay", function(request_id)
+ return saml_response({
+ id = "on-confirmation",
+ conditions = conditions({ body = audience("sp") }),
+ confirmations = confirmation({
+ recipient = ACS, not_on_or_after = at(3600),
+ in_response_to = request_id,
+ }),
+ }, ACS, request_id)
+ end))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("on-confirmation"))
+ ngx.say("tracked: ", ttl > 3600 and ttl <= 3660)
+ }
+ }
+--- response_body
+302 /
+tracked: true
+
+
+=== TEST 37: an assertion naming no expiry falls back to replay_ttl
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- nothing bounds it, so it is replayable once the record lapses.
+ -- That is what replay_ttl is for and the README says so.
+ ngx.say(login_with("replay", saml_response({ id = "unbounded" })))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("unbounded"))
+ ngx.say("default: ", ttl > 590 and ttl <= 600)
+ }
+ }
+--- response_body
+302 /
+default: true
+
+
+=== TEST 38: replay_ttl settles that fallback
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ ngx.say(login_with("replay_short", saml_response({ id = "configured" })))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("configured"))
+ ngx.say("configured: ", ttl > 80 and ttl <= 90)
+ }
+ }
+--- response_body
+302 /
+configured: true
+
+
+=== TEST 39: an assertion good for years is remembered for a day
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- the schema takes any year up to 9999, and an entry that never
+ -- lapses is a slot the dict never reclaims
+ ngx.say(login_with("replay", saml_response({
+ id = "forever",
+ conditions = conditions({ not_on_or_after = "9999-12-31T23:59:59Z" }),
+ })))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("forever"))
+ ngx.say("capped: ", ttl > 86300 and ttl <= 86400)
+ }
+ }
+--- response_body
+302 /
+capped: true
+
+
+=== TEST 40: a full dict leaves the login working and says so
+--- config
+ location /t {
+ content_by_lua_block {
+ local dict = ngx.shared.saml_replay_full
+ dict:flush_all()
+ dict:flush_expired()
+ local filler = string.rep("x", 256)
+ local i, ok, err = 0, true, nil
+ while ok do
+ ok, err = dict:safe_set("filler-" .. i, filler, 600)
+ if ok then i = i + 1 end
+ if i > 5000 then break end
+ end
+ local j = 0
+ while dict:safe_add("small-" .. j, true, 600) do
+ j = j + 1
+ if j > 5000 then break end
+ end
+ ngx.say("full: ", i > 0 and j > 0 and err == "no memory")
+
+ -- evicting would take the record away from whoever holds it and
+ -- report it against this request, so this login goes untracked
+ ngx.say(login_with("replay_full", saml_response({ id = "untracked" })))
+ }
+ }
+--- response_body
+full: true
+302 /
+--- error_log
+in saml_replay_full: no memory, this login is not covered by replay tracking
+
+
+=== TEST 41: a login refused after the checks leaves the assertion unspent
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- idp_issuers refuses this one below where the record used to be
+ -- written, so writing it early told the retry it was a replay
+ local xml = saml_response({ id = "unspent" })
+ ngx.say(login_with("replay_pinned", xml))
+ ngx.say("remembered: ", ngx.shared.saml_replay:get(replay_key("unspent")) ~= nil)
+ ngx.say(login_with("replay", xml))
+ }
+ }
+--- response_body
+401 nil
+remembered: false
+302 /
+--- error_log
+unexpected issuer in response from IdP
+
+
+=== TEST 42: a response refused part way spends none of its assertions
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- one signature over the whole Response, so it carries two
+ -- assertions and the reader draws identity from both
+ local spent = sign_doc(response(assertion({ id = "pair-b" })))
+ ngx.say(login_with("replay", spent))
+
+ local pair = sign_doc(response(
+ assertion({ id = "pair-a" }) .. assertion({ id = "pair-b" })))
+ ngx.say(login_with("replay", pair))
+ ngx.say("remembered: ", ngx.shared.saml_replay:get(replay_key("pair-a")) ~= nil)
+
+ ngx.say(login_with("replay", sign_doc(response(assertion({ id = "pair-a" })))))
+ }
+ }
+--- response_body
+302 /
+401 nil
+remembered: false
+302 /
+--- error_log
+assertion pair-b has been presented already
+
+
+=== TEST 43: replay configuration is weighed when the SP is built
+--- config
+ location /t {
+ content_by_lua_block {
+ local resty_saml = require("resty.saml")
+ local function build(extra, drop)
+ local opts = {
+ sp_issuer = "sp",
+ idp_uri = "http://127.0.0.1:1984/idp",
+ login_callback_uri = "/acs",
+ sp_cert = CERT_PEM,
+ sp_private_key = KEY_PEM,
+ idp_cert = CERT_PEM,
+ secret = "very-secret-key-that-is-32-byte!",
+ }
+ for k, v in pairs(extra) do opts[k] = v end
+ if drop then opts[drop] = nil end
+ local ok, err = pcall(resty_saml.new, opts)
+ return ok and "built" or err:gsub("^.-:%d+: ", "")
+ end
+
+ ngx.say(build({ replay_dict = true }))
+ ngx.say(build({ replay_dict = "no-such-dict" }))
+ ngx.say(build({ replay_dict = "saml_replay" }, "sp_issuer"))
+ -- zero means never expire to lua_shared_dict, and text is what a
+ -- YAML or environment config path hands over
+ ngx.say(build({ replay_dict = "saml_replay", replay_ttl = 0 }))
+ ngx.say(build({ replay_dict = "saml_replay", replay_ttl = "600" }))
+ ngx.say(build({ replay_dict = "saml_replay", replay_ttl = 90 }))
+ }
+ }
+--- response_body
+replay_dict must be the name of a lua_shared_dict
+no lua_shared_dict named no-such-dict
+sp_issuer must be a string to track assertions
+replay_ttl must be a positive number of seconds
+replay_ttl must be a positive number of seconds
+built
+
+
+=== TEST 44: a confirmation bound this parser will not take is skipped
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- an offset rather than Z is legal xs:dateTime and refused here, so
+ -- that confirmation is unsatisfiable and the login rides on the
+ -- other one. Refusing on it would let replay_dict decide who is let
+ -- in, which is what the option must never do.
+ ngx.say(login_with("replay", function(request_id)
+ return saml_response({
+ id = "unreadable-bound",
+ confirmations = confirmation({
+ recipient = ACS, not_on_or_after = at(600),
+ in_response_to = request_id,
+ }) .. confirmation({
+ recipient = ACS, not_on_or_after = "2030-01-01T00:00:00+00:00",
+ in_response_to = request_id,
+ }),
+ }, ACS, request_id)
+ end))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("unreadable-bound"))
+ ngx.say("tracked: ", ttl > 600 and ttl <= 660)
+ }
+ }
+--- response_body
+302 /
+tracked: true
+
+
+=== TEST 45: a confirmation naming no close keeps the fallback in charge
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- the dated sibling gives out in a minute; the dateless one never
+ -- does, so it decides, and the record falls to replay_ttl rather
+ -- than to the shortest date in sight
+ ngx.say(login_with("replay", function(request_id)
+ return saml_response({
+ id = "never-gives-out",
+ confirmations = confirmation({
+ recipient = ACS, not_on_or_after = at(60),
+ in_response_to = request_id,
+ }) .. confirmation({
+ recipient = ACS, in_response_to = request_id,
+ }),
+ }, ACS, request_id)
+ end))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("never-gives-out"))
+ ngx.say("fallback: ", ttl > 590 and ttl <= 600)
+ }
+ }
+--- response_body
+302 /
+fallback: true
+
+
+=== TEST 46: acceptance ends at whichever close comes first
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- the Conditions window and the confirmations combine as an AND,
+ -- so the Conditions closing first is when acceptance ends
+ ngx.say(login_with("replay", function(request_id)
+ return saml_response({
+ id = "conditions-first",
+ conditions = conditions({ not_on_or_after = at(300) }),
+ confirmations = confirmation({
+ recipient = ACS, not_on_or_after = at(3600),
+ in_response_to = request_id,
+ }),
+ }, ACS, request_id)
+ end))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("conditions-first"))
+ ngx.say("earlier: ", ttl > 300 and ttl <= 360)
+ }
+ }
+--- response_body
+302 /
+earlier: true
+
+
+=== TEST 47: a confirmation that cannot confirm here has no say in the record
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.shared.saml_replay:flush_all()
+ -- the dateless one is addressed elsewhere, so it can never keep
+ -- this assertion alive here and does not unbound the record
+ ngx.say(login_with("replay", function(request_id)
+ return saml_response({
+ id = "elsewhere-dateless",
+ confirmations = confirmation({
+ recipient = ACS, not_on_or_after = at(3600),
+ in_response_to = request_id,
+ }) .. confirmation({
+ recipient = "https://other-sp.example.com/acs",
+ }),
+ }, ACS, request_id)
+ end))
+ local ttl = ngx.shared.saml_replay:ttl(replay_key("elsewhere-dateless"))
+ ngx.say("dated one decides: ", ttl > 3600 and ttl <= 3660)
+ }
+ }
+--- response_body
+302 /
+dated one decides: true