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