feat: send a PKCE challenge, and stop holding providers between requests - #77
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #77 +/- ##
=============================================
- Coverage 100.00% 99.74% -0.26%
- Complexity 195 201 +6
=============================================
Files 15 15
Lines 774 799 +25
=============================================
+ Hits 774 797 +23
- Misses 0 2 +2
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:
|
Wires RFC 7636 S256 PKCE through the login flow on itk-dev/openid-connect 5.1. The login route generates a verifier, keeps it in the session, and sends only its challenge; the authenticator redeems the code with it. On by default — RFC 6749 §3.1 makes an authorization server ignore parameters it does not know — with `pkce: false` per provider for one that rejects them instead. The verifier joins the one-time session values consumed on every callback, so it cannot be redeemed against a code it does not belong to. getProvider() now builds a fresh provider each call. AbstractProvider assigns $this->state on every getAuthorizationUrl(), so a memoized instance carried one request's state into the next — nothing reads it today, but that stops being true the moment a process outlives a request. The HTTP client is cached per provider instead, so the connection pool still survives; it is built with league's own option filter, which keeps `verify` reachable only alongside a proxy.
turegjorup
force-pushed
the
feature/pkce-and-stateless-manager
branch
from
August 26, 2026 12:34
c5a1e61 to
9691244
Compare
This was referenced Aug 26, 2026
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.
Wires PKCE on
itk-dev/openid-connect5.1, and removes the one place the bundle carried request data between requests.Changes
oauth2pkce_verifier, and sends only its challenge. The authenticator redeems the code with it.pkceoption, defaulting totrue, andOpenIdConfigurationProviderManager::isPkceEnabled().getProvider()returns a fresh provider each call; the Guzzle client is cached per provider instead.itk-dev/openid-connectbumped to^5.1.Why PKCE is on by default
RFC 6749 §3.1 requires an authorization server to ignore parameters it does not recognise, so a provider that has never heard of PKCE behaves exactly as before, and one that does gets the protection with no configuration.
pkce: falseis there for a provider that rejects unknown parameters rather than ignoring them.Nonce validation already covered RFC 9700's code-injection countermeasure for a confidential client, so this is defence in depth rather than a fix — but it also protects the code in transit, and it is what every provider we target expects now.
Why providers are no longer held
AbstractProvider::getAuthorizationParameters()assigns$this->stateon everygetAuthorizationUrl()call (league/oauth2-client,AbstractProvider.php:432), not only when the caller omits state. So a memoized provider always ends a request holding that request's state.Nothing reads it today. That stops being a safe thing to rely on the moment a process outlives a request, which is the point of worker mode — so the instance goes.
The HTTP client is what gets kept instead, which is the part actually worth keeping: it owns the connection pool, so a token exchange still does not renegotiate TLS on every login. It holds no request data and its options never change. Constructing the provider around it is cheap — discovery and JWKS are lazy and cached in the PSR-6 pool.
Building the client here means applying league's own filter, since league builds one per provider instance and we no longer keep those.
AbstractProvider::getAllowedClientOptions()allowstimeoutandproxyalways andverifyonly alongside a proxy — its rule that TLS verification may be turned off for a proxy and nowhere else.testVerifyIsNotForwardedWithoutAProxypins that, andtestProviderCredentialsNeverReachTheHttpClientpins that nothing else in a provider's configuration reaches Guzzle's request options.The rest of the worker-mode audit
Every non-readonly property on every service in
src/, and what it holds:OpenIdConfigurationProviderManager::$providers$stateOpenIdConfigurationProviderManager::$redirectUriPathsOpenIdConfigurationProviderManager::$httpClientsOpenIdLoginAuthenticator::$loggerEverything else in
src/isreadonlyor stateless.ClientSecretExpiryhas public mutable properties but is a DTO, not a service.One residue is left, and it is the library's to fix:
OpenIdConfigurationProvider::generateState()still assigns$this->state. It is harmless now that we do not hold the instance, but a statelessgenerateState()— or one that only returns — would let a consumer hold a provider safely. Worth an issue on the library if worker mode is going ahead.Tests
344 tests, 904 assertions.
pkce: falsesends neither; a stale verifier is overwritten when PKCE is off.getIdToken(); a missing or non-string one sends nocode_verifier; the verifier is consumed on every callback.pkcedefaults on, and can be turned off.The escaped mutant is the pre-existing one in
ItkDevOpenIdConnectExtension.php:114, untouched here. As before,task test:matrixcannot run on my machine — composer's parallel extraction races in the matrix containers — so I ran each combination with install and phpunit in one invocation. 8.4-lowest and 8.5-lowest report deprecations from the downgraded dev tooling, the artefact.github/workflows/php.yaml:47already documents.Note for the maintainer
CLAUDE.mdstill describessupports()as triggering onstate+code, and now also predates PKCE and the provider-construction change. Flagging rather than editing, as last time.Not done
No
devops_itksitesvalidation. PKCE against real Azure AD B2C is worth confirming before release — it is the one change here that alters what goes over the wire to the identity provider.