Skip to content

Generate MVC binding models from core request models#188

Merged
kirill-abblix merged 14 commits into
developfrom
feature/mvc-model-source-generation
Jun 11, 2026
Merged

Generate MVC binding models from core request models#188
kirill-abblix merged 14 commits into
developfrom
feature/mvc-model-source-generation

Conversation

@kirill-abblix

Copy link
Copy Markdown
Member

Summary

Replaces the hand-written MVC binding models with an incremental Roslyn source generator that produces them from the core request models, eliminating the duplicated model layer and the silent-parameter-drop class of bugs it bred.

How it works

  • A hand-written partial record stub marked with the trigger attribute names its core counterpart; the generator emits the matching partial: bound properties with wire names taken from the core serialization metadata, model binders resolved from the core wire-format markers, validation attributes translated to their executable MVC counterparts, XML documentation inherited from the core, and an implicit conversion operator projecting the bound model back onto the core type.
  • The core declares only semantics: wire-format markers (space-separated string, total seconds, embedded JSON, culture list) and transport-source markers (named request header, Authorization header, client certificate) carry no binding technology. Each MVC binder self-declares the marker it realises, so the marker-to-binder knowledge lives next to the binders and the generator carries no mapping table.
  • Diagnostics replace silent failures: a marker with no binder, a bound property without a wire name, or an unrecognised marker on a payload-excluded property all fail the build.

Resulting model layout

  • Form-bound request models are generated (authorize, token, client authentication material, introspection, revocation, userinfo, device authorization, CIBA, end-session). The end-session model keeps its hand-written cross-property validation in the stub.
  • The JSON-bound registration request has no MVC mirror at all: the body deserializes straight into the core model, whose serialization metadata and property defaults are authoritative; the controller supplies the transport-level Authorization header on top.
  • The management authorization projection stays hand-written: the client identifier travels in the URL path, a routing concept the core deliberately does not know about.

Behavioural fixes surfaced by the migration

  • The CIBA endpoint silently dropped the claims and signed request parameters: the core model accepts both, but the hand-written MVC model carried no binding for either.
  • The absolute-URI validation attributes on hand-written models resolved to the inert core markers instead of the executable MVC versions, so they never ran; the generated models enforce them. The request_uri annotation was relaxed to plain absolute-URI accordingly, since the pushed authorization request URN is a legitimate value.
  • Static AllowedValues lists were removed where the supported set is defined by host DI registrations (grants, response types) and already validated at runtime against the same union the discovery document advertises; the duplicated MVC copy had drifted from the core one (eight grants versus nine). Remarks on the affected properties document why no declarative constraint is present.

Also included

  • XML documentation fixes across the core models (a factually wrong CIBA summary, copy-paste drift, undocumented wire-parameter constants).
  • Repository-level package source pinning with a source mapping, silencing NU1507.

All 2910 tests pass, including the full E2E suite.

Introduce four semantic markers in DeclarativeValidation that name the wire
format of a parameter without referencing any binding technology:
space-separated string, integer seconds, embedded JSON document, and
space-separated BCP 47 language tags. Annotate the authorization request
model with them.

Relax the request_uri annotation to plain AbsoluteUri: the value is either
an HTTPS URL (OIDC Core 6.2) or the urn:ietf:params:oauth:request_uri:
value issued by the PAR endpoint (RFC 9126 2.2), so requiring the https
scheme contradicted the supported PAR flow.
Add an incremental source generator that produces the MVC binding model
from the core request model: bound properties with wire names taken from
the core serialization metadata, model binders resolved from the core
wire-format markers, validation attributes translated to their executable
MVC counterparts, and the projection method back onto the core type.

Each binder self-declares the marker it realises via the Binds attribute,
so the marker-to-binder knowledge lives next to the binders and the
generator carries no mapping table. A marker with no binder fails the
build instead of silently dropping the parameter. A hand-written partial
record stub marked with GeneratedFrom opts a model into generation.

Migrate the authorization request model as the first one: the handwritten
model is replaced by a stub, and the generated counterpart is identical
except that the absolute-URI validation is now actually enforced - the
handwritten model referenced the inert core marker instead of the
executable attribute.
Restores resolved against two user-level package sources without a source
mapping, which central package management flags as NU1507 on every project.
The library consumes only public packages, so the repository pins itself to
nuget.org and maps every package there. Release-workflow jobs that stage
local packages restore in sibling checkout directories, outside the scope
of this file.
…ined

Remove the AllowedValues lists from the token request grant type, the
authorization request response type, and the client registration grant and
response types. All four sets are defined by host DI registrations and are
already validated at runtime against the same union the discovery document
advertises - by the composite grant handler, the flow validator, and the
registration pipeline respectively. A static list duplicated that gate and
misstated it: it rejected host-added extensions at the model-binding layer
with a transport-level 400 instead of the protocol-level error, and the
duplicated MVC copy had already drifted from the core one (eight grants
versus nine).

Document the rule on every runtime-defined field: remarks now explain why
the signing and encryption algorithm fields, the token endpoint
authentication method, the registration grant and response types, and the
authorization request client identifier carry no declarative constraints.
The server never supported response_type=none: no response processor is
registered for it and no code references the constant, so it only
suggested a capability the discovery document does not advertise.
…ators

Migrate the introspection, revocation, userinfo, device authorization,
backchannel authentication and end-session models to source generation:
the hand-written models become generation stubs and the corresponding core
models gain wire-format markers. The end-session model keeps its
hand-written cross-property validation in the stub.

Replace the generated Map method with an implicit conversion operator to
the core model, so controllers and tests assign the bound model directly
and a forgotten projection call is no longer possible.

Generation closes two silent parameter drops on the CIBA endpoint: the
core model accepts the claims and signed request parameters, but the
hand-written MVC model carried no binding for either. It also enforces the
absolute-URI constraint on the post-logout redirect URI, which the
hand-written model declared with the inert core marker, and removes the
accidental GET binding the CIBA model carried on one property of the
POST-only endpoint.

Restructure the generator emission into a ModelEmitter holding the
per-model state, and add the System.Index polyfill so list patterns
compile on netstandard2.0.
… own files

The pipeline record types moved to one-type-per-file; this removes the
copies left behind in the original file.
The hand-written MVC model becomes a generation stub and the core model
gains the space-separated marker on the scope parameter. The token
controller assigns the bound model through the implicit conversion.
Introduce three semantic source markers in the core: a value arriving in a
named HTTP request header, the parsed Authorization header, and the client
certificate presented at the transport layer. The client request model is
annotated with them and its hand-written MVC counterpart becomes a
generation stub; the header and certificate binders self-declare the
markers they realise.

In the generator, a source marker overrides the payload exclusion, and an
unrecognised declarative marker on a payload-excluded property now fails
the build, so a renamed or mistyped marker cannot silently drop a bound
parameter. Names of types the generator cannot reference are gathered as
documented constants; reachable ones use nameof.
Correct the factually wrong summary of the backchannel authentication
success model (the request is accepted while end-user authentication is
still pending), fix copy-paste drift on the post-logout redirect URIs
summary, repair broken grammar, and document the previously undocumented
wire-parameter constants across the model parameter classes.
Delete the hand-written MVC mirror of the client registration request: the
JSON body deserializes directly into the core model, whose serialization
metadata and property defaults are authoritative, so a transport copy adds
nothing but drift risk. The controller supplies the transport-level
Authorization header on top of the bound model.

Convert the client management authorization projection to an implicit
operator for consistency, and document why that model stays hand-written:
the client identifier travels in the URL path, a routing concept the core
deliberately does not know about.
A bound model consumed by one call converts implicitly at the call site; a
model feeding both the handler and the formatter keeps one explicit
conversion into a core-prefixed local, so both calls receive the same core
instance.
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
Restore the explicit projection methods on the generated models and the
management authorization: the method is discoverable in completion lists
and reads naturally in tests, while the implicit operator stays as a
one-line delegate to it, so there is still a single projection body and
no call to forget.
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
Comment thread Abblix.Oidc.Server.Mvc.SourceGeneration/MvcModelGenerator.cs Fixed
…oop filters into Where

Address the pull request analysis findings: the static helpers used only
by the emitter move inside it, and the loops that filtered their sequence
in the body now filter explicitly in the enumeration pipeline.
@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +508 to +512
foreach (var declaration in declarations)
{
if (declaration.ConstructorArguments is [{ Value: INamedTypeSymbol marker }])
map[marker.ToDisplayString()] = type;
}
@kirill-abblix kirill-abblix merged commit 8c4c3f3 into develop Jun 11, 2026
3 checks passed
@kirill-abblix kirill-abblix deleted the feature/mvc-model-source-generation branch June 11, 2026 18:35
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.

1 participant