Skip to content

Release 6.0.0 - #75

Merged
turegjorup merged 39 commits into
mainfrom
release/6.0.0
Aug 25, 2026
Merged

Release 6.0.0#75
turegjorup merged 39 commits into
mainfrom
release/6.0.0

Conversation

@turegjorup

Copy link
Copy Markdown
Contributor

Release branch for 6.0.0, following the same shape as #61 (5.1.1) and #59 (5.1.0).

Only change on top of develop: the changelog's unreleased entries collapsed into a ## [6.0.0] - 2026-08-25 section, plus the compare link. No version field to bump — the version comes from the tag.

The section states what changed and nothing else. The arguments, measurements and background that had accumulated in those entries live in the commits and PR bodies; a reader who wants them can follow the links. Entry count is unchanged; the section went from 77 lines to 34.

What 6.0.0 is

Breaking — a failed callback throws AuthenticationFailedException and escapes the firewall instead of redirecting to the identity provider again; getPrevious() is the OpenID Connect cause; a callback is recognised only on the provider's callback path; every provider must declare redirect_uri, redirect_route or callback_path; ItkOpenIdConnectBundleException, UserDoesNotExistException and symfony/deprecation-contracts are gone.

Addedcallback_path, getSupportedProviderKeys(), createTargetPathRedirect(), ?target_path=, getRedirectUriPaths()/isCallbackPath().

Not breakingclient_secret_expires_at stays optional.

Verified against consumers

Three applications walked through UPGRADE-6.0.md against develop, none needing a code change and none needing a configuration change:

Consumer From Result
devops_itksites 5.1.1 compiles dev and prod, 52 tests pass
economics 4.2.0 (via the 5.0 hop) compiles dev and prod, 431 tests / 19,246 assertions pass
display-api-service 5.1.1 reviewed; its authenticator is not on a firewall, so the callback changes do not reach it

Bundle itself: 274 tests, 100% lines, 100% covered MSI, PHPStan clean at both ends of the supported dependency range.

Not exercised: a real Azure login round trip, and the staging Playwright reproduction of the original outage. The callback path has been driven as far as a live token exchange that Azure rejected on a fabricated code.

After merge

Annotated tag on the merge commit, then back-merge to develop. The GitHub Release is created by the Action on tag push — nothing to publish by hand.

This is the fix for the outage the whole line has been building towards. The 5.1
releases made the cause visible; this stops the loop.

A failed callback threw AuthenticationException, which Symfony's security
ExceptionListener catches and answers by calling the firewall's entry point — for
this authenticator, another redirect to the identity provider. With a live SSO
session upstream the IdP returned a fresh code immediately, which failed the same
way. Nothing in the cycle degraded, so nothing stopped it: nine or more rounds in
twenty-five seconds, no error page, nothing logged.

onAuthenticationFailure() now throws AuthenticationFailedException, a
\RuntimeException implementing the bundle marker per ADR 001.
AuthenticatorManager::executeAuthenticator() catches only AuthenticationException,
so this propagates to HttpKernel and the application renders its own error. The
loop is prevented by the type of the exception rather than by a counter, which is
why the regression guard is a single assertion: the thrown type must not be an
AuthenticationException. That test catches the exception as Throwable on purpose —
catching the expected type first would narrow it statically and make the assertion
a tautology.

Deviation from the plan worth noting: CliLoginTokenAuthenticator is left alone. It
is not an entry point, so its failures fall through to the OIDC entry point and
redirect to normal login, which cannot loop because supports() requires a
loginToken and the redirect carries none. For a single-use token that has already
been consumed, the login page beats a 500. The loop needs an authenticator whose
own entry point re-triggers it, and only the OIDC one has that shape.

Adds ADR 002 and UPGRADE-6.0.md. The upgrade guide covers only this change;
making client_secret_expires_at required and removing the deprecated exception API
follow separately, and the guide gains those sections then.
ADR 002 from 172 lines to 69, the upgrade guide from 78 to 46. The decisions,
rejected alternatives and consequences are unchanged; what went was restatement —
a Drivers section repeating the context, prose walking through the same mechanism
twice, and migration advice padded around a one-line diff.
The security ExceptionListener walks the whole $previous chain, so chaining the
AuthenticationException it hands us re-entered the entry point and the loop
survived the type change. Chain the first cause below it instead.

Reproduced through a booted firewall, which is what a unit test on the thrown
type could not show.
- warn against rendering the exception message, which now reaches consumers
- make the listener example paste-runnable
- note in the changelog that 6.0 is incomplete until the expiry option is required
- ADR 002 to Accepted
- cover a library cause that hides a security exception beneath it
- say why the functional test narrows where the unit test must not
FrameworkBundle autoconfigures a setLogger() call onto every LoggerAwareInterface
service, so every OpenIdLoginAuthenticator subclass gets two: the bundle's, for the
configured logger, and the application logger. The last call wins, and which one
that is depends on the order the bundles were registered. The conventional order
puts FrameworkBundle first and the configured logger wins; register this bundle
first and logging_options.logger silently does nothing — including
itkdev_openid_connect.null_logger, the documented way to switch logging off.

Method calls carry no priority, so a compiler pass rewrites them after the
instanceof conditionals are resolved. It recognises definitions by the call the
bundle put there rather than by class, since checking classes would autoload every
class in a consumer's container.

Also key the test kernel's cache directory on the pid. Infection substitutes a
mutant through an include interceptor rather than on disk, so nothing Symfony tracks
as a resource changes and mutants were served a cached container: every mutation of
compile-time code survived. Three did, and are now killed.
feat!: fail closed when an OpenID Connect callback cannot be validated
References in method calls are rewritten to concrete ids by
ResolveReferencesToAliasesPass, which runs in the optimization phase ahead of this
pass, so comparing them against a configured alias id never matched and the pass
no-oped — leaving the ordering dependency it exists to remove, silently, for anyone
who configured `logger` or another alias. Emitting the alias instead would have
risked a dangling reference, since private aliases are removed afterwards.

Also key the test kernel's cache on the pid only under Infection. Plain runs have
nothing to isolate and were paying a container recompile per process, plus a
directory each that nothing cleaned up.
fix: settle which logger an authenticator receives
A missing date now fails while the container compiles instead of emitting the 5.1
deprecation. The bundle cannot warn about an expiry it does not know about, which is
how a routine credential rotation became an outage.

Removes with it the deprecated ItkOpenIdConnectBundleException, the never-thrown
UserDoesNotExistException, and symfony/deprecation-contracts, whose only caller was
the deprecation.
A required node carrying a default is contradictory. Symfony 7.4 reports it by
deprecating rather than refusing, raised through trigger_deprecation() and therefore
suppressed, so PHPUnit's own failOnDeprecation never sees it and Symfony 6.2's config
does not check at all — it surfaced on a consuming application's console instead.

Guarded by a test that installs a handler while the tree is built and asserts no
deprecation, and restore the exception handler a handled request leaves behind on
6.4, which PHPUnit was reporting as risky on the prefer-lowest leg.
YAML reads an unquoted 2027-01-31 as the integer 1801353600, and the parse check only
inspected strings. Such a value passed configuration, was discarded as untyped by the
extension, and reached the checker as null — which returns Unknown without logging.
The most natural way to write the option therefore left the provider permanently
unmonitored with nothing reported anywhere, which is the outcome the option exists to
prevent. An explicit null had the same effect and is rejected too.

Corrects the comment that claimed the checker reports this, the README's account of
which parser runs where, and narrows the trait undoing 6.4's leaked exception handler
so it pops only what a test added.
The tearDown() added in the previous commit landed between failingCallback() and its
docblock. The pop loop also assumed a test only ever adds handlers: one that removed
the handler it inherited would leave the current handler at null against a non-null
baseline, and restore_exception_handler() on an empty stack is a no-op.
The docblock claimed Symfony will not accept an environment variable on a validated
node, which this PR makes plainly false: the node now carries two closures and env
vars compile fine. What Symfony refuses is a validated node that also disallows empty
values, which is why cannotBeEmpty() is absent — and the reason runtime reporting is
still needed is that the value is an unresolved placeholder at compile time.

Also widen the rejection message beyond the quoting mistake, since null and true are
not quoting errors and an %env(int:...)% cast fails here with a config file that does
contain a string.
supports() matched state and code on any path, so every URL behind the firewall was a
potential callback. Since 6.0 fails closed, that meant an unauthenticated caller could
raise a 500 on any URL by appending two query parameters. Requiring the provider's
configured callback path leaves a forged callback to the firewall's ordinary handling,
without weakening fail-closed behaviour for real ones.

Paths are derived from configuration and memoized rather than read off a provider
instance: building one pulls in discovery, an HTTP client and a cache pool, and this
runs on every request. Nothing consults the session — that would start one for
anonymous traffic, and a lost session would put the redirect loop back.

callback_path covers proxies that rewrite the path, and each provider must now declare
one of redirect_uri, redirect_route or callback_path, since without one it could never
recognise a callback. getSupportedProviderKeys() defaults to every provider, so
existing multi-authenticator firewalls are unaffected.

Also adds createTargetPathRedirect(), in the same file, for returning the user to the
page that sent them to log in.
Also corrects the ADR index, which still listed 002 as Draft, and a stale
parent::__construct($providerManager, $requestStack) in the README example.
…ts limits

The kernel test covered only the firewall's half — that the requested page is saved.
This adds the whole trip: a link to a protected page is denied, the user comes back
from the identity provider, and lands on that page rather than a default. The fixture
authenticator now uses createTargetPathRedirect(), as the README tells consumers to.

The deep link needed a route. Routing runs before security (RouterListener at priority
32 on kernel.request, the firewall at 8), so an unrouted path is a 404 before the
firewall is reached: no entry point, nothing saved, and a test that measures 404
handling instead of the round trip. Documented, since it is also the answer to why a
link to a non-existent page does not come back after login.
createTargetPathRedirect() could only return users to a page the firewall had denied.
A login link on a public page — a header "Log in" button — denies nothing, so there was
nothing saved and those users landed on the application's fallback. They can now name
the destination: ?target_path=/admin/reports.

Stored under a bundle-private session key, not TargetPathTrait's, which is keyed by
firewall: LoginController has no firewall name, and writing there would put a value in
the firewall's own record that the firewall never saved. A page the firewall denied
still wins, since that is what the user was actually stopped from reaching, and both
keys are cleared on use.

The value reaches a Location header, so it is validated as a path within the
application — one leading slash, no backslash, no scheme separator, no control
characters — and dropped with a warning otherwise. Correcting one would be guessing at
intent on a security boundary.
getPathInfo() has both a subdirectory deployment's base path and any trusted
X-Forwarded-Prefix stripped out of it: Request::preparePathInfo() subtracts
getBaseUrlReal(), while getBaseUrl() is the trusted prefix plus that. The configured
paths contain them — a redirect_uri is the URL the identity provider was given, and
UrlGenerator prepends the routing context's base URL, which RequestContext::fromRequest()
takes from getBaseUrl(). So comparing path info alone rejected every callback both in a
subdirectory deployment and behind a prefix-announcing proxy, and the README claimed the
opposite of what happened.

Derived paths are now memoized per routing-context base URL rather than once, so a
service seeing both proxied and direct traffic is not frozen to whichever came first.

Also: an empty callback_path no longer normalizes to "/" and shadows redirect_uri; a
plain login link clears a target left by an abandoned one; the expiry docblock is back
on its own method; and ADR 003 documents the comparison basis rather than trailing a
second decision after References.
…lback-path

feat!: accept a callback only on the provider's callback path
…nterface

PHPStan reports the test kernel's inherited return type on Symfony 8.1, which
deprecates HttpKernel\Bundle\BundleInterface in favour of the one in
DependencyInjection\Kernel. Naming either would break the other end of the supported
range, so the docblock names the three bundle classes the fixture actually returns:
covariant with both, and more precise than either.

CI does not see this today — the phpstan job runs in the PHP 8.3 container, and
symfony/http-kernel 8.1 requires PHP >= 8.4.1, so it resolves 7.4 there. Verified on
8.4 (error before, clean after), on 8.3, and on prefer-lowest, where PHPStan's 30
pre-existing errors are unchanged by this.
PHPStan assumes the PHP version it runs on when phpVersion is unset, so the analysis
only ever spoke for one point in the range composer.json declares (php ^8.3).

Measured on PHP 8.5 with 8.4-only syntax (property hooks) in an analysed file:

  phpVersion min 80300 / max 80500 -> "Property hooks are supported only on PHP 8.4
                                      and later"
  phpVersion unset                 -> No errors

Dormant on the current job, which runs in the PHP 8.3 container: there the runtime
assumption already equals the floor, and all three configurations behave identically.
It matters the moment analysis moves to a newer PHP — which is the only way to reach
Symfony 8.1 at all, since symfony/http-kernel 8.1 requires PHP >= 8.4.1 — and it is
insurance against the container image being bumped without anyone noticing that the
floor stopped being analysed.

It does not widen the dependency axis, which is where both of this bundle's real
static-analysis findings came from. That needs a second job, noted in the config.
composer.json advertises symfony/* ^8.0, but the phpstan job ran in the default
phpfpm service — PHP 8.3 — and symfony/http-kernel 8.1 requires PHP >= 8.4.1. Composer
could not install the version the bundle claims to support, so it was never analysed.
That is how the BundleInterface deprecation stayed invisible.

Only the phpstan job moves, to phpfpm85. Bumping the default image instead would have
been wrong: the unit-test matrix names `service: phpfpm` as its PHP 8.3 leg, so the
matrix would have quietly lost 8.3 coverage.

The floor is protected by the phpVersion range in this branch rather than by the
runtime version: verified that 8.4-only syntax is flagged when analysing on 8.5 with
the range and passes silently without it.

Verified as CI will run it — no lock in the repository, so composer resolves fresh:
PHP 8.5 with Symfony 8.1.5 gives no PHPStan errors and 273 passing tests.
ci: analyse on PHP 8.5, pinned to the declared PHP range
… guide

Two changes that cannot sensibly ship apart: the guide is reordered around what a
consumer actually meets first, and the expiry date it used to lead with stops being
required. Merging them separately would put a requirement into develop that the next
commit removes, and a guide that documents it in between.

Optional again, because requiring it could force a value but never a correct one.
Symfony compiles a container per environment, so a required node has to appear in all
of them: putting it under when@prod compiles in prod and fails dev with "must be
configured". What that produces is a date in a committed .env default, reporting ok
forever while monitoring nothing — worse than the unknown it replaced, because it is
invisible rather than absent. It also asked for ceremony from applications that never
read it: only ClientSecretExpiryChecker consumes the value and only LoginController
injects it. Unset now means the provider is not monitored and reports unknown, which
monitoring can alert on, while a value that is set is still judged — a non-string is
rejected at compile time and an unparseable one is reported at error.

The guide had accumulated across four PRs and opened with the fail-closed change,
burying the compile-time blockers behind optional material. Reordered, and corrected
against three consumers: devops_itksites (5.1.1), economics (4.2.0) and
display-api-service (5.1.1). They exposed a composer step that only applies to the 4.x
hop, an undefined environment variable that breaks the login route for applications
using the bundle's LoginController and silently removes monitoring for those that do
not, two compile errors quoted inexactly, and a README that linked neither upgrade
guide.
feat: keep client_secret_expires_at optional, and reorder the upgrade guide
PHPStan only ever ran against current dependencies, so the floor this bundle declares
was never analysed. It had a real defect: Symfony 6.4 declares Bundle::$extension
untyped, where 7.0 typed it, so getContainerExtension() returned mixed against its own
?ExtensionInterface signature. Returning a local narrows it without an annotation or an
ignore, and behaviour is unchanged — the property holds null, or false for a bundle with
no extension, and this bundle always has one.

The job lowers only the packages `require` names, derived from composer itself rather
than duplicated in the workflow, so adding a requirement cannot leave the job behind. A
plain --prefer-lowest also downgrades PHPUnit and phpstan-phpunit, which produced 29
artefacts for every 1 real finding — an analysis nobody would keep green.

It runs on PHP 8.3, the declared floor, as the counterpart to the phpstan job's 8.5
ceiling. Verified both ends: Symfony 6.4.13 and 8.1.5 each report no errors with 274
tests passing.
…ncies

ci: analyse the dependency floor, and fix what it found
Twelve references across six workflows. codecov/codecov-action is already on v7, the
current major, and no other action is used — the jobs drive docker compose directly.

v7.0.0's one behavioural change is that it refuses to check out a fork's PR head under
pull_request_target and workflow_run. Neither trigger appears in this repository, which
uses pull_request, push and tag push, so the change does not reach us. The only input
passed anywhere is fetch-depth in the changelog workflow, unchanged in v7.
The first command could not do what the guide asked of it. Consumers pin a major —
^4.0, ^5.0 and ^5.1.1 across the three checked — and `composer update
itk-dev/openid-connect-bundle` will not cross a major, so it reports nothing to do and
leaves the reader on 5.x wondering why none of the guide applies. Nothing in the
document mentioned ^6.0 at all.

Found by walking the guide again after the restructure, this time following it literally
rather than installing the bundle from a path repository, which had hidden the step.

The 4.x hop gets the same treatment: `composer require` for both packages rather than
`composer update`, which was where the partial-update refusal came from in the first
place.
docs: open the upgrade guide with require, not update
Collapse the unreleased entries into a 6.0.0 section stating what changed, and add the
compare link. Rationale, measurements and history stay in the commits and PRs where
they belong.
@codecov-commenter

codecov-commenter commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (5021974) to head (9c61921).

Additional details and impacted files
@@             Coverage Diff              @@
##                main       #75    +/-   ##
============================================
  Coverage     100.00%   100.00%            
- Complexity       125       177    +52     
============================================
  Files             13        14     +1     
  Lines            590       732   +142     
============================================
+ Hits             590       732   +142     
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.

@turegjorup turegjorup self-assigned this Aug 25, 2026
202 lines for four breaking changes, most of it explaining why each decision was taken.
Now 80: what changed, what to do about it, and the compile error to search for. The
reasoning is in the commits, the PRs and ADR 002; the features are in the README.
@turegjorup
turegjorup merged commit 79be9eb into main Aug 25, 2026
17 checks passed
@turegjorup
turegjorup deleted the release/6.0.0 branch August 25, 2026 19:09
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