OpenId Connect Authentication with .NET
To log in a user according to this guide, you must have configured an application (client) as a web application (server).
To log in a user, you use the OpenID Connect support available in .NET. This guide will show how it’s done in .NET 5, but for the most part, the process is similar regardless of the framework version.
Add the NuGet package for OpenID Connect:
<ItemGroup>
<PackageReference Include="IdentityModel" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.3" />
</ItemGroup>
Here is also support for IdentityModel, which has several useful classes when working with OpenID Connect and claims.
Configure OpenID Connect and Cookies so that the application uses the IdP to identify users (in Startup.ConfigureServices):
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://environment-company.irmciam.se/";
options.ClientId = "Company.ApplicationId";
options.ClientSecret = "supertopsecret";
options.ResponseType = "code";
options.SaveTokens = true;
options.MapInboundClaims = false;
});
In the project template, authentication is not enabled by default, a line must also be added in Startup.Configure (before UseAuthorization):
app.UseAuthentication();
app.UseAuthorization();
To trigger the login, there needs to be one or more protected pages. This can be configured in many different ways and is well-documented by Microsoft. For this example, we make a simple modification to the HomeController by adding the Authorize attribute, which forces the user to log in immediately.
[Authorize]
public class HomeController : Controller
Now you can test the application, and assuming everything is configured in the IdP, you should land directly on the IdP’s login page. After the login is completed, you will be redirected back to the application.
With the above configuration, User.Identity.Name may not display anything. This is because, for compatibility reasons, Microsoft reads the wrong claim, and the code must specify which claim to use instead. Make the following change to the options for the OpenID Connect configuration (which also sets the correct role claim):
...
options.SaveTokens = true;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
NameClaimType = JwtClaimTypes.Name, //JwtClaimTypes.PreferredUserName, eller vad du önskar
RoleClaimType = JwtClaimTypes.Role
};
JwtClaimTypes is part of the IdentityModel package that we added to the project file in the first step above.
To see which claims and other relevant information are available, you can add the following to a Razor view:
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
What you quickly notice on a page displaying the user’s claims is that there are only a few claims compared to those available in the IdP. To get more claims, additional configuration is needed. All this configuration is also done in the AddOpenIdConnect call from above.
To retrieve more claims, you need to specify that more claims should be fetched from the UserInfo endpoint:
options.GetClaimsFromUserInfoEndpoint = true;
In OpenID Connect, the client (the application) requests which scopes it wants. Each scope is associated with one or more claims (this configuration is in the IdP). Common standard scopes include:
- openid (must be requested).
- profile (contains profile information for the user, such as name, picture, web pages, etc.).
- email (contains email and whether the email address is verified).
- phone (contains phone number and whether the number is verified, but this is not used by the IdP today).
- address (contains address information, but is not used by the IdP today).
- org (contains organizational information for users belonging to an organization; this is a custom scope for the IdP).
Microsoft’s default configuration of OpenID Connect automatically adds openid and profile, but if you want to include the user’s email, you need to add that scope to the configuration:
//Begär de scopes (gruppering av claims) som önskas
//options.Scope.Add("openid"); //Default by Microsoft
//options.Scope.Add("profile"); //Default by Microsoft
options.Scope.Add("email");
options.Scope.Add("org"); //IRM Idp organisationsinformation
If you run the application with these settings, you will unfortunately still notice that several claims are missing. This is because Microsoft has a concept called ClaimActions, which means that only the claims for which there is a ClaimAction will be transferred to the ClaimsIdentity during login. Therefore, we need to add more actions:
options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.Picture, JwtClaimTypes.Picture);
options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.EmailVerified, JwtClaimTypes.EmailVerified);
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.TenantId, Constants.JwtClaimTypes.TenantId); //IRM IdP tenant id
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.OrganisationId, Constants.JwtClaimTypes.OrganisationId); // IRM IdP organization id (same as before if the person belongs to the parent company, but may be different)
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.CompanyName, Constants.JwtClaimTypes.CompanyName);
Several of these claims are unique to the IdP, and for this, we have created our own Constants class in the project (also declared in IRM.dll):
public static class Constants
{
public static class JwtClaimTypes
{
public static string TenantId = "tid";
public static string OrganisationId = "orgin";
public static string CompanyName = "companyname";
public static string Permission = "perm";
}
}
With this configuration, you will see many of the claims that the IdP can provide about a user. In IRM.AspNetCpre.Mvc, there is a convenient extension method to add scopes and typical claim actions so that the application can access the claims included in each scope:
options.AddScope(StandardScopes.OpenId); //Default added by Microsoft, but here, a mapping is also done for OwnerId/TenantId
options.AddScope(StandardScopes.Profile); //Default added by Microsoft, but here, mappings are also done for, for example, picture.
options.AddScope(StandardScopes.Email);
options.AddScope(CustomScopes.Roles);
options.AddScope(CustomScopes.Permission);
options.AddScope(CustomScopes.Organisation);
Since we have specified SaveTokens = true above, we can retrieve the access token to make calls to an API:
var accessToken = await HttpContext.GetTokenAsync("access_token");
Note that if you retrieve many scopes and map many claims, it can result in a large number of claims, leading to a large cookie. In general, it’s good to keep the cookie size down, so this is something to consider. In AddCookie, there is an option to configure a SessionStore, allowing you to choose to store the content on the server instead, for example, in a distributed cache.
In IRM.AspNetCore.Mvc, there is a SessionStore that uses MemoryCache and/or DistributedCache if configured. Just call AddCacheSessionStore after AddCookie:
.AddCookie("Cookies", options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.IsEssential = true;
}).AddCacheSessionStore()