OIDC Authentication in ASP.NET Core apps explained
14 October 2024In the times that I’ve had to implement Single Sign On authentication for ASP.NET Core apps, I’m always trying to do it as quickly as possible. I usually get it done in a single-minded period filled with anger at documentation and bewilderment around what I’ve configured incorrectly, then eventually I’ll achieve a signed in user (yay). The information online about OpenIDConnect in ASP.NET Core is either:
- 2 lines telling you to call a couple of methods with no context or explanation; or
- A meandering novel filled with technical jargon
This post is here to help you avoid what I’ve been through. It’s here to help you reason about what’s going on when you use OIDC authentication in ASP.NET core.
What is OIDC? #
OIDC, or OpenIDConnect is a protocol for Single Sign On. It allows everyone in your business to have a username and password with the OIDC provider, and they don’t need to remember a separate username and password for your special snowflake app (and the other 40 special snowflake apps they have to use every day at the business).
Some definitions:
- The OIDC provider is a 3rd party - eg. Microsoft Azure, Google, Okta etc
- The OIDC client is your app
OIDC is built on top of another protocol called OAuth. OAuth is also for communication between apps, but it’s for a user to be able to give your app permissions to access something in another app, whereas OIDC is designed to tell your app who the user is. OAuth is about authorization, OIDC is about authentication. You can find more about the difference on Nat Sakimura’s blog.
The OIDC provider tells our app who the user is by sending a post request to our app with url-encoded form data. This data will include an ID token, which is a JWT: a Base64 encoded string of JSON providing information about the user. You can read more on the Microsoft documentation about what the ID token format looks like.
Let’s create a web app that uses OIDC Single Sign On. I’m going to first provide instructions to create the app, then I’ll go deeper into what we’ve done:
Setting up Azure as an OIDC provider #
First set up an OIDC provider. This isn’t the focus of this post but in my last post, I wrote a bit about how to set up azure as your OIDC provider. We basically just follow this handy tutorial from the Microsoft docs. After you’ve set it up, you’ll need your tenant ID, client ID, and client secret.
Scaffolding a web app with OIDC #
Let’s scaffold an ASP.NET core web app. We’ll create an MVC application. To do this, you’ll need the DotNetCore SDK installed. Navigate to a new project folder and run this in the command line:
dotnet new mvc
Then you should be able to start your app:
dotnet run
This will print off a URL where you will be able to see your app in action. You’ll need to update whatever URL you see here with your OIDC provider - with signin-oidc
as the relative path. This is so the provider knows where to redirect back to on sign in. Now let’s install the OpenIDConnect package:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
We'll add a using
statement to the top of our Program.cs
file to import this package. We’re also going to import the cookies authentication namespace (you don’t need to install any more packages to get this):
// Program.cs
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.Cookies;
Then anywhere before the app = builder.Build();
statement in Program.cs
, add the following lines of code to register our authentication. If you’re using Azure Active Directory, this where you add your tenant ID, your client ID and your client secret:
// Program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
// Replace mytenantid with your tenant ID
options.Authority = "https://login.microsoftonline.com/mytenantid/v2.0/";
// Replace my client id with your client ID
options.ClientId = "my client id";
// Replace my client secret with your client secret
// This should not be committed to source and would be better living in an
// appsettings.json file
options.ClientSecret = "my client secret";
});
Now, after the app.UseStaticFiles();
statement, and before app.UseRouting();
, add the following line to add authentication to the middleware pipeline. The position of this method call matters:
// Program.cs
app.UseAuthentication();
Now we’re mostly set up, let’s hide a page from non-authenticated users. Head to your HomeController.cs
file. First we’ll add a using statement at the top to pull in the Authorization
namespace:
using Microsoft.AspNetCore.Authorization;
Now add an [authorize]
attribute above the Index
method:
// HomeController.cs
[Authorize]
public IActionResult Index()
{
return View();
}
When you hit the index page of your application, you should now be redirected to a Single Sign On page from Microsoft. Hooray!
We can update the controller and the index page to show data we’ve received back from Azure about who the user is. Here’s how we change our HomeController’s Index
method:
//HomeController.cs
[Authorize]
public IActionResult Index()
{
ViewData["name"] = HttpContext.User.Claims.First(c=>c.Type=="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name").Value;
return View();
}
And here’s how we change our Index.cshtml
page:
//Index.cshtml
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<h1 class="display-4">Welcome @(ViewData["name"] )</h1>
<p>Learn about <a href="https://learn.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>
Now when you hit your index page and sign in, you should see your name in big letters. We’re going to dive into what is happening under the hood to make this work.
What have we done? #
We haven’t added many new lines to our application, but we’ve gone from an app that has no idea who the useris, to one that integrates with Microsoft to get information about the user and can lock unauthenticated usersout on a per-controller-method basis. That’s a lot of heavy lifting we’re getting from the framework and theOIDC library, so let’s get into how this actually works:
The OpenIDConnect library and ASP.NET Core Cookies Authentication #
One of our first steps was to install the Microsoft.AspNetCore.Authentication.OpenIdConnect
package. This package is doing the heavy lifting to get OIDC working. You can find the code for this package deep within the AspNetCore source code on github. We’re also using Microsoft’s cookie authentication package, which you can find in the AspNetCore github repository. The cookies package comes included in with the ASP.NET core framework so we don’t have to install anything from nuget to use it. What are these libraries doing?
Registering Authentication #
First we registered our authentication schemes by calling AddAuthentication
. Authentication schemes are different sets of logic for authenticating the user. ASP.NET Core uses a string-based registration system for keeping track of authentication schemes, where it stores a dictionary of string-to-AuthetnicationScheme mappings. There’s a good writeup about Authentication Schemes on Matteo Contrini’s blog. In our app above we passed it strings for our authentication schemes by setting properties of the [AuthenticationOptions](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.authenticationoptions?view=aspnetcore-8.0)
class. We set the default scheme to be cookies and our default challenge scheme to be OpenIDConnect.
In AddAuthentication
when we set the schemes, we were actually just passing in strings, so we could change the addAuthentication
lines in our code above to:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "OpenIdConnect";
})
And this would do the same thing as the earlier code we wrote. The issue might be that the two strings "``Cookies``"
and "``OpenIdConnect``"
for the packages default scheme names might change, so it’s better practice to import the default names from the package defaults.
Why are there two schemes? One for cookies and one for OIDC? The reason is that most of the work of handling authentication can be done with cookies authentication, but we want the challenge (the point we ask the user to login with username/email and password) to be done by OpenIDConnect. The OIDC package is a specific type of scheme called a RemoteAuthenticationScheme
.
This type of scheme is for third-party single sign on, but only handles the challenge, and requires another scheme for persisting the user after they’ve logged in. We can keep information about the logged in user with cookie authentication, which will attach a cookie to the user’s browser to store who they are. This cookie API will also handle making sure that the user isn’t tampering with their cookie to pretend to be someone else.
Passing a couple of strings into the app builder isn’t enough to fully register our authentication. We also have to execute a couple of extension methods provided by the libraries. First we run the AddCookie
method, then the AddOpenIdConnect
method on the returned authentication builder (passing OpenIdConnect
the details for our open ID connect setup).
Under the hood these extension methods will set up their configuration, and call methods to register the app logic for their authentication schemes. This is done when the AddScheme and AddRemoteScheme methods get called on the AuthenticationBuilder Class. These methods each register an authentication handler class.
Authentication Handlers #
Authentication handlers are where the actual logic of the authentication happens. You can find a good post about them on joints westlins blog.
Authentication handlers are used to check whether the user is logged in. They need to implement one method: HandleAuthenticateAsync
This method will get called for every request - we triggered it by adding the [authorize]
attribute to our controller. Authentication handlers have access to all the HTTP request information, which is how the OIDC library can access the JWT data sent by our OIDC provider.
When the middleware pipeline gets to a page/endpoint that requires authentication, the endpoint will check if the user is logged in based off the result HandleAuthenticateAsync
returns. This method needs to either return Success
, Failure
or NoResult
based on the HTTP request. What is the difference between failure and no result? If there are multiple types of authentication registered to your app, then passing NoResult
will pass it to another one of the authentication handlers. Failure
will mean that our handler should handle the request, but the check fails. If every authentication scheme returns NoResult
then the user also won’t be able to access the page.
Inside the OpenIDConnectHandler, the HandleRemoteAuthenticateAsync method will listen for POST requests and will check whether they look like an incoming OIDC message. If so, it will parse the JWT, verify that it’s from the OIDC provider, and verify it’s responding to a request our app sent. The OpenIDConnectOptions
class inherits from the RemoteAuthenticateOptions class, which has a property called [SignInScheme](https://github.com/dotnet/aspnetcore/blob/f14f99eb634dc5a3ce8d5e11c7522577e5473e90/src/Security/Authentication/Core/src/RemoteAuthenticationOptions.cs#L112)
. This SignInScheme
is the scheme that will persist the user (it will default to the default authentication scheme). In our case: the Cookies
scheme. This property is what allows our two schemes to connect. After the OpenIDConnectHandler finds the user has successfully logged in, the RemoteAuthenticationHandler parent class will call Context.SignInAsync
, passing in the SignInScheme
to tell the Cookies authentication to store the user.
Forwarding the challenge to the OIDC provider #
We know how the user gets authenticated after the OIDC provider gets back to us, but how does the OIDC provider know we have a user who needs to log in?
To ask for this, we send a Sign-In Request to our authentication provider. To do this, we need to send a 302 redirect
to the user sending them to the provider’s authorization endpoint. In our case with Azure, the authorization endpoint is the https://login.microsoftonline.com/mytenantid/v2.0
URL we specified as the Authority
property in our Program.cs
file. If you’re using another provider, the OIDC spec defines that there should be a metadata file describing what the authorization endpoint is. When we send the request to the authority, we also provide a redirect URL for the provider to send the user back to us, and our client ID in the GET
request parameters (among a few other things).
I say “we” do this, but it’s all handled by the OpenIdConnectHandler
class. AuthenticationHandler
classes have a method you can override called HandleChallengeAsync
, which gets called when there is a 401 Unauthorized
HTTP response code later in the middleware pipeline. Our [Authorize]
attribute on our controller method will trigger this when the user isn’t logged in. The HandleChallengeAsync
method of the OpenIdConnectHandler
will change the response to a 302 Redirect
and send the user to the OIDC provider. The default redirect URL to send the user back to is signin-oidc
.
Middleware pipeline #
We’ve registered what authentication we’re using. However, we need to put our authentication into our middleware pipeline to tell ASP.NET core what stage in the pipeline the authentication should go. In the example above, we registered the middleware after app.UseStaticFiles();
statement, and before app.UseRouting();
.
The order matters. This is because the order of the methods you call in this part of the program.cs
file decide the order that each piece of middleware gets run If we wanted to prevent unauthenticated users from accessing our static files we could move the useAuthenticate
call to before UseStaticFiles
call (we’d also need to make an update to the UseStaticFiles
options):
app.UseAuthentication();
app.UseStaticFiles(
new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
if (!ctx.Context.User.Identity.IsAuthenticated)
{
ctx.Context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
}
}
);
And this would mean only authenticated users would be able to access the static files (CSS, JS, images etc…) in our site.
This also means that any middleware which might have tampered with HTTP requests before they get to the authentication handlers may affect how our authorization handlers respond to the request.
Claims #
Once a user is logged in, we can access their information in controllers and middleware. The user's details are available through the HttpContext.User
object, which is of type ClaimsPrincipal
. This is a class which holds a list of claims. Claims in ASP.NET Core are string key-value pairs that store information about the user. Integrating with Azure, you’ll get back something like this (among other claims):
Claim Name: name
Claim Value: Fred Flinstone
Claim Name: http://schemas.microsoft.com/identity/claims/objectidentifier
Claim Value: A-GUID-Here-asdf
Claim Name: preferred_username
Claim Value: FredFlinstone@gmail.com
Claim Name:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
Claim Value: randomstring
Claim Name:ttp://schemas.microsoft.com/identity/claims/tenantid
Claim Value: A-GUID-Here-asdf
As you can see, you can get the name of a user, and their preferred username.
Matching user with database data #
Often you’ll have a bunch of data associated with your single-sign-on user stored in your application’s database. You don’t want to hit your database every time the user makes a request, so it’d be good to have your important database info in the ClaimsPrincipal
. The ideal way to do this is using the OnTicketReceived
event. This is an event which will fire after the user has successfully logged in. We can add the claims that we want here, which will cache information about the user. Here’s an example of how to do this:
.AddOpenIdConnect(
"oidc",
options =>
{
/*
* Insert Tenant id, client id, ClientSecret code here
*/
options.Events = new OpenIdConnectEvents
{
OnTicketReceived = e =>
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
/* Hit database and grab information about user here */
claimsIdentity.AddClaim(new Claim("myNewClaim", "myClaimValue"));
e.Principal.AddIdentity(claimsIdentity);
return Task.CompletedTask;
}
};
}
);
In the OnTicketReceived
event, you could match the user with the preferred_username
, or email address, or user ID, with a table in your database, then alter their claims based on what you get back.
Wrapping up #
Hopefully this helps you with adding OIDC to your ASP.NET Core apps. We looked at configuration, adding middleware, authentication schemes, user claims, and authentication events. OIDC can save you and your users from the frustration of managing multiple logins, but the amount of magic in ASP.NET Core can mean some of that frustration gets passed to you. Maybe a little less after this guide! At the very least, if you are able to set up OIDC authentication with your app, you’ll thank yourself for not having to deal with the stress of storing passwords and dealing with forgotten passwords. Anything is better than that!
Back to blog