Skip to main content

Passthrough authentication with OpenIddict

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/authorize and /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!

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) ...

    Azure DevOps/ GitHub emoji

    I’m really bad at remembering emoji’s. So here is cheat sheet with all emoji’s that can be used in tools that support the github emoji markdown markup: All credits go to rcaviers who created this list.