OAuth 2.0 and OpenID Connect

Signing in with an external provider: the authorisation-code flow with PKCE, the exact redirect URI, and mapping claims to a local user.

Syntaxbuilder.Services.AddAuthentication(...).AddCookie().AddOpenIdConnect(options => { options.Authority = ...; options.ClientId = ...; })

Two specifications, one flow

OAuth 2.0 is about permission. It lets your application obtain a token to call an API on someone's behalf. OpenID Connect is a thin layer on top that also tells you who the person is, in an ID token. "Sign in with Google", or with a company directory, is OpenID Connect.

The authorisation-code flow with PKCE

Your application sends the browser to the provider with a code challenge: a hash of a random secret it just generated. The person signs in there, never on your site, so you never see their password. The provider sends the browser back to your redirect URI carrying a short-lived code. Your server then exchanges that code for tokens, presenting the original secret. A stolen code is useless without it, which is what PKCE is for. UsePkce defaults to true in .NET 10.

The redirect URI

The provider will only send the browser to an address you registered, matched exactly: scheme, host, port and path. CallbackPath defaults to /signin-oidc, so a local app registers https://localhost:5001/signin-oidc. Requesting a protected page in the program below produces a 302 to the provider with response_type=code, code_challenge and state in the query string.

Mapping to a local user

The ID token describes the person at the provider, not a row in your database. Key your own user on the provider's subject id, which is stable, rather than the email address, which can change. The OnTicketReceived event is where you find or create that row and add your own claims. AddOpenIdConnect() with no name is shorthand for AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme).

Example

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

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
    // The cookie keeps the session; OpenID Connect is used to start it.
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
    .AddCookie()
    .AddOpenIdConnect(options =>
    {
        options.Authority = "https://login.example.com";
        options.ClientId = "orders-web";
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]
            ?? throw new InvalidOperationException("Oidc:ClientSecret is not configured.");
        options.ResponseType = OpenIdConnectResponseType.Code; // code flow, not implicit
        options.CallbackPath = "/signin-oidc";                 // the redirect URI path
        options.Scope.Add("email");   // "openid" and "profile" are added for you
        options.GetClaimsFromUserInfoEndpoint = true;
    });
builder.Services.AddAuthorization();

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

app.MapGet("/me", (ClaimsPrincipal user) =>
    user.FindFirst(ClaimTypes.Email)?.Value ?? "(no email claim)").RequireAuthorization();

app.Run();

When to use it

  • Add "sign in with Google" or a corporate directory to an application without storing passwords.
  • Match an external identity to a row in your own database on first sign-in.
  • Diagnose a provider error about an invalid or mismatched redirect URI.
  • Make sign-out end the provider's session as well as the local cookie.

More examples

Turning an external identity into a local user

The join between the provider's view of a person and your database. Running it here rather than in each endpoint means the local id is already in the cookie, so no request has to look it up again. First sign-in and tenth sign-in take the same path.

Example · csharp
using System.Security.Claims;

// Inside AddOpenIdConnect(options => { ... })
// OnTicketReceived runs after the tokens are validated and before the
// cookie is written, so claims added here end up in the session.
options.Events.OnTicketReceived = async context =>
{
    if (context.Principal?.Identity is not ClaimsIdentity identity)
    {
        return;
    }

    // The provider's subject id is stable. The email address is not:
    // people change it, and keying on it merges or loses accounts.
    string? subject = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    if (subject is null)
    {
        return;
    }

    var users = context.HttpContext.RequestServices.GetRequiredService<UserStore>();
    string localId = await users.FindOrCreateAsync(
        provider: "login.example.com", subject: subject);

    // Everything downstream reads this claim instead of the provider's id.
    identity.AddClaim(new Claim("local_user_id", localId));
};

One named provider instead of a generic one

Shorter than wiring OpenID Connect by hand when the provider is one the framework already knows. Results.Challenge is what starts any external sign-in: it returns the 302 to the provider rather than an error, which is the same mechanism [Authorize] triggers automatically.

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

// Program.cs (excerpt). Google's handler is OAuth 2.0 based and comes
// pre-configured, so there is no Authority to set.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie()
    .AddGoogle(options =>
    {
        options.ClientId = builder.Configuration["Google:ClientId"]
            ?? throw new InvalidOperationException("Google:ClientId is not configured.");
        options.ClientSecret = builder.Configuration["Google:ClientSecret"]
            ?? throw new InvalidOperationException("Google:ClientSecret is not configured.");

        // The default, shown so it can be matched against the registered
        // redirect URI in the Google console.
        options.CallbackPath = "/signin-google";
    });

// Starting the flow: challenge the Google scheme and come back to /.
app.MapGet("/login/google", () => Results.Challenge(
    new AuthenticationProperties { RedirectUri = "/" },
    ["Google"]));

Signing out of both places

The bug report that follows every first OpenID Connect integration: "sign out does not work". It did work locally; the provider simply still considers the person signed in. SignedOutCallbackPath defaults to /signout-callback-oidc and has to be registered with the provider like any other redirect.

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

// Program.cs (excerpt). Deleting the local cookie ends your session but
// leaves the provider's session open, so clicking sign-in again walks
// straight back in with no prompt. Sign out of both.
app.MapPost("/logout", async (HttpContext http) =>
{
    await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

    // This one returns a redirect to the provider's end-session endpoint,
    // which then returns the browser to SignedOutCallbackPath.
    await http.SignOutAsync(
        OpenIdConnectDefaults.AuthenticationScheme,
        new AuthenticationProperties { RedirectUri = "/" });
});

Discussion

  • Be the first to comment on this lesson.