You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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).
Priority: Medium. The API is new (Let consumers register their own Credential subclasses [minor] #194) and the failure needs a consumer to register a malformed type. When it happens, though, credentials are silently "lost" (case b), or one bad registration breaks every credential type, including the built-in ones (case c). InMemoryCredentialStore hides all of it from tests.
Area / suggested assignee:CredentialSerialization.RegisterDerivedType and Deserialize/DeserializeFromString (CredentialCache/Storage/CredentialSerialization.cs). Owner: @matt-edmondson
Next step: build the candidate options and validate them with GetTypeInfo for both Credential and T before publishing the change, then test-deserialize a minimal payload to reject types the serializer cannot construct. Also catch InvalidOperationException in the deserialize paths as a safety net. Add tests for all three shapes against the real serializer.
Activity
matt-edmondson commentedon Oct 29, 2026
Triage
InMemoryCredentialStorehides all of it from tests.CredentialSerialization.RegisterDerivedTypeandDeserialize/DeserializeFromString(CredentialCache/Storage/CredentialSerialization.cs). Owner: @matt-edmondsonGetTypeInfofor bothCredentialandTbefore publishing the change, then test-deserialize a minimal payload to reject types the serializer cannot construct. Also catchInvalidOperationExceptionin the deserialize paths as a safety net. Add tests for all three shapes against the real serializer.Generated by Claude Code