-
Notifications
You must be signed in to change notification settings - Fork 2
fix: accept OneTimeUse, and let the replay record honour it #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5624c19
8bf6364
bd5226e
b162b7b
a01ffb8
c769806
b8728ad
a2e67be
3884513
fe7774e
9fcf54f
c3d5bc5
672ccd7
2c483e5
193da86
44e5034
b935eaf
8dd5b26
21e0d9a
57bb15f
6de2bf8
603b046
30f4b17
ca97738
e734364
e8f1dad
1668ce8
934000b
0678dbf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The same value slips through the Both close with the positive form:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined, and the queue behind it is the reason: by the same live-read logic, sp_audiences, sp_acs_url, the binding method, the four routing URIs and secret are all next, each a future "grew guards for its siblings, skipped this one" comment. The new() guards are #50's deliberate exception for the replay family; the ecosystem norm this library otherwise follows validates nothing up front (lua-resty-openidc has no constructor at all and duck-types at use). The one real consumer's schema does not expose clock_skew, so the YAML-string path has no occupant, and a hand-typed table fails on first test. |
||
| local accepted = opts.sp_audiences or { opts.sp_issuer } | ||
|
|
||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Of the four gate-halves across the two OneTimeUse warns, this Paired mutations on an isolated harness, baseline 259/259 PASS. Rewriting this to Under the surviving mutation, every login through any dictless SP logs One line closes it: add
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken (934000b): TEST 15 forbids the no-dict warn for an unstamped assertion, with the [error] guard carried explicitly. The 2x2 has no empty cell left, and the truthy-integer regression you sketched now fails a test. |
||
| ngx.log(ngx.WARN, "assertion ", loggable(assertion.id), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Volume: a refused attempt on that loop already writes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pushback 2 verified and withdrawn for the two named consumers — 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 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 README:150, as edited this round, states the opposite: "Without it, the login is accepted and a line at And |
||
| " from ", loggable(assertion.issuer or ""), | ||
| " 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 | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Separately, and independent of any mutation: the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the one failure path that returns without rolling back 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 It is worth closing anyway because if it ever fires it fires wrong. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
|
@@ -592,17 +605,28 @@ 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. | ||
| -- Stated as the property itself, the stored record falling short of | ||
| -- the lifetime, so no revision of the clamp can leave this line behind | ||
| local outlives = usable_until == nil or ttl < usable_until + skew - now | ||
|
|
||
| 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 } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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 Related, and the reason I would not spend much on the laziness:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. The accumulation's occupant is a response carrying several stamped assertions that all outlive their records, which no real IdP produces, and the worst regression is one missing warn line inside that unoccupied shape. The lazy form stays as shipped; per your own numbers the table it saves is noise either way, and re-shaping it again buys nothing. |
||
| end | ||
| elseif add_err == "exists" then | ||
| -- this response authenticates nobody, so the assertions already | ||
| -- taken from it are handed back rather than left spent | ||
|
|
@@ -611,12 +635,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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken, with the wording adjusted one notch down from yours (934000b): |
||
| " from ", loggable(assertion.issuer or ""), " in ", dict_name, ": ", add_err, | ||
| ", this assertion is not tracked", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 They share neither wording ( 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filed as #57 rather than unified here. Each of the three shapes is load-bearing where it stands, two of them at this review's own request: the no-dict warn fires where the config gap is known, the lapse warn defers for the rollback, and the full-dict suffix rides the ERR because it is the same failed add, one event, one line. A shared template would couple lines whose timing, level and truth conditions differ, which is how the earlier falsehoods got in; if the three are to converge, that is a design pass of its own, which #57 now holds. |
||
| assertion.one_time_use and " though it carries OneTimeUse" or "", | ||
| ", and the login is not refused for it") | ||
| end | ||
| end | ||
|
|
||
| -- said only once every record stands: a warn spoken sooner would describe | ||
| -- a record the rollback above may yet take back | ||
| if warned then | ||
| for _, w in ipairs(warned) do | ||
| ngx.log(ngx.WARN, "assertion ", loggable(w.id), " from ", loggable(w.issuer or ""), | ||
| " carries OneTimeUse but stays acceptable past its record, which lapses in ", | ||
| w.ttl, " seconds") | ||
| end | ||
| end | ||
| return true | ||
| end | ||
|
|
||
|
|
@@ -705,7 +740,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) | ||
|
|
@@ -748,8 +784,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) | ||
|
|
@@ -944,6 +980,9 @@ function _M.new(opts) | |
| if obj.replay_dict == nil then | ||
| error("no lua_shared_dict named " .. opts.replay_dict, 2) | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Two supporting details. The zone now has three representations on one object: And the per-field mechanism this extends has already failed once in this same function:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Verified by probe. This is live rather than hypothetical. The only consumer, The altitude is what I would change rather than adding a second snapshot. Unrelated defect in the same one-line function, found while probing it:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 That makes three consumers of one value with two normalizations. Applying
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. loggable already neuters the injection half, a newline renders as \x0A on one line, so what remains is a grep inconvenience for operators of IdPs that pretty-print their own Issuer, reading their own indentation back. The allowlist trims where matching matters, and the replay key is only ever compared against itself, so canonicalising it buys consistency with nothing. Normalising at the read would also open the which-other-fields question this review has spent enough laps on. |
||
| -- 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ typedef struct { | |
| xmlChar* id; | ||
| xmlChar* issuer; | ||
| int has_conditions; | ||
| int one_time_use; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Verified with This PR escapes by luck:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed (3884513): both object rules list There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed fixed and it is load-bearing: The identical class survives one level up, if you want to close it properly while you are here. Minor, opposite direction:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Makefile-and-archives half is in and I verified both: Variables still do not trigger anything, and they are the way this build is actually configured. Measured on the built tree: The default |
||
| xmlChar* not_before; | ||
| xmlChar* not_on_or_after; | ||
| xmlChar* unknown_condition; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 An operator who smuggles This is not a regression against any shipped version, since #42's refusal postdates 0.2.5. But a companion PR exposing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 As you note, 0.2.5 already accepts |
||
| 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"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Verified: an assertion whose only condition is So the PR body's premise — "there is no configuration that gets past the refusal" — remains true for this encoding after the fix.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing and deliberate: #42 refuses the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 — The message degradation is new too. Schema validation runs first and Worth one sentence in the deferral note saying the split is new, so #54 is not read as purely pre-existing.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 A single
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined, on your own harness's evidence: the always-false mutation at the flag-read, which a misspelling is, was reported killed in your r3893962271 run, TESTs 16 and 51 assert the flag true, and TEST 13 covers the refusal side. Both ends of the coupling are tripwired by tests, which is the durable form of the link; a static const for one element name in a file that inlines every other element name would be a convention of one. |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (a)-(c) are mitigated in practice: (d) is not mitigated. Separately confirmed and worth recording: TEST 51 does pin order-independence — re-merging the read into the loop with the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken (934000b): TEST 51's loop carries a third body with only the unknown Condition and reads one_time_use=false, so mutation (d) now fails alongside the degenerates. The namespace guards stay as defence in depth behind the unconditional validation, per your own triage. Noted with thanks that TEST 51 pins the order-independence it was written for. |
||
|
|
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
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 writesname_id, attrs andissuerinto the session, andlogin():198-216 serves them from that cache on every later request without returning to the IdP. I checked the bound:expiresis set only fromSessionNotOnOrAfter(:764-778), and at :200-206 a nilexpiresleavesexpiredfalse. So an IdP that omitsSessionNotOnOrAfter— 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.newsets 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_dictset, 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Argued rather than assumed now, in the README (0678dbf): consuming the assertion into a session is the immediate use 2.5.1.5 commands, and what the login retains lives in the session, bounded by SessionNotOnOrAfter where sent and the session library's own timeouts where not; OneTimeUse does not shorten it.
The reading rests on the profile's own division of labour: session lifetime has a dedicated instrument, SessionNotOnOrAfter, which this SP honours, and OneTimeUse's retention clause targets reusing the assertion as a source of later decisions, its own example a cached AuthzDecisionStatement. The reading has to be this one, or an OneTimeUse IdP could not do SSO with any SP at all, every implementation mints a session from the assertion, and Shibboleth ignores the condition entirely while doing so. Your group-membership case is real and is what short session bounds and SLO exist for; it is every session-based SSO system's window, not this module's.