Skip to content

fix(serializers): chain the reason of the serializer a token belongs to - #818

Merged
Spomky merged 1 commit into
4.3.xfrom
fix/jose-serializer-managers-format-reason
Nov 9, 2026
Merged

Spomky merged 1 commit into
4.3.xfrom
fix/jose-serializer-managers-format-reason

Conversation

@Spomky

@Spomky Spomky commented Nov 9, 2026

Copy link
Copy Markdown
Member

Follow-up of #817, whose "Not covered" section listed this.

The defect

When no serializer can convert a token, JWSSerializerManager and JWESerializerManager throw Unsupported input. and chain one exception, so that the reason remains available. Since #817, that exception is the one that goes back to a bound of the JSON decoder; otherwise, it is the exception of the last serializer tried.

The serializers did not tell two cases apart:

  • The input is in another serialization. A compact serializer given a JSON token, or a JSON serializer given a compact one.
  • The token is in their serialization, but invalid.

So a token rejected by the serializer of its own format, for a reason other than a bound, was reported with the exception of a serializer that had only found it foreign. For example:

  • A compact token whose protected header is not a JSON object. The compact serializer rejects it for Bad protected header., but the manager chained the JSON general serializer's Unsupported input.: a syntax error, because a compact token is not JSON.
  • A flattened token whose protected header is not valid JSON. The flattened serializer rejects it for Syntax error, but the manager chained the general serializer's Unsupported input., which only says that the signatures member is missing.

The reason was kept only when the serializer of the token happened to be the last one tried.

The fix

  • A new Jose\Component\Core\Exception\UnrecognizedSerializationException, which extends InvalidSerializationException. A serializer throws it when the input is not in its serialization at all:

    • compact serializers: another number of parts, or a protected header part that is not base64url. The second check classifies a failure only after it has happened, so what is accepted does not change. A JSON token never starts with a base64url part, even when it contains exactly the number of dots of a compact token;
    • JSON serializers: a document that is not valid JSON, that is not an object, or that lacks the members of the serialization (signature, signatures, ciphertext, recipients).
  • BoundedJsonDecoder::decodeSerialization() (@internal) decodes the JSON document of a JSON serializer. It turns invalid JSON into that exception, keeping its message, code and chained JsonException. A document beyond a bound is still rejected with a JsonBoundExceededException.

  • The managers chain, in this order of priority:

    1. the last exception that goes back to a bound (fix(serializers): chain the bound a token goes beyond in the serializer managers #817);
    2. else, the last exception without the mark;
    3. else, as before, the exception of the last serializer.

    A serializer of another library that marks the inputs it does not recognize is skipped in the same way. Its unmarked exceptions count as those of a serializer that recognized the input; they never hide a bound.

  • The messages and codes of every exception are unchanged, and so is the exception that the managers throw.

Backward compatibility

  • API. One new class, UnrecognizedSerializationException, registered in JoseExceptionTest with its SPL parent, InvalidArgumentException. BoundedJsonDecoder, which is @internal, gains decodeSerialization(). No signature changes.

  • Exception classes. The rejections of an input of another serialization now throw the subclass:

    • those that threw an InvalidSerializationException: the parts count of the compact serializers, a JSON document that is not an object, missing members;
    • those that threw the Jose\Component\Core\Exception\InvalidArgumentException of the JSON decoder: invalid JSON given to a JSON serializer.

    A catch of InvalidArgumentException, Jose or SPL, still catches them, and so does a catch of InvalidSerializationException for the first group. For the second group, a catch of InvalidSerializationException now catches them too, which it did not before.

  • Managers. The exception they throw is unchanged. The exception they chain changes only when the serializer of the token rejected it and a serializer of another format came after it.

  • Behaviour. The probe of fix(serializers): chain the bound a token goes beyond in the serializer managers #817 ran the same 819 cases on 4.3.x (69e9709) and on this branch. In the base run, the 4.3.x versions of BoundedJsonDecoder, of the six serializers and of the two managers were loaded before Composer's autoloader. The cases are the six serializers, the SD-JWT parser, JWK and JWKSet, plus every JWS and JWE token of that corpus through the two managers, with the serializers in three orders.

    • 675 cases are identical: 322 accepted inputs and 353 refusals.
    • 32 refusals change their class only, to UnrecognizedSerializationException: 20 were InvalidSerializationExceptions and 12 were InvalidArgumentExceptions.
    • 96 refusals change only in their chain: they keep their class, message and code, and only the classes in their chain change.
    • 16 manager refusals now chain the exception of the serializer of the token, such as Unsupported input → Bad protected header. instead of Unsupported input. → Syntax error.
    • There is no other difference. Whenever the serializer of the token rejects it without the mark, the manager now chains exactly that exception: 267 of 267 cases.

Verification

  • New tests:
    • Signature\JWSSerializerManagerTest and Encryption\JWESerializerManagerTest (34 cases each):
      • every serializer rejects an input of another serialization with the mark: 12 cases, among them a JSON token with exactly the dots of a compact one;
      • an invalid token of its own serialization is rejected without the mark: 6 cases;
      • the manager chains the exception of the serializer of the token, with the serializers in three orders. It checks the message of the chained exception and the innermost reason;
      • a serializer of another library neither hides a bound nor the reason, when it marks the inputs it does not recognize.
    • JoseExceptionTest registers the new class.
  • On 4.3.x, 30 of the 34 cases of each file fail: 19 errors, because the class does not exist, and 11 failures. The 4 others pass before and after: they are the cases where the serializer of the token is the last one tried.
  • The tests of fix(serializers): name the bound a token goes beyond when it is decoded #816 and fix(serializers): chain the bound a token goes beyond in the serializer managers #817 pass unchanged.
  • Full suite with phpunit-11 in phpqa:8.4: 2,958 tests, no failure. The 4 skipped and 2 incomplete tests are the PKCS#12 and ML-DSA platform tests.
  • ECS, Rector, PHPStan (baseline unchanged) and Deptrac pass with phpqa:8.4. The changed files pass php -l with PHP 8.2.
  • Mutations on the diff: 53 mutants, all killed with a 60-second timeout. A first run left two alive:
    • a mutant of the index of the part checked by the compact serializer, now killed by a test input whose second part is base64url;
    • a redundant is_array() check, merged with the check of the members.

Not covered

  • The JSON decoding of the JWKs, the key sets and the other documents. That is the next PR.

When no serializer could convert a token, the managers chained the
exception of a bound or, without one, that of the last serializer they
tried. A token rejected by the serializer of its format for another
reason was then reported with the exception of a serializer of another
format, which had only rejected the token as foreign. A compact token
whose protected header is not a JSON object was reported as invalid
JSON by the general serializer.

A serializer now rejects an input of another serialization with an
UnrecognizedSerializationException, a subclass of
InvalidSerializationException. That is a compact serialization of
another number of parts or whose protected header part is not base64url,
or a JSON document that is not valid JSON, not an object, or without
the members of the serialization. The managers chain the exception of a
bound first, then the last exception without that mark, and the
exception of the last serializer otherwise. Messages and codes are
unchanged.
@Spomky Spomky added this to the 4.3.0 milestone Nov 9, 2026
@Spomky Spomky added the bug label Nov 9, 2026
@Spomky
Spomky merged commit e39db7e into 4.3.x Nov 9, 2026
18 checks passed
@Spomky
Spomky deleted the fix/jose-serializer-managers-format-reason branch November 9, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant