In most enterprise applications authentication is externalized to an external identity provider. For our applications we had to support multiple identity providers. How can we do this without tight coupling to each of these providers?
In this post we'll bring OpenIddict into the picture and show how it helps to solve this nicely.
What is OpenIddict?
OpenIddict is an open-source OAuth2/OpenID Connect server and client stack for .NET. It plugs into ASP.NET Core Identity (or your own user store) and lets you run your own authorization server instead of depending fully on an external one. You get the standard endpoints — /connect/authorize, /connect/token, /connect/userinfo — backed by your own database and your own claims.
That's exactly what makes the passthrough pattern possible: OpenIddict is the piece in the middle that can defer to external providers for the actual authentication, then still be the one issuing the token your APIs trust.
The naive approach
The first instinct is to wire the providers directly into the application. For example: AddGitHub(), AddOpenIdConnect("ADFS", ...), done. It works for one app. It falls apart once you have more than one:
- Every application needs its own client registration with GitHub and with ADFS.
- Every application ends up with its own copy of the provider config, and its own claims-mapping logic — GitHub and ADFS don't agree on a claims schema.
- Adding a new provider, or rotating a secret, means touching every application that logs users in, not just one place.
- Each application validates a token from a different issuer, so there's no single, consistent identity your APIs can rely on.
Multiplied over several applications and providers, that's the same integration duplicated everywhere, instead of built once.
The passthrough approach
OpenIddict has a literal "passthrough" concept: EnableAuthorizationEndpointPassthrough() and EnableTokenEndpointPassthrough(). Normally OpenIddict fully owns the /connect/authorize and /connect/token endpoints. Passthrough mode tells it: validate the request, then hand control back to my own MVC action instead of handling it internally. That's the hook you need to delegate the actual authentication to GitHub or ADFS and mint your own internal token afterwards.
Remark: this is different from a custom grant type. You're not writing a new OAuth grant — you're still doing an authorization code flow, you're just taking over what happens at the authorization step.
Here's the flow end to end:
The important line in that diagram: the client never talks to GitHub or ADFS for a token exchange. It only ever exchanges a code with your OpenIddict server. GitHub and ADFS are consulted once, server-side, to establish who the user is.
This involves three separate projects, and it's easy to lose track of which code goes where:
- The OpenIddict server — a new project you own. Registers GitHub/ADFS, exposes
/connect/authorizeand/connect/token, does the claims transformation. - The ASP.NET Core client — your existing web app. Never sees GitHub or ADFS, only ever talks OIDC to the OpenIddict server.
- The internal API — validates the token the OpenIddict server issued. Never talks to GitHub, ADFS, or the client directly.
Each section below is labeled with the project it belongs to.
Registering the external providers
Both providers sign in against a cookie scheme first. OpenIddict never talks to GitHub or ADFS directly.
builder.Services.AddAuthentication()
.AddCookie(IdentityConstants.ExternalScheme)
.AddGitHub("GitHub", options =>
{
options.ClientId = builder.Configuration["Authentication:GitHub:ClientId"]!;
options.ClientSecret = builder.Configuration["Authentication:GitHub:ClientSecret"]!;
options.SignInScheme = IdentityConstants.ExternalScheme;
options.Scope.Add("user:email");
})
.AddOpenIdConnect("ADFS", options =>
{
options.Authority = "https://adfs.contoso.com/adfs";
options.ClientId = builder.Configuration["Authentication:ADFS:ClientId"]!;
options.SignInScheme = IdentityConstants.ExternalScheme;
options.ResponseType = "code";
options.GetClaimsFromUserInfoEndpoint = true;
});
Turning on passthrough in the OpenIddict server
builder.Services.AddOpenIddict()
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("connect/authorize")
.SetTokenEndpointUris("connect/token");
options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange();
// Ephemeral keys are fine for development
options.AddEphemeralEncryptionKey()
.AddEphemeralSigningKey();
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough();
});
The token endpoint doesn't need custom code — code-for-token exchange is standard, OpenIddict handles it. It's the authorization endpoint where the delegation happens.
The authorization controller
This is where you check if the user already has an external identity, challenge the right provider if not, and — once they do — transform the external claims into your own internal identity.
[HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme);
if (result?.Succeeded != true)
{
var provider = Request.Query["provider"].ToString() switch
{
"github" => "GitHub",
"adfs" => "ADFS",
_ => throw new InvalidOperationException("Unknown provider.")
};
return Challenge(new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.Query.Where(p => p.Key != "provider"))
}, provider);
}
var identity = new ClaimsIdentity(
TokenValidationParameters.DefaultAuthenticationType,
Claims.Name, Claims.Role);
// Only map what you actually trust — don't just copy every external claim over.
identity.SetClaim(Claims.Subject, result.Principal.FindFirstValue(ClaimTypes.NameIdentifier));
identity.SetClaim(Claims.Email, result.Principal.FindFirstValue(ClaimTypes.Email));
identity.SetClaim(Claims.Name, result.Principal.FindFirstValue(ClaimTypes.Name));
identity.SetClaim("idp", result.Principal.Identity?.AuthenticationType);
identity.SetScopes(request.GetScopes());
identity.SetDestinations(GetDestinations);
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
private static IEnumerable<string> GetDestinations(Claim claim)
{
switch (claim.Type)
{
case Claims.Subject:
yield return Destinations.AccessToken;
yield return Destinations.IdentityToken;
break;
case Claims.Name or Claims.Email:
yield return Destinations.AccessToken;
if (claim.Subject!.HasScope(Scopes.Profile))
yield return Destinations.IdentityToken;
break;
default:
yield return Destinations.AccessToken;
break;
}
}
Tip: SetDestinations is easy to forget and easy to get wrong. A claim without a destination silently disappears from every token. If a downstream API is missing a claim you're sure you set, this is the first place to check.
From here, the internal token — not GitHub's, not ADFS's — is what every downstream API validates. One issuer, one claims schema, one place to revoke, regardless of how many external providers you add later.
The client application
The client app doesn't know GitHub or ADFS exist. It only speaks standard OAuth2/OIDC to your OpenIddict server, using the OpenIddict.Client package.
Remark: You can also use the standard OIDC libraries from ASP.NET Core.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictClientAspNetCoreDefaults.AuthenticationScheme;
})
.AddCookie();
builder.Services.AddOpenIddict()
.AddClient(options =>
{
options.AllowAuthorizationCodeFlow();
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableRedirectionEndpointPassthrough();
options.UseSystemNetHttp();
options.AddRegistration(new OpenIddictClientRegistration
{
Issuer = new Uri("https://auth.contoso.com/", UriKind.Absolute),
ClientId = "client_app",
ClientSecret = builder.Configuration["OpenIddict:ClientSecret"],
Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess },
RedirectUri = new Uri("callback/login/local", UriKind.Relative),
PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative)
});
});
Triggering the login just challenges the OpenIddict client scheme, still inside the client app. The provider value is what the server-side Authorize action reads to decide whether to challenge GitHub or ADFS:
app.MapGet("login/{provider}", (string provider) =>
Results.Challenge(new AuthenticationProperties
{
RedirectUri = "/",
Items = { ["provider"] = provider }
}, [OpenIddictClientAspNetCoreDefaults.AuthenticationScheme]));
Remark: Items["provider"] alone won't reach the authorization request as a query parameter — you still need a small OpenIddictClientEvents.PrepareAuthorizationRequestContext handler that copies it from the authentication properties into the outgoing request. Worth its own post, but don't skip it, or the server never learns which provider to challenge.
Once signed in, the internal access token is available like any other cookie-based OIDC token, still in the client app, and can be attached to outgoing calls:
app.MapGet("me", async (HttpContext context) =>
{
var token = await context.GetTokenAsync("access_token");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync("https://api.contoso.com/profile");
return Results.Content(await response.Content.ReadAsStringAsync());
});
The internal API
The API doesn't know about GitHub, ADFS, or the client's login flow. It only validates that the token was issued by your OpenIddict server, using OpenIddict.Validation:
builder.Services.AddOpenIddict()
.AddValidation(options =>
{
options.SetIssuer("https://auth.contoso.com/");
options.AddAudiences("api_app");
options.UseSystemNetHttp();
options.UseAspNetCore();
});
builder.Services.AddAuthorization();
app.MapGet("profile", (ClaimsPrincipal user) => new
{
Subject = user.FindFirstValue(Claims.Subject),
IdentityProvider = user.FindFirstValue("idp")
})
.RequireAuthorization();
That idp claim is the one set back in the authorization controller on the OpenIddict server — a small example of a claim traveling all the way from GitHub or ADFS, through the server's transformation, into something the API can act on.
That's the whole loop: client challenges the OpenIddict client scheme, OpenIddict server delegates to GitHub or ADFS, transforms the result, and hands the client an internal token the API validates without ever knowing where the user actually logged in.
That's it!