Skip to content

πŸ› Fix / IdP-initiated flow never completes β€” validate_authresp returns bare :ok#27

Merged
docJerem merged 3 commits into
mainfrom
fix/idp-initiated-flow-completion
May 19, 2026
Merged

πŸ› Fix / IdP-initiated flow never completes β€” validate_authresp returns bare :ok#27
docJerem merged 3 commits into
mainfrom
fix/idp-initiated-flow-completion

Conversation

@docJerem

Copy link
Copy Markdown
Owner

Closes #24.

Context

ExSaml.SPHandler.consume_signin_response/2 calls validate_authresp/4 in a with clause 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 empty InResponseTo was rejected with access_denied β€” even when allow_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_flow had simply never been wired through to the end. The absence of a test on this clause let it slip.

Changes

  1. Fix β€” validate_authresp/4 now returns {:ok, flow, nonce} on success, where:

    • flow is :idp_initiated or :sp_initiated
    • nonce is nil for IdP-initiated (no AuthnRequest exists to bind a nonce to), the SAML nonce for SP-initiated.
  2. Add flow: to the success map β€” consume_signin_response/2 now returns %{flow: flow, assertion: …, nonce: …, user_token: …, redirect_uri: …}. Consumers no longer have to deduce the flow type from nonce == nil or assertion.subject.in_response_to == "". Adding a map field is non-breaking for consumers that only pattern-match the fields they use.

  3. 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.

  4. Tests β€” added test/ex_saml/sp_handler_test.exs covering the four validate_authresp/4 paths (previously uncovered): IdP-initiated success / :idp_initiated_not_allowed / :invalid_target_url, SP-initiated success + :invalid_relay_state / :invalid_idp_id.

  5. Doc β€” consume_signin_response/2's @doc updated to reflect the new success shape and to enumerate the possible error reasons.

⚠️ Breaking changes (for release notes)

This PR does not touch the CHANGELOG β€” that will be done on the release branch. To capture for release notes:

  • Public return shape change: consume_signin_response/2 success map now includes flow: :idp_initiated | :sp_initiated. Consumers that match %{assertion: a, nonce: n} = result keep working; consumers that pattern-match a strict map (%{assertion: a, nonce: n} with = rather than <- in with body) are unaffected too. Only consumers explicitly enumerating map keys would notice.
  • Public nonce semantics change: nonce is now nil for IdP-initiated flows. Downstream code consuming nonce (e.g. SAML nonce validators) must accept nil iff allow_idp_initiated_flow: true. Follow-up issues will track this in Cryptr consumers.
  • Error atom rename: :idp_first_flow_not_allowed β†’ :idp_initiated_not_allowed. Verified non-breaking for Cryptr consumers.

Test plan

  • mix test β€” 206 tests, 0 failures
  • mix compile --warnings-as-errors β€” clean
  • Manual smoke: post an IdP-initiated SAMLResponse with empty InResponseTo against an SP configured with allow_idp_initiated_flow: true and verify the consumer receives {:ok, %{flow: :idp_initiated, nonce: nil, ...}} instead of {:error, :access_denied}.

@docJerem docJerem force-pushed the fix/idp-initiated-flow-completion branch from 3aa62fc to 2f95241 Compare May 19, 2026 12:23
docJerem added 3 commits May 19, 2026 14:34
…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.
@docJerem docJerem force-pushed the fix/idp-initiated-flow-completion branch from cb62251 to 41bfb06 Compare May 19, 2026 12:35
@docJerem docJerem merged commit bd9c047 into main May 19, 2026
3 checks passed
@docJerem docJerem mentioned this pull request May 19, 2026
5 tasks
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.

πŸ› IdP-initiated success branch in SPHandler.validate_authresp returns :ok β€” caller expects {:ok, nonce} β†’ unreachable

2 participants