Skip to content

feat: add S256 PKCE, carried by the caller rather than the provider - #64

Merged
turegjorup merged 1 commit into
developfrom
feature/pkce-s256
Aug 26, 2026
Merged

feat: add S256 PKCE, carried by the caller rather than the provider#64
turegjorup merged 1 commit into
developfrom
feature/pkce-s256

Conversation

@turegjorup

Copy link
Copy Markdown
Collaborator

Adds PKCE (RFC 7636), S256 only.

// Authorization request
$verifier = $provider->generatePkceVerifier();
$session->set('oauth2pkce', $verifier);

$authUrl = $provider->getAuthorizationUrl([
    'state' => $state,
    'nonce' => $nonce,
    'response_type' => 'code',
    'code_challenge' => $provider->getPkceChallenge($verifier),
]);

// Callback
$idToken = $provider->getIdToken($request->query->get('code'), $session->get('oauth2pkce'));

Opt-in without a flag

Passing a code_challenge turns PKCE on; omitting it changes nothing. There is no pkce boolean, 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_verifier at 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=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 — not something to leave to the caller to remember. An explicitly passed method is respected, so a provider that needs plain is not locked out.

Deliberately not built on league's PKCE support

league/oauth2-client has PKCE, and using it would have been less code. It also holds the verifier on $this->pkceCode: overriding getPkceMethod() 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() mirrors generateNonce() — returns a value, stores nothing
  • getPkceChallenge() is a pure function of its argument, so the verifier never comes to rest on the provider
  • getIdToken() takes the verifier as an explicit second parameter rather than reading it from state

Verifier 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

  • the RFC 7636 Appendix B verifier/challenge vector, so the derivation is checked against the spec rather than against itself
  • the verifier never appears in the authorization URL
  • code_challenge_method defaults to S256, and an explicit method survives
  • no PKCE parameters appear when no challenge is passed
  • code_verifier is 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 match

Docs

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 normalize all clean.

Note: getIdToken() gains an optional parameter, which is backwards compatible. #63 is still open and touches a different region of README.md, so the two should merge cleanly in either order.

@turegjorup turegjorup self-assigned this Aug 26, 2026
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (3f7724f) to head (39f56fb).

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     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
turegjorup merged commit d7b1956 into develop Aug 26, 2026
18 checks passed
@turegjorup
turegjorup deleted the feature/pkce-s256 branch August 26, 2026 11:36
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.

1 participant