OAuth authentication with individual user accounts on ASP.NET Core 2.2

Piero De Tomi
6 min readJan 4, 2023

--

In this article I’ll walk you through the configuration of an ASP.NET Core 2.2 application that supports OAuth authentication with individual user accounts on SQL Server (through EntityFramework Core).

If you’re in a hurry…

… you can go directly to the GitHub repository containing the final project, ready to use.

What you’ll need

Here’s a short list of what you’ll need to follow through this article:

  • Visual Studio: I’m currently using the 2017 Community version, but also Visual Studio 2019 should be fine
  • SQL Server: the Express edition will be fine
  • Postman: we’ll use this tool to test our final configuration. You can use any other tool that allows you to make HTTP requests

Project creation

Create a new ASP.NET Core 2.2 Web Application with the “Individual User Accounts” option selected in the “Change Authentication” menu.

Also choose to “Store user accounts in-app”: we’ll switch to SQL Server in a while.

Switch from “in-app” database to SQL Server

Look at your project structure, locate and open the appsettings.json configuration file and change the connection string named DefaultConnection in order to point to an existing (empty) SQL Server database.

Now you need to apply the schema to your empty database, in order to have the tables required for the authentication to work.

Open the Package Manager Console and execute the Update-Database command: your database should now have the following tables:

Before moving to the next part, let’s create a demo user account and test the authentication.

Launch the application, navigate to the /Identity/Account/Register page and register a new user account: I used demo@domain.com as username and Password_123 as password.

OpenIddict installation

To configure OAuth we’ll use the OpenIddict library.
Open the NuGet Package Manager and install the following packages:

  • OpenIddict — version 2.0.0
  • OpenIddict.EntityFrameworkCore — version 2.0.0

Now we’ll add a simple API controller that you’ll use later to test the authentication.

Sample API Controller

Create a new folder called Controllers and add a new file SampleController.cs to it.

The controller will have the following code:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation;
using System.Collections.Generic;

namespace OAuthWebApp.Controllers
{
[Authorize(AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class SampleController : ControllerBase
{
[HttpGet]
public List<string> Get()
{
return new List<string>
{
"This",
"is",
"a",
"test"
};
}
}
}

Startup configuration

Following the instructions provided in the official repository of OpenIddict, change the content of the Startup.cs file as follows:

using AspNet.Security.OpenIdConnect.Primitives;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OAuthWebApp.Data;

namespace OAuthWebApp
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}

public IConfiguration Configuration { get; }

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection"));

options.UseOpenIddict();
});
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

// Configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
});

services.AddOpenIddict()
.AddCore(options =>
{
// Configure OpenIddict to use the default entities
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>();
})
.AddServer(options =>
{
// Register the ASP.NET Core MVC binder used by OpenIddict.
// Note: if you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
options.UseMvc();

// Enable the token endpoint (required to use the password flow).
options.EnableTokenEndpoint("/auth/token");

// Allow client applications to use the grant_type=password flow.
options.AllowPasswordFlow();

// Accept token requests that don't specify a client_id.
options.AcceptAnonymousClients();
})
.AddValidation();
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseAuthentication();
app.UseMvcWithDefaultRoute();
app.UseWelcomePage();
}
}
}

Token Endpoint implementation

In our configuration we specified the /auth/token path as our token endpoint, but the application doesn’t have this endpoint yet.

Inside the Controllers folder add a new file called AuthController.cs, with the following content:

using AspNet.Security.OpenIdConnect.Extensions;
using AspNet.Security.OpenIdConnect.Primitives;
using AspNet.Security.OpenIdConnect.Server;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.EntityFrameworkCore.Models;
using System.Linq;
using System.Threading.Tasks;

namespace OAuthWebApp.Controllers
{
[Route("[controller]")]
public class AuthController : ControllerBase
{
private readonly OpenIddictApplicationManager<OpenIddictApplication> applicationManager;
private readonly SignInManager<IdentityUser> signInManager;
private readonly UserManager<IdentityUser> userManager;

public AuthController(
OpenIddictApplicationManager<OpenIddictApplication> applicationManager,
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager)
{
this.applicationManager = applicationManager;
this.signInManager = signInManager;
this.userManager = userManager;
}

[HttpPost]
[Route("token")]
public async Task<IActionResult> Token(OpenIdConnectRequest requestModel)
{
if (!requestModel.IsPasswordGrantType())
{
// Return bad request if the request is not for password grant type
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.UnsupportedGrantType,
ErrorDescription = "The specified grant type is not supported."
});
}

var user = await userManager.FindByNameAsync(requestModel.Username);
if (user == null)
{
// Return bad request if the user doesn't exist
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "Invalid username or password"
});
}

// Check that the user can sign in and is not locked out.
// If two-factor authentication is supported, it would also be appropriate to check that 2FA is enabled for the user
if (!await signInManager.CanSignInAsync(user) || (userManager.SupportsUserLockout && await userManager.IsLockedOutAsync(user)))
{
// Return bad request is the user can't sign in
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user cannot sign in."
});
}

if (!await userManager.CheckPasswordAsync(user, requestModel.Password))
{
// Return bad request if the password is invalid
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "Invalid username or password"
});
}

// The user is now validated, so reset lockout counts, if necessary
if (userManager.SupportsUserLockout)
{
await userManager.ResetAccessFailedCountAsync(user);
}

// Create the principal
var principal = await signInManager.CreateUserPrincipalAsync(user);

// Claims will not be associated with specific destinations by default, so we must indicate whether they should
// be included or not in access and identity tokens.
foreach (var claim in principal.Claims)
{
// For this sample, just include all claims in all token types.
// In reality, claims' destinations would probably differ by token type and depending on the scopes requested.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken, OpenIdConnectConstants.Destinations.IdentityToken);
}

// Create a new authentication ticket for the user's principal
var ticket = new AuthenticationTicket(
principal,
new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);

// Include resources and scopes, as appropriate
var scope = new[]
{
OpenIdConnectConstants.Scopes.OpenId,
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIddictConstants.Scopes.Roles
}.Intersect(requestModel.GetScopes());

ticket.SetScopes(scope);

// Sign in the user
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
}
}

Final test

Open Postman and create a new request with the following configuration:

The request body should be configured as follows (using your username and password values):

Execute the request and obtain the access_token:

At this point you can use the access token to setup a request to your /api/sample protected endpoint, specifying the access token as Bearer Token:

If you did everything correctly, you’ll receive the following response:

Conclusion

I created a GitHub repository with the project used in this article, feel free to download the code and use it as a starting point for your application (just remember to have SSL enabled on your project settings, otherwise the code won’t work properly).

I hope this article will help you to get up and running quickly — that’s the goal.

--

--

Piero De Tomi
Piero De Tomi

Written by Piero De Tomi

I’m a software architect based in Trieste, Italy.

No responses yet