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.Request — OpenIddictRequest 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 |
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!