Skip to content

fix: bind the assertion to the request this SP issued - #43

Open
shreemaan-abhishek wants to merge 8 commits into
mainfrom
fix/assertion-in-response-to
Open

fix: bind the assertion to the request this SP issued#43
shreemaan-abhishek wants to merge 8 commits into
mainfrom
fix/assertion-in-response-to

Conversation

@shreemaan-abhishek

@shreemaan-abhishek shreemaan-abhishek commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Part of #37, item 4. #42 brought items 1 to 3 and has merged, so this now targets main. Item 5, the assertion replay cache, follows in #44.

What was wrong

generate_saml_id minted an ID for every AuthnRequest and login threw it away. Nothing afterwards tied the response back to a login this SP had started, so an assertion captured from one login stayed usable in any later one.

RelayState does not cover this. It is opaque state this SP chose, it is not signed, and the party replaying an assertion controls their own browser session: start a fresh login to get a matching saml_state, then post the old assertion back with the RelayState that login handed out.

What it does now

login keeps the ID it issued on the session as saml_request_id and clears it on success alongside saml_state. login_callback then refuses:

The first is the one that binds. SubjectConfirmationData sits inside the assertion the signature covers, so an attacker replaying a captured assertion cannot rewrite it. The Response around it is usually left unsigned, so its InResponseTo catches a stray or misdirected answer rather than a deliberate one, which is why both are weighed rather than only the outer.

The value is required inside the assertion, and weighed when present on the Response. Checking it only when it happens to be there leaves it skippable by exactly whoever benefits from skipping it: the outer copy is deleted by the replaying party, and the inner one never has to be, since an IdP that omits it produces the same nothing. That puts it on the footing Recipient already stands on, for the same reason. Profile 4.1.4.2 requires the value of an IdP answering an AuthnRequest, and answering one is the only thing this SP ever asks for: a response arriving with no login in progress is refused before any of this is reached, which is the one case, IdP-initiated SSO, where the value legitimately goes missing.

So this is the second place #42's line, that a constraint the IdP did not send is not invented, gives way to a profile requirement whose absence disarms the check it belongs to. An IdP that leaves InResponseTo off SubjectConfirmationData is refused, and the README says so.

A session minted before the ID was kept starts the login again. Its saml_request_id is empty, so there is nothing for the assertion to name, and refusing dead-ends a login that is genuinely the user's. Restarting cannot repeat, because the session it mints carries an ID. login takes the URI to return to now, so the restart keeps the page the user was heading for. The window lasts as long as an AuthnRequest is outstanding across an upgrade.

Falling back to #42's behaviour when the ID is empty was the other option and is worse: anyone holding a pre-upgrade session cookie could then turn the check off by presenting it.

Merging main in

#41 and #42 both landed as squashes, so this branch's merge base never moved and the three-way merge saw their content as new on one side and half-present on the other. Conflicting files are taken from main and this branch's own change is re-applied on top, which is why the diff is a handful of files rather than everything the two of them touched. Re-applying it took three adjustments to fit what #42 settled on after this branch forked:

Also from review

  • The workflow triggers on pull_request: branches: [ main ], which filters on the base branch, so nothing on this PR had ever run in CI while it was stacked on fix: weigh the conditions an assertion attaches to itself #42. Retargeting fixed it and the suite runs here now. fix: let an assertion be presented only once #44 is in the same position until it retargets.
  • A block naming the error it expects got no other assertion about its log, so a block driving a rejection and then a success said nothing at all about the second half. The severities that never legitimately appear are asserted on those blocks now, which is the part of it that can be said generically. The narrower case, a stray [error] on the success half of a mixed block, needs an exact-set assertion per block and is not worth the brittleness.
  • resty.jit-uuid is seeded from the clock and the process ID when this module is first loaded, so loading it from init_by_lua seeds once in the master and every worker inherits the sequence. Pre-existing, and this PR is what makes it load-bearing, since the same generator now supplies the request ID the checks pin against. README says to load the module from init_worker_by_lua. Changing how the library seeds itself belongs in its own PR.

Still open, deliberately

An assertion carrying no SubjectConfirmation at all is accepted, so it binds to no request and no endpoint. That is #42's settled choice, recorded in its TEST 15, and it is an IdP shape rather than something an attacker can produce: removing the element from a signed assertion breaks the signature. Worth revisiting on its own, since profile 4.1.4.2 requires a bearer confirmation to be there.

Tests

TESTs 27 to 31 in t/assertion-conditions.t. TEST 29 builds the response after the SP has issued its request, reading the ID back out of the redirect the SP sent the browser, so it exercises a genuine matching ID rather than a fixture. TESTs 10, 12 and 18 from #42 do the same now, since a confirmation has to name a request to be satisfiable.

Full run on this branch, t/assertion-conditions.t, t/signed-response.t and t/login-callback.t, 256 subtests, all pass.

With the whole change taken back out and the new tests kept, the four that should fail do and only those:

Failed 8/160 subtests    # TESTs 27, 28, 30 and 31, body and error log each

TEST 29 passes on both, which is the point of it.

Summary by CodeRabbit

  • Security Enhancements

    • SAML responses and subject confirmations are now verified against the originating login request.
    • Responses with missing or mismatched request identifiers are rejected.
    • Request identifiers are cleared after successful authentication.
    • ACS URL and assertion validation consistently use the expected request context.
  • Bug Fixes

    • Logins can restart correctly when older sessions lack a stored request identifier, while preserving the return destination.
  • Documentation

    • Added guidance on response correlation, restart behavior, and UUID seeding.

An assertion says when it is good, for whom it was issued and where it may
be presented. None of that was read: a verified signature was the whole of
the check, so an assertion never expired and one minted for another SP in
the same federation was accepted here as-is.

Conditions/@NotBefore and @NotOnOrAfter now bound the assertion, every
AudienceRestriction has to name this SP, SubjectConfirmationData has to be
addressed here and still open, and Response/@destination has to be this
endpoint. A constraint the IdP did not send is not invented, so an IdP that
omits AudienceRestriction keeps working.

Timestamps are converted with plain civil-date arithmetic. os.time reads
its table as local time, which shifted every SAML timestamp by the
machine's UTC offset.
login generated an AuthnRequest ID and threw it away, so nothing tied the
response back to a login this SP started. An assertion captured from one
login stayed usable in any later one.

The ID is kept on the session now. A SubjectConfirmationData naming a
different request makes that confirmation unsatisfiable, and a Response
answering a different request is refused outright. The confirmation is the
binding that holds: it sits inside the signature, while the Response
around it is usually unsigned.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SAML login requests now persist request IDs. Callbacks validate response and subject-confirmation InResponseTo values against the issued request. Legacy sessions without stored request IDs restart login while preserving the return URI. Native XML helpers and tests support the flow.

Changes

SAML request correlation

Layer / File(s) Summary
InResponseTo extraction API
src/saml.h, src/xml.c, src/lua_saml.c
The native API reads the root InResponseTo attribute and exposes it through Lua.
Request context validation and legacy restart
lua/resty/saml.lua
Login stores the request ID, validates response and subject-confirmation correlation, restarts callbacks without a stored ID, preserves the return URI, and clears the ID after successful authentication.
Correlation and restart test coverage
t/assertion-conditions.t
Test helpers construct correlated responses and cover matching, mismatched, absent, configured-ACS, and legacy-session cases.
Correlation and UUID documentation
README.md
The README documents request correlation, legacy-session behavior, and worker-level UUID seeding.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to e9728

This change binds accepted SAML responses to the login request that issued them, with the supplied test suite passing; no actionable merge-blocking risk remains beyond normal review.

Sequence Diagram(s)

sequenceDiagram
  participant SAMLLogin
  participant IdentityProvider
  participant LuaSAMLBinding
  SAMLLogin->>IdentityProvider: Send AuthnRequest with request ID
  IdentityProvider-->>SAMLLogin: Return SAML response with InResponseTo
  SAMLLogin->>LuaSAMLBinding: Extract response InResponseTo
  LuaSAMLBinding-->>SAMLLogin: Return correlation value or error
  SAMLLogin->>SAMLLogin: Validate response and subject-confirmation request IDs
Loading
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning E2E TEST 31 checks only the initial restart 302; it stores request_uri as '/' and strips the redirect query, so it does not verify preserved return URI after the restarted login. Use a non-default request URI, complete the restarted AuthnRequest/ACS callback, and assert the final redirect equals that URI.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed Diff review found no introduced plaintext credential/token exposure, database secret storage, permission or ownership bypass, TLS error, shared-resource flaw, or unresolved secret reference; new lo...
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: binding SAML assertions to the AuthnRequest issued by the service provider.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/assertion-in-response-to

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Binds SAML responses and signed subject confirmations to the originating authentication request.

Changes:

  • Persists each generated AuthnRequest ID in the session.
  • Validates InResponseTo values and clears request state after success.
  • Adds end-to-end mismatch and success coverage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
lua/resty/saml.lua Stores and validates the request ID.
src/lua_saml.c Exposes root InResponseTo to Lua.
t/assertion-conditions.t Tests mismatched and matching request IDs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lua/resty/saml.lua
-- the Response is often left unsigned, so this only catches a stray answer;
-- the binding that holds is the one inside the signed assertion below
local in_response_to = saml.doc_in_response_to(doc)
if in_response_to and in_response_to ~= expected.request_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.

Neither InResponseTo check ever requires the binding to be present, and the side an attacker controls is the removable one — so against a real replay this adds nothing for a large class of IdPs.

The outer check here reads an attribute on the <samlp:Response> wrapper, which is unsigned in the default shape your own tests use (insert_after = { XMLNS_ASSERTION, "Issuer" }). Deleting the attribute is not a parse or schema error, so in_response_to comes back nil and the check is skipped rather than failed.

The inner one at line 334 is the one meant to hold, but it is the same if x and x ~= expected shape, and InResponseTo on SubjectConfirmationData is optional in the schema — plenty of IdPs omit it, and IdP-initiated SSO omits it by definition.

I ran the combination through your harness: an IdP-signed assertion whose SubjectConfirmationData has no InResponseTo, delivered in a Response with the attribute deleted, returns 302 /. saml.doc_in_response_to(doc) is nil so :433 does not fire, subject_confirmations[1].in_response_to is nil so :334 does not fire, and the login is bound to no request at all. Put the attribute back and the same document is correctly rejected, which shows the check works and is simply skippable.

TEST 18 and 19 both feed a wrong ID, which is the easy half; there is no test for a removed one. If this is meant to be a binding, it needs to be "at least one of the two was present and matched" — ideally opt-in so IdPs that genuinely do not send it keep working.

Worth noting this is also reachable via the empty-<SubjectConfirmation/> shape I flagged on #42: one extra element next to a correctly bound confirmation makes :334 unreachable even when the IdP does send InResponseTo.

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, with the requirement on the inner copy only. SubjectConfirmationData/@InResponseTo has to be there and has to name the request this SP issued; the Response attribute stays weighed-when-present, since a replaying party deletes it at will and demanding it buys nothing.

Same footing as Recipient, for the same reason: profile 4.1.4.2 requires the value of an IdP answering an AuthnRequest, and answering one is the only thing this SP asks for. The one case where it legitimately goes missing, IdP-initiated SSO, is already refused upstream of all of this, since login_callback exits on a callback with no login in progress. Not opt-in, because an option nobody reads is an option switched on to make an error go away.

TEST 30 covers the removed value. TESTs 10, 12 and 18 now name the request they answer, which is what a compliant IdP sends.

One correction. The last paragraph does not hold after #42: adding an element beside a correctly bound confirmation changes the signed content, so the signature fails. The empty-SubjectConfirmationData shape reached that check because Recipient was optional then, and requiring it closed the family.

Comment thread lua/resty/saml.lua Outdated
local acs_url = saml_get_redirect_uri(opts.login_callback_uri)
local expected = {
acs_url = saml_get_redirect_uri(opts.login_callback_uri),
request_id = sess:get("saml_request_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.

saml_request_id only exists on sessions minted by the new login, and sessions are cookie-backed, so during a rolling upgrade every login that started before the deploy comes back with saml_state but no saml_request_id. expected.request_id is nil, and any IdP that does send InResponseTo on the Response — Keycloak, Okta and ADFS all do — hits line 433 and dead-ends at 401 rather than bouncing back to the IdP.

Failing closed is the right instinct in general, but a nil request_id means "this session predates the binding", not "this response answers someone else". Degrading to #42's behaviour when it is nil would ride out the upgrade window without weakening anything for new sessions.

Either way this is user-visible, and #43 is the only one of the three that does not touch the README — the new saml_request_id session key and the upgrade behaviour are both worth a line.

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.

Real, and the fix is in. A session carrying saml_state with no saml_request_id now restarts the login rather than answering 401, keeping the request_uri the user was heading for. It cannot repeat, because the session the restart mints carries an ID. TEST 31 covers it.

Degrading to #42's behaviour on an empty ID is the one thing I did not want: it would let anyone holding a pre-upgrade session cookie turn the check off by presenting it, for as long as that cookie lives.

README now covers the session key, what an IdP has to send, and the upgrade window.

Comment thread t/assertion-conditions.t
302 /



Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Heads up that none of these tests run in CI. .github/workflows/test.yml triggers on pull_request: branches: [ main ], which filters on the base branch, and this PR's base is fix/assertion-conditions. gh pr checks 42 shows a build check; 43 and 44 show only CodeRabbit and the CLA.

So TESTs 18-20 here and 21-23 in #44 have never executed anywhere but your machine. Since these are the evidence for both the request binding and the replay cache, worth either adding the stacked branches to the workflow filter or retargeting to main before merge rather than after.

Two things I hit running the suite locally that are worth fixing while you are in here:

TEST_NGINX_USE_HUP=1 prove t/assertion-conditions.t fails 5 subtests — TESTs 22 and 23 get assertion a1 has been presented already, because nginx reuses an shm zone of the same name and size across a reload, so saml_replay carries sp|a1 over from TEST 21. It passes today only because Test::Nginx fully restarts per block by default. A ngx.shared.saml_replay:flush_all() at the top of each replay block, or unique IDs per block, makes them self-contained.

The block preprocessor sets no_error_log => "[error]" only when neither error_log nor no_error_log is defined, so the mixed blocks that assert both a rejection and a success (TESTs 4, 7, 13, 14, 21) assert nothing about unexpected errors on their success path. I confirmed by injecting an ngx.log(ngx.ERR, ...) just before the final redirect: nine blocks catch it, those five do not.

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.

Right, and retargeted. #42 has merged so this now points at main, the suite has run here for the first time, and #44 follows once this lands.

The preprocessor point is taken as far as it generalises: a block naming the error it expects now also asserts the severities that never legitimately appear. The narrower case you demonstrated, a stray [error] on the success half of a mixed block, needs an exact-set assertion per block, and I would rather not pay that brittleness across the file.

The TEST_NGINX_USE_HUP=1 leak is #44's, and it goes on #44.

Comment thread lua/resty/saml.lua
local request_uri = ngx.var.request_uri
-- kept so the callback can tell the answer to this request from the answer
-- to some other one
local request_id = generate_saml_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.

Minor and pre-existing, but this PR is what makes it load-bearing. uuid.seed() runs at module scope (line 3), and jit-uuid seeds with ngx.time() + ngx.worker.pid(). If resty.saml is first required from init_by_lua, that is the master's pid and the forked workers all inherit the same PRNG state, so every worker emits the same UUID sequence.

Until now that only affected saml_state; now it is also the request ID the InResponseTo checks pin against. Worth a README note that the module has to be required — or uuid.seed() called — from init_worker_by_lua.

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 documented. jit-uuid seeds from ngx.time() + ngx.worker.pid(), so loading this module from init_by_lua seeds once in the master and the forked workers inherit it. README now says to load it from init_worker_by_lua, or to seed there.

Changing how the library seeds itself is a change to how it initialises and belongs in its own PR rather than this one.

The endpoint checks compared against a URL assembled from the request's
scheme and host. That value has only ever fed the AssertionConsumerService
URL announced to the IdP, which many IdPs ignore in favour of the one
registered against the SP, so a wrong value carried no symptom. Making it
an acceptance criterion turns the same divergence into every login being
refused, and a proxy terminating TLS outside the trusted addresses is
enough to cause it.

sp_acs_url states the endpoint outright. It is announced to the IdP and
enforced on the way back, so the two cannot drift, and it settles what
Destination and Recipient are measured against rather than leaving that to
headers. Unset keeps the assembled value.

An Audience with no text also left a hole in the list handed to Lua, where
ipairs stops early and the error path then walked onto the nil. The index
is dense now.
OneTimeUse sat on the list of conditions this SP claims to satisfy while
nothing acted on it. 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.

Off the list, so it lands on the same path as a condition nobody here has
heard of. The message says the SP cannot satisfy the condition rather than
that it does not recognise it, which is the truth for both.

ProxyRestriction stays, since it binds an IdP issuing on behalf of another
IdP and asks nothing of the SP consuming the assertion.
#41 and #42 both landed on main as squashes, so this branch's merge base
did not move and the three-way merge saw their content as new on one side
and half-present on the other. Conflicting files are taken from main, and
this branch's own change is re-applied on top:

- confirmation_ok and assertions_acceptable take an expected table rather
  than an acs_url, since there are two things to compare against now.
- doc_in_response_to reports through an out parameter and returns the
  error alongside the value, the shape doc_destination took on #42, so an
  InResponseTo that could not be read is not read as absent.
- The refusal names the value through loggable, the line #42 drew around
  every value read out of a SAML message.
- Tests renumbered past #42's 26.
@shreemaan-abhishek
shreemaan-abhishek changed the base branch from fix/assertion-conditions to main August 21, 2026 04:49
Checking InResponseTo only when it happens to be there left the binding
skippable by whoever benefits from skipping it. The copy on the Response
is unsigned, so a replay deletes it; the copy inside the assertion is
covered by the signature, so a replay never has to, since an IdP that
omits it produces the same nothing. A confirmation now has to name the
request this SP issued, on the footing Recipient already stands on:
profile 4.1.4.2 requires the value of an IdP answering an AuthnRequest,
and this SP asks for nothing else. A response arriving with no login in
progress is refused before any of this is reached, so the one case the
value legitimately goes missing, IdP-initiated SSO, was already out.

A session minted before the ID was kept has nothing to compare against.
Refusing dead-ends a login that is genuinely the user's, so it starts the
login again instead, which cannot repeat: the session it mints carries an
ID. login takes the URI to return to, so the restart keeps the one the
user was heading for.

TEST 30 covers the confirmation that names no request, TEST 31 the
restart. TESTs 10, 12 and 18 name the request they answer now, which
means building the response after the SP has issued one.

Two review points from the same round, both about the harness:

- A block naming the error it expects got no other assertion about the
  log, so a block driving a rejection and then a success said nothing
  about the second half. The severities that never legitimately appear
  are asserted now, which is the part of it that can be said generically.
- README covers the binding, what an IdP has to send, the upgrade
  window, and jit-uuid seeding once in the master when this module is
  loaded from init_by_lua, which now decides request IDs as well as
  RelayState.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
t/assertion-conditions.t (1)

248-252: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep confirmation-time tests bound to the issued request.

TEST 11 and the confirmation case in TEST 25 create SubjectConfirmationData without InResponseTo. They now fail at request correlation before they exercise expiration or an inverted time window. Use the deferred XML callback to insert the issued request ID in both tests.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@t/assertion-conditions.t` around lines 248 - 252, Update TEST 11 and the
confirmation case in TEST 25 to use the deferred XML callback, adding the issued
request ID as SubjectConfirmationData.InResponseTo after the service provider
issues the request. Preserve each test’s existing expiration or
inverted-time-window assertions.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Outside diff comments:
In `@t/assertion-conditions.t`:
- Around line 248-252: Update TEST 11 and the confirmation case in TEST 25 to
use the deferred XML callback, adding the issued request ID as
SubjectConfirmationData.InResponseTo after the service provider issues the
request. Preserve each test’s existing expiration or inverted-time-window
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fa5ba24e-d60a-45ab-81c6-9c295f5dda45

📥 Commits

Reviewing files that changed from the base of the PR and between 32f021f and e9728eb.

📒 Files selected for processing (3)
  • README.md
  • lua/resty/saml.lua
  • t/assertion-conditions.t

Included review availability: 4 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants