Skip to content

feat: configurable scopes, config bounds, and two clearer failures - #78

Merged
turegjorup merged 1 commit into
developfrom
feature/scopes-and-config-bounds
Aug 26, 2026
Merged

feat: configurable scopes, config bounds, and two clearer failures#78
turegjorup merged 1 commit into
developfrom
feature/scopes-and-config-bounds

Conversation

@turegjorup

Copy link
Copy Markdown
Contributor

Four small items that had been queued behind the callback and PKCE work. Nothing here changes what a correctly configured application does today.

Changes

  • Scopes are a per-provider option. scopes defaults to openid, email, profile — what the bundle has always asked for. A list or a space-separated string, so the value can come from an environment variable.
  • leeway and cache_duration reject a negative value at compile time, and accept zero.
  • OpenIdLoginAuthenticator implements InteractiveAuthenticatorInterface.
  • A stateless firewall is named as such: new StatelessFirewallException.
  • OpenIdConfigurationProviderManager::getScopes().
  • README, upgrade notes, changelog.

Why

Scopes were the literal 'openid email profile' at LoginController.php:83. An application needing groups, an API audience, or a provider-specific scope had no way to ask for one short of overriding the controller.

openid is required and enforced at compile time. OpenID Connect Core 1.0 §3.1.2.1 defines an authentication request as one that asks for it; without it the provider answers with a plain OAuth2 grant and no ID token, and an ID token is the only thing this bundle knows how to validate. Failing at container compile beats failing at the first login.

The bounds close two different holes. A negative leeway reached the library and came back as NegativeLeewayException at the first login that needed it. A negative cache_duration went into the cache unnoticed. Both are now a compile error, and both still accept 0 — no clock-skew window and no caching are each coherent settings.

isInteractive() makes Symfony dispatch security.interactive_login and lets remember-me treat the resulting token as one a user actually asked for. Both are true of every login this authenticator completes. Session-fixation protection is unaffected either way.

The stateless firewall case: the authorization code flow spans two requests, and the state, nonce and PKCE verifier wait in the session between them. On a firewall declared stateless: true there is nowhere to put them, so $request->getSession() threw Symfony's SessionNotFoundException and the application got an unexplained 500. It now throws StatelessFirewallException — a \LogicException, since the fix is in security.yaml rather than in a retry — naming the setting to remove and chaining the original.

Decisions worth reviewing

  • scopes normalises a string rather than taking only a list. %env()% can carry a scalar and nothing else, and the space-delimited form is what RFC 6749 §3.3 already puts on the wire. Splitting on whitespace runs with PREG_SPLIT_NO_EMPTY handles the leading and trailing spaces an env var picks up.
  • The default lives in two places — the config tree and getScopes()'s fallback — with a comment on each saying so. The fallback is unreachable through the container; it is there so a manager built by hand in a test cannot silently ask for no scopes.
  • StatelessFirewallException is a new concrete type, the bundle's first \LogicException. A consumer will not catch it — the point is the message — but the bundle's contract says everything thrown from a public method implements the marker interface, so it needs a type of its own rather than a bare SPL exception.
  • min(0), not min(1), on cache_duration. Zero means fetch the discovery document every time. Wasteful, but a coherent thing to ask for, and not the bundle's business to forbid.

Tests

364 tests, 957 assertions.

  • Config: scopes default, configured, and normalised from a string across four whitespace shapes; rejected without openid as both a list and a string; rejected empty; negative leeway and cache_duration rejected; zero accepted for both.
  • Controller: configured scopes reach the authorization request.
  • Authenticator: isInteractive(); a request with no session throws StatelessFirewallException naming stateless: true and chaining SessionNotFoundException.
  • ExceptionHierarchyTest gains the new concrete, pinning it to \LogicException and the bundle marker.
Check Result
PHPUnit 364 tests, 957 assertions, OK
PHPStan max No errors
php-cs-fixer, markdownlint Clean
Infection 471 killed, 1 escaped
Matrix, all 6 combinations 364 pass on each

The escaped mutant is the pre-existing one in ItkDevOpenIdConnectExtension.php:114, untouched here. Same local matrix caveat as the previous two PRs: composer's parallel extraction races in the matrix containers, so I ran each combination with install and phpunit in a single invocation.

This completes the hardening set — B1 through B7 are now all either merged or in review.

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.
@turegjorup turegjorup self-assigned this Aug 26, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.59459% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.52%. Comparing base (8dacea5) to head (f461b76).

Files with missing lines Patch % Lines
...rc/Security/OpenIdConfigurationProviderManager.php 0.00% 2 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop      #78      +/-   ##
=============================================
- Coverage      99.74%   99.52%   -0.23%     
- Complexity       201      205       +4     
=============================================
  Files             15       15              
  Lines            799      834      +35     
=============================================
+ Hits             797      830      +33     
- Misses             2        4       +2     
Flag Coverage Δ
unittests 99.52% <94.59%> (-0.23%) ⬇️

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 merged commit 54d406e into develop Aug 26, 2026
17 checks passed
@turegjorup
turegjorup deleted the feature/scopes-and-config-bounds branch August 26, 2026 12:54
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