Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ test: build deps/
clean:
rm -rf *.so *.o xmlsec1-$(XMLSEC_VER)*

saml.o: src/*.c
saml.o: src/*.c src/*.h
$(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
$(CC) -c $(CFLAGS_ALL) -I$(LUA_INCDIR) -Isrc/ -o $@ $<

saml.so: lua_saml.o saml.o
Expand Down
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,18 @@ 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
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. Without it, the login is accepted and a line at `warn` level names `replay_dict`,
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
27 changes: 24 additions & 3 deletions lua/resty/saml.lua
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,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
local accepted = opts.sp_audiences or { opts.sp_issuer }

Expand All @@ -419,6 +419,16 @@ 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
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.

" 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 @@ -599,6 +609,15 @@ local function spend_assertions(dict, opts, assertions, expected, now)
ttl = MAX_REPLAY_TTL
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
if assertion.one_time_use and (usable_until == nil or ttl == MAX_REPLAY_TTL) then
ngx.log(ngx.WARN, "assertion ", loggable(assertion.id),
" carries OneTimeUse but stays acceptable past its record, which lapses in ",
ttl, " seconds")
end

local key = replay_key(opts, assertion)
local added, add_err = dict:safe_add(key, true, ttl)
if added then
Expand All @@ -613,7 +632,8 @@ local function spend_assertions(dict, opts, assertions, expected, now)
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")
", this login is not covered by replay tracking",
assertion.one_time_use and " though it carries OneTimeUse" or "")
end
end

Expand Down Expand Up @@ -705,7 +725,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
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.

xmlChar* not_before;
xmlChar* not_on_or_after;
xmlChar* unknown_condition;
Expand Down
25 changes: 15 additions & 10 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.

}


Expand Down Expand Up @@ -510,14 +511,18 @@ static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) {
}

for (xmlNode* child = conditions->children; child != NULL; child = child->next) {
if (child->type == XML_ELEMENT_NODE && !is_known_condition(child)) {
if (is_assertion_el(child, "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 sits before the break below, so one_time_use is document-order dependent: a <saml:OneTimeUse/> appearing after an unknown condition is never seen, and the newly exported public field silently reads false.

Verified through saml.doc_assertions(): Conditions with <saml:OneTimeUse/> then an unknown <saml:Condition> reports one_time_use=true unknown_condition=Condition; the reverse order reports one_time_use=false unknown_condition=Condition. Same document content, different derived state.

So the field does not mean "this assertion carries OneTimeUse", it means "it carries OneTimeUse and no unrecognised condition preceded it". Harmless only because assertions_acceptable refuses on unknown_condition (417) before reading one_time_use (426) — two facts in different files coupled by statement order, with nothing recording the dependency. Soften unknown-condition handling the way this PR just softened OneTimeUse, and the flag starts under-reporting.

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 (c769806). The scan no longer stops at the first unknown condition; the first unknown name is still what is reported. TEST 51 reads the flag in both orders.

a->one_time_use = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Setting the flag on every occurrence means a <Conditions> carrying two or more <saml:OneTimeUse/> elements is now accepted. Core 2.5.1.5 makes that a MUST NOT, and the bundled XSD cannot catch it — xsd/saml-schema-assertion-2.0.xsd:128-135 declares the ConditionsType body as <choice minOccurs="0" maxOccurs="unbounded">, so duplicates validate. Before this PR any occurrence was refused, so they could not get through.

Verified: <saml:OneTimeUse/><saml:OneTimeUse/> returns 302 / and parses as one_time_use=true, unknown_condition=nil.

A PR whose subject is spec conformance for this element leaving the element's own cardinality rule unenforced seems worth a second look, especially since the body cites Keycloak's broker ("only checks there is at most one") as the peer behaviour being matched. count_assertion_el(conditions, "OneTimeUse") — already used by read_audience_restrictions and read_subject_confirmations — gives presence and cardinality in one call.

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 worth doing: it is fail-closed and Keycloak's broker checks exactly this. Tracked in #55 rather than here, together with ProxyRestriction, which has the same at-most-one rule in Core 2.5.1 and the same gap since #42.

}
if (child->type == XML_ELEMENT_NODE && !is_known_condition(child) &&
a->unknown_condition == NULL) {
// the caller refuses the assertion on this name, so losing it would
// let the condition through rather than fail the read
// let the condition through rather than fail the read. The scan goes
// on so what else the assertion carries is read whatever the order
a->unknown_condition = xmlStrdup(child->name);
if (a->unknown_condition == NULL) {
return -1;
}
break;
}
}

Expand Down
150 changes: 142 additions & 8 deletions t/assertion-conditions.t
Original file line number Diff line number Diff line change
Expand Up @@ -574,9 +574,10 @@ offers no subject confirmation this SP can satisfy
ngx.say(login_with("plain", saml_response({
conditions = conditions({ body = "<saml:ProxyRestriction Count=\"1\"/>" }),
})))
-- OneTimeUse asks this SP to remember which assertions it has spent
-- OneTimeUse is always valid (Core 2.5.1.5); with no replay_dict it
-- asks for a record this SP does not keep, which is said, not refused
ngx.say(login_with("plain", saml_response({
conditions = conditions({ body = "<saml:OneTimeUse/>" }),
id = "single", conditions = conditions({ body = "<saml: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 block lost its only behavioural assertion about OneTimeUse and gained no replacement, so the weakening the PR ships has no coverage.

The deleted 401 nil plus qr/carries a condition this SP cannot satisfy: OneTimeUse/ proved the assertion was refused. The new 302 / plus the warn regex proves only that one presentation is accepted. Nothing in the suite presents the same OneTimeUse assertion twice to an SP without replay_dict, which is the property that actually changed. Verified that it is genuinely reusable: two login_with("plain", xml) calls with the identical response both return 302 /.

As it stands, a change that started refusing OneTimeUse again on the no-dict path breaks no test, and neither does a regression in the other direction.

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.

TEST 13's first 302 is the coverage: refusing OneTimeUse again on the dict-less SP returns 401 there and fails the block, which is what the run against main shows. The narrower gap is real, and TEST 52 now presents the same OneTimeUse assertion twice to the dict-less SP and expects 302 both times (b8728ad).

})))
-- and a condition it has never heard of asks who knows what
ngx.say(login_with("plain", saml_response({
Expand All @@ -589,10 +590,10 @@ offers no subject confirmation this SP can satisfy
}
--- response_body
302 /
401 nil
302 /
401 nil
--- error_log eval
[qr/carries a condition this SP cannot satisfy: OneTimeUse/,
[qr/\[warn\] .* assertion single carries OneTimeUse, which this SP cannot enforce without replay_dict/,
qr/carries a condition this SP cannot satisfy: Condition/]


Expand Down Expand Up @@ -631,7 +632,7 @@ response from IdP is addressed to http://evil.example.com/acs
content_by_lua_block {
local xml = sign_doc(response(
assertion({ id = "a1", conditions = conditions({ not_on_or_after = "2026-07-21T00:00:00Z",
body = audience("sp") }) }) ..
body = audience("sp") .. "<saml:OneTimeUse/>" }) }) ..
assertion({ id = "a2", name_id = "second@example.com",
confirmations = confirmation({ recipient = ACS }) })))
local doc, err = parse(xml)
Expand All @@ -641,14 +642,15 @@ response from IdP is addressed to http://evil.example.com/acs
ngx.say(a.id, " conditions=", tostring(a.has_conditions),
" expires=", tostring(a.not_on_or_after),
" audiences=", #a.audience_restrictions,
" confirmations=", #a.subject_confirmations)
" confirmations=", #a.subject_confirmations,
" one_time_use=", tostring(a.one_time_use))
end
ngx.say("destination: ", tostring(saml.doc_destination(doc)))
}
}
--- response_body
a1 conditions=true expires=2026-07-21T00:00:00Z audiences=1 confirmations=0
a2 conditions=false expires=nil audiences=0 confirmations=1
a1 conditions=true expires=2026-07-21T00:00:00Z audiences=1 confirmations=0 one_time_use=true
a2 conditions=false expires=nil audiences=0 confirmations=1 one_time_use=false
destination: nil


Expand Down Expand Up @@ -1386,3 +1388,135 @@ earlier: true
--- response_body
302 /
dated one decides: true



=== TEST 48: with a record, an OneTimeUse assertion is treated like any other
--- config
location /t {
content_by_lua_block {
ngx.shared.saml_replay:flush_all()
local xml = saml_response({
id = "stamped",
conditions = conditions({ not_on_or_after = at(600), body = "<saml:OneTimeUse/>" }),
})
ngx.say(login_with("replay", xml))
-- remembered until acceptance ends plus clock_skew, as any other
local ttl = ngx.shared.saml_replay:ttl(replay_key("stamped"))
ngx.say("recorded: ", ttl > 650 and ttl <= 660)
ngx.say(login_with("replay", xml))
}
}
--- response_body
302 /
recorded: true
401 nil
--- error_log
assertion stamped has been presented already
--- no_error_log
[crit]
[alert]
[emerg]
OneTimeUse



=== TEST 49: a full dict says when the untracked login asked for single use
--- config
location /t {
content_by_lua_block {
local dict = ngx.shared.saml_replay_full
dict:flush_all()
dict:flush_expired()
local filler = string.rep("x", 256)
local i, ok = 0, true
while ok do
ok = dict:safe_set("filler-" .. i, filler, 600)
if ok then i = i + 1 end
if i > 5000 then break end
end
local j = 0
while dict:safe_add("small-" .. j, true, 600) do
j = j + 1
if j > 5000 then break end
end
ngx.say(login_with("replay_full", saml_response({
id = "untracked-stamped",
conditions = conditions({ body = "<saml:OneTimeUse/>" }),
})))
}
}
--- response_body
302 /
--- error_log
in saml_replay_full: no memory, this login is not covered by replay tracking though it carries OneTimeUse



=== TEST 50: an OneTimeUse assertion that outlives its record says so
--- config
location /t {
content_by_lua_block {
ngx.shared.saml_replay:flush_all()
-- nothing bounds it, so the record falls back to replay_ttl
ngx.say(login_with("replay", saml_response({
id = "stamped-unbounded",
conditions = conditions({ body = "<saml:OneTimeUse/>" }),
})))
-- valid for years, so the record is capped at a day
ngx.say(login_with("replay", saml_response({
id = "stamped-forever",
conditions = conditions({ not_on_or_after = "9999-12-31T23:59:59Z",
body = "<saml:OneTimeUse/>" }),
})))
}
}
--- response_body
302 /
302 /
--- error_log eval
[qr/\[warn\] .* assertion stamped-unbounded carries OneTimeUse but stays acceptable past its record, which lapses in 600 seconds/,
qr/\[warn\] .* assertion stamped-forever carries OneTimeUse but stays acceptable past its record, which lapses in 86400 seconds/]



=== TEST 51: OneTimeUse is read wherever it sits among the conditions
--- config
location /t {
content_by_lua_block {
local unknown = '<saml:Condition xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' ..
'xsi:type="saml:AudienceRestrictionType"><saml:Audience>sp</saml:Audience></saml:Condition>'
for _, body in ipairs({ "<saml:OneTimeUse/>" .. unknown, unknown .. "<saml:OneTimeUse/>" }) do
local doc, err = parse(sign_doc(response(assertion({
id = "ordered", conditions = conditions({ body = body }),
}))))
if err then ngx.say("err: ", err) return end
local a = saml.doc_assertions(doc)[1]
ngx.say("one_time_use=", tostring(a.one_time_use),
" unknown_condition=", tostring(a.unknown_condition))
end
}
}
--- response_body
one_time_use=true unknown_condition=Condition
one_time_use=true unknown_condition=Condition



=== TEST 52: without a record, an OneTimeUse assertion is accepted again
--- config
location /t {
content_by_lua_block {
local xml = saml_response({
id = "stamped-untracked",
conditions = conditions({ not_on_or_after = at(600), body = "<saml:OneTimeUse/>" }),
})
ngx.say(login_with("plain", xml))
ngx.say(login_with("plain", xml))
}
}
--- response_body
302 /
302 /
--- error_log eval
qr/\[warn\] .* assertion stamped-untracked carries OneTimeUse, which this SP cannot enforce without replay_dict/
Loading