Skip to main content

Passing custom parameters through OpenIddict's client authorization request

In my passthrough authentication post, the login endpoint set a provider value on AuthenticationProperties so the OpenIddict server would know whether to challenge GitHub or ADFS:

app.MapGet("login/{provider}", (string provider) =>
    Results.Challenge(new AuthenticationProperties
    {
        RedirectUri = "/",
        Items = { ["provider"] = provider }
    }, [OpenIddictClientAspNetCoreDefaults.AuthenticationScheme]));

Run that as-is and the server never sees it. Items round-trips through your own app's correlation cookie — it was never meant to become part of the outgoing OAuth2 request.

Why this doesn't just work

AuthenticationProperties.Items is an ASP.NET Core concept. It's how the authentication middleware remembers things like the redirect URI across the round trip to an external provider and back. The OpenIddict client builds the actual /connect/authorize request from a completely different object — an OpenIddictRequest — and nothing copies one into the other automatically. If you want something in Items to show up as a query parameter on the real authorization request, you have to move it there yourself.

That's what OpenIddictClientEvents.PrepareAuthorizationRequestContext is for: it fires right before the client builds the outgoing authorization request, and it's the one place where you still have both the AuthenticationProperties from the challenge and the OpenIddictRequest that's about to be sent.

Registering the handler

This is still client-side config, in the same AddClient() block from the passthrough post:

builder.Services.AddOpenIddict()
    .AddClient(options =>
    {
        // ... AllowAuthorizationCodeFlow, registrations, etc. from before ...

        options.AddEventHandler<OpenIddictClientEvents.PrepareAuthorizationRequestContext>(builder =>
            builder.UseInlineHandler(context =>
            {
                var properties = context.Transaction.GetProperty<AuthenticationProperties>(
                    typeof(AuthenticationProperties).FullName!);

                if (properties is not null &&
                    properties.Items.TryGetValue("provider", out var provider) &&
                    !string.IsNullOrEmpty(provider))
                {
                    context.Request["provider"] = provider;
                }

                return default;
            }));
    });

context.Transaction is the OpenIddict client's per-request state. GetProperty<T> is how the ASP.NET Core integration exposes the original AuthenticationProperties from the challenge inside that transaction. From there it's a plain lookup: read provider out of Items, and set it directly on context.RequestOpenIddictRequest behaves like a dictionary, so this becomes an actual provider=github (or adfs) parameter on the /connect/authorize URL.

Remark: this only forwards the parameter on the way out. If your OpenIddict server redirects back through the client with its own extra parameters, that's a different context (OpenIddictClientEvents.PrepareTokenRequestContext or a validation-side handler) — don't assume one handler covers both directions.

Using the built-in OpenIdConnect handler instead

Not every client uses OpenIddict.Client — plenty just use the built-in Microsoft.AspNetCore.Authentication.OpenIdConnect package against the OpenIddict server, since it's still a standard OIDC endpoint. The equivalent hook there is OpenIdConnectEvents.OnRedirectToIdentityProvider, which fires just before the outgoing request and hands you both the AuthenticationProperties and the OpenIdConnectMessage being built:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "OpenIddict";
})
.AddCookie()
.AddOpenIdConnect("OpenIddict", options =>
{
    options.Authority = "https://auth.contoso.com/";
    options.ClientId = "client_app";
    options.ClientSecret = builder.Configuration["OpenIddict:ClientSecret"];
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.Scope.Add("email");
    options.Scope.Add("profile");
    options.Scope.Add("offline_access");

    options.Events.OnRedirectToIdentityProvider = context =>
    {
        if (context.Properties!.Items.TryGetValue("provider", out var provider) &&
            !string.IsNullOrEmpty(provider))
        {
            context.ProtocolMessage.Parameters["provider"] = provider;
        }

        return Task.CompletedTask;
    };
});

The login endpoint doesn't change at all — it's still challenging with AuthenticationProperties.Items["provider"], just against the "OpenIddict" scheme name instead of OpenIddictClientAspNetCoreDefaults.AuthenticationScheme.

It's really the same idea with different names:

  OpenIddict.Client Built-in OpenIdConnect
Hook PrepareAuthorizationRequestContext OnRedirectToIdentityProvider Reading
Reading the properties context.Transaction.GetProperty context.Properties directly
Outgoing request object OpenIddictRequest OpenIdConnectMessage
Remark: pick one package for the client, not both. They solve the same problem, and mixing them just for this one feature isn't worth the added complexity.

Reading it back on the server

This is the half that was already in the passthrough post's Authorize action — worth restating so the two ends of the wire are next to each other. It's identical no matter which client package sent the request:

var provider = Request.Query["provider"].ToString() switch
{
    "github" => "GitHub",
    "adfs" => "ADFS",
    _ => throw new InvalidOperationException("Unknown provider.")
};

Nothing OpenIddict-specific there — by the time it reaches the server, provider is just a regular query string value.

Tip: if Request.Query["provider"] still comes back empty after wiring up the handler, check the handler actually got registered on the same AddClient() options as the registration you're challenging. It's easy to add it to the wrong builder when there's more than one client registration in play.

That's it!

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

A complex system designed from scratch never works

A few years ago, I worked as an architect on a big mainframe rewrite. I still count it as one of my failures. Not because the technology was wrong, but because I couldn't convince the management team to simplify the approach. Years later, the organization is still struggling to get the new system up and running. I left the project at the time, because I couldn't put my name behind an approach that would take very long and cost a lot of money without a working system to show for it along the way. Gall’s Law That memory keeps coming back to me, because it's a textbook case of Gall's Law playing out in real life. Gall's Law , from John Gall's Systemantics , states it plainly: A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works, and it cannot be patched to make it work. You have to start over with a simple system that works. What does that mean in practice,...