Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5624c19
fix: accept OneTimeUse, and let the replay record honour it
shreemaan-abhishek Aug 27, 2026
8bf6364
fix: warn on the handle the gate enforces on, and say when a full dic…
shreemaan-abhishek Aug 27, 2026
bd5226e
fix: say when an OneTimeUse assertion outlives its record
shreemaan-abhishek Aug 27, 2026
b162b7b
docs: name the level the OneTimeUse warning is logged at
shreemaan-abhishek Aug 27, 2026
a01ffb8
docs: bound the OneTimeUse claim by the record's own limits
shreemaan-abhishek Aug 27, 2026
c769806
fix: read OneTimeUse whatever the order of the conditions
shreemaan-abhishek Aug 27, 2026
b8728ad
test: pin that without a record an OneTimeUse assertion is accepted a…
shreemaan-abhishek Aug 27, 2026
a2e67be
test: give the recorded OneTimeUse case an expiry, a record check, an…
shreemaan-abhishek Aug 27, 2026
3884513
build: rebuild both objects when a header changes
shreemaan-abhishek Aug 27, 2026
fe7774e
fix: weigh the OneTimeUse record against the cap before the clamp
shreemaan-abhishek Aug 31, 2026
9fcf54f
fix: say an OneTimeUse record falls short only once it stands
shreemaan-abhishek Aug 31, 2026
c3d5bc5
test: pin that an unstamped assertion never draws the OneTimeUse warn
shreemaan-abhishek Aug 31, 2026
672ccd7
test: pin that the full-dict line names OneTimeUse only when carried
shreemaan-abhishek Aug 31, 2026
2c483e5
fix: name the zone from new() in the full-dict line, and pin fail-open
shreemaan-abhishek Aug 31, 2026
193da86
refactor: answer OneTimeUse presence on its own line
shreemaan-abhishek Aug 31, 2026
44e5034
test: carry the log guards in the two OneTimeUse warn blocks
shreemaan-abhishek Aug 31, 2026
b935eaf
docs: carry the OneTimeUse diagnostics through the record's bounds
shreemaan-abhishek Aug 31, 2026
8dd5b26
build: relink when the Makefile or the xmlsec archives change
shreemaan-abhishek Aug 31, 2026
21e0d9a
fix: claim only what the failed add knows, and name the issuer
shreemaan-abhishek Aug 31, 2026
57bb15f
refactor: buffer the facts of the deferred warn, and name the issuer
shreemaan-abhishek Aug 31, 2026
6de2bf8
test: anchor the fail-open line's severity
shreemaan-abhishek Aug 31, 2026
603b046
test: allow the standard slack on the stamped record's TTL
shreemaan-abhishek Aug 31, 2026
30f4b17
test: fill the zone through one helper that vouches it is full
shreemaan-abhishek Aug 31, 2026
ca97738
docs: state the opts handover contract, and claim less for the kept name
shreemaan-abhishek Aug 31, 2026
e734364
fix: clamp only what the assertion claims, never replay_ttl
shreemaan-abhishek Aug 31, 2026
e8f1dad
test: retire the mutation blocks the handover contract obsoleted
shreemaan-abhishek Aug 31, 2026
1668ce8
test: count the fail-open line, once per presentation
shreemaan-abhishek Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ test: build deps/
clean:
rm -rf *.so *.o xmlsec1-$(XMLSEC_VER)*

saml.o: src/*.c
saml.o: src/*.c src/*.h Makefile
$(CC) -c $(CFLAGS_ALL) -o saml.o src/saml.c

lua_saml.o: src/lua_saml.c
lua_saml.o: src/lua_saml.c src/*.h Makefile
$(CC) -c $(CFLAGS_ALL) -I$(LUA_INCDIR) -Isrc/ -o $@ $<

saml.so: lua_saml.o saml.o
$(CC) -o $@ $^ $(LDFLAGS_ALL)
saml.so: lua_saml.o saml.o $(XMLSEC1_STATIC_LIBS) Makefile

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things this line newly broke, both measured with real runs rather than -n.

The archives are a prerequisite of the link but not of the compiles, and the compiles are what consume -Ixmlsec1-$(XMLSEC_VER)/include/. With xmlsec1-1.2.28/ moved aside and the objects removed — the fresh-clone condition — make saml.so OPENSSL_DIR=... goes straight for the objects: gcc -c ... -Ixmlsec1-1.2.28/include/ ... -o lua_saml.o src/lua_saml.csrc/lua_saml.c:9:10: fatal error: xmlsec/xmlsec.h: No such file or directorymake: *** [Makefile:44: lua_saml.o] Error 1. The wget/tar/configure recipe never runs. make build still works only because build: $(XMLSEC1_STATIC_LIBS) saml.so happens to list the archives first, so the goal now looks self-bootstrapping while it is not. saml.o lua_saml.o: | $(XMLSEC1_STATIC_LIBS) fixes it and the make -j ordering with it.

make -B saml.so regressed into a double re-download. $(XMLSEC1_STATIC_LIBS) is two targets sharing one recipe, and GNU make expands A B: ; recipe into two independent rules carrying that recipe. Making it a prerequisite of saml.so exposes that on this goal: make -n -B saml.so at head emits the wget twice; the same probe against git show 2c483e5:Makefile emits it zero times. Under -j two wgets write the same tarball, two tar zxf extract over each other, and two ./configure; make run in the same directory — executing it for real hit automake-1.15: command not found, because tar restores the 2019 mtimes and trips maintainer-mode regeneration, leaving a tree needing make clean. Before this commit make -B saml.so was the safe way to force a relink. GNU Make >= 4.3 has grouped targets — $(XMLSEC1_STATIC_LIBS) &: — or one stamp file both archives depend on.

$(CC) -o $@ lua_saml.o saml.o $(LDFLAGS_ALL)

### install: Install the library to runtime
.PHONY: install
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ local saml = resty_saml.new(opts)

`opts` is a table of below items:

`new` keeps `opts` by reference and reads it for the SP's whole life: hand the
table over and do not mutate it afterwards. An embedder whose configuration table
is shared or reused passes a copy (`core.table.deepcopy(conf)` in APISIX).

| key | type | default value | Description |
| ----------- | ----------- | ----------- | ----------- |
| `sp_issuer` | string | None | SP name to access IdP. |
Expand Down Expand Up @@ -128,25 +132,33 @@ long as that assertion could still be used. A response normally carries one, so
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,
the assertion and the zone, saying too when that assertion carried `OneTimeUse`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence enumerates what the full-dict error names as a closed list — "naming the assertion and the zone, saying too when that assertion carried OneTimeUse" — but 21e0d9a then added the issuer to that same line and this paragraph was not revisited. One line down, :149-150 says the no-dict line "names replay_dict", still true, but it also names the issuer now.

That matters more here than it usually would, because an operator following this paragraph to build a log filter writes a pattern for assertion <id> in <zone> and misses that <id> and <zone> are no longer adjacent — which is precisely why TEST 40's own assertion had to be rewritten to assertion untracked from https://idp.example.com in saml_replay_full: no memory. Every other behaviour sentence in this section is kept literally in step with the code; that is why 44e5034 and b935eaf exist as separate commits.

Minor, same paragraphs: the rewritten sentences were spliced in without re-flowing. awk '!/^\|/ && length>95' over README.md at head returns exactly :4 (pre-existing), :132 and :150; at the merge-base it returns only :4. Costs nothing today, but it makes the next edit to either paragraph a whole-block reflow diff.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more place the section did not keep step, and this one has an established convention to follow.

The replay_dict row at :86 still describes the option purely as replay tracking — "Unset leaves them untracked" — so nothing tells a reader who is not enabling it that this PR changes their behaviour too. For them an assertion carrying <saml:OneTimeUse/> goes from 401 to 302, and the only notice is a warn line their deployment may well not print, by this same README's account at :152-153.

The file already has the shape for exactly this: One note for upgrading. at :111, used for the InResponseTo change. An operator tracking main whose IdP stamps OneTimeUse and who deliberately left replay_dict unset is the population that most needs it, and is the one the current text does not address anywhere.

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
and accepted again past it. Where either happens to an assertion carrying
`<saml:OneTimeUse/>`, the login says so at `warn` level, since the single use its
IdP asked for ends with the record. 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 `<saml:OneTimeUse/>`
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.
**This is what `<saml:OneTimeUse/>` asks for.** An IdP stamps that condition on an

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph takes one half of Core 2.5.1.5 and states it as the whole. Worth settling explicitly, because it is the PR's central premise.

2.5.1.5 has two halves. The one quoted here is that the condition is always valid and asks the SP to keep a record. The other is what it asks the SP not to do: it forbids retaining "assertions, or the information they contain in some other form, for reuse", and makes observing OneTimeUse a MUST for any implementation that does retain.

This module retains exactly that, in a session. login_callback:791-796 writes name_id, attrs and issuer into the session, and login():198-216 serves them from that cache on every later request without returning to the IdP. I checked the bound: expires is set only from SessionNotOnOrAfter (:764-778), and at :200-206 a nil expires leaves expired false. So an IdP that omits SessionNotOnOrAfter — the common shape for the profile that stamps OneTimeUse — gets an SP session with no assertion-derived expiry at all, governed only by lua-resty-session's cookie defaults, for which _M.new sets no timeouts.

That is the case 2.5.1.5 names as its motivation: an IdP stamps OneTimeUse on an assertion carrying group membership, the user is removed from the group a minute later, and this SP keeps authorizing them from the session cache — with replay_dict set, and silently, because the record covers the acceptance window so no warn fires. Before this PR the same response was a 401 and nothing was retained.

Flagging the overlap honestly: the PR body argues the opposite reading, that this SP does not retain assertions for future use. The session cache is the thing I would want that sentence to address directly, since "the information they contain in some other form" is what a session is. Either the README should say the retention bound is the session's, not the record's, or the reading should be argued rather than assumed.

assertion to ask the SP to keep exactly this record. SAML Core 2.5.1.5 makes the
condition always valid, a condition on use rather than on validity, so the login goes
through with or without the option. With it, the assertion is single-use within the
bounds above, the zone with no room included. Without it, the login is accepted and a line at `warn` level names `replay_dict`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things in this paragraph, both from b935eaf.

"the assertion is single-use within the bounds above, the zone with no room included" — the absolute construction "X included" extends the domain of the preceding predicate (compare "it works in all weather, snow included"), so the first-pass reading is "single-use even when the zone has no room". The code does the opposite: at :635-639 a failed safe_add logs at ERR and falls through without return false, the login proceeds untracked, and TEST 49 pins exactly that — two consecutive 302s for the same stamped assertion against a wedged saml_replay_full. This is the one sentence stating the feature's security guarantee, so the inversion matters. Something like "...single-use within the bounds above — a zone with no room among them."

Second, README:137-141 says "one the IdP made valid beyond a day is remembered for the day and accepted again past it. Where either happens ... the login says so at warn level". But :605 and :617 key on remaining validity (usable_until + skew - now), not issued validity. An assertion the IdP made valid for 30 hours and presented 10 hours in has ~20h left: it is remembered for ~20h rather than "for the day", is not accepted again past the record, and draws no warn. "one still acceptable more than a day from now" says what the code does.

Minor: both edited lines are un-rewrapped at 132 and 127 characters; every other line in README.md:124-156 is 73-88.

so an IdP that asks for this is the signal to set it; a deployment logging at `error`
or above does not see it.

**One thing it deliberately does not do.** 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

Expand Down
66 changes: 52 additions & 14 deletions lua/resty/saml.lua
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ 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.
-- nobody is still trying to complete that login a day later. It bounds the
-- IdP's window, never replay_ttl: that one is the operator's own choice
local MAX_REPLAY_TTL = 86400

local function time_bounds_ok(not_before, not_on_or_after, now, skew)
Expand Down Expand Up @@ -405,7 +406,7 @@ end

-- Every top-level assertion the verified signature left in the document is one
-- the readers draw identity from, so every one of them has to hold up.
local function assertions_acceptable(opts, assertions, expected, now)
local function assertions_acceptable(opts, assertions, expected, now, replay_dict)
local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clock_skew is the one option in this family with no guard at all, and this PR made it load-bearing for the replay record's TTL on top of acceptance.

_M.new grew guards for replay_dict (:969), sp_issuer (:981) and replay_ttl (:986); clock_skew is only ever read as opts.clock_skew or DEFAULT_CLOCK_SKEW at :409 and :593. Measured on the suite harness: an SP built with clock_skew = 0/0 constructs fine, and an assertion whose Conditions NotOnOrAfter is now - 3600 — expired an hour ago — logs in with 302 /, while the byte-identical assertion through a default-skew SP correctly returns 401 nil. NaN poisons every comparison in time_bounds_ok (all false, so nothing is ever out of bounds) and simultaneously makes ttl = usable_until + skew - now NaN. .nan is a valid YAML scalar, and the replay_ttl guard's own comment names YAML as the anticipated config source.

The same value slips through the replay_ttl guard, which is worth fixing in the same shape. type(opts.replay_ttl) ~= "number" or opts.replay_ttl < 1 does not reject NaN, and neither clamp arm catches it either — I probed all three predicates in the real runtime: type(0/0)=="number" is true, 0/0 < 1 is false, 0/0 > 86400 is false. ngx.shared.saml_replay:safe_add(k, true, 0/0) returns ok=true, err=nil and :ttl(k) reads back 0, which is lua_shared_dict's "never expire". End to end: an SP with replay_ttl = 0/0 builds, a login with an unbounded assertion returns 302 /, and its record never expires. Every subsequent login pins a slot nothing reclaims; once the zone fills every login takes the fail-open path and goes untracked, and the OneTimeUse warn prints lapses in nan seconds.

Both close with the positive form: not (type(v) == "number" and v >= 1).

local accepted = opts.sp_audiences or { opts.sp_issuer }

Expand All @@ -419,6 +420,17 @@ local function assertions_acceptable(opts, assertions, expected, now)
assertion.unknown_condition
end

-- Core 2.5.1.5: OneTimeUse is always valid, and asks the SP to keep a
-- record of the assertions it has spent. replay_dict is that record;
-- without it the IdP's request goes unmet, and the operator is told
-- what to configure rather than the user refused. The handle is the
-- one the last gate enforces on, so the two cannot disagree
if assertion.one_time_use and not replay_dict then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of the four gate-halves across the two OneTimeUse warns, this assertion.one_time_use conjunct is the only one nothing pins — which is notable because c3d5bc5 was written specifically to pin the equivalent conjunct on the sibling warn.

Paired mutations on an isolated harness, baseline 259/259 PASS. Rewriting this to if not replay_dict then — dropping assertion.one_time_use andpasses 259/259. The three siblings all fail: dropping outlives at :623 is caught by TEST 48; dropping assertion.one_time_use at :623 is caught by TESTs 39 and 45; replacing this condition with if assertion.one_time_use then is caught by TESTs 48 and 53. So the 2x2 has exactly one empty cell — (no replay_dict, unstamped assertion).

Under the surviving mutation, every login through any dictless SP logs carries OneTimeUse, which this SP cannot enforce without replay_dict for assertions carrying no such condition — including assertions with no <saml:Conditions> at all — and CI stays green. A concrete regression it admits: if set_bool_field at src/lua_saml.c:701 were ever swapped for an integer push, Lua 0 is truthy and every dictless deployment starts warning per login with nothing failing.

One line closes it: add cannot enforce without replay_dict to the no_error_log of a block driving an unstamped assertion through the plain SP. TEST 15 is the natural home.

ngx.log(ngx.WARN, "assertion ", loggable(assertion.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is the only thing standing between the old refusal and silent acceptance, and it fails in both directions.

Invisible by default. nginx's documented default is error_log logs/error.log error;, and OpenResty does not raise it, so an embedder that never sets a level discards this entirely — the login changes from refused to accepted-and-replayable with no notice at all. The suite only sees it because t/assertion-conditions.t:3 calls log_level('info'). README:148 presents the warning as the delivery mechanism without saying what level is required to see it.

Unbounded when it is visible. It sits behind only a session lookup and a RelayState comparison against the caller's own session, with no once-per-worker latch. Verified: a OneTimeUse assertion restricted to another audience returns 401 nil and still writes the warning, so one captured signed assertion replayed in a GET / -> POST /acs loop writes WARN lines indefinitely for logins that never succeed. And the population this PR unblocks — IdPs that stamp OneTimeUse on every assertion — gets one line per login forever, with no way to silence it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning is a courtesy, not the safety line; silent acceptance is what Core 2.5.1.5 prescribes and what Spring, Shibboleth SP and Keycloak do.

Level: both consumers default error_log_level to warn (apisix/cli/config.lua, EE config-default.yaml), so it is visible where it matters. The README now says it is logged at warn (b162b7b).

Volume: a refused attempt on that loop already writes response from IdP rejected: ... at ERR, so the vector exists today one level up; this adds a line to it. One line per accepted login is the correct signal for a deployment whose IdP asks for single use and which has not configured it, and a once-per-worker latch would hide a persistent state after the first hit. Left as is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushback 2 verified and withdrawn for the two named consumers — warn at apisix/apisix/cli/config.lua:94, apisix/conf/config.yaml.example:203, api7-ee-3-gateway/conf/config-default.yaml:221, CP docker-compose/gateway_conf/config.yaml:5, helm charts/gateway/values.yaml:702, and both cli/ngx_tpl.lua:37 emit warn even when the key is absent. Inside those two products the line is visible. The residual is only that this is a generic rock whose README addresses OpenResty operators directly, where a level-less error_log logs/error.log; is error and drops it — confirmed empirically.

What I did not raise last time, and is the sharper point: this warning fires before the SP decides whether to refuse the login at all. It sits at :427, ahead of time_bounds_ok, the audience loop and the confirmation loop. Verified: three presentations of a OneTimeUse assertion audience-restricted to elsewhere on the plain SP produce three [warn] ... cannot enforce without replay_dict lines, each followed by [error] response from IdP rejected: assertion probe-refused is restricted to elsewhere. The advice is wrong for those requests — replay_dict would not have changed the outcome — and it means anyone holding any signed assertion from the trusted IdP, including one minted for a different SP or long expired, has an unrate-limited log-write lever at one line per POST.

Your volume argument was that a refused attempt already writes an ERR one level up, so the vector exists. It does, but that one is the SP reporting its own refusal; this one advertises a configuration change that would not have helped. spend_assertions is deliberately placed at "the last gate" (:586) for exactly this reason. Moving the OneTimeUse check to the same place makes the line mean "a login the SP accepted went untracked", which is the statement you actually want, and it drops the refused-login volume to zero.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dict-side warn moved because it asserted a record that did not exist. This one is true whenever it fires: the SP cannot enforce the condition without replay_dict, refused login or not, and the refusal's own ERR is printed beside it with the actual outcome. Relocating it buys log adjacency at the cost of another round of churn on a diagnostic line, so it stays where it is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now measured rather than argued, and b935eaf makes it a documentation contradiction as well.

An SP with no replay_dict handed a validly signed but hour-expired assertion carrying <saml:OneTimeUse/> produces, in this order: [warn] saml.lua:428: assertion otu-expired carries OneTimeUse, which this SP cannot enforce without replay_dict, then [error] saml.lua:739: response from IdP rejected: assertion otu-expired is not valid on or after ..., then a 401. The warn sits after the unknown_condition check but before time_bounds_ok (:432), the audience loop (:439), the confirmation loop (:446), and before login_callback's issuers_allowed / name_id / SessionNotOnOrAfter gates.

README:150, as edited this round, states the opposite: "Without it, the login is accepted and a line at warn level names replay_dict". For this login the line was emitted and the login was not accepted.

And spend_assertions now buffers into warned and flushes only after every record stands, with the comment "sooner would describe a record the add may yet refuse". That is the same reasoning one function up: sooner describes a login the SP may yet refuse. Operationally an operator alerting on this line over-counts unprotected logins, and anyone holding a captured expired signed response can still drive one line per POST.

" from ", loggable(assertion.issuer),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things about the issuer now being named, all verified.

assertion.issuer can be nil, and the line then reads from nil. <saml:Issuer></saml:Issuer> is schema-valid — Issuer is a NameIDType extending xs:string, which permits empty — so loggable(nil) renders nil in the middle of an operator-facing sentence. replay_key at :580 already anticipates exactly this with (assertion.issuer or ""). The three new sites do not.

The loggable() wrapper is pinned by nothing. Unwrapping all three — loggable(assertion.issuer)assertion.issuer at :430 and :638, loggable(w.issuer)w.issuer at :649 — leaves the suite green. Since the whole point of loggable on this field is that the issuer arrives from the network, that is the one wrapper worth a negative test: an issuer containing a newline or an ANSI escape should be visibly neutered in the assertion, and today nothing would notice if it stopped being.

The four new patterns pin that some issuer is named, not that it is this assertion's. Replacing assertion.issuer with assertions[1].issuer at all three sites keeps the suite green, because every fixture that reaches these lines carries a single assertion from a single IdP. A two-assertion, two-issuer response would separate them; idp_issuers takes a list precisely because that is a supported deployment.

" carries OneTimeUse, which this SP cannot enforce without replay_dict")
end

local ok, err = time_bounds_ok(assertion.not_before, assertion.not_on_or_after, now, skew)
if not ok then
return false, where .. err
Expand Down Expand Up @@ -579,9 +591,10 @@ end
-- 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 function spend_assertions(dict, dict_name, opts, assertions, expected, now)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two options this function re-reads live per request are the two _M.new validates on behalf of this path, so the validation does not bind where it matters.

local ttl = opts.replay_ttl or DEFAULT_REPLAY_TTL (:602) and opts.clock_skew (:593) both read the caller's table. _M.new :986-988 rejects a non-number replay_ttl, its comment naming the case exactly — "a number arriving from YAML or the environment as text compares against nothing". Verified: construct with replay_ttl = 900, then set o.replay_ttl = "900". An assertion with nothing bounding acceptance — precisely the shape TEST 49 builds — leaves the string in ttl, and :607 if ttl < 1 raises attempt to compare string with number → HTTP 500. Data-dependent, which is worse than a hard break: an assertion carrying NotOnOrAfter overwrites ttl at :604 and the bad config is invisible, so one deployment serves some IdP responses and 500s on others depending on which optional element the IdP emitted. clock_skew has the same shape at :593 and :409 with no construction guard at all.

Separately, and independent of any mutation: the MAX_REPLAY_TTL clamp is applied to the operator's replay_ttl as well as to the IdP-derived window, so a value above a day is accepted at construction and silently halved at request time. Verified: new({... replay_ttl = 172800}) returns an object with no complaint and the record lands at ttl 86399.999. That contradicts the README, which scopes the cap to "one the IdP made valid beyond a day" and closes "The limit an operator can move is replay_ttl; the day cap is fixed", and saml.lua:326-329, which calls the cap a bound on "how long any assertion is remembered at most, whatever it claims" — the assertion, not the operator. _M.new validates the lower end and names the option at construction, which is that block's stated philosophy; the upper end gets no error at startup and no log at runtime, and TEST 43 does not probe it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live-read half is answered by the handover contract the README now states (ca97738): new() keeps the table, hand it over and do not mutate it; a shared table is passed as a copy at the call site. The string-typed replay_ttl and the unguarded clock_skew both need a mutation that contract forbids.

The upper-bound half was real and is fixed the other way around (e734364): rather than refusing the value at new(), the day cap now binds only the IdP-derived window, and an explicit replay_ttl is taken as given, past a day included. That makes the README's sentence literally true, nothing is silently halved any more, and the cap keeps doing the one job its comment claims, distrusting the assertion's window. The operator pinning their own dict's slots for a week is their own sized-for-it choice, already validated a number at construction. TEST 54 pins a 172800 value recorded in full.

local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW
local spent = {}
local warned

for _, assertion in ipairs(assertions) do
if not assertion.id then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one failure path that returns without rolling back spent, and the comment added at :643-644 now leans on the invariant it breaks — records stand, and their buffered messages are discarded.

Latent rather than live, and I checked rather than assumed: an assertion with no ID inside a Response-level signature is rejected at parse with document does not validate against schema (400), because xsd/saml-schema-assertion-2.0.xsd marks ID use="required" on AssertionType. So the branch is unreachable today.

It is worth closing anyway because if it ever fires it fires wrong. The exists branch 30 lines below deliberately deletes what it took, for exactly this reason; this one keeps it. Assertion A would be burned into the dict for its full TTL on a login that 401s, and a genuine retry of A is then refused as a replay — a self-inflicted lockout with no way for the user to recover except waiting out the TTL. Either run the same delete loop before the return, or check IDs in a pre-pass before the first safe_add.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, on the reachability you established yourself: Core's schema requires the ID and validation runs before any read, so no compliant or non-compliant input reaches this branch today. A rollback in code that cannot run is correctness with no observable behaviour, and this PR's diff stays on behaviour that exists. If the validation invariant ever moves, whoever moves it owns re-weighing this branch, and this thread documents what to fix.

Expand All @@ -592,17 +605,27 @@ local function spend_assertions(dict, opts, assertions, expected, now)
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
if ttl < 1 then
ttl = 1
elseif ttl > MAX_REPLAY_TTL then
ttl = MAX_REPLAY_TTL
end
end

-- the record is bounded where acceptance is not, so past it the
-- assertion is accepted again. An IdP that asked for single use is
-- told, since it is the IdP's window that made the record fall short.
-- Weighed before the clamp: a window of exactly the cap is covered
local outlives = usable_until == nil or usable_until + skew - now > MAX_REPLAY_TTL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outlives weighs only the time gate and never the InResponseTo binding, so the warn b935eaf just wrote documentation for is factually false for a request-bound assertion with no NotOnOrAfter — the SP reports its single-use guarantee falls short while the assertion is in fact already unusable.

Reproduced with a control. Assertion carrying <saml:OneTimeUse/>, no NotOnOrAfter in Conditions, and one SubjectConfirmationData with Recipient=ACS and InResponseTo=<request_id> — TEST 45's never-gives-out fixture plus a OneTimeUse stamp. First login 302 /, the record falls back to replay_ttl (600), and the log carries carries OneTimeUse but stays acceptable past its record. Delete the record — exactly the state the warn says leaves it acceptable again — and re-present the same document: 401 nil. Control, identical but with InResponseTo omitted: re-presentation after the record lapses gives 302 /.

The divergence is last_moment_usable:544-549, which sets unbounded = true on a confirmation with no not_on_or_after without carrying its in_response_to forward, while confirmation_ok:399-401 refuses any later presentation because a new session has a different saml_request_id. So usable_until is nil, outlives is true, and the message is wrong.

TEST 50 pins the message text only for stamped-unbounded, which has no subject confirmations at all — the one input for which the claim happens to hold. This is distinct from the record-length question deferred to #56: that one is about how long the record should be, this is a new operator-facing message being false about a shape the code already handles correctly.


local key = replay_key(opts, assertion)
local added, add_err = dict:safe_add(key, true, ttl)
if added then
spent[#spent + 1] = key
if assertion.one_time_use and outlives then
warned = warned or {}
warned[#warned + 1] = { id = assertion.id, issuer = assertion.issuer, ttl = ttl }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The buffer this commit introduced is pinned by nothing.

Two mutations, both green at 259/259: warned[#warned + 1] = ...warned[1] = ... (so a multi-assertion response reports only its last stamped assertion), and dropping warned = warned or {} in favour of eager local warned = {} at :597. Neither the accumulation nor the laziness has a test.

The accumulation one is the one that can bite: nothing in the suite drives two OneTimeUse assertions that both outlive their records through one response, so "one line per assertion" is asserted nowhere. TEST 42 already builds a two-assertion response and would only need both stamped and unbounded plus a grep_error_log_out with two lines — the same shape 1668ce8 just used for the fail-open ERR, which is exactly the right instrument for this.

Related, and the reason I would not spend much on the laziness: local warned (:597) / warned = warned or {} (:626) / if warned then (:647) is three coupled sites across fifty lines to avoid one empty LuaJIT table per login. A table constructor is a handful of nanoseconds against an RSA verify in the same request. If the accumulation gets a test, keep the lazy form; if not, eager {} and a plain #warned > 0 at the flush is one site instead of three and cannot desynchronise.

end
elseif add_err == "exists" then
-- this response authenticates nobody, so the assertions already
-- taken from it are handed back rather than left spent
Expand All @@ -611,12 +634,23 @@ local function spend_assertions(dict, opts, assertions, expected, now)
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")
ngx.log(ngx.ERR, "could not remember assertion ", loggable(assertion.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a consequence of the narrowing I asked for, and I think 21e0d9a over-corrected — worth a second look rather than leaving as is.

The old tail said "this login is not covered by replay tracking". I objected because it claims something about the whole login that one assertion's failed add cannot establish. The replacement, "this assertion is not tracked", is correct — but it dropped the only statement of outcome at ERR level. Nothing in this line now says the request was served.

That matters precisely for the reader this line exists for. A 32k zone fills in production; an on-call operator logging at error — which README:152-153 explicitly describes as a deployment that does not see the warn-level lines — gets only could not remember assertion a1 from https://idp.example.com in saml_replay: no memory, this assertion is not tracked. That reads equally as "the request was refused" and as "the request was served without replay protection", and those are the two answers that decide whether this is a user-facing outage or a silent security degradation. Before this PR the line answered it.

The suite still carries the old contract, which is the tell: TEST 40 at t:1186 is titled "a full dict leaves the login working and says so", pinning a message that no longer says it.

Both properties fit in one line — something like ..., this assertion is not tracked and this login proceeds. That keeps the scoping fix and restores the outcome.

" from ", loggable(assertion.issuer), " in ", dict_name, ": ", add_err,
", this assertion is not tracked",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR now states one concept three ways within ~220 lines, and this site is the odd one out in a way that will cost later.

The no-dict case gets a dedicated ngx.log at :427-431. The record-will-lapse case gets a structured buffer and a deferred flush at :624-627 plus :646-651. This case — safe_add failed outright, no record exists, and the login proceeds anyway — gets a bare inline ternary appended as a suffix to somebody else's ERR.

They share neither wording (carries OneTimeUse vs though it carries OneTimeUse) nor fields (two name the issuer, one names the zone), so nothing can be factored into a helper, and four anchored regexes across three shapes pin three different templates. Any future change to how the SP names an assertion in these lines — adding the ACS URL, the throttling already discussed, a structured prefix — has to be made three times in three different syntactic forms, and this is the one that gets missed, because it is not a log statement in its own right but a fragment of another one. Nothing catches the divergence either: the tests assert each template independently, so the three drifting apart is green.

Worth noting the severity ordering runs backwards against the three: the weakest case (not configured) gets its own line, and the strongest (configured, and the record was not written) gets a suffix.

assertion.one_time_use and " though it carries OneTimeUse" or "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative direction of this suffix is untested: a mutant that appends it unconditionally passes all 246 tests.

Verified by replacing assertion.one_time_use and " though it carries OneTimeUse" or "" with the unconditional string — suite stays 246/246 green. TEST 40's --- error_log is a plain substring (in saml_replay_full: no memory, this login is not covered by replay tracking) for id='untracked', an assertion carrying no OneTimeUse, and that string is a strict prefix of the longer message, so it still matches.

So a regression that tells every zone-exhausted login its IdP asked for single use — when it did not — ships with green CI. One line closes it: --- no_error_log: though it carries OneTimeUse on TEST 40.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken (672ccd7): TEST 40 asserts though it carries OneTimeUse stays out of its line, with the [crit]/[alert]/[emerg] guards carried explicitly since a block-level no_error_log replaces the injected list.

end
end

-- said only once every record stands: sooner would describe a record the
-- add may yet refuse, or one the rollback above takes back. The facts are
-- what was kept, so the line is built only where the level prints it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I owe you a correction here, and this comment is where it landed. The justification I gave for buffering facts instead of the built string — "so nothing is built where the level discards it" — is wrong, and the comment now states it as an invariant the code does not hold.

Lua evaluates ngx.log's arguments before the C function is entered, so loggable() can never be level-gated. log_wrapper() in ngx_http_lua_log.c opens with if (level > log->log_level) { return 0; }, before lua_getstack, before the per-arg size loop, before lua_newuserdata — but by then every argument has already been evaluated. The old code, which passed one already-built string, hit that same early return.

Instrumented: with the flush counting loggable() calls and buffer tables, a run at --- log_level: error that emits no [warn] line still reports flush_loggable=2 warn_tables=1. The control at warn reports the same numbers plus the printed line — identical Lua-side work at both levels.

Measured like-for-like on this runtime (300k iterations, three runs within 2%): ngx.log with pre-built strings 45 ns; one loggable() ~570 ns; 21e0d9a's form 639 ns/entry; this shape rebuilt with one loggable() 671 ns. The deferred concat saves ~30 ns; the ~1140 ns outside it is untouched. So the restructuring is very slightly worse than the string it replaced, not better.

None of that matters in absolute terms — once per login, against an RSA verify in the same request. What matters is the comment, since it is the whole stated reason the machinery exists and the next reader will believe it. The buffering still earns its place on the other ground you gave it, which is true and load-bearing: nothing is said until every record stands. I would keep the structure and cut the second clause, or replace it with the ordering reason.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following my own correction with a second one, since a later pass found the other clause is wrong too — and this one is the author's, not mine.

"sooner would describe a record the add may yet refuse" is false for the record being described. The append sits at :625-628, inside if added thensafe_add for that key has already returned true by the time anything is buffered. There is no later moment at which that particular add can be refused.

What is true is the clause after it: "or one the rollback above takes back". A later sibling colliding at :629 deletes the keys already spent, and deferring is what keeps the warn from describing a record that gets retracted. That is a real and sufficient reason for the machinery.

So of the three-line comment: clause one is false, clause two is true and load-bearing, clause three is the one I supplied and it is false. Reducing it to the rollback reason alone leaves it correct and still explains why the buffer exists.

if warned then
for _, w in ipairs(warned) do
ngx.log(ngx.WARN, "assertion ", loggable(w.id), " from ", loggable(w.issuer),
" carries OneTimeUse but stays acceptable past its record, which lapses in ",
w.ttl, " seconds")
end
end
return true
end

Expand Down Expand Up @@ -705,7 +739,8 @@ local function login_callback(self, opts)
end

local now = ngx.time()
local acceptable, reason = assertions_acceptable(opts, assertions, expected, now)
local acceptable, reason = assertions_acceptable(opts, assertions, expected, now,
self.replay_dict)
if not acceptable then
ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason))
ngx.exit(ngx.HTTP_UNAUTHORIZED)
Expand Down Expand Up @@ -748,8 +783,8 @@ local function login_callback(self, opts)
-- 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)
local unused, used_reason = spend_assertions(self.replay_dict, self.replay_dict_name,
opts, assertions, expected, now)
if not unused then
ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(used_reason))
ngx.exit(ngx.HTTP_UNAUTHORIZED)
Expand Down Expand Up @@ -944,6 +979,9 @@ function _M.new(opts)
if obj.replay_dict == nil then
error("no lua_shared_dict named " .. opts.replay_dict, 2)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment claims more than the change delivers, and it is one of the two places a maintainer will look for the rule.

"kept beside the handle, so what the ERR names is the zone written to, whatever happens to the caller's table afterwards" — two problems. dict_name is read on exactly one line (:637), inside the else branch where safe_add failed; nothing was written, and the ERR itself says could not remember. And the trailing clause states a general property of the object that is false for sp_issuer, replay_ttl, clock_skew and sp_acs_url (see the :978 thread). A reader who trusts this comment — or TEST 53's title, which says the same thing — will assume the replay key is pinned the same way and will not check. Either narrow both to replay_dict, or make the claim true by copying opts.

Two supporting details. The zone now has three representations on one object: obj.replay_dict (handle), obj.replay_dict_name (frozen), obj.opts.replay_dict (live). The third is dead after this commit yet is the shortest and most guessable spelling, and the one guaranteed wrong in exactly the scenario TEST 53 was added for. Keeping the name is unavoidable — lua-nginx-module's shdict object exposes get/set/add/safe_add/incr/ttl/expire/flush_all/flush_expired/get_keys/capacity/free_space and no name accessor — so the cost is specifically the third field sitting beside a live copy.

And the per-field mechanism this extends has already failed once in this same function: obj.auth_protocol_binding_method = opts.auth_protocol_binding_method (:962) has zero readers anywhere in lua/, t/, or the gateway. authn_request reads opts.auth_protocol_binding_method live at :168 and _M.new reads it again at :992 for cookie_secure/cookie_same_site, so the snapshot is dead and the option has two live consumers that disagree the moment the table is mutated — set it to HTTP-POST after new() and the AuthnRequest asks the IdP to POST cross-site while the session cookie stays SameSite-default and not Secure, the browser withholds it, and every login 503s with nothing naming the binding method. A shallow copy of opts subsumes both this and the new field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment narrowed in ca97738: it now says why the name is kept at all, the shdict handle has no name accessor, and promises nothing about the caller's table; the object-level claim lives in the README's new handover contract instead. The third representation, opts.replay_dict, is stable under that contract, and the snapshot stays as shipped rather than growing a per-field scheme. The dead auth_protocol_binding_method snapshot is pre-PR code and stays out of this diff.

-- the handle carries no name accessor, so the name it was resolved
-- from rides beside it for the diagnostics
obj.replay_dict_name = opts.replay_dict

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line freezes the dict name three lines above a guard whose own comment calls sp_issuer "half the key" — and sp_issuer is still read live. The mutation TEST 53 models silently disables replay protection when applied one field over.

Verified by probe. replay_key (:578) is opts.sp_issuer .. "|" .. (assertion.issuer or "") .. "|" .. assertion.id, called at :619 with the live self.opts. Build an SP exactly as TEST 53 does, present a OneTimeUse assertion (302, recorded under sp|idp|id), then set o.sp_issuer = "sp-b" and present the SAME response again: 302 /, not 401 nil. The replay is accepted — no ERR, no WARN, nothing in the log. Set it to nil instead and every login 500s at :578 on attempt to concatenate field 'sp_issuer' (a nil value), at the last gate, after the response passed every other check. A third symptom comes earlier, via :410 accepted = opts.sp_audiences or { opts.sp_issuer }: with sp_issuer nil'd, ipairs over { nil } yields zero iterations, so a correctly-addressed assertion is refused with assertion p6 is restricted to sp — a 401 asserting it is restricted to an audience that is this SP. One mutation, three symptoms, none naming the option.

This is live rather than hypothetical. The only consumer, api7-ee-3-gateway/apisix/plugins/saml-auth.lua:87, does core.lrucache.plugin_ctx(lrucache, ctx, nil, resty_saml.new, conf) — the raw etcd-derived plugin conf, uncloned, cached 300s. The sibling openid-connect.lua:1179 clones before handing conf to lua-resty-openidc for exactly this reason.

The altitude is what I would change rather than adding a second snapshot. setmetatable({opts = opts}, ...) at :954 aliases the caller's table, and twelve options are still read live through local opts = self.opts (:939) — sp_issuer, replay_ttl, clock_skew, sp_audiences, sp_acs_url, idp_uri and the four routing URIs. Three feed the replay record directly: sp_issuer is half the key, replay_ttl and clock_skew set the TTL, and expected.acs_url = sp_acs_url(opts) (:701) decides which SubjectConfirmations count in last_moment_usable — i.e. how long the record lives. The sharpest form of the inconsistency: _M.new opens if opts.replay_dict ~= nil then and inside that one block resolves the handle, snapshots the name, validates sp_issuer is a string, and validates replay_ttl is a positive number. Both validations exist only because the replay path needs them, and the replay path then reads both live. local o = {}; for k, v in pairs(opts) do o[k] = v end; obj.opts = o covers all twelve at once and makes both replay_dict_name and the new 6th parameter unnecessary.

Unrelated defect in the same one-line function, found while probing it: replay_key uses the raw assertion.issuer while issuers_allowed matches on trim(issuer) (:515). A pretty-printed <saml:Issuer> is accepted by idp_issuers = {IDP} and recorded under the padded key (probe: trimmed key nil, padded key set). Not a bypass — the whitespace is inside the signature, so the same assertion always yields the same key and the replay is still refused. But one IdP identity maps to two replay namespaces, so a serializer change across an upgrade silently starts a fresh one, and anything deriving the key from the canonical issuer looks in the wrong slot — including this suite's own replay_key(id, issuer) helper at t:268, whose comment says naming the layout once "keeps a change to the scheme from surfacing as a comparison against nil". trim already exists with a comment about this precise hazard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The probes are sound and the class is real in theory; what it lacks is an occupant. The one live host treats plugin conf as immutable by convention: a config change builds a new table with a new version, which misses the lrucache and constructs a fresh SP, so nothing rewrites a handed-over table in place, and schema defaults are injected before new() ever sees it. openid-connect clones for the reverse reason, to protect the shared conf from the plugin's own writes.

So rather than copying inside the library for a scenario with no occurrence, the boundary is now the documented contract (ca97738): new() keeps the table by reference, hand it over and do not mutate it afterwards; an embedder whose table is shared or reused passes a copy at the call site, where core.table.deepcopy already exists. A host that mutates mid-flight is outside the contract the README now states, the same footing every resty library stands on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trim asymmetry got wider with 57bb15f, in a way worth folding into whatever fix this thread lands on.

All three log lines this PR adds now print assertion.issuer raw — :430, :638 and :649 — while trim() exists in this same file (:467) precisely because "libxml2 hands back the element text as written, indentation included", and issuers_allowed applies it at :490 and :517. So a pretty-printed <saml:Issuer> is matched against the allowlist trimmed, recorded in the replay key untrimmed, and now also printed untrimmed — the operator sees a log line with an embedded newline and leading whitespace where the configured value is clean, and grep for the configured issuer misses it.

That makes three consumers of one value with two normalizations. Applying trim once at the read, rather than at each consumer, collapses all of it: the allowlist keeps matching, the key becomes canonical, and the three new lines print what the operator configured.

-- 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
Expand Down
1 change: 1 addition & 0 deletions src/lua_saml.c
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ static int doc_assertions(lua_State* 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_bool_field(L, "one_time_use", a->one_time_use);
set_str_field(L, "not_before", a->not_before);
set_str_field(L, "not_on_or_after", a->not_on_or_after);
set_str_field(L, "unknown_condition", a->unknown_condition);
Expand Down
1 change: 1 addition & 0 deletions src/saml.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ typedef struct {
xmlChar* id;
xmlChar* issuer;
int has_conditions;
int one_time_use;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a field here is worth pairing with a one-line Makefile fix: neither object rule lists src/saml.h as a prerequisite. saml.o: src/*.c globs only .c files, and lua_saml.o: src/lua_saml.c names one.

Verified with make -n: touch src/saml.h alone reports "saml.so is up to date"; touch src/saml.h src/xml.c rebuilds only saml.o and relinks it against the old lua_saml.o. Had this field changed the layout, saml_doc_assertions would stride the array with one sizeof while doc_assertions indexed fields with another — shifted pointers into Lua and xmlFree on garbage, on the login path, with no compiler or linker diagnostic.

This PR escapes by luck: int one_time_use lands in the tail padding after int has_conditions, and both layouts measure sizeof=80 with id=0 issuer=8 has_conditions=16 not_before=24 not_on_or_after=32 unknown_condition=40 — byte-identical. CI always builds fresh, so it would never surface there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (3884513): both object rules list src/*.h.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed fixed and it is load-bearing: touch src/saml.h now rebuilds both objects where the old rules printed "up to date". Worth noting src/saml.c is a unity build #include-ing xml.c, so a mid-struct field would have shifted every field after has_conditions in a stale lua_saml.o — the failure mode was real, not theoretical.

The identical class survives one level up, if you want to close it properly while you are here. touch Makefile && make -n saml.so still reports "up to date", and so does switching OPENSSL_DIR from /usr/local/openresty/openssl3 to .../openssl111: objects compiled against one OpenSSL's headers get linked against the other's libs with no diagnostic. That is the two-runtime shape this project actually builds in, so it is more likely to bite than the header case. touch xmlsec1-1.2.28/src/.libs/libxmlsec1.a likewise does not relink, since saml.so does not list $(XMLSEC1_STATIC_LIBS).

Minor, opposite direction: saml.o: src/*.c over-approximates — touch src/lua_saml.c needlessly recompiles saml.o. -MMD -MP plus a Makefile prerequisite covers all of it and is smaller than the glob.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and the small half is in (8dd5b26): the objects depend on the Makefile, and saml.so lists the archives and the Makefile, with the link recipe naming its objects since $^ would hand the archives to the linker a second time inside --whole-archive. -MMD -MP stays out: four sources and two objects do not carry the machinery, and the glob's overshoot costs seconds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Makefile-and-archives half is in and I verified both: touch Makefile recompiles both objects and relinks, touch xmlsec1-1.2.28/src/.libs/libxmlsec1.a relinks. Two things the commit message claims more broadly than it delivers, plus a stale default it sits next to.

Variables still do not trigger anything, and they are the way this build is actually configured. Measured on the built tree: make saml.so OPENSSL_DIR=/usr/local/openresty/openssl111 prints make: 'saml.so' is up to date., exits 0, compiles and links nothing, and readelf -d saml.so still reports RUNPATH [/usr/local/openresty/openssl3/lib] — the tree that was not requested. These are not hypothetical knobs: rockspec/lua-resty-saml-main-0-0.rockspec:22-29 passes CFLAGS/LIBFLAG/LUA_INCDIR as make build_variables, and .github/workflows/test.yml:16 sets OPENSSL_DIR in env:. I accept the -MMD -MP argument for header deps, but flags are a different axis — a stamp file rewritten only when $(CFLAGS_ALL)$(LDFLAGS_ALL) changes, depended on by the objects, is the small version. Otherwise narrowing the commit message would do.

The default OPENSSL_DIR is stale, and the failure is silent. Makefile:8 is ?= /usr/local/openresty/openssl111; this machine has only openssl3. touch src/saml.h && make saml.so with no override: exit 0, no warning. readelf -d saml.soRUNPATH: [/usr/local/openresty/openssl111/lib], a directory that does not exist; lddlibcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3, the system OpenSSL rather than OpenResty's. gcc ignores nonexistent -I/-L silently, so a contributor following the Makefile as written gets a .so that loads and then misbehaves at the crypto boundary — two OpenSSL builds in one nginx process. CI never sees it because the workflow sets the variable. Pre-existing, but this is the commit that makes OPENSSL_DIR load-bearing for both compile and link, so it is the natural place to update the default or fail loudly when the directory is absent.

xmlChar* not_before;
xmlChar* not_on_or_after;
xmlChar* unknown_condition;
Expand Down
19 changes: 12 additions & 7 deletions src/xml.c
Original file line number Diff line number Diff line change
Expand Up @@ -396,18 +396,19 @@ static size_t count_assertion_el(xmlNode* parent, const char* name) {
}


// Conditions this SP can actually satisfy. SAML Core 2.5.1 makes an assertion
// Conditions this SP understands. SAML Core 2.5.1 makes an assertion
// carrying any other one Indeterminate rather than valid, so everything else is
// reported for the caller to refuse.
//
// ProxyRestriction is here because it binds an IdP issuing on behalf of another
// IdP and asks nothing of the SP consuming the assertion. OneTimeUse is not,
// because honouring it means remembering which assertions have been spent, and
// Core 2.5.1.5 tells a party that cannot keep that record to treat the
// assertion as invalid.
// ProxyRestriction binds an IdP issuing on behalf of another IdP and asks
// nothing of the SP consuming the assertion. OneTimeUse is always valid by
// Core 2.5.1.5, a condition on use rather than on validity: it asks the SP to
// keep a record of the assertions it has spent, which the caller has or has
// not, so it is reported as a flag.
Comment on lines +403 to +407

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"which the caller has or has not" is the load-bearing assumption, and in the shipping product the caller cannot have it.

Both apisix/apisix/plugins/saml-auth.lua and api7-ee-3-gateway/apisix/plugins/saml-auth.lua are pinned to lua-resty-saml = 0.2.5 and declare a schema with no replay_dict, replay_ttl, idp_issuers, sp_audiences or clock_skew, and there is no saml_replay shared dict to name. Every gateway deployment therefore lands permanently on the accept-and-warn path, with a warning naming an option the operator has no way to set. There is also no strict / refuse-unenforceable knob anywhere in this diff to restore the old behaviour.

An operator who smuggles replay_dict past the schema (there is no additionalProperties: false) without a matching shared dict hits _M.new's error("no lua_shared_dict named ..."), which core/lrucache.lua calls without pcall — a hard 500 on every request through the route.

This is not a regression against any shipped version, since #42's refusal postdates 0.2.5. But a companion PR exposing replay_dict in both plugin schemas should land with this one; otherwise the net effect of the release is OneTimeUse going from refused to accepted and unenforced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the facts, and it is the follow-up: api7/api7-ee-3-gateway#2177 covers exposing replay_dict/replay_ttl in saml-auth, declaring the dict in ngx_tpl.lua, and the same in apache/apisix plus the control-plane sync. It cannot land with this PR: the plugins pin 0.2.5, so it follows #39 and the pin bump, in two other repos.

As you note, 0.2.5 already accepts OneTimeUse unenforced; this PR keeps that and adds the warning, where 0.2.6 without it would refuse. A knob to restore that refusal would restore a spec-nonconformant behaviour nobody asked for, so none is added.

static int is_known_condition(xmlNode* node) {
return is_assertion_el(node, "AudienceRestriction") ||
is_assertion_el(node, "ProxyRestriction");
is_assertion_el(node, "ProxyRestriction") ||
is_assertion_el(node, "OneTimeUse");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching on element name only leaves the other schema-valid encoding of the same condition refused: <saml:Condition xsi:type="saml:OneTimeUseType"/>. OneTimeUseType extends ConditionAbstractType, so the XSD accepts that form, but is_assertion_el compares node->name — which is Condition for that shape — so is_known_condition returns 0 and unknown_condition is set.

Verified: an assertion whose only condition is <saml:Condition xmlns:xsi=... xsi:type="saml:OneTimeUseType"/> returns 401 nil with "carries a condition this SP cannot satisfy: Condition" on both the plain SP and the replay_dict-configured one — the refusal fires at saml.lua:417, before one_time_use is ever consulted. The same holds for xsi:type="saml:ProxyRestrictionType", which #42 explicitly meant to accept.

So the PR body's premise — "there is no configuration that gets past the refusal" — remains true for this encoding after the fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing and deliberate: #42 refuses the xsi:type spelling for every condition, AudienceRestriction included, and TEST 13 pins that with xsi:type="saml:AudienceRestrictionType" as its unrecognised case. Refusing is fail-closed and in spec (Core 2.5.1.1 rule 3). Accepting it means resolving xsi:type in C for every reader at once; an xsi:type AudienceRestriction accepted as known with its audiences unread would be a bypass. No browser-SSO IdP writes that spelling. Tracked in #54, out of scope here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #54 deferral is defensible on fail-closed grounds and I am not asking for it here. What the note does not say is that the asymmetry is created by this PR rather than inherited.

Before this PR both spellings of the same condition — <saml:OneTimeUse/> and the schema-legal <saml:Condition xsi:type="saml:OneTimeUseType"/> — produced the same 401. After it, the first is 302 and the second is still 401 ... carries a condition this SP cannot satisfy: Condition. An IdP that switches SAML serializers flips from working logins to a hard 401 for every user, where before the behaviour was at least uniform.

The message degradation is new too. Schema validation runs first and ConditionsType (xsd/saml-schema-assertion-2.0.xsd:128-136) is a closed choice over exactly four elements; this PR promotes the third to known, so Condition becomes the only name that can reach unknown_condition, and xmlStrdup copies the element name and never the xsi:type. Verified: an operator whose IdP sends xsi:type="saml:ProxyRestrictionType" — a condition this SP does support in its element spelling — gets a 401 naming neither the condition nor replay_dict. Two semantically different unknown conditions in one Conditions both report Condition.

Worth one sentence in the deferral note saying the split is new, so #54 is not read as purely pre-existing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, taken: #54 now carries a section saying the asymmetry is created by #53, both spellings 401'd before it, and that the same promotion is what reduced the refusal message to the fixed string.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OneTimeUse is now named twice in this file with nothing linking the two: here, so it does not land in unknown_condition, and again at :515 in the standalone assertion_child read. Both must name the same element for the feature to work, and they are 100 lines apart with no comment on either pointing at the other.

Delete or misspell one and the failure is silent in opposite directions — drop it here and every OneTimeUse assertion 401s as an unknown condition; misspell it at :515 and one_time_use is永 false, so the warn never fires and replay_dict silently stops being asked for. Neither is caught by a compiler, and only the first is caught by the suite.

A single static const char ONE_TIME_USE[] = "OneTimeUse"; used at both sites, or a one-line comment on each naming the other, makes the coupling visible to whoever edits either next.

}


Expand Down Expand Up @@ -509,6 +510,10 @@ static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) {
return -1;
}

// answered on its own, so the refusal scan below owes it nothing and
// reads the same whatever the order of the conditions
a->one_time_use = assertion_child(conditions, "OneTimeUse") != NULL;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shape is much better — the flag reads on its own line and the refusal scan is back to its pre-PR form. The negative-control gap I raised last round is not closed, though, and one of the surviving mutations is not mitigated by anything.

Four mutations, four distinct binaries (md5 verified different each time), clean isolated baseline 259/259 PASS before each: (a) namespace-blind name match, (b) namespace-optional, accepting ns == NULL, (c) descendant search one level deeper, (d) assertion_child(conditions,"OneTimeUse") != NULL || assertion_child(conditions,"ProxyRestriction") != NULL. All four pass 259/259. Only the degenerate constants (= 1, = 0, and the wrong-scope assertion_child(node, ...)) are killed.

(a)-(c) are mitigated in practice: saml_doc_validate at src/binding.c:196/314 is unconditional and rejects those shapes — I probed <OneTimeUse/>, <x:OneTimeUse xmlns:x="urn:other"/> and a nested <saml:OneTimeUse/>, all document does not validate against schema. So the namespace check is defence in depth rather than load-bearing.

(d) is not mitigated. <saml:ProxyRestriction Count="1"/> is schema-valid and reaches read_assertion, and TEST 13 already drives exactly that shape through an SP with no replay_dict — under (d) it would emit a false carries OneTimeUse warn, and TEST 13's presence-only --- error_log eval plus its preprocessor-supplied [crit]/[alert]/[emerg] would not notice. Two lines close it: a third body in TEST 51 carrying only ProxyRestriction (or only the unknown Condition) asserting one_time_use=false.

Separately confirmed and worth recording: TEST 51 does pin order-independence — re-merging the read into the loop with the break kept is caught.


for (xmlNode* child = conditions->children; child != NULL; child = child->next) {
if (child->type == XML_ELEMENT_NODE && !is_known_condition(child)) {
// the caller refuses the assertion on this name, so losing it would
Expand Down
Loading
Loading