feat: handle provider error callbacks - #76
Merged
Conversation
RFC 6749 §4.1.2.1: a provider that refuses redirects back with `error` and `state` and no `code`. supports() required a code, so that request was not a callback: the firewall answered it, the entry point asked again, and the provider refused again. Captured in production against Azure AD B2C as dozens of rounds, nothing logged, the one-time session values never spent. supports() now accepts `state` with either `code` or `error`, on the callback path only, so ADR 003 and issue #63 are untouched. validateClaims() consumes all three one-time values up front, checks state before reading anything else the URL carries, and throws ProviderErrorException with the provider's error code as an accessor. ProviderErrorException extends AuthenticationFailedException, so existing catches keep matching, and implements HttpExceptionInterface so a refusal is a 403 rather than a 500. onAuthenticationFailure() rethrows it unwrapped. State is now compared with hash_equals(), behind an explicit guard against an empty or missing stored value. See ADR 004.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #76 +/- ##
===========================================
Coverage 100.00% 100.00%
- Complexity 177 195 +18
===========================================
Files 14 15 +1
Lines 732 774 +42
===========================================
+ Hits 732 774 +42
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
A provider that refuses an authorization request no longer sends the user into a redirect loop.
First of three PRs toward 6.1. The next covers configurable scopes,
min(0)on the duration options,InteractiveAuthenticatorInterfaceand a friendlier stateless-firewall failure; PKCE trails wheneveritk-dev/openid-connectships S256.Changes
supports()accepts a callback carryingstateand eithercodeorerror. The path check is untouched, so ADR 003 holds and Constrain supports() to the configured redirect URI path #63 does not reopen.validateClaims()restructured around four ordering rules: all three one-time session values consumed up front; state checked before anything else in the URL is read; the error branch above the nonce and code steps;getProvider()moved below the state check.ProviderErrorException, extendingAuthenticationFailedExceptionand implementingHttpExceptionInterface. CarriesgetError(),getErrorDescription(),getStatusCode()and anACCESS_DENIEDconstant.onAuthenticationFailure()rethrows a provider error unwrapped; everything else is wrapped exactly as before.hash_equals(), behind an explicit guard against an empty or missing stored value.UPGRADE-6.1.md, README consumer section, changelog.Why
RFC 6749 §4.1.2.1 gives an authorization request two answers.
supports()only recognised one of them, so a refusal — cancelled consent screen, expired provider session, tenant policy — was not a callback at all. The firewall answered it, the entry point asked the provider again, and the provider refused again.Captured in production against Azure AD B2C: dozens of rounds between
/openidconnect/login/azure_azand/openid-connect/generic, the browser never settling. Nothing was logged, because no failing callback existed to log, and the one-time session values were never consumed, so each round replayed a session already half spent.This is the ADR 002 outage shape reached through the one door that decision did not close. ADR 002 stopped a failing callback being retried; it did not help a request never recognised as a callback.
Decisions worth reviewing
access_deniedmeans the user clicked Cancel; answering that with a 500 pages someone for a normal outcome.getStatusCode()maps refusals to 403,server_error/temporarily_unavailableto 503, everything else to 500. ADR 002 is unaffected either way — the securityExceptionListenerkeys onAuthenticationException, not on HTTP status, and the exception carries an empty cause chain.ThrownExceptionImplementsBundleMarkerreturns[]as soon as the thrown class implements the bundle marker, before it consults the file path; the/src/Controller/carve-out only addsHttpExceptionInterfacewhere the marker is absent. A class implementing both passes unchanged. Flagging it because CLAUDE.md's prose reads as if the carve-out were exclusive.ValidationException. Nothing of ours failed validation. A consumer wants to tell "you cancelled" from "we could not verify that" — different page, different level — and the only alternative is matching on message text. It extendsAuthenticationFailedExceptionso every 6.0catchkeeps matching, which is what makes this a minor.supports()tests for the parameter's presence, not a usable value.?state=…&error=has to be recognised: if it is not, the entry point mints a fresh state and the next refusal arrives with a state that matches, making the loop indistinguishable from a first attempt.testAnEmptyErrorIsStillACallbackis the guard.getProvider()moved below the state check. A refusal has no use for discovery, an HTTP client and a cache pool. Behaviour change to note: a forged callback naming an unconfigured provider is now reported as an invalid state atwarningrather than an unconfigured provider aterror, so an anonymous caller can no longer produceerror-level records on the callback path.CustomUserMessageAuthenticationException($exception->getMessage())with noprevious, which discards the status and the type. The feature would not have worked for anyone following the documented shape.Tests
331 tests, 838 assertions. New coverage:
tests/Security/ProviderErrorCallbackDoesNotLoopTest.php— six kernel tests through a real firewall with the entry point wired. The load-bearing one asserts theLocationheader is null: a redirect there is the loop.tests/Security/OpenIdLoginAuthenticatorTest.php— the error shape insupports(), the refusal path, and the negative assertions that a forged state keeps the sender's text out of the log entirely.tests/Exception/ProviderErrorExceptionTest.php— status mapping including an unknown vendor code for thedefaultarm.Two caveats, both pre-existing and neither on this branch. The escaped mutant is in
ItkDevOpenIdConnectExtension.php:114, untouched here, so the suite was not at 100% MSI before this either. Andtask test:matrixfails wholesale on my machine: composer's parallel extraction races in the matrix containers, and the Taskfile's install-then-test split across twodocker compose runinvocations does not survive it. I ran all six by doing install and phpunit in one invocation. 8.4-lowest and 8.5-lowest report 44 deprecations, the dev-tooling downgrade artefact the workflow already documents at.github/workflows/php.yaml:47.Not done
No
devops_itksitesvalidation yet, unlike #66. Worth doing before merge given that is where the loop was captured — installing from disk and confirming a cancelled consent screen stops on a 403 instead of ping-ponging. Say if you want that before review or after.