feat: add S256 PKCE, carried by the caller rather than the provider - #64
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #64 +/- ##
===========================================
Coverage 100.00% 100.00%
- Complexity 88 93 +5
===========================================
Files 1 1
Lines 224 235 +11
===========================================
+ Hits 224 235 +11
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:
|
PKCE is opt-in with no configuration flag: passing a code_challenge to getAuthorizationUrl() turns it on, omitting it changes nothing. That keeps existing consumers untouched — a default-on switch would have broken them, since RFC 7636 §4.6 requires code_verifier at the token endpoint once a challenge has been accepted, and no released consumer stores one yet. code_challenge_method=S256 is filled in whenever a challenge is present. Omitting it makes the server assume "plain" (RFC 7636 §4.3), which would quietly turn an S256 challenge into a secret sent in the clear, so it is not left to the caller to remember. Deliberately not built on league's PKCE support. Overriding getPkceMethod() is what makes league generate the verifier and hold it on $this->pkceCode; on a provider instance shared between requests — a long-running worker, or a container that memoizes the service — that lets one request's verifier be sent for another request's token exchange. This is the same hazard class as reading getState() back, and the fix is the same: the caller carries the value. generatePkceVerifier() mirrors generateNonce() in storing nothing, getPkceChallenge() is a pure function of its argument, and getIdToken() takes the verifier as an explicit parameter. The verifier length is not configurable. RFC 7636 §4.1 caps it at 128 characters and requires servers to accept the full range, so there is no reason to ask for less entropy — and 96 random bytes base64url-encode to exactly 128 characters, so no truncation is involved. Tested against the RFC 7636 Appendix B verifier/challenge vector, plus that the verifier never appears in the authorization URL, that an explicit challenge method is respected, and that code_verifier is sent only when the caller supplies one. The README gains the first code-flow example it has had; it previously documented only the implicit flow.
turegjorup
force-pushed
the
feature/pkce-s256
branch
from
August 26, 2026 11:32
1362ac7 to
39f56fb
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.
Adds PKCE (RFC 7636), S256 only.
Opt-in without a flag
Passing a
code_challengeturns PKCE on; omitting it changes nothing. There is nopkceboolean, because the presence of the challenge already is the switch — one less option, and no default to flip later.Default-on was not an option: RFC 7636 §4.6 requires
code_verifierat the token endpoint once a challenge has been accepted, and no released consumer stores one yet, so turning it on for everyone would have broken every existing deployment on upgrade.code_challenge_method=S256is filled in whenever a challenge is present. Omitting it makes the server assumeplain(RFC 7636 §4.3), which would quietly turn an S256 challenge into a secret sent in the clear — not something to leave to the caller to remember. An explicitly passed method is respected, so a provider that needsplainis not locked out.Deliberately not built on league's PKCE support
league/oauth2-clienthas PKCE, and using it would have been less code. It also holds the verifier on$this->pkceCode: overridinggetPkceMethod()is precisely what makes league generate the verifier and keep it on the provider object. On an instance shared between requests — a long-running worker, or a container that memoizes the service — that lets one request's verifier be sent for another request's token exchange.That is the same hazard class as reading
getState()back off the provider, and it has the same remedy: the caller carries the value.generatePkceVerifier()mirrorsgenerateNonce()— returns a value, stores nothinggetPkceChallenge()is a pure function of its argument, so the verifier never comes to rest on the providergetIdToken()takes the verifier as an explicit second parameter rather than reading it from stateVerifier length is fixed
RFC 7636 §4.1 allows 43–128 characters and requires servers to support the whole range, so there is no reason to ask for less entropy than the maximum. 96 random bytes base64url-encode to exactly 128 characters with no padding, so nothing is truncated and there is no length argument to validate.
Tests
code_challenge_methoddefaults toS256, and an explicit method survivescode_verifieris sent at the token endpoint only when the caller supplies one — the request stub matches on exact form parameters, so an unconditionally added key would fail to matchDocs
The README gains the first authorization-code-flow example it has had; it previously documented only the implicit flow.
Verification
143 tests, all green. Coverage 100% (34/34 methods, 202/202 lines). Mutation: 249 generated, 245 killed, 4 ignored, zero escaped, MSI 100% — with no new exclusions. PHPStan max clean at the ceiling and at the dependency floor, php-cs-fixer, markdownlint, prettier and
composer normalizeall clean.Note:
getIdToken()gains an optional parameter, which is backwards compatible.#63is still open and touches a different region ofREADME.md, so the two should merge cleanly in either order.