Skip to content

Release 6.1.0 - #82

Merged
turegjorup merged 15 commits into
mainfrom
release/6.1.0
Aug 26, 2026
Merged

Release 6.1.0#82
turegjorup merged 15 commits into
mainfrom
release/6.1.0

Conversation

@turegjorup

Copy link
Copy Markdown
Contributor

Hardening release. Everything here is additive or a fix; nothing is required of a consumer.

See UPGRADE-6.1.md and the changelog.

What is in it

A refused login no longer loops. RFC 6749 §4.1.2.1 sends a refusal back as error with no code, which supports() did not recognise as 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 with nothing logged. Now it ends in a page that says so, at a status matching the cause — 403 where the user declined, 503 where the provider reports its own trouble, 500 otherwise. ADR 004.

PKCE (RFC 7636, S256), on by default. Verified against the real Azure AD B2C tenant on the devops_itksites 6.0 branch: code_challenge_method=S256, 43-char challenge matching S256 of the 128-char verifier, and the verifier never leaving the session.

Configurable scopes, defaulting to what the bundle has always asked for, accepting a space-separated string so the value can come from an environment variable, and rejecting a list without openid at compile time.

Worker-mode readiness. getProvider() no longer memoizes — league/oauth2-client writes the authorization request's state onto the provider, so a held instance carried one request's state into the next. The Guzzle client is kept per provider instead, so connection reuse survives. CI gates this with Igor against a baseline that gives a written reason for each value the bundle shares on purpose.

Plus hash_equals() on the state comparison behind an explicit guard, min(0) on leeway and cache_duration, InteractiveAuthenticatorInterface, and StatelessFirewallException naming a firewall that can never complete a login.

Requires itk-dev/openid-connect ^5.1

That release enforces allowHttp on every endpoint the discovery document announces rather than only the metadata URL, requires exp and iat on the ID token, and changes the JWKS cache key. Read its changelog before deploying — a deployment talking to a plain-http provider without allow_http will stop working.

Verified

Check Result
PHPUnit 364 tests, 957 assertions, OK
Matrix, all 6 combinations 364 pass on each
PHPStan max No errors
Infection 471 killed, 1 escaped
Worker-mode gate 0 findings outside the baseline; baseline clean
php-cs-fixer, markdownlint, composer Clean
PKCE against real Azure AD B2C Confirmed, see above

The one escaped mutant is the long-standing one in ItkDevOpenIdConnectExtension.php:114, untouched by this release.

Known, and not in this release

  • A consumer whose authenticate() does not chain the bundle exception into the AuthenticationException it raises loses the 403: causeOutsideSecurity() finds nothing and the refusal arrives as a plain AuthenticationFailedException. The README and upgrade guide both say to pass previous:. devops_itksites is one such consumer today — raised there rather than worked around here.
  • OpenIdConfigurationProvider::generateState() in the library still assigns $this->state. Harmless now that the bundle holds no provider, but a consumer holding one inherits it. Worth an issue upstream.

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.
…lbacks

feat: handle provider error callbacks
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.
feat: send a PKCE challenge, and stop holding providers between requests
Scopes are a per-provider option, defaulting to the openid/email/profile the
bundle has always asked for. A list or a space-separated string, so the value
can come from an environment variable. `openid` is required: OIDC Core §3.1.2.1
defines an authentication request as one that asks for it, and without it the
provider returns no ID token.

`leeway` and `cache_duration` reject a negative value at compile time. A
negative leeway used to fail at the first login that needed it; a negative
cache duration passed through unnoticed.

The authenticator implements InteractiveAuthenticatorInterface, so a completed
login dispatches security.interactive_login and remember-me treats the token as
one a user asked for.

A firewall declared `stateless: true` now throws StatelessFirewallException
naming the setting, rather than surfacing Symfony's SessionNotFoundException as
an unexplained 500.
feat: configurable scopes, config bounds, and two clearer failures
docs: state the bundle's worker-mode contract
Igor reports shared mutable state, which is not the same thing as a leak. The
four values it finds here are shared on purpose — Guzzle clients and their
connection pools, the derived callback paths, the authenticator's logger, and
Symfony's own lazy init of the bundle extension — and igor-baseline.json
records each with a written reason. What the gate protects is the difference:
state that appears without one.

That is the case worth catching. The provider memoization removed in the PKCE
work was exactly this shape, and nothing but review would have stopped it
coming back.

IGOR_VERSION pins the binary the composer bootstrapper fetches, in the Taskfile
and the workflow. Igor is pre-1.0 and its rules move between releases, which
would move the baseline underneath us.
ci: gate worker-mode state leaks with Igor
docs: developing against a mock identity provider
@turegjorup turegjorup self-assigned this Aug 26, 2026
@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.72131% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.52%. Comparing base (79be9eb) to head (1577a2f).

Files with missing lines Patch % Lines
...rc/Security/OpenIdConfigurationProviderManager.php 76.47% 4 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##                main      #82      +/-   ##
=============================================
- Coverage     100.00%   99.52%   -0.48%     
- Complexity       177      205      +28     
=============================================
  Files             14       15       +1     
  Lines            732      834     +102     
=============================================
+ Hits             732      830      +98     
- Misses             0        4       +4     
Flag Coverage Δ
unittests 99.52% <96.72%> (-0.48%) ⬇️

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.

Collapse the unreleased entries into a 6.1.0 section stating what changed, and add
the compare link. Rationale, measurements and history stay in the commits and PRs
where they belong.
@turegjorup
turegjorup merged commit c990bd6 into main Aug 26, 2026
18 checks passed
@turegjorup
turegjorup deleted the release/6.1.0 branch August 26, 2026 14:22
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.

2 participants