Skip to content

PR B: Document and tighten the exception system; narrow JSON payload accesses#44

Merged
turegjorup merged 2 commits into
developfrom
feature/json-payload-narrowing
May 12, 2026
Merged

PR B: Document and tighten the exception system; narrow JSON payload accesses#44
turegjorup merged 2 commits into
developfrom
feature/json-payload-narrowing

Conversation

@turegjorup
Copy link
Copy Markdown
Collaborator

Summary

Second of three follow-ups (A → B → C) on the path to level: max PHPStan. Combines three closely-related concerns into one PR after review feedback on PR #42 surfaced naming + documentation gaps:

  1. JSON-payload narrowing in getJwtVerificationKeys and getIdToken — replaces dynamic $jwks['keys'][n]['kid'] / $payload['id_token'] reads with explicit is_array / is_string guards that throw a typed exception at the actual error boundary.
  2. Documentation on every concrete exception class. Audit confirms each of the 15 concretes covers a distinct failure category.
  3. Rename KeyExceptionJwksException for symmetry with MetadataException.

Stacked on PR #42 — based on fix/replace-string-casts-with-guards. When #42 merges, this PR rebases onto develop cleanly. The MetadataException introduced by #42 is referenced from this PR's cross-type docblocks.

1. JSON-payload narrowing

Site New throw
$jwks['keys'] not an array JwksException('JWKS payload missing array "keys" property (RFC 7517 §5)')
$key not an object JwksException('JWK entry is not a JSON object')
$key['kty'] not a string JwksException('JWK entry missing string "kty" for key id: $kid')
RSA $key['e'] / $key['n'] not strings JwksException('JWK RSA entry missing string "e"/"n" for key id: $kid')
Token endpoint response missing string id_token CodeException('Token endpoint response missing string "id_token"')

All new throws are existing types implementing OpenIdConnectExceptionInterface — consumers catching the marker are unaffected.

Adds a createProviderWithCustomJwks(string) test helper used by four new JWKS validation tests plus one inline getIdToken test. 100% coverage maintained (161/161 lines, up from 151).

2. Documentation

Every concrete in src/Exception/ now carries a class-level PHPDoc describing:

  • What failure category it represents.
  • When the library throws it (specific trigger sites).
  • The rationale for its SPL parent type.
  • The boundary against related concrete types where confusion is plausible.

The audit shows each of the 15 concretes covers a distinct failure category — no two would be handled identically by a reasonable consumer (per ADR 001's granularity rule).

The overall count (15) is higher than ADR 001's "three to five per package" target. Consolidation candidates are flagged via the cross-references but not collapsed in this PR (any rename or removal is a deferred MAJOR break).

3. Rename KeyExceptionJwksException

The type fires for both document-level errors (keys array missing in the JWKS top level) and entry-level errors (missing kid / kty / e / n on a JWK inside). Naming it after the JWKS document type rather than the individual key is more accurate, and mirrors MetadataException (named after the OIDC discovery document).

Casing: JwksException (PSR-1/12 PascalCase) — matches Symfony's JwkSet convention. Avoids the eye-poke of all-caps acronyms in catch blocks.

Consumers catching OpenIdConnectExceptionInterface are unaffected. Consumers catching the concrete need to swap the class name.

Test plan

  • task test:coverage — 100% on OpenIdConfigurationProvider (24/24 methods, 161/161 lines).
  • task analyze:php at level: 8 — no errors.
  • task analyze:php at level: max — 7 errors removed (26 → 19). Remaining 19 covered by PR C.
  • task lint:php — clean.
  • task lint:markdown — clean.
  • CI matrix on PR.

Follow-up: PR C

Will narrow validateIdToken's claim accesses and the test-side Mockery / fixture-helper return types. After PR C, level: max is achievable in a single-line config bump.

🤖 Generated with Claude Code

turegjorup and others added 2 commits May 12, 2026 11:56
Two methods read fields out of JSON payloads (JWKS, token endpoint
response) where PHPStan could only see `mixed`:

- `getJwtVerificationKeys` walks `$jwks['keys']` and reads `kid`,
  `kty`, `e`, `n` on each entry without validating the structure.
  Spec-compliant payloads from real IdPs work, but a malformed
  payload silently produces garbage (a `Key` constructed from
  empty/wrong inputs) or trips a downstream type error in
  `XMLSecurityKey::convertRSA`.
- `getIdToken` reads `$payload['id_token']` without checking that
  `$payload` is an array or that the field is a string, returning
  whatever it finds.

Each access is now gated by an explicit `is_array` / `is_string`
guard at the actual error boundary, throwing the appropriate typed
exception with a precise message:

- JWKS missing array `keys` → `KeyException`.
- JWK entry not a JSON object → `KeyException`.
- JWK entry missing string `kty` → `KeyException`.
- RSA JWK missing string `e` / `n` → `KeyException`.
- Token endpoint response missing string `id_token` → `CodeException`.

Adds a `createProviderWithCustomJwks(string $jwksJson)` helper to the
test and uses it for the four new JWKS test cases (the existing
`testGetJwtVerificationKeysRejectsNonStringKid` /
`testGetJwtVerificationKeysUnsupportedKeyType` cases keep their
existing inline setup — out of scope to refactor). One additional
test covers the new getIdToken throw. 100% coverage maintained
(161/161 lines).

Removes 7 errors when PHPStan is run at `level: max`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related changes to the exception layer:

1. **Document every concrete.** Each of the 15 concretes in
   `src/Exception/` now has a class-level PHPDoc describing what it
   represents, when it's thrown, the rationale for its SPL parent
   (`\RuntimeException` / `\LogicException` / `\InvalidArgumentException`),
   and the boundary against related concrete types — most importantly:
   `DecodeException` vs `JwksException` (bytes-level vs shape-level
   failure inside the JWK); `JsonException` vs `MetadataException`
   (parse failure vs parsed-but-non-conformant document);
   `JwksException` vs `MetadataException` (JWKS vs OIDC-discovery
   document, both IdP-side spec violations at different layers);
   `CodeException` vs `ValidationException` vs `ClaimsException`
   (different stages of the auth flow: token-exchange vs signature
   vs claim-value).

2. **Rename `KeyException` → `JwksException`** for symmetry with
   `MetadataException` and clearer scope: the type fires for both
   document-level errors (`keys` array missing) and entry-level
   errors (missing `kid` / `kty` / `e` / `n`). Naming after the
   document type (JWKS) rather than the individual key is more
   accurate; matches Symfony's `JwkSet` PascalCase convention.

3. **Narrow JSON payload accesses** in `getJwtVerificationKeys` and
   `getIdToken`. Each previously-dynamic field read now has an
   explicit `is_array` / `is_string` guard at the actual error
   boundary, throwing the appropriate typed exception with a precise
   message: `JwksException` for malformed JWKS structure;
   `CodeException` for a token endpoint response missing string
   `id_token`. Adds a `createProviderWithCustomJwks(string)` test
   helper used by four new JWKS validation tests plus one inline
   getIdToken test. 100% coverage maintained (161/161 lines, up
   from 151).

Audit confirms each of the 15 concretes covers a distinct failure
category — no two would be handled identically by a reasonable
consumer (per ADR 001's granularity rule). The overall count is
higher than the ADR's "three to five per package" target;
consolidation candidates are flagged via the cross-references but
not collapsed (any rename or removal is a deferred MAJOR break).

Static analysis: PHPStan at `level: max` drops 7 errors (was 26 on
this branch tip, now 19). Remaining 19 covered by PR C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@turegjorup turegjorup self-assigned this May 12, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (39ac2f8) to head (6dae797).

Additional details and impacted files
@@                           Coverage Diff                            @@
##             fix/replace-string-casts-with-guards       #44   +/-   ##
========================================================================
  Coverage                                  100.00%   100.00%           
- Complexity                                     64        72    +8     
========================================================================
  Files                                           1         1           
  Lines                                         175       185   +10     
========================================================================
+ Hits                                          175       185   +10     
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 Sentry.
📢 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 requested a review from jekuaitk May 12, 2026 10:11
Base automatically changed from fix/replace-string-casts-with-guards to develop May 12, 2026 10:30
@turegjorup turegjorup merged commit 425c75b into develop May 12, 2026
15 of 16 checks passed
@turegjorup turegjorup deleted the feature/json-payload-narrowing branch May 12, 2026 10:35
turegjorup added a commit that referenced this pull request May 12, 2026
`[Unreleased]` had two `### Documentation` subsections — one added in
PR #44 for the README "Exception handling" section, one added later
for the per-class PHPDoc audit. markdownlint flags duplicate sibling
headings (MD024) and the CI step was failing. Merge the README
bullet into the per-class section so there's a single `###
Documentation` heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
turegjorup added a commit that referenced this pull request May 12, 2026
A few docblocks in `OpenIdConfigurationProvider` and the test suite
had their descriptions wrapped onto a continuation `*` line:

    * @param int $length
    *                    Length of the random string to be generated
    *
    * @return string
    *                The generated state

The `phpdoc_align: vertical` rule from the @symfony preset doesn't
create those wraps — it just aligns whatever multi-line structure
exists in the source. Flattening each description back onto its
`@param` / `@return` line lets the rule pad the columns into a
tidy table instead:

    * @param int $length Length of the random string to be generated
    * @return string     The generated state

No `.php-cs-fixer.dist.php` change: the @symfony default produces
the clean form once the source isn't pre-wrapped.

Also consolidates two `### Documentation` subsections under
`[Unreleased]` (left over from PR #44) into one — markdownlint's
MD024 was flagging the duplicate heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
turegjorup added a commit that referenced this pull request May 12, 2026
A few docblocks in `OpenIdConfigurationProvider` and the test suite
had their descriptions wrapped onto a continuation `*` line:

    * @param int $length
    *                    Length of the random string to be generated
    *
    * @return string
    *                The generated state

The `phpdoc_align: vertical` rule from the @symfony preset doesn't
create those wraps — it just aligns whatever multi-line structure
exists in the source. Flattening each description back onto its
`@param` / `@return` line lets the rule pad the columns into a
tidy table instead:

    * @param int $length Length of the random string to be generated
    * @return string     The generated state

No `.php-cs-fixer.dist.php` change: the @symfony default produces
the clean form once the source isn't pre-wrapped.

Also consolidates two `### Documentation` subsections under
`[Unreleased]` (left over from PR #44) into one — markdownlint's
MD024 was flagging the duplicate heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@turegjorup turegjorup mentioned this pull request May 12, 2026
5 tasks
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