π Fix / IdP-initiated flow never completes β validate_authresp returns bare :ok#27
Merged
Merged
Conversation
Actalab
approved these changes
May 19, 2026
3aa62fc to
2f95241
Compare
β¦s bare :ok
The IdP-initiated success branch of `validate_authresp/4` returned the bare
atom `:ok`, but the caller pattern-matched `{:ok, nonce}` inside `with`. The
mismatch fell through to the `_ -> {:error, :access_denied}` catch-all,
making the entire IdP-initiated success path effectively dead code: any IdP
posting a Response with an empty `InResponseTo` was rejected with
`access_denied`, even when `allow_idp_initiated_flow: true`.
Changes:
* `validate_authresp/4` now returns `{:ok, flow, nonce}` on success,
where `flow` is `:idp_initiated` or `:sp_initiated` and `nonce` is `nil`
for IdP-initiated (no AuthnRequest exists to bind a nonce to).
* `consume_signin_response/2` propagates a new `flow:` field in its
success map, so consumers do not have to deduce the flow type from
`nonce == nil` or `assertion.subject.in_response_to == ""`.
* Error atom `:idp_first_flow_not_allowed` renamed to
`:idp_initiated_not_allowed` for alignment with standard SAML terminology
used elsewhere in the module.
* Added regression tests covering all four `validate_authresp/4` paths
(previously uncovered).
Closes #24
The tightened return type of `validate_authresp/4` (now strictly
`{:ok, flow, nonce} | {:error, atom}`) makes the defensive catch-all in
the library-facing clause of `consume_signin_response/2` provably
unreachable too β same fail-closed rationale as the router-facing clause.
Updates:
* Document the library-facing catch-all with the same inline rationale
as the router-facing one
* `.dialyzer_ignore.exs`: shift the existing ignore from `:95` β `:108`
(line moved due to the expanded `@doc` block) and add `:144` for the
new defensive catch-all
β¦nin_response/2
The `_ -> {:error, :access_denied}` catch-all in the library-facing
`consume_signin_response/2` had no real fail-closed justification (unlike
its router-facing sibling which returns an HTTP 403 to safely close out an
auth endpoint). Here it just hid a `WithClauseError` behind a generic
error tuple, swallowing the diagnostic signal Dialyzer was emitting.
Removing it means a future change introducing an unexpected return shape
from the `with` chain will surface as a `WithClauseError` with the actual
mismatched value in the stack trace β strictly more informative than
masking it as `:access_denied`. The router-facing 403 fallback is kept
intact (HTTP endpoints must fail closed).
Also drops the matching `.dialyzer_ignore.exs` entry for line 144; the
ignore for line 108 is preserved.
cb62251 to
41bfb06
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24.
Context
ExSaml.SPHandler.consume_signin_response/2callsvalidate_authresp/4in awithclause that pattern-matches{:ok, nonce}. The SP-initiated clause complied ({:ok, nonce}), but the IdP-initiated clause returned the bare atom:ok. The mismatch fell through to the_ -> {:error, :access_denied}catch-all, so any IdP posting a Response with an emptyInResponseTowas rejected withaccess_deniedβ even whenallow_idp_initiated_flow: true.In other words: the IdP-initiated success path was effectively dead code. The guard rails (
:idp_first_flow_not_allowed,:invalid_target_url) worked correctly, but the happy path never returned a usable result.This was not strictly a regression β the feature
allow_idp_initiated_flowhad simply never been wired through to the end. The absence of a test on this clause let it slip.Changes
Fix β
validate_authresp/4now returns{:ok, flow, nonce}on success, where:flowis:idp_initiatedor:sp_initiatednonceisnilfor IdP-initiated (no AuthnRequest exists to bind a nonce to), the SAML nonce for SP-initiated.Add
flow:to the success map βconsume_signin_response/2now returns%{flow: flow, assertion: β¦, nonce: β¦, user_token: β¦, redirect_uri: β¦}. Consumers no longer have to deduce the flow type fromnonce == nilorassertion.subject.in_response_to == "". Adding a map field is non-breaking for consumers that only pattern-match the fields they use.Rename
:idp_first_flow_not_allowedβ:idp_initiated_not_allowedβ aligns the atom with standard SAML terminology used everywhere else in the module. A grep across the Cryptr consumer projects (cryptr_platform_beta,cryptr_api,cryptr_gateway) confirmed none of them pattern-match the old atom in application code, so the rename has no internal blast radius.Tests β added
test/ex_saml/sp_handler_test.exscovering the fourvalidate_authresp/4paths (previously uncovered): IdP-initiated success /:idp_initiated_not_allowed/:invalid_target_url, SP-initiated success +:invalid_relay_state/:invalid_idp_id.Doc β
consume_signin_response/2's@docupdated to reflect the new success shape and to enumerate the possible error reasons.This PR does not touch the
CHANGELOGβ that will be done on the release branch. To capture for release notes:consume_signin_response/2success map now includesflow: :idp_initiated | :sp_initiated. Consumers that match%{assertion: a, nonce: n} = resultkeep working; consumers that pattern-match a strict map (%{assertion: a, nonce: n}with=rather than<-inwithbody) are unaffected too. Only consumers explicitly enumerating map keys would notice.nonceis nownilfor IdP-initiated flows. Downstream code consumingnonce(e.g. SAML nonce validators) must acceptniliffallow_idp_initiated_flow: true. Follow-up issues will track this in Cryptr consumers.:idp_first_flow_not_allowedβ:idp_initiated_not_allowed. Verified non-breaking for Cryptr consumers.Test plan
mix testβ 206 tests, 0 failuresmix compile --warnings-as-errorsβ cleanInResponseToagainst an SP configured withallow_idp_initiated_flow: trueand verify the consumer receives{:ok, %{flow: :idp_initiated, nonce: nil, ...}}instead of{:error, :access_denied}.