Skip to content

Replace (string) casts on JSON payload values with is_string guards#42

Merged
turegjorup merged 3 commits into
developfrom
fix/replace-string-casts-with-guards
May 12, 2026
Merged

Replace (string) casts on JSON payload values with is_string guards#42
turegjorup merged 3 commits into
developfrom
fix/replace-string-casts-with-guards

Conversation

@turegjorup
Copy link
Copy Markdown
Collaborator

Summary

Pre-existing follow-up flagged during PR #41's review. Two (string) casts in OpenIdConfigurationProvider coerced JSON-decoded mixed values into strings:

  • $kid = (string) $key['kid']; — JWK key identifier (RFC 7517 §4.5 specifies it as a JSON string).
  • $value = (string) $configuration[$key]; — OIDC discovery document value (OIDC Discovery specifies these as strings).

Both spec-compliant payloads work today. The cast silently masks the malformed-payload case: an int / bool / array gets coerced to a useless string ("42", "", "Array") that flows downstream and produces a confusing assertion failure rather than diagnosing "the IdP returned a malformed payload at this key".

What changes

Each cast is replaced with an is_string guard that throws an appropriate typed exception:

  • JWK with non-string kidKeyException('JWK entry missing string "kid" (RFC 7517 §4.5)').
  • Discovery doc with non-string value at a required key → CacheException(sprintf('OIDC discovery document value for "%s" is not a string (got %s)', $key, get_debug_type(...))).

Both KeyException and CacheException already implement OpenIdConnectExceptionInterface (per the 5.0 contract), so consumers catching the marker pick up the new throw paths without code changes.

Backward compatibility

Spec-compliant IdPs (every IdP we test against) are unaffected. The new throw paths only fire when the upstream payload violates the spec — and in that case the new behaviour is strictly better than silent coercion.

The two new throws share the same OpenIdConnectExceptionInterface marker as their pre-existing siblings; existing catch (OpenIdConnectExceptionInterface) blocks pick them up automatically. Documented under the 5.0 BREAKING section of the CHANGELOG.

Test plan

  • task test:coverage — 89 tests; 100% coverage on OpenIdConfigurationProvider (24/24 methods, 151/151 lines including the two new throw lines).
  • task analyze:php — PHPStan level 8, no errors.
  • task lint:php — clean.
  • task lint:markdown — clean.
  • CI matrix on PR (PHP 8.3/8.4/8.5 × stable/lowest).

🤖 Generated with Claude Code

@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 (1774bc0) to head (39ac2f8).

Additional details and impacted files
@@             Coverage Diff             @@
##             develop       #42   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity        62        64    +2     
===========================================
  Files              1         1           
  Lines            172       175    +3     
===========================================
+ Hits             172       175    +3     
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 self-assigned this May 12, 2026
@turegjorup turegjorup requested a review from jekuaitk May 12, 2026 09:21
throw new CacheException('Required config key not defined: '.$key);
}
if (!is_string($configuration[$key])) {
throw new CacheException(sprintf('OIDC discovery document value for "%s" is not a string (got %s)', $key, get_debug_type($configuration[$key])));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the existing code throws a CacheException here, but surely theres a more fitting exception?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added new MetadataException

<?php

namespace ItkDev\OpenIdConnect\Exception;

/**
 * Thrown when the IdP-returned OIDC discovery metadata does not conform to the
 * OIDC Discovery spec — for example, a required key is missing or has the
 * wrong type. Distinct from `JsonException` (the document didn't even parse)
 * and `CacheException` (the cache layer failed): the JSON parsed fine, the
 * cache is fine, but the document's contents are structurally invalid.
 *
 * Consumers typically can't recover by retrying — the IdP needs to fix the
 * payload — so a `catch (MetadataException $e)` block normally logs + alerts.
 */
class MetadataException extends \RuntimeException implements OpenIdConnectExceptionInterface
{
}

turegjorup and others added 2 commits May 12, 2026 11:32
Two `(string)` casts in `OpenIdConfigurationProvider` coerced
JSON-decoded mixed values into strings: the JWK `kid` lookup and the
OIDC discovery doc value lookup. Both upstream payloads specify the
field as a string by spec (RFC 7517 §4.5 for `kid`; OIDC Discovery for
the document values), but a malformed-payload case silently turned an
int / bool / array into a useless string and produced confusing
downstream failures rather than diagnosing the malformed input.

Replace each cast with an `is_string` guard that throws an appropriate
typed exception (`KeyException` and `CacheException` respectively).
Both already implement `OpenIdConnectExceptionInterface`, so consumers
catching the marker pick up the new throw paths without code changes.

Adds two tests covering the new branches to preserve 100% coverage
(151/151 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`getConfiguration` has two distinct failure boundaries:

  - `catch (InvalidArgumentException $e) → throw new CacheException(...)`
    correctly maps PSR-6 cache-layer failures to `CacheException`.
  - The inline `throw` for "required key missing" / "value is not a
    string" was *also* using `CacheException`, but those validation
    failures fire regardless of whether `$configuration` came from
    cache or from a fresh `fetchJsonResource()` call — the IdP-
    returned (or previously cached) JSON payload doesn't conform to
    the OIDC Discovery schema. Calling that a "Cache" exception
    misleads operators looking at logs.

Switch both validation throws to `JsonException`, the existing
concrete the library already uses for "JSON payload from the IdP is
malformed". The new check (added in this PR for the previously-
silent-coerced non-string value case) and the pre-existing
missing-key check now share the same type, since they're the same
failure category.

Both `CacheException` and `JsonException` implement
`OpenIdConnectExceptionInterface`, so consumers catching the marker
are unaffected. Consumers catching `CacheException` specifically for
the missing-key case will need to widen — flagged in the CHANGELOG.

Addresses the review comment on the original throw:
#42 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@turegjorup turegjorup force-pushed the fix/replace-string-casts-with-guards branch from 47e288c to a320df6 Compare May 12, 2026 09:34
`JsonException` stretches its semantic when used for "the JSON parsed
fine but the document doesn't conform to the OIDC Discovery spec" —
the existing usage of `JsonException` is strictly for `json_decode`
failures (the bytes aren't valid JSON). Mixing the two failure modes
under one type means a consumer catching `JsonException` to retry on
transient parse failures would incorrectly retry a malformed-discovery-
document case (the IdP will keep returning the same bad payload — no
retry will help).

New `MetadataException extends \RuntimeException implements
OpenIdConnectExceptionInterface` covers this distinct failure
category. The two validation throws in `getConfiguration` (missing
required key; non-string value at a required key) switch to it.
`fetchJsonResource` still throws `JsonException` for actual parse
failures, so the categories stay clean.

Updates the `@throws` lists on `getBaseAuthorizationUrl`,
`getEndSessionUrl`, `validateIdToken`, and `getConfiguration` to
advertise the new transitively-thrown type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@turegjorup turegjorup merged commit 44dfae9 into develop May 12, 2026
16 checks passed
@turegjorup turegjorup deleted the fix/replace-string-casts-with-guards branch May 12, 2026 10:30
@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