OAuthClientMetadata serializes unset optional fields as null — auth servers reject the client-metadata document (invalid_client_metadata) #41

Closed
opened 2026-06-21 02:08:38 +00:00 by Grandiras · 2 comments
Owner

Summary

OAuthClientMetadata is clearly meant to be serialized directly into the OAuth client-metadata document — every property carries a [JsonPropertyName]. But serializing it emits unset optional fields as JSON null, and ATProto authorization servers reject that: the spec distinguishes absent from null, so optional fields must be omitted.

So any consumer that serves JsonSerializer.Serialize(metadata) at its client_id URL gets invalid_client_metadata and can't complete OAuth.

Repro

  1. Build a public-client OAuthClientMetadata, leaving LogoUri, TosUri, PolicyUri, TokenEndpointAuthSigningAlg, Jwks, JwksUri unset.
  2. Serve Results.Json(metadata) / JsonSerializer.Serialize(metadata) at the client_id URL.
  3. Call OAuthClient.StartAuthorizationAsync(...).

PAR fails (observed against the reference @atproto/oauth-provider / Bluesky PDS):

invalid_client_metadata: Unable to obtain client metadata for "https://.../client-metadata.json":
  Validation of "token_endpoint_auth_signing_alg" failed with error: Expected string, received null
  Validation of "jwks_uri" failed with error: Expected string, received null
  Validation of "jwks" failed with error: Expected object, received null
  Validation of "logo_uri" / "tos_uri" / "policy_uri" failed with error: Expected string, received null

Suggested fix

Annotate the optional properties with [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)], or expose a serializer helper / ToJson() that applies DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, so serializing the metadata yields a spec-compliant document out of the box.

Workaround

Serialize with new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }.

Environment

ATProtoNet 0.4.0, net10.0.

### Summary `OAuthClientMetadata` is clearly meant to be serialized directly into the OAuth client-metadata document — every property carries a `[JsonPropertyName]`. But serializing it emits **unset optional fields as JSON `null`**, and ATProto authorization servers reject that: the spec distinguishes *absent* from *null*, so optional fields must be **omitted**. So any consumer that serves `JsonSerializer.Serialize(metadata)` at its `client_id` URL gets `invalid_client_metadata` and can't complete OAuth. ### Repro 1. Build a public-client `OAuthClientMetadata`, leaving `LogoUri`, `TosUri`, `PolicyUri`, `TokenEndpointAuthSigningAlg`, `Jwks`, `JwksUri` unset. 2. Serve `Results.Json(metadata)` / `JsonSerializer.Serialize(metadata)` at the `client_id` URL. 3. Call `OAuthClient.StartAuthorizationAsync(...)`. PAR fails (observed against the reference `@atproto/oauth-provider` / Bluesky PDS): ``` invalid_client_metadata: Unable to obtain client metadata for "https://.../client-metadata.json": Validation of "token_endpoint_auth_signing_alg" failed with error: Expected string, received null Validation of "jwks_uri" failed with error: Expected string, received null Validation of "jwks" failed with error: Expected object, received null Validation of "logo_uri" / "tos_uri" / "policy_uri" failed with error: Expected string, received null ``` ### Suggested fix Annotate the optional properties with `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, or expose a serializer helper / `ToJson()` that applies `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`, so serializing the metadata yields a spec-compliant document out of the box. ### Workaround Serialize with `new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }`. ### Environment ATProtoNet 0.4.0, net10.0.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/42); the reply will link the full session transcript.
Collaborator

Fixed and pushed to branch claude/issue-41 (commit d67d790).

What changed

src/ATProtoNet/Auth/OAuth/OAuthModels.cs

  • Every optional property on OAuthClientMetadata now carries [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]: client_name, client_uri, logo_uri, tos_uri, policy_uri, token_endpoint_auth_signing_alg, jwks, jwks_uri. This is the attribute-based variant of the suggested fix, so it holds under any JsonSerializerOptions — including the ASP.NET Core defaults behind Results.Json(...) — with no consumer change.
  • Same treatment for the nested JsonWebKey (crv, x, y, kid, use, alg). A confidential client embedding a jwks would otherwise hit the identical rejection one level down, e.g. "use": null inside a key.
  • Added OAuthClientMetadata.ToJson(bool writeIndented = false) for serving the document directly:
    app.MapGet("/client-metadata.json", () =>
        Results.Content(oauthOptions.ClientMetadata.ToJson(), "application/json"));
    

Tests — four new cases in OAuthModelsTests: that serializing with stock JsonSerializer.Serialize(metadata) (no options) omits each of the six fields named in the error output while still emitting the set ones; that ToJson() and ToJson(writeIndented: true) produce null-free, round-trippable documents; and that nested JWK optionals are omitted too.

Docs/CHANGELOGdocs/oauth.md §1 now shows serving the document from the configured metadata object and notes the absent-vs-null distinction; CHANGELOG gets entries under Added (ToJson) and Fixed.

Verified

Built the full solution and ran the unit suite on .NET 10 (SDK 10.0.302, installed in the container):

  • dotnet build -p:EnableSourceControlManagerQueries=falseBuild succeeded, no new warnings on the touched files.
  • dotnet test tests/ATProtoNet.Tests/ -p:EnableSourceControlManagerQueries=false1019 passed, 0 failed (up from 1015; the 4 new tests fail against the pre-fix model).

Not verified end-to-end against a live authorization server — the integration tests are PDS-gated and no PDS is reachable here — so the PAR round-trip against @atproto/oauth-provider is covered only by the serialization assertions matching the exact field names in your error output.


⏱ 3m 48s · 33 turns · tokens: 43.3k in / 12.2k out (+1460.4k cache-read)
📜 Full session transcript
→ Opened PR: #58

Fixed and pushed to branch **`claude/issue-41`** (commit `d67d790`). ## What changed **`src/ATProtoNet/Auth/OAuth/OAuthModels.cs`** - Every optional property on `OAuthClientMetadata` now carries `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`: `client_name`, `client_uri`, `logo_uri`, `tos_uri`, `policy_uri`, `token_endpoint_auth_signing_alg`, `jwks`, `jwks_uri`. This is the attribute-based variant of the suggested fix, so it holds under *any* `JsonSerializerOptions` — including the ASP.NET Core defaults behind `Results.Json(...)` — with no consumer change. - Same treatment for the nested `JsonWebKey` (`crv`, `x`, `y`, `kid`, `use`, `alg`). A confidential client embedding a `jwks` would otherwise hit the identical rejection one level down, e.g. `"use": null` inside a key. - Added `OAuthClientMetadata.ToJson(bool writeIndented = false)` for serving the document directly: ```csharp app.MapGet("/client-metadata.json", () => Results.Content(oauthOptions.ClientMetadata.ToJson(), "application/json")); ``` **Tests** — four new cases in `OAuthModelsTests`: that serializing with *stock* `JsonSerializer.Serialize(metadata)` (no options) omits each of the six fields named in the error output while still emitting the set ones; that `ToJson()` and `ToJson(writeIndented: true)` produce null-free, round-trippable documents; and that nested JWK optionals are omitted too. **Docs/CHANGELOG** — `docs/oauth.md` §1 now shows serving the document from the configured metadata object and notes the absent-vs-null distinction; CHANGELOG gets entries under `Added` (`ToJson`) and `Fixed`. ## Verified Built the full solution and ran the unit suite on .NET 10 (SDK 10.0.302, installed in the container): - `dotnet build -p:EnableSourceControlManagerQueries=false` → **Build succeeded**, no new warnings on the touched files. - `dotnet test tests/ATProtoNet.Tests/ -p:EnableSourceControlManagerQueries=false` → **1019 passed, 0 failed** (up from 1015; the 4 new tests fail against the pre-fix model). Not verified end-to-end against a live authorization server — the integration tests are PDS-gated and no PDS is reachable here — so the PAR round-trip against `@atproto/oauth-provider` is covered only by the serialization assertions matching the exact field names in your error output. --- ⏱ 3m 48s · 33 turns · tokens: 43.3k in / 12.2k out (+1460.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-41/run-42.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/58
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#41
No description provided.