Skip to main content

Understanding the Architecture

This article walks through the internal architecture of Abblix OIDC Server: the components that make up its protocol core and the boundaries they expose, then shows how the server integrates with ASP.NET Core. Understanding the structure helps when you want to customize behavior, test the protocol logic in isolation, or plug the server into an existing .NET application.

Basic Concepts

Designed on the principles of Hexagonal Architecture, Abblix OIDC Server prioritizes separation of concerns by isolating its core logic through interfaces. Known alternatively as the Ports and Adapters architecture, this approach separates the application's core functionality from external interactions.

Core

The Core is the center of the hexagon: the business logic specific to authentication and authorization. In the context of Abblix OIDC Server, the Core holds the essential business logic and rules specific to authentication and authorization processes. This includes handling of OpenID Connect protocols, token generation, validation, user session management and the execution of authentication flows. The Core is agnostic to the external entities it communicates with, such as databases, web services or user interfaces.

Ports

Ports are the interfaces through which the Core interacts with the outside world. They are the capabilities the application offers, abstracted in a way that external actors or systems can use them without knowing the Core's internals.

In Abblix OIDC Server, Ports might include:

  • API Endpoints: interfaces for receiving authentication requests, token requests, and other OpenID Connect-specific operations.
  • Data Access: abstractions for how data (e.g., tokens, user sessions, client configurations) is stored and retrieved, which could be through databases or external services.

Ports act as contractual boundaries that define what capabilities are available from the Core. External changes then never reach the core business logic.

Adapters

Adapters are the implementations that connect the Ports to the actual external services or systems. They translate the external requests into operations that the Core can understand and execute, and conversely, adapt the Core's responses back to forms suitable for the external world.

In the Abblix OIDC Server, Adapters might include:

  • Web Controllers: these adapt HTTP requests into calls to the application's Core services, handling web-specific protocols and data formats.
  • Database Connectors: they translate the Core's data access interfaces into actual queries to a relational database, NoSQL store, or other persistence mechanisms.
  • Client Libraries: software components that enable other applications to interact with the Abblix OIDC Server, adapting the server's capabilities into convenient methods for external use.

By employing a Hexagonal Architecture, Abblix OIDC Server ensures that its core functionality remains stable, secure, and isolated from external changes, whether those changes come from new UIs or new storage.

Inside the Core

The hexagon gives the shape of the whole server.

Understanding Endpoints

In OAuth 2.0 and OpenID Connect, endpoints typically refer to the URLs clients call to request tokens, retrieve user information, or initiate logout. In Abblix OIDC Server's core, endpoints are more than URLs. They are abstract logical units that group specific capabilities.

Endpoint Catalog

The easiest way to see the catalog is to follow a client application through it.

A client's first contact with the server is Discovery, which publishes the OpenID Provider Metadata as the .well-known/openid-configuration document. A deployment that allows it can also let clients register and manage themselves at runtime through Dynamic Client Management (RFC 7591 and RFC 7592).

Then comes the main road of the protocol. Authorization is the front door for the authorization code, implicit, and hybrid flows; its validation pipeline chains composable validators that each check a single aspect of the request. A client that wants to keep the request parameters off the browser URL sends them ahead over a back channel to Pushed Authorization (RFC 9126) and receives a request_uri to present at the authorization endpoint. Input-constrained devices start at Device Authorization (RFC 8628) instead, and clients that authenticate users without any browser interaction open a CIBA flow at Backchannel Authentication. Whatever the entry point, everything converges on the Token endpoint, which exchanges grants for tokens: authorization code, client credentials, refresh token, resource owner password, device code, CIBA, and JWT bearer.

Once tokens are issued, the serving endpoints take over. UserInfo returns claims about the authenticated user based on the granted scopes. Introspection lets resource servers verify whether an access token is active and inspect its metadata. Revocation lets clients invalidate access or refresh tokens they no longer need. And the session ends where it began, in the browser: Check Session serves the iframe for silent session monitoring, and End Session handles RP-Initiated Logout, coordinating front-channel and back-channel notifications.

Every one of these endpoints is built the same way inside, so learning how one works (say, the Token endpoint) gives you a reliable mental model for all the others. Let's take one apart.

The Role of Handlers

Each endpoint has a Handler that coordinates its operations. To achieve this, a typical Handler is composed of two key elements: a Validator and a Processor.

Validators

Validators are the first line of defense, tasked with examining incoming requests for authenticity and compliance with established protocols. To manage the complexity inherent in these requests, some of our more involved Validators are structured as a pipeline of simpler validation steps sharing a common validation context. This design adheres to the Composite pattern, where each step in the pipeline is responsible for validating a single aspect of the request. Decomposing complex validation into focused checks gives us fine-grained control over which aspects of a request succeed or fail: useful both for security reasoning and for writing tests that exercise a single validation rule at a time.

Processors

Processors take over after a request has been validated. They carry out the necessary actions based on the request, such as generating tokens or managing sessions. Unlike Validators, which ensure requests meet certain criteria, Processors actively perform actions or modify the system's state.

Features

Processors are built from Features: specialized application-level services, each focused on one isolated capability. Processors typically engage several Features, which in turn can use other Features. By following the Single Responsibility Principle (SRP), each Feature is responsible for one specific task. You can think of them as the tiles in our mosaic, where each tile has its own color and its shape is very simple. But when you step back, all those tiles blend together to make a brilliant picture. When the server needs new capabilities, we add new Features and reuse existing ones instead of modifying monolithic classes. This keeps the Core extensible as OAuth 2.0 and OIDC evolve: new RFCs, new flows, new compliance requirements.

To give you a sense of scale, here is the ground the current Feature set covers.

A large share of it deals with the two parties of every exchange. On the client side, Client Authentication verifies applications through shared secrets sent in a header or request body, signed JWT assertions using either a shared secret or a private key, or mutual TLS with a client certificate, while Client Information manages registrations, secrets, keys, and mTLS options behind a pluggable provider/manager abstraction. On the user side, User Information resolves claims based on granted scopes, with pairwise subject identifiers for privacy. Consent Management handles consent decisions through an IUserConsentsProvider abstraction, and a decorator injects consent prompts into the authorization flow. Session Management tracks authentication sessions, supports the OIDC Session Management spec including the check_session_iframe, and coordinates front-channel and back-channel logout notifications.

Another cluster shapes the artifacts the server issues. Token Lifecycle creates, formats, and validates access, identity, refresh, and logout tokens, with separate formatters for auth-service JWTs and client-facing JWTs. Scope and Resource Management resolves scopes to claim sets and manages resource indicators per RFC 8707. Hashing computes the specification-mandated at_hash, c_hash, and s_hash values for token binding.

Some flows bring Features of their own. Device Authorization implements the RFC 8628 grant, including user-code generation, rate limiting, and device-code storage. Backchannel Authentication supports CIBA with its ping, poll, and push notification modes. JWT Bearer handles the matching grant type with issuer validation and a replay cache backed by distributed storage. Request Objects fetches and validates JWT-Secured Authorization Requests (JAR).

Underneath all of that sits the plumbing no protocol implementation survives without. Storage persists authorization codes, authorization requests, and token metadata through IEntityStorage abstractions, with a distributed-cache implementation and Protobuf serialization. Secure HTTP Fetching makes outbound calls with SSRF protection and response caching wherever the server retrieves request objects or JWKS documents. URI Validation guards redirect URIs with composable strategies, closing the door on open redirects. Random Generation covers the cryptographically secure generation of authorization codes, client IDs and secrets, session and token IDs, and request URIs. Issuer Resolution determines the issuer identifier from preconfigured values or from the request itself.

Each of these Features exposes its own narrow interface, so replacing or extending any single capability (say, swapping the URI validation strategy or adding a custom token formatter) touches only that interface, not the surrounding flow.

Composition Patterns

The building blocks described above are deliberately assembled with classic patterns from the Gang of Four catalog rather than with a bespoke plugin model. Composite, Decorator, and Adapter carry most of the weight, and standard .NET dependency injection is what snaps them together.

The Composite pattern lets the server combine multiple implementations of the same interface into a single logical unit. Client authentication is a good example: a composite tries dedicated per-method authenticators in turn (one for each method the spec defines) behind a single interface. The same pattern applies to authorization grant handlers, logout notifiers, URI validators, and the authorization validation pipeline itself. Adding a new authentication method means registering one more implementation. The composite picks it up automatically.

The whole CompositeClientAuthenticator is a dozen lines: a constructor array of authenticators and a loop that asks each one until it gets a result. The wiring is one line too: services.Compose<IClientAuthenticator, CompositeClientAuthenticator>(), a helper from Abblix.DependencyInjection that collects every registered IClientAuthenticator, replaces those registrations with the composite, and hands them to its constructor.

The Decorator pattern layers cross-cutting concerns onto existing behavior without modifying the original class. Token status validation, consent prompt injection, HTTP response caching, and session-management enrichment of authorization responses are all implemented as decorators. Each one wraps an inner service, adds its concern, and delegates the rest. This keeps individual classes focused while letting the DI container assemble the full behavior chain.

A concrete one is AuthorizationCodeReusePreventingDecorator at the token endpoint. It implements the same ITokenRequestProcessor interface it wraps; when it sees an authorization code that has already been used, it revokes every token issued from that code and rejects the request with invalid_grant, and in every other case it simply passes the request to the inner processor. One registration line (services.Decorate<ITokenRequestProcessor, AuthorizationCodeReusePreventingDecorator>()) wraps whatever ITokenRequestProcessor is currently in the container, whether the default one or one you have already replaced.

The Adapter pattern works at the boundary rather than inside the Core. The entire Abblix.Oidc.Server.Mvc package is one large adapter (in the Gang-of-Four sense as much as in the hexagonal one) and its internal pieces repeat the pattern at a smaller scale: model binders adapt HTTP wire formats into typed request models, response formatters adapt typed responses back into HTTP. The section on ASP.NET Core integration below walks through those pieces in detail.

None of this is wired by hand; the container assembles the chains. And where the protocol itself names the variants of a behavior, the server goes one step further and uses keyed services, making the protocol identifier the DI key. JWT signers and encryptors are registered under their JOSE algorithm names, CIBA token-delivery handlers under their delivery modes, token-exchange subject-token resolvers under RFC 8693 token-type URNs, Rich Authorization Request validators under their authorization_details types. A keyed registration is the container-native form of the Strategy pattern: the key selects the strategy, and the dispatch that would otherwise be a switch inside a factory lives in the container.

Token encryption is the richest illustration, because a single JWE operation composes two keyed strategies. A JWE header names two algorithms: alg for key management and enc for content encryption. The encryptor resolves both from the container (the key encryptor under the key-management name (RSA-OAEP-256, A256GCMKW, dir), the content encryptor under the content-encryption name (A256GCM, A128CBC-HS256)) and combines them into one encryption pass. Signing works the same way with a single key: the signer for RS256, ES384, or any other algorithm is looked up by the name from the token header. The algorithm lists the server advertises in its discovery document are accumulated from those same registrations, so what is registered and what is promised cannot drift apart.

CIBA shows the same shape outside cryptography: each delivery mode the spec defines registers its own completion handler under the mode string, and when an authentication completes, the router asks the container for the handler keyed by the delivery mode the client declared at registration. Supporting one more algorithm or delivery mode means one more registration.

Working with DI at this depth is practical because of Abblix.DependencyInjection: a small standalone package that extends Microsoft.Extensions.DependencyInjection with tools the standard container does not provide out of the box. It deserves attention in its own right. Beyond Compose and Decorate, it offers service aliasing (the same implementation exposed under a second interface without creating a second instance) and per-registration dependency overrides: Dependency.Override supplies one constructor argument as a type, an instance, or a factory while the container resolves everything else. That override mechanism is exactly how each JWE encryptor above receives its algorithm name. The package depends only on Microsoft abstractions, so any .NET application can use it independently of the OIDC server. A companion article on advanced DI in .NET walks through these tools in depth: service aliasing, Dependency.Override, in-place decoration, and the live cursor that reopens a composed pipeline after the fact.

The payoff of all this composition shows up when you need to change something. New functionality is added by composing Features rather than editing monolithic classes; existing behavior is customized by implementing narrow interfaces rather than subclassing large default implementations. The surface area you touch is sized to the change itself.

Design Decisions

Three internal design decisions shape how code is written throughout the library and are worth understanding before you start extending it.

Standard .NET Mechanisms First

Wherever .NET or ASP.NET Core already ships a mechanism, the library uses it instead of inventing a parallel one. Configuration flows through the options pattern (IOptions<T>). Logging goes through ILogger with source-generated LoggerMessage methods. Protocol state that must survive between requests (authorization codes, device codes, replay caches) sits behind IDistributedCache by default. Outbound HTTP uses IHttpClientFactory, time is injected as TimeProvider, cryptography is native System.Security.Cryptography, JSON is System.Text.Json. The extension mechanism throughout is IServiceCollection itself, keyed services included.

This is the inward-facing half of the same principle the ASP.NET Core integration follows on the hosting side: no parallel vocabulary. There is nothing library-specific to learn before you can operate or extend the server. Redirecting logs works the way it does in any other ASP.NET Core app; so does replacing a store or faking time in a test. And when the platform's mechanisms improve, the library inherits the improvement for free.

Result Pattern for Error Handling

Every operation that can fail returns a Result<TSuccess, TFailure>: success with a typed value, or failure with a structured error. Success and failure are different shapes in the type signature, not two faces of the same object distinguished by a boolean flag. The compiler enforces that callers handle both cases. You cannot accidentally read success data from a failed result. Validation failures become typed values you can bind, map, and match on, rather than exceptions floating up a call stack or string-coded error fields you need to remember to check.

The pattern runs through the whole pipeline, not just its edges: validators return typed failures, processors return typed outcomes, and the failure branch travels untouched all the way to the response formatter, which turns it into the correct protocol error response. Nothing is thrown for control flow, and nothing gets silently swallowed on the way out.

In a protocol library, where OAuth 2.0 error responses are a first-class part of the specification, this matters: the type system does the correctness work instead of each caller doing it manually. Migrating the entire codebase onto this pattern was a project of its own; the numbers and the lessons are in a separate article.

JSON-Native JWT Layer

The JWT library, Abblix.Jwt, is built on System.Text.Json.Nodes. A claim value is a JsonNode and can carry any JSON type: string, number, boolean, array, or object. There is no intermediate serialization through strings.

This sounds like a baseline expectation, but many JWT implementations in the .NET ecosystem are built around a string-valued claim model inherited from the ClaimsPrincipal abstraction. That model works until you hit standard claims that carry structured data: aud as an array of audiences, amr as an array of authentication methods (RFC 8176), address as a JSON object (OIDC Core), or cnf carrying a proof-of-possession key (RFC 7800). Squeezing these through a string bottleneck introduces subtle bugs: whitespace differences, key ordering, numeric type ambiguity, single-element arrays serialized differently by different serializers.

In Abblix.Jwt, a JSON object put into a token payload comes out as a JSON object on the other side. End to end, types are preserved without round-tripping through strings.

Solution Structure

The server is not a single monolithic assembly. It ships as a set of focused NuGet packages, each with a clear responsibility:

  • Abblix.Oidc.Server: the protocol Core: endpoints, features, domain model, and all the abstractions described above. Has no dependency on ASP.NET Core MVC.
  • Abblix.Oidc.Server.Mvc: the ASP.NET Core adapter: controllers, model binders, response formatters, and routing conventions that bridge the Core to the MVC pipeline.
  • Abblix.Jwt: a standalone JWT library handling token creation, signing, encryption, and validation. Used by the Core but also usable independently.
  • Abblix.Utils: general-purpose utilities: cryptographic helpers, URI builders, caching extensions, and the Result<T> type used for error handling throughout the solution.
  • Abblix.DependencyInjection: advanced DI tooling built on top of the standard IServiceCollection: composite and decorator registration (Compose, Decorate), service aliases, dependency overrides, and keyed-service utilities that keep the ServiceCollectionExtensions in the other packages concise. Usable on its own, outside the OIDC stack.

This separation matters in practice. If you need JWT operations without the full OIDC stack, reference Abblix.Jwt alone. If you want to host the server on a transport other than MVC, reference Abblix.Oidc.Server and write your own adapter. The Core will work the same way.

Integration with ASP.NET Core

Abblix OIDC Server plugs into ASP.NET Core through a dedicated MVC adapter. The protocol Core has no dependency on HttpContext, controllers, model binding, or action results. Those concerns are handled by the adapter. This has one deliberate consequence: inside your ASP.NET Core application, you continue to use the ecosystem's native mechanisms (routing, model binding, validation attributes, filters, CORS configuration, standard action results) rather than learning a parallel vocabulary invented inside the OIDC library.

We deliberately scoped the library to OpenID Connect and OAuth 2.0. Everything the framework already provides (HTTP plumbing, DI, logging, configuration) we defer to the framework. Everything specific to the OIDC and OAuth 2.0 specifications (issuance rules, flow state machines, conformance behavior) lives inside Abblix's Core and is the part we want to do well.

The MVC adapter itself is organized around a few focused responsibilities:

  • Controllers translate HTTP requests into Core handler calls and Core responses back into action results. Each controller maps to a group of related endpoints (authentication, tokens, discovery, client management) rather than to individual protocol operations, so the number of controllers stays small.
  • Model Binders handle the OAuth/OIDC-specific quirks of HTTP input: parsing Authorization headers for client credentials, converting space-separated scope strings into collections, deserializing seconds-since-epoch values into TimeSpan, and forwarding client certificates from TLS termination proxies.
  • Response Formatters convert the Core's typed response objects into the appropriate HTTP representations: JSON bodies, redirect responses with fragment or query parameters, HTML pages for front-channel logout. Each endpoint has its own formatter behind a narrow interface, so the Core never constructs HTTP responses directly.
  • Conventions and Filters wire up configurable routing (so endpoint paths can be changed per deployment) and conditional endpoint activation (so unused endpoints can be disabled entirely via configuration).

Formatters in Depth

Formatters exist for a single reason: the Core is isolated from constructing HTTP responses. This is intentional. Its job ends when it produces a typed response object for an endpoint: the semantic outcome, free of wire-level details. What happens next depends on HTTP concerns the Core has no business knowing: response modes, status codes, header conventions, media types, cookies, signed-JWT serialization. That translation is the formatter's job.

Keeping the split has two concrete payoffs. The Core stays testable without any web infrastructure, because its response objects are abstract and framework-agnostic: never an MVC ActionResult, a Minimal API IResult, or any other transport-specific shape. And the same Core can serve different hosting models: a different adapter brings its own formatters without touching the protocol logic.

Each OIDC endpoint has its own formatter, registered in DI and replaceable independently. The variety of HTTP outputs they produce is worth showing, because it is what justifies the concern having its own layer:

  • Response-mode handling: the same authorization response can be delivered as query parameters, a URL fragment, or an auto-submitting form_post HTML page. The Core produces the response once; the formatter picks the delivery channel.
  • Typed error to HTTP status code: the failure branch of a Result carries a protocol-level error code. The formatter translates it into the correct HTTP status, adding protocol-required headers such as a WWW-Authenticate challenge on 401 matching the client's authentication scheme.
  • Content negotiation driven by client configuration: the same response may travel as plain JSON or as a signed JWT, depending on what the client registered. The Core emits the claims once; the formatter decides the wire shape.
  • Cross-cutting response enrichment: HTTP-layer concerns that the Core cannot know about, such as session cookies and routing-resolved endpoint URLs, are attached at the formatter boundary, keeping the Core free of hosting-specific details.

The same typed response from the Core could be routed through a Minimal API handler, serialized as a gRPC message, or captured by a test double. The conversion strategy is a detail of the hosting layer, not of the protocol.

Conclusion

Two properties of the architecture matter in practice. The Core can be exercised in unit tests without HTTP, storage, or any web framework infrastructure. And new adapters (different storage backends, different transport surfaces) can be added without modifying the protocol logic. The MVC adapter shipped today is one possible adapter among several, and the upcoming release makes this concrete: it adds a Minimal API adapter, bringing the same protocol logic to services built on Minimal API without a single change to the Core. Inside the Core, the recursive pattern of Handlers splitting into Validators and Processors, which in turn compose Features, keeps the same separation going: each customization implements an interface matched to the concept being changed, not to the class that happened to contain it. And because the assembly language for all of it is standard .NET dependency injection, customizing the server feels like writing ordinary ASP.NET Core code, which is exactly the intent.