Implementing OpenId Connect on Net 7

Miguel Rodriguez Cimino
GlobalLogic LatAm
9 min readJul 18, 2023

--

Authentication and Authorization are critical parts of any software solution that requires access to secure resources and application features. Most of us are familiar with these concepts. Authentication refers to “who am I?” as a user, while authorization refers to “what am I allowed to do?”.

Introduction

We know the drill to implement simple mechanisms to support authentication and authorization on monolithic systems: persist a list of users, passwords, and grants; prompt for login credentials and check each resource access attempt against the user’s grants. Maybe we get fancy and group multiple grants under specific roles. Although there is always room for customization, the whole structure has many commonalities that can be reduced to boilerplate code. Often, adding authentication to a new project is available as an option from the get-go when selecting the project template.

In .Net, we have ASP.NET Core Identity which provides out-of-the-box components to sign in users to our application or an external login provider. But what happens when our solution architecture is more complex? Microservices, distributed applications, multiple clients accessing shared resources, etc. We want the users to sign in once and have them access all the owned resources hosted in different places. We want to be the external login provider for all these applications.

OpenId Connect

OpenId Connect (OIdC) is a standard that adds authentication on top of the OAuth2.0 authorization framework. By requesting the user to authenticate against an Identity Provider (IdP); access tokens, and if requested, ID tokens, are issued to client applications. Tokens are formatted as JWT (JSON Web Tokens) and represent claims securely between two parties. We also have the option of using Reference tokens. Unlike JWTs which are self contained and hold all the information needed, Reference tokens require the client to connect with the IdP to get the user’s claims. On the upside, this allows the token to be revoked at any time, without waiting until the preset expiration time.

OIdC server implementations are available in a variety of shapes and flavors:

  • Self-hosted products, e.g., Ory Hydra, Keycloak
  • Cloud based SaaS solutions, e.g., Okta Auth0, Azure AD/B2C
  • Libraries, e.g., IdentityServer, OpenIdDict

A full list of certified developer tools can be found in the OpenID Foundation site

For our most recent experience implementing an IdP service, we went with IdentityServer a C# framework for OIdC and OAuth2. IdentityServer started as a free, open-source project but as of Oct 2020 development shifted to a dual license model where the current version (now called Duende.IdentityServer) is free for development, testing and personal use, as well as for indviduals or companies under 1M USD annual revenue. A paid license is required otherwise. Yet the latest free version IS4 has all the features we require for a proper IdP service.

Before moving on, let’s take a step back. Access to secured resources can be divided into three components, some of them we have already mentioned in passing.

  • Authentication is verifying the identity of the person requesting access to resources, or the software application who wants to do that on behalf of the user.
  • Identity management is keeping track of those identities assigned to the persons using the system, as well as the resources each one is allowed to access.
  • Authorization is matching the claims the authenticated user has been granted against the access requirements of a particular resource, defined in a policy, and allowing or denying the use of the resource.

IdentityServer

IdentityServer helps us primarily by providing tools to create a centralized login service and issuing tokens that can be then used by client applications to evaluate authorization policies. Same as with OAuth, clients need to adhere to a flow to request tokens, different flows are available and the one to be used will be determined by factors such as the type of token required, if the client can be trusted with secrets or credentials, if the client is a server web app, native, mobile etc. An entire article can be written dedicated exclusively to these flows.

Regardless, we have our starting point; setting the clients. Lets start our Program.cs like this:

var builder = WebApplication.CreateBuilder(args); 
var isBuilder = builder.Services.AddIdentityServer();
isBuilder.AddInMemoryClients(new List<Client>
{
new Client
{
ClientId = "client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes = { "api1" }
}
});

With the AllowedGrantTypes we set the flow the client will need to follow, in this case ClientCredentials means the client has been trusted an id/secret pair which will be used as credentials to exchange for an access token. This token is issued for the client itself, and it can be used to access resources under the “api1” scope. Other flows allow interactive participation of the user entering credentials, so the application can request a token to access resources on behalf of the user.

For now, we need to make sure IdentityServer knows about the “api1” scope:

isBuilder.AddInMemoryApiScopes(new List<ApiScope> 
{
new ApiScope("api1", "My API")
});

We can define API Resources and map them to one or more scopes and design our API surface accordingly.

isBuilder.AddInMemoryApiResources(new List<ApiResource> 
{
new ApiResource("myResource")
{
Scopes = { "api1" },
UserClaims = { "myClaim" }
}
});

Besides API Resources we can also add Identity Resources which represent claims about the user, such as ID, name, email, address, etc.

When requesting tokens on behalf of the user, there is one mandatory scope that maps to an Identity Resource, the openid scope which tells the provider to include the sub (or Subject ID) claim in the token. As with the API Resources, we can define whatever Identity Resource we need:

isBuilder.AddInMemoryIdentityResources(new List<IdentityResource> 
{
new IdentityResource(
name: "openid",
userClaims: new[] { "sub" },
displayName: "Your user identifier")
});

But, being a standard scope, there is a built-in shorthand for it:

isBuilder.AddInMemoryIdentityResources(new List<IdentityResource> 
{
new IdentityResources.OpenId()
});

You may have noticed in the examples above, all these configurations have been added with .AddInMemory* methods and hard-coded values. It’s also possible to read them from aspnet core appsettings.json file, but that would still leave them in memory. If our IdP is expected to support configurations that will change often, such as changing clients, adding scopes, etc.; we can add a configuration store, and use an Entity Framework DatabaseContext implementing the IConfigurationDbContext interface. This way, we can add configurations without needing to restart the IdP.

Finally, because JWTs are signed to prevent tampering, we need to provide IS4 with signing material. For now, we can just add isBuilder.AddDeveloperSigningCredential(); but for production deployments we have several alternatives like X509 certificates (either in file format or read from a system store), RSA keys, etc. For example:

var rsa = RSA.Create();
var rsaKey = new RsaSecurityKey(rsa);
isBuilder.AddSigningCredential(rsaKey, IdentityServerConstants.RsaSigningAlgorithm.RS256)

This will generate a new key and instruct IS4 to use it for JWT signing, but it will create a new key each time the server is started. If we want to be able to verify token signatures between server restarts, the key should be persisted and reused. But this doesn’t means the key should be reused indefinitely, it’s a good practice to rotate keys periodically. For this purpose, when we decide to renew our key we can use the old one with the .AddValidationKey()method; the new key will be used for signing new tokens but the old one can be still used to validate previously issued tokens that haven’t expired yet. Version 6 of Duende.IdentityServer even includes automatic key rotation as a feature, but it can also be manually implemented without much hassle.

With the service properly configured, all we have left is adding the IS4 middleware to the pipeline:

var app = builder.Build(); 
app.UseIdentityServer();
app.Run();

Of the 3 components for securing resources mentioned before, identity management is up to us. We can implement our own store to keep users and their claims in whatever way we see fit, but IS4 allows us to lean on ASP.NET Core Identity mentioned at the start of the article for sign in, password validations, profile service and a long list of features which will save us plenty of work. We just need to add isBuilder.AddAspNetIdentity<IdentityUser>(); and we’re set.

The framework provided IdentityUser class doesn’t come with much, just a username, id; plus, phone and email for validation purposes. But we can inherit from it and extend our model with any personal data we need; name, address, DOB, etc. We can add a custom implementation of the IProfileService interface and add all these properties as claims to be returned when the client invokes the userInfo endpoint.

Speaking of which, let’s talk about the OIdC endpoints. IS4 will automatically publish the endpoints needed for clients to negotiate tokens, such as the authorization and token endpoints. The client will use these depending on the chosen flow. We also have the aforementioned userInfo endpoint, revocation endpoint, etc. A discovery document with all the relevant information is published on/.well-known/openid-configuration

Discovery document
An example of the discovery document

Authenticating clients

We could write our own code to follow the desired flow using HTTP requests back and forth to get our tokens, sorting out headers, requests bodies, responses, etc.; but as expected there are many libraries available for all sort of languages and application types that already implement all the details, so we don’t need to worry about them.

Let’s take for example an aspnet web app. Adding the Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet package we get access to extension methods for the AuthenticationBuilder class. We can configure the client so when an unauthenticated user request access to a secure resource, the application responds with a challenge requiring the user to provide and identification. We delegate the process of evaluating the credentials the user presents for identification to our IdP.

builder.Services.AddAuthentication() 
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, opt =>
{
opt.Authority = "https://[idpHost]";
opt.ClientId = "[clientId]";
opt.ClientSecret = "[secret]";
opt.ResponseType = "code";
opt.Scope.Add("user");
opt.SaveTokens = true;
});

With the ResponseType property we tell the server which flow we intend to use; we identify the client and provide a secret the server has assigned for the client. We also add a list of scopes we are requesting access.

Our IdP will take charge of the log in process and present the user with a page to enter username and password. IdentityServer does not provide an implementation of the log in UI, but there is a QuickStart Quickstart UI open-source repo we can copy and use as example/starting point for our UI implementation. There is a Quickstart UI repo for the most recent, dual license, version of the Duende.IdentityServer library too.

Once authenticated, the requested token will be issued to the client application, and the aspnet authentication pipeline will use this token to build a ClaimsPrincipal object which will be available under the ControllerBase.User property.

We’re only delegating the authentication challenge to the IdP; because we don’t want the users having to enter credentials every time they access a secure resource, we need to remember they have already done so, and we persist this info in a cookie.

builder.Services.AddAuthentication(opt => 
{
opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, …

This cookie will be used to rebuild the ClaimsPrincipal object during subsequent requests. IdentitySever will also register its own cookie, so if the user accesses another application from the same device, it will be recognized as already authenticated and hold the credentials presented during the previous challenge as valid; though if the second application asks for a different scope, and user consent is needed and not previously granted it will be required at this point.

Accessing secure APIs

Once the application has a token issued by the IdP, it can use it not only to validate access authorization for its own resources, but it can also be presented to APIs to validate access to their resources. Every request to the API will need to include the Authorization header with the Bearer schema.

In aspnet, an API can use the Microsoft.AspNetCore.Authentication.JwtBearer NuGet package to add services to the authentication pipeline which will build the ClaimsPrincipal object from the token passed in the Authorization header.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opt =>
{
opt.Authority = "https://[idpHost]";
});

Remember the discovery document /.well-known/openid-configuration mentioned earlier? One of the endpoints listed there (/.well-known/openid-configuration/jwks) provides the public key of the key material used to sign the JWTs. The IdP will use the private key for signing and expose the public key so the middleware of our API can verify the signature and be sure the JWT has not been tampered with. It will do so with the URL provided in the Authority configuration, as shown above. We can specify additional checks and validations when configuring the service options.

Authorizing resources

The final step is adding the authorization policies to the client, so we can allow or deny access to resources to the authenticated user.

builder.Services.AddAuthorization(opt => 
{
opt.AddPolicy("MyPolicy", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "myAllowedScope");
});
});

Individual Actions or whole Controllers can be secured with the [Authorize] attribute or any other mechanism provided by aspnet such as filters. With a ClaimsPrincipal object built from the tokens issued by the IdP, user claims are checked against these policies.

Conclusion

The full code sample can be found here, along with examples of client applications making use of the tokens issued by the IdP.

We have just scratched the surface of what Open ID can do for us. There are many aspects of the security of a software solution which are dependent on the specific needs of the project and the chosen architecture. OpenID is a useful standard and good option when compared to complex alternatives like SAML 2.0.

--

--