Skip to content

RegisterDerivedType accepts subclasses System.Text.Json can't round-trip: they save but never load, and one bad registration breaks Save/TryGet for every credential type until restart #197

Open

Description

@matt-edmondson

What happens

CredentialSerialization.RegisterDerivedType<T> (added in #194 for #171, CredentialCache/Storage/CredentialSerialization.cs:93-130) checks only that T isn't abstract, isn't built in, and that the discriminator is free. It never asks System.Text.Json whether T has a usable contract, and it rebuilds the shared options immediately (options = BuildOptions(updated)), with no way to undo a registration. Deserialize / DeserializeFromString (:175-195, :244-263) catch only JsonException and NotSupportedException.

Reproduced with a console app referencing CredentialCache.csproj (HEAD b2c2772), each case in a fresh process:

(a) Constructor parameter that doesn't bind to a property — e.g. public ApiKeyCredential(string apiKey) { Key = apiKey; } public string Key { get; }. Registration and save succeed; every load throws a raw InvalidOperationException out of TryGet:

saved blob: {"$type":"ApiKey","Key":"k-123"}
Deserialize(blob): System.InvalidOperationException: Each parameter in the deserialization constructor on type 'ApiKeyCredential' must bind to an object property or field on deserialization. ...

(b) Only a private constructor. Save writes {"$type":"PrivCtor","Key":"secret"} to the OS store; Deserialize maps the resulting NotSupportedException to null, so TryGet reports "not found" for a credential that is sitting in the keychain/credential manager.

(c) Two properties mapping to the same JSON name (e.g. [JsonPropertyName("A")] on B while A also exists). Registration succeeds; from then on every serialize and deserialize throws — including the built-in CredentialWithToken:

register Colliding: ok
serialize builtin after Colliding registered: InvalidOperationException: The JSON property name for 'Colliding.A' collides with another property.
deserialize Guarded missing: InvalidOperationException: The JSON property name for 'Colliding.A' collides with another property.

Why it matters

The failure surfaces far from the cause: the registration call at start-up succeeds, and the break shows up later as lost credentials (b), a crash on load (a), or every persona in the app failing to save or load (c). InMemoryCredentialStore never serializes, so a consumer's tests stay green while the production store breaks — the exact gap #171 set out to close.

Suggested fix

  • In RegisterDerivedType, build the candidate options first and, inside a try, call built.GetTypeInfo(typeof(Credential)) and built.GetTypeInfo(type); convert InvalidOperationException / NotSupportedException into an ArgumentException naming T. Only publish options/registeredTypes if that succeeds. This catches (c).
  • Also reject a type that can't be constructed by the serializer (no CreateObject and no constructor whose parameters all bind) — or test-deserialize a minimal {"$type":"<disc>", ...} payload and treat InvalidOperationException/NotSupportedException as a rejection. This catches (a) and (b).
  • As a safety net, also catch InvalidOperationException in Deserialize/DeserializeFromString so a load failure follows the documented null / CredentialStoreException path instead of escaping TryGet.

Acceptance criteria

  • Registering each of the three shapes above throws ArgumentException naming the type, and leaves previously registered and built-in types working.
  • A type that registers successfully round-trips through Serialize/Deserialize.
  • Tests cover all three shapes against the serializer, not only InMemoryCredentialStore.

Related but distinct: #171 (introduced the API, closed), #164 (missing $type, closed), #180 (null fields in built-in types).

Activity

  1. matt-edmondson commented on Oct 29, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions