OpenID Connect with an External Provider

Sign users in through Entra ID, Auth0 or Keycloak with AddOpenIdConnect, code flow and PKCE, and run a BFF that keeps tokens off the browser.

Syntax.AddCookie() .AddOpenIdConnect(o => { o.Authority = "https://login.example.com/realms/shop"; o.ClientId = "shop-bff"; o.ResponseType = "code"; o.SaveTokens = true; });

OpenID Connect is how a server-side app signs a user in at an identity provider. AddOpenIdConnect (package Microsoft.AspNetCore.Authentication.OpenIdConnect) is a remote scheme: its challenge redirects the browser to the provider, and the provider sends the browser back to /signin-oidc with a code. The handler then exchanges the code for tokens, validates the ID token, and signs the user into a local scheme, almost always the cookie. Resource APIs never use it; they validate access tokens with JWT bearer.

Code flow with PKCE

  1. Challenge: redirect to the authorize endpoint with client_id, redirect_uri, scope, state, nonce and a PKCE code_challenge; correlation and nonce cookies are set.
  2. The user authenticates at the provider (password, MFA, passkey: none of it touches your app).
  3. The provider POSTs code and state to /signin-oidc (response_mode=form_post).
  4. The handler redeems the code with the client secret and the PKCE verifier, validates the ID token, builds the principal and calls SignInAsync on the cookie scheme.

Watch the defaults: ResponseType is id_token, the implicit-style flow, so set ResponseType = "code". UsePkce is already true, and pushed authorization requests are used when the provider advertises them. SaveTokens is false; set it to keep the access and refresh tokens inside the encrypted cookie for later API calls. Scopes default to openid profile; add offline_access for a refresh token and the API's scopes.

Providers

ProviderAuthorityWatch for
Entra IDhttps://login.microsoftonline.com/{tenant-id}/v2.0API scopes look like api://orders-api/orders.read; Microsoft.Identity.Web wraps this setup
Auth0https://{tenant}.auth0.com/Send an audience parameter or the access token is not a JWT for your API
Keycloakhttps://{host}/realms/{realm}Roles sit in realm_access.roles; map them with claims transformation

BFF pattern

A backend-for-frontend runs the OIDC flow for a single-page app: the SPA calls /bff/login, gets an HttpOnly cookie, and calls /bff/... endpoints that attach the saved access token to downstream API calls. Tokens never reach JavaScript, which is the current recommendation for browser apps over storing tokens in the SPA. Keep the cookie as the default challenge scheme and challenge OIDC only in /bff/login: a 302 to the provider breaks fetch with a CORS error, while a 401 lets the SPA navigate to /bff/login.

Production failure modes

  • redirect_uri mismatch behind a proxy: TLS ends at the load balancer, the app sees http and builds http://.../signin-oidc. Configure forwarded headers before authentication.
  • Correlation failed: the correlation cookie is SameSite=None and therefore requires HTTPS; it is also lost when the callback lands on another host name.
  • Logged out at the API but not the app: the cookie outlives the access token. Refresh the token, or cap the cookie lifetime to the token lifetime.
  • Open redirect: validate returnUrl as a local path before putting it in RedirectUri.

Example

Example · csharp
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    // The cookie stays the challenge scheme, so fetch calls to /bff/* get 401, not a 302 to the provider.
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "__Host-shop";
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax; // Strict drops the cookie on the redirect back from the provider
        // Provider access tokens last minutes, not hours: before production, add an OnValidatePrincipal
        // step that checks expires_at and redeems the refresh_token, or rejects the principal.
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = false;
    })
    .AddOpenIdConnect(options =>
    {
        options.Authority = builder.Configuration["Oidc:Authority"];
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.UsePkce = true;
        options.SaveTokens = true;
        options.MapInboundClaims = false;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.Scope.Add("offline_access");
        options.Scope.Add("orders.read");
        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "role";
    });

builder.Services.AddAuthorization();
builder.Services.AddHttpClient("orders-api", client =>
    client.BaseAddress = new Uri(builder.Configuration["OrdersApi:BaseUrl"] ?? "https://orders.internal.example.com/"));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

var bff = app.MapGroup("/bff").DisableCookieRedirect();

// The only place the provider is challenged; the SPA navigates here after a 401 from /bff/*.
bff.MapGet("/login", (string? returnUrl) =>
    TypedResults.Challenge(new AuthenticationProperties { RedirectUri = LocalPathOrRoot(returnUrl) },
        [OpenIdConnectDefaults.AuthenticationScheme]));

bff.MapPost("/logout", () =>
        TypedResults.SignOut(new AuthenticationProperties { RedirectUri = "/" },
            [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]))
    .RequireAuthorization();

bff.MapGet("/user", (ClaimsPrincipal user) =>
        TypedResults.Ok(new { name = user.Identity?.Name, email = user.FindFirstValue("email") }))
    .RequireAuthorization();

// The SPA calls the BFF; the BFF calls the API with the user's access token.
bff.MapGet("/orders", async (HttpContext context, IHttpClientFactory clients, CancellationToken cancellationToken) =>
{
    var accessToken = await context.GetTokenAsync("access_token");
    if (accessToken is null) return Results.Unauthorized();

    var client = clients.CreateClient("orders-api");
    using var request = new HttpRequestMessage(HttpMethod.Get, "orders/mine");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    using var response = await client.SendAsync(request, cancellationToken);
    return Results.Content(await response.Content.ReadAsStringAsync(cancellationToken), "application/json",
        statusCode: (int)response.StatusCode);
}).RequireAuthorization();

app.Run();

static string LocalPathOrRoot(string? returnUrl) =>
    returnUrl is { Length: > 0 } && returnUrl[0] == '/' && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\")
        ? returnUrl
        : "/";

When to use it

  • Let shop staff sign in to the back-office BFF with their company Entra ID accounts, including the company's MFA rules.
  • Run a BFF for a React storefront that signs customers in through Keycloak and calls orders-api with their access token.
  • Fix a login loop where Keycloak rejects redirect_uri=http://shop.example.com/signin-oidc behind the load balancer.

More examples

Keycloak settings with the secret kept out of the file

The client secret identifies the BFF to the provider when it redeems the code, so it is a real credential: inject it from Key Vault, AWS Secrets Manager or a Kubernetes Secret, and rotate it on the provider side with an overlap. In Keycloak, register exactly <code>https://shop.example.com/signin-oidc</code> and <code>https://shop.example.com/signout-callback-oidc</code> as redirect URIs; wildcard redirect URIs are how authorization codes get stolen.

Example · json
{
  "Oidc": {
    "Authority": "https://id.example.com/realms/shop",
    "ClientId": "shop-bff",
    // Supplied by the secret store as the Oidc__ClientSecret environment variable.
    "ClientSecret": "<from-secret-store>"
  },
  "OrdersApi": {
    "BaseUrl": "https://orders.internal.example.com/"
  },
  "ForwardedHeaders": {
    "KnownProxyCidr": "10.0.0.0/8"
  }
}

Pitfall: http redirect_uri behind a load balancer

The provider compares <code>redirect_uri</code> character for character with the registered value. Behind a TLS-terminating proxy the app sees <code>http</code> and the internal host, the provider answers with an invalid redirect URI error, and nobody can log in. Trust forwarded headers only from the proxy's network; accepting them from anywhere lets a client spoof its scheme, host and IP. The forwarded headers lesson covers <code>KnownIPNetworks</code> and <code>KnownProxies</code> in detail.

Example · csharp
using System.Net;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8")); // the load balancer's subnet
    options.AllowedHosts = ["shop.example.com"];
});

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect(options =>
    {
        options.Authority = builder.Configuration["Oidc:Authority"];
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = "code";
    });

var app = builder.Build();

// Must run before authentication, which builds redirect_uri from Request.Scheme and Request.Host.
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", () => "Shop").RequireAuthorization();
app.Run();

Auth0: asking for an access token for your API

Without the <code>audience</code> parameter Auth0 returns an access token meant only for its own userinfo endpoint, and every call from the BFF to your API fails JWT bearer validation with an invalid audience or signature. <code>OnRedirectToIdentityProvider</code> is the hook for any provider-specific authorize parameter, such as <code>prompt=login</code> for step-up or <code>acr_values</code> to demand MFA.

Example · csharp
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect(options =>
    {
        options.Authority = "https://shop-prod.eu.auth0.com/";
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = "code";
        options.SaveTokens = true;
        options.Scope.Add("orders.read");

        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = context =>
            {
                // Auth0 issues a JWT access token for an API only when the audience is requested.
                context.ProtocolMessage.SetParameter("audience", "https://api.shop.example.com");
                return Task.CompletedTask;
            },
        };
    });

var app = builder.Build();
app.MapGet("/", () => "Shop").RequireAuthorization();
app.Run();

Discussion

  • Be the first to comment on this lesson.