First of all, authentication in general is a complex topic. It is critical to understand how the ASP.NET Core authentication process works, especially when integrating third-party authentication providers like Facebook, Google, Microsoft, Twitter (X), etc.

This article describes the basic concepts of authentication without using ASP.NET Identity Framework.

Authentication vs Authorization

  • Authentication: verifies a users identity (who the user is)
  • Authorization: checks the users permissions/roles (what the user can do)

Principal vs Identity

  • Principal represents the user including the identity and roles
  • Identity represents who/what the user is, like a passport, identity card (ID), social security number, driving licence, etc.

ASP.NET Core uses claims based authentication represented by the classes ClaimsPrincipal and ClaimsIdentity.

A ClaimsPrincipal can have multiple identities like mentioned before e.g. 'Passport', 'IdentityCard', 'SocialSecurityNumber', 'DrivingLicence'.

A Claim is a property of a specific identity. e.g. 'DrivingLicence' identity has a property 'IssuedOn' and 'ValidUntil'.

In a real world application, an identity at least contains the 'UserId' claim. With that claim we we can load everything related to the 'UserId' from the database.

After successfull authentication, we can access the current authenticated user (ClaimsPrincipal) in ASP.NET Core with HttpContext.User.

Authentication scheme

An authentication scheme is a name that belongs to an authentication handler and options. A scheme can be named anything.

services
    .AddCookie("ApplicationScheme", options => { }) // CookieAuthenticationHandler
    .AddCookie("ExternalScheme", options => { })    // CookieAuthenticationHandler
    .AddFacebook("Facebook", options =>             // FacebookHandler
    {
        options.SignInScheme = "ExternalScheme";
    });

To automatically authenticate the user on every request:

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = "ApplicationScheme";
});

To manually authenticate the user only when needed:

await HttpContext.AuthenticateAsync("ExternalScheme");

AuthenticationTicket

The AuthenticationTicket class members:

  • AuthenticationScheme responsible for the ticket
  • ClaimsPrincipal with all the claims attached to it
  • AuthenticationProperties with custom key/value pairs (like IsPersistent, RedirectUri, etc.)

The AuthenticationTicket is created:

  • serialized (TicketSerializer) on sign-in (e.g. stored in a cookie)
  • deserialized (TicketSerializer) on authentication in subsequental requests (e.g. restored from the cookie)
  • by third-party authentication by mapping external profile data to Claims

Authentication handler

All authentication handlers implement IAuthenticationHandler and provide the following functionalities.

  • Authenticate - verifies the request and creates the AuthenticationTicket (ClaimsPrincipal) on success
  • Challenge - set response status code to 401 (Unauthorized) and usually redirect to login page
  • Forbid - set response status code to 403 (Forbidden) and usually redirect to access denied page

We can customize the behavior of an authentication handler by listening to their events (CookieAuthenticationEvents, BearerTokenEvents, etc.).

services
    .AddCookie("ApplicationScheme", options =>
    {
        options.Events.OnRedirectToAccessDenied = async redirectContext =>
        {
            await redirectContext.HttpContext.Response.WriteAsync("Sorry, no access to this page.");
        });
    });

Authenticate vs Sign-In

  • 1. Step - Authenticate: verifies and creates the AuthenticationTicket on success
  • 2. Step - Sign-In: creates the user session by serializing the AuthenticationTicket as a token (string) for subsequental requests

ASP.NET Core segregates IAuthenticationHandler and IAuthenticationSignInHandler (extends IAuthenticationSignOutHandler).

  • Third-party authentication providers (RemoteAuthenticationHandler) do NOT support sign-in*
  • CookieAuthenticationHandler support sign-in by writing the token (serialized AuthenticationTicket) to the response cookie-header
  • BearerTokenHandler support sign-in by writing the JSON Web Token (JWT) (serialized AuthenticationTicket) to the response body

*While the RemoteAuthenticationHandler does not support sign-in, it requires a SignInScheme (or DefaultAuthenticateScheme) to delegate the sign-in proccess to another AuthenticationHandler (usually CookieAuthenticationHandler 'ExternalScheme').

Interaction with authentication schemes and handlers

To interact with the diffent authentication schemes in a unified way, we can use the IAuthenticationService which will call (dispatch) the scheme specific IAuthenticationHandler method.

  • Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
  • Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
  • Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
  • Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties)
  • Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)

Let's remember. SignIn/SignOut is not supported by all authentication handlers!

public async Task OnPost(LoginInputModel input)
{
    if (ModelState.IsValid)
    {
        var user = await _applicationDbContext.Users.FirstAsync(x => x.Email == input.Email);

        Claim[] claims = [new Claim("UserId", user.Id.ToString(), ClaimValueTypes.Integer32)];
        var identity = new ClaimsIdentity(claims, "ApplicationScheme");
        var principal = new ClaimsPrincipal(identity);

        await _authenticationService.SignInAsync(HttpContext, "ApplicationScheme", principal, new AuthenticationProperties
        {
            IsPersistent = true,
        });

        HttpContext.Response.Redirect("/MyAccount");
    }
}

We can also use the extension methods for HttpContext (AuthenticationHttpContextExtensions) as a shortcut.

await HttpContext.SignInAsync("ApplicationScheme", principal, new AuthenticationProperties
{
    IsPersistent = true,
});

ASP.NET Core Authentication workflow WITHOUT external provider

  1. User clicks on the Login link
  2. User logs in with email and password
  3. Validate user credentials and sign-in with the provided AuthenticationScheme (usually 'ApplicationScheme' with cookie) and ClaimsPrincipal
  4. Redirect to ReturnUrl
  5. CookieAuthenticationHandler restores 'ApplicationScheme' cookie (AuthenticationTicket)
  6. AuthenticationMiddleware sets HttpContext.User with AuthenticateResult.Ticket.Principal
  7. Done! Middleware pipeline continuous with available HttpContext.User

ASP.NET Core Authentication workflow WITH external provider

  1. User clicks on the Login link
  2. User clicks on 'Login with Facebook' link (e.g. '/Account/ExternalLogin/Facebook')
  3. Endpoint redirects to Facebook with our state (a.o. CallbackUrl, RedirectUrl, ReturnUrl) as a query string
  4. User logs in on the Facebook site
  5. Facebook redirects back to our CallbackUrl (e.g. '/signin-facebook')
  6. FacebookHandler (OAuthHandler) creates ClaimsPrincipal by requesting the Facebook profile API
  7. FacebookHandler sign-in by the provided SignInScheme (usually 'ExternalScheme' cookie)
  8. FacebookHandler redirect to RedirectUrl (e.g. '/Account/ExternalLoginCallback')
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null)
{
    var authenticateResult = await _authenticationService.AuthenticateAsync("ExternalScheme");
    if (!authenticateResult.Succeeded)
    {
        return BadRequest();
    }

    var email = authenticateResult.Ticket.Principal.FindFirst(ClaimsTypes.Email)?.Value;
    if (email == null)
    {
        return BadRequest();
    }

    var user = await _applicationDbContext.Users.FirstOrDefaultAsync(x => x.Email == email);
    if (user == null)
    {
        user = await CreateUserAsync(authenticateResult.Ticket.Principal);
    }

    Claim[] claims = [new Claim("UserId", user.Id.ToString(), ClaimValueTypes.Integer32)];
    var identity = new ClaimsIdentity(claims, "ApplicationScheme");
    var principal = new ClaimsPrincipal(identity);

    // Cleanup external cookie
    await _authenticationService.SignOutAsync(HttpContext, "ExternalScheme");

    // SignIn to our application.
    // By setting DefaultAuthenticateScheme to "ApplicationScheme",
    // the 'external user' now also get authenticated on every request.
    await _authenticationService.SignInAsync(HttpContext, "ApplicationScheme", principal, new AuthenticationProperties
    {
        IsPersistent = true,
    });

    return LocalRedirect(returnUrl ?? "/MyAccount");
}
  • CallbackUrl - 'marker' URL that triggers the FacebookHandler for authentication
  • RedirectUrl - for custom logic after FacebookHandler authenticated successfully
  • ReturnUrl - redirect the user to the protected page where they initially tried to access

Register without a Password

When a user logs in with a trusted external login provider, we don't have a password for that user. While this is great for not remembering another password, though it will make us dependent to the external provider. If the external provider is unavailable, the user won't be able to login to our website. Mechanisms to allow the user to set a new password or provide a login by sending an email with a login-link could solve that.