- 09 May 2024
- 43 Minutes to read
- Print
Getting Started
- Updated on 09 May 2024
- 43 Minutes to read
- Print
Introduction
The need for strong authentication methods is growing as technology advances. Creating these solutions from scratch is a complex task.
It requires a lot of knowledge and resources to meet various requirements and standards.
This guide offers you a different way - building an OpenID Connect Provider using ASP.NET MVC and the Abblix OIDC Server solution.
What We're Going to Do
We're setting out to build a secure and scalable OpenID Connect provider using ASP.NET MVC and the Abblix solution. Here's a quick look at our plan:
- Dive Into Application Roles: First, we'll explore the key players-the OpenID Connect Provider and the Client Application. Understanding their roles and interactions is essential for the rest of the process.
- Project Setup: We'll begin by properly setting up two ASP.NET MVC projects. We'll choose our tools and settings carefully to ensure a smooth development process.
- OpenID Provider Configuration: Here, we start the main work. We'll set up the OpenID Connect Provider application, add important services to the Dependency Injection (DI) container, and explain why we chose these elements. Our goal is to create a strong foundation for our authentication system.
- Client Application Setup: The TestClient application also requires our attention. We'll configure it to work well with the OpenID Connect provider, ensuring that user authentication is smooth and secure.
- Testing Our Setup: Development isn't complete without testing. We'll test different scenarios to make sure everything works perfectly and is completely reliable.
- Reflection and Future Paths: We'll conclude by reviewing what we've built and considering future improvements. The field of authentication is continuously evolving, and so will our project.
By the end of this guide, you'll fully understand OpenID Connect and have a functioning implementation using ASP.NET MVC and Abblix OIDC Server. Let's commence this development process together.
Introduction to the Applications and Their Roles
This guide will assist you in setting up an OpenID Connect provider using ASP.NET MVC and the Abblix OIDC Server. We will develop two specific applications: OpenIDProviderApp
and TestClientApp
.
OpenIDProviderApp
serves as the OpenID Connect provider. Its key responsibilities include authenticating users, managing their sessions, and issuing tokens according to the OpenID Connect protocol. It validates client requests and provides both access and refresh tokens that authorize user resource access, as well as ID tokens that verify user identity. The application employs the Abblix OIDC Server solution to function effectively as an OpenID Connect protocol server.TestClientApp
acts as the Relying Party, a client that relies onOpenIDProviderApp
for user authentication. It demonstrates how a client application interacts with an OpenID Connect provider to authenticate users, obtain tokens, and access protected resources. This scenario offers practical insight into how OpenID Connect authentication can be integrated into client applications. For its operations,TestClientApp
utilizesMicrosoft.AspNetCore.Authentication.OpenIdConnect
to act as an OpenID Connect client.
This introduction sets the stage for an in-depth exploration of how to configure both applications to establish a fully operational OpenID Connect authentication flow.
Create New ASP.NET MVC Projects
Here's how to create two new ASP.NET MVC projects and add them to a solution using the .NET CLI (Command Line Interface):
Open your command prompt or terminal.
Create a new solution:
dotnet new sln -n GettingStarted
Create the first project,
OpenIDProviderApp
:dotnet new mvc -n OpenIDProviderApp
Create the second project,
TestClientApp
:dotnet new mvc -n TestClientApp
Add both projects to the solution:
dotnet sln GettingStarted.sln add ./OpenIDProviderApp/OpenIDProviderApp.csproj dotnet sln GettingStarted.sln add ./TestClientApp/TestClientApp.csproj
These commands will set up two ASP.NET MVC projects and organize them within a single solution, making it easier to manage the projects as you develop your OpenID Connect provider and client application.
Change Default Port Numbers
Setting specific port numbers for your applications ensures predictable and stable interaction between them. This is particularly important when your applications need to communicate securely, such as in the case of an OpenID Connect provider and a client application. Here's how you can set specific port numbers using the .NET CLI and editing configuration files directly, instead of using Visual Studio UI.
Reasons to Change Default Ports:
- Predictability: Fixed port numbers remove uncertainty around application URLs, making it easier for developers to remember and use them.
- Avoid Conflicts: Designating port numbers avoids conflicts with other applications on the same machine.
- Simplify Configuration: Consistent port numbers simplify the configuration of both applications, especially for network-related settings like callback URLs and CORS policies.
- Deployment Readiness: Setting port numbers prepares for production, where specific ports might be needed.
Steps to Set Port Numbers
Modify the launchSettings.json
files for each project. This file is typically found in the Properties
folder of each ASP.NET Core project. If it doesn't exist, you might need to create it.
For OpenIDProviderApp:
{ "profiles": { "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } }
For TestClientApp:
{ "profiles": { "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:5002", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } }
Checking Port Availability
Before running your applications, you can check if the designated ports (5001 for OpenIDProviderApp
and 5002 for TestClientApp
) are available.
You can use tools specific to your operating system to check port usage:
- Windows Command Prompt: Use
netstat -an | find "5001"
andnetstat -an | find "5002"
to see if those ports are already in use. - Linux/Mac Terminal: Use
sudo lsof -i :5001
andsudo lsof -i :5002
to check for port usage.
These commands help verify that the specified ports are not occupied, ensuring that your applications run without interference and facilitating a smoother development and testing process.
Configuring OpenIDProviderApp
Now, let's set up the OpenIDProviderApp. This is an important part because it helps us create the main functions of an OpenID Connect Provider.
These functions are essential for checking user identities, managing user sessions, and securely giving out tokens as required by the OpenID Connect protocol.
By setting up these features correctly, our application will be a trusted source for other applications that need user authentication and authorization services.
Add Abblix.Oidc.Server.Mvc NuGet Package
To add the necessary NuGet Package using the command line, follow these steps:
- Open your command prompt or terminal.
- Navigate to your project directory. Use the
cd
command to change to the directory where your project is located. - Run the following command to install the Abblix.Oidc.Server.Mvc package:
dotnet add package Abblix.Oidc.Server.Mvc
This command will download and install the latest available version of the Abblix.Oidc.Server.Mvc NuGet package into your project, making it ready for further configuration.
Register Abblix Services into Dependency Injection
File: Program.cs
Here's how to integrate Abblix services into your ASP.NET Core application.
At the beginning of the file, include
using
directives for the necessary namespaces:using Abblix.Jwt; using Abblix.Oidc.Server.Common.Constants; using Abblix.Oidc.Server.Features.ClientInformation; using Abblix.Oidc.Server.Mvc;
These directives make the extension methods and types from the Abblix OIDC Server accessible within your application.
This setup ensures that you can fully utilize the functionalities provided by the Abblix OIDC framework throughout your application.After creating the
builder
instance but before building the app, add the registration for the Abblix services. Configure the options as needed for your application environment.
Here is how you can insert the necessary configurations:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options => {
options.Clients = new[] {
new ClientInfo("test_client") {
ClientSecrets = new[] {
new ClientSecret {
Sha512Hash = SHA512.HashData(Encoding.ASCII.GetBytes("secret")),
}
},
AllowedGrantTypes = new[] { GrantTypes.AuthorizationCode },
ClientType = ClientType.Confidential,
OfflineAccessAllowed = false,
TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
PkceRequired = true,
RedirectUris = new[] { new Uri("https://localhost:5002/signin-oidc", UriKind.Absolute) },
PostLogoutRedirectUris = new[] { new Uri("https://localhost:5002/signout-callback-oidc", UriKind.Absolute) },
}
};
options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);
options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
});
var app = builder.Build();
This setup provides a starting point for using the Abblix OIDC Server in your application. The options
configured in the AddOidcServices
method are important for setting up the OIDC server's behavior to meet your specific needs.
For example, the code snippet sets up a client with a hashed secret and specifies the grant types and client type. Later we will explore more detailed and customized configuration options,
focusing on adapting the OpenID server to different operational requirements.
The basic configuration above enables your application can effectively interact with the client application requiring authentication and authorization services through the OpenID Connect protocol.
Understanding AddOidcServices
Configuration
Configuration Method:
builder.Services.AddOidcServices(options => { ... });
- This line adds and configures the services necessary for the Abblix OIDC Server to operate within your application.
- The
AddOidcServices
extension method takes a lambda expression where you can specify various options to tailor the OIDC server's behavior.
Configuring Clients:
options.Clients = new []
{
new ClientInfo("test_client") { ... },
};
- This section defines an array of
ClientInfo
objects, each representing a client application that will interact with your OIDC server. - Each client is identified by a unique ID (
"test_client"
in this example), which is used by the server to recognize and handle requests from that client.
Setting Client Secrets:
A client secret acts as a password for the client application to authenticate itself to the authorization server.
When a client application requests access tokens or refresh tokens, it must prove its identity by presenting the client secret alongside the request.
This guarantees that only registered and verified clients can request tokens and access user information.
ClientSecrets = new []
{
new ClientSecret
{
Sha512Hash = SHA512.HashData(Encoding.ASCII.GetBytes("secret")),
},
},
Security Notes for Production Environments:
- Replace the placeholder "secret" with a strong and unique secret for each client application.
- Store client secrets securely by avoiding plain text; instead, store their hashes. Abblix configuration is designed to prevent storing raw passwords, enhancing security awareness and ensuring compliance with industry standards.
- Always ensure that secrets are stored securely using methods such as environment variables or dedicated secure vault solutions to manage sensitive information safely and reduce the risk of leakage.
Allowed Grant Types:
AllowedGrantTypes = new []{ GrantTypes.AuthorizationCode },
This code specifies the grant types the client can use. Here, the client is configured to use the Authorization Code Flow.
The Authorization Code flow is a secure grant type ideal for clients that can maintain a client secret between themselves and the authorization server (typically server-side applications).
This flow is more secure than others, such as the Implicit flow, because the tokens are not exposed to the user or stored in potentially less secure places like the browser.
Also it supports refresh tokens, which are essential for applications that require prolonged access to resources on the user's behalf without needing re-authentication.
How It Works:
- Initially, the user authenticates with the authorization server and grants the application permission to access their information.
- The authorization server does not directly issue tokens to the client. Instead, it issues an authorization code which is passed through the user's browser.
- The client application exchanges this authorization code for an access token, and optionally, a refresh token, using its client secret.
- The access token allows the application to request resources from the resource server and obtain information about the user.
Client Type:
ClientType = ClientType.Confidential,
- Indicates whether the client is considered
Public
orConfidential
. Public clients (such as mobile or desktop applications) cannot securely store credentials, affecting how they authenticate with the server.
Offline Access, Token Endpoint Auth and Proof Key for Code Exchange:
OfflineAccessAllowed = false,
TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
PkceRequired = true,
OfflineAccessAllowed
allows the client to request refresh tokens, enabling access to resources even when the user is not actively using the application. Use this option carefully and only if required.TokenEndpointAuthMethod
determines how the client application authenticates itself at the token endpoint. SettingClientAuthenticationMethods.ClientSecretPost
specifies that the client application includes theclient_id
andclient_secret
in the body of a POST request to the token endpoint.PkceRequired
specifies whether Proof Key for Code Exchange (PKCE) is required. PKCE enhances the security of the Authorization Code flow.
Setting this to false
might be suitable for trusted or internal clients but is strongly recommended to be true
for public clients.
Redirect URIs:
RedirectUris = new [] { new Uri("https://localhost:5002/signin-oidc", UriKind.Absolute) },
PostLogoutRedirectUris = new []{ new Uri("https://localhost:5002/signout-callback-oidc", UriKind.Absolute) },
RedirectUris
are the URLs to which the OIDC server can send responses (tokens or authorization codes) after authenticating the user.PostLogoutRedirectUris
define where the user is redirected after logging out. These must be pre-registered to prevent redirection attacks.
By specifying which URLs are allowed to receive tokens and codes, you protect the application from redirection attacks.
In such attacks, an unauthorized party could redirect a user to a malicious site instead of the intended destination after authentication or logout.
Pre-registering URIs ensures that the OIDC server only sends sensitive information to trusted locations.
Additional Configuration Options:
LoginUri:
options.LoginUri = new Uri("/Auth/Login", UriKind.Relative);
LoginUri
specifies the URL to which users are redirected to log in. This URI is relative, meaning it is appended to the base URI of your server. It is used primarily in scenarios where the user needs to authenticate before proceeding, ensuring the login process is centralized and managed consistently across the application.
SigningKeys Configuration:
options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
SigningKeys
includes cryptographic keys used for signing the tokens issued by your OIDC server. Specifically, theJsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig)
function generates a new RSA key pair that is intended for signing. This key pair consists of both public and private components:- Public Key: Used by clients to verify the authenticity of the signed token.
- Private Key: Held securely by the server to sign the tokens.
Using RSA keys for signing is essential because it ensures that the tokens are not only issued by your server but are also secure from alterations. This method is part of a robust security strategy to prevent token forgery and to verify token integrity transparently during the authentication process.
Authentication with Cookies
File: Program.cs
After configuring the Abblix services, it's necessary to set up the application's authentication mechanism.
Add the following code to the registration of services:
builder.Services
.AddAuthentication()
.AddCookie();
Explanation
AddAuthentication Method:
.AddAuthentication()
- This call to
AddAuthentication()
initializes the authentication services in your application, marking the foundational step for configuring user identification.
Without this setup, the application lacks the mechanism to manage user sign-ins or to maintain user session states effectively.
AddCookie Method:
.AddCookie();
- Following
AddAuthentication()
, the.AddCookie()
method specifies that your application will utilize cookie-based authentication for session management.
This approach is widely adopted in web applications for its effectiveness in tracking authenticated user sessions.
Upon a user's login to your OpenID Connect provider, the system generates a session cookie and dispatches it to the user's browser.
This cookie accompanies every subsequent server request, enabling the server to recognize the user and persist their logged-in status across the application's OIDC flows.
Such a mechanism is vital for seamless operation, as it allows the authorization endpoint to proceed with request handling based on the user's authenticated state, eliminating the need for repeated login actions.
Moreover, cookie-based authentication serves as the foundational technology for enabling Single Sign-On (SSO) capabilities.
SSO permits a user to authenticate once and access multiple applications without the need for repeated sign-ins.
Achieved through the OIDC server's recognition of the session cookie, SSO ensures that once a user is authenticated, they can seamlessly obtain access tokens for other applications.
This functionality significantly enhances the user experience by minimizing login prompts and facilitating smooth navigation across a suite of services.
Managing State Across Requests
For an OpenID Provider to function effectively, it must retain certain information between requests.
This includes authorization codes following successful authentication, authorization requests for Pushed Authorization Requests (PAR), and JWT statuses to manage their revocation.
The storage for these items needs to be persistent (retaining data even after an application restart) and durable, and often distributed if using multiple hosts for load balancing and high availability - a common practice today.
Using IDistributedCache for Persistent Storage
Abblix solutions relies on the IDistributedCache
interface, a standard in Microsoft environments, to store these entities.
The IDistributedCache
interface provides a framework for implementing distributed cache solutions, making it easier to maintain state across different servers and instances in a scalable manner.
This setup ensures that even in environments with high traffic and multiple servers, your application can efficiently retrieve and store critical data needed for processing OpenID Connect requests.
Setting Up a Distributed Cache
To set up a distributed cache, you can opt for technologies like Redis, NCache, SQL Server, Memcached, Couchbase, and others that support scalable and robust caching mechanisms.
These systems are well-suited for applications requiring high availability and swift data access across multiple servers.
Most modern caching solutions offer NuGet packages that implement IDistributedCache
, simplifying integration into your ASP.NET projects.
For example:
- Redis: By adding the
Microsoft.Extensions.Caching.StackExchangeRedis
package, you can easily configure Redis as a distributed cache. - NCache: Another option is NCache, which also provides a NuGet package (
Alachisoft.NCache.OpenSource.SDK
) that ties intoIDistributedCache
. - SQL Server: For applications already using SQL Server, you can integrate it as a distributed cache using the
Microsoft.Extensions.Caching.SqlServer
package. - Memcached: For those preferring Memcached, the
EnyimMemcachedCore
package can be used to integrate Memcached withIDistributedCache
. - Couchbase: Couchbase can be configured as a distributed cache by using the
Couchbase.Extensions.Caching
package which also implements theIDistributedCache
interface.
Each of these options has specific strengths, such as Redis's support for data persistence and complex data types, NCache's scalability features, SQL Server's convenience for .NET applications deeply integrated with Microsoft technologies, Memcached's simplicity and efficiency for smaller workloads, and Couchbase's rich querying capabilities and flexibility.
Additionally, there are many other products and technologies not mentioned here that you might consider based on your specific requirements. Choose the one that best aligns with the needs and existing infrastructure of your application.
Using MemoryCache for the simplicity
Just to keep the simplicity of our test sample, we'll use the built-in MemoryCache
implementation of IDistributedCache
.
This method enables quick setup and is suitable for development environments or scenarios where distributed architecture is not required and the service runs on the single host.
Add the following code to your project setup:
builder.Services.AddDistributedMemoryCache();
Important Note for Production
It's important to understand that MemoryCache
, while easy to set up, should not be used in real-world production systems.
The primary reason is that MemoryCache
is not truly distributed and does not share data across multiple instances or servers.
This leads to inconsistencies and issues in environments where high availability and resilience are required.
In upcoming articles, we will delve deeper into how to create your own persistent storages and define custom policies for managing them, giving you the tools to build more tailored and robust storage solutions.
For now, we focus on simplicity to help you get up and running quickly.
Using Cross-Origin Resource Sharing (CORS)
CORS is a security feature implemented in web browsers that allows or restricts web pages from making requests to a different domain than the one that served the web page.
Add the following code to your project setup:
app.UseRouting();
// Add 'UseCors' after 'UseRouting'
app.UseCors();
app.UseCors()
enables Cross-Origin Resource Sharing (CORS), allowing the application to accept requests from different origins. This is crucial whenPkceRequired = true
because the authorization code flow with PKCE involves multiple cross-origin requests between the client and the OpenID provider.
Implementation and Testing
For initial development and testing, using an in-memory cache to store authorization codes, tokens, and consent decisions may be enough.
This approach allows for rapid development and easy testing without the complexity of integrating with external storage systems.
However, for a production-ready solution, you should transition to a more robust and persistent storage solution that supports your application's scalability and security requirements.
Whether it's a relational database, a NoSQL database, or a key-value store like Redis, the choice should be based on thorough analysis and consultation with your architectural team.
Implementing these core services tailored to the specific needs of your application ensures flexibility, scalability, and compliance with the OpenID Connect protocol.
The Abblix OIDC Server empowers developers to design these implementations according to the application's architecture and operational requirements, providing the groundwork for a secure,
efficient, and scalable OIDC provider.
Creating a Login Page and Integrating It into the Authorization Flow
When a user attempts to access a protected resource and is redirected to the OpenID Provider's authorization endpoint, it signifies the start of the authorization flow.
The initial task for the OpenID Connect provider is to determine if the user is already authenticated. If not, the server must pause the authorization flow temporarily to handle the incoming authorization request:
- The server temporarily stores the details of the authorization request. This is essential as it allows the user to be redirected to a login UI without losing the context of the original request.
- The server then presents the user with an authentication interface, typically a login page, where they can enter their credentials (username and password).
How Abblix OIDC Server uses the Pushed Authorization Request (PAR)
In general, the PAR mechanism allows an authorization request to be sent to the OpenID Connect provider in advance of the user interaction at the authorization endpoint.
The server securely stores this request and issues a unique ID for it. This ID, known as the request_uri
, represents the stored request.
The Abblix OIDC Server employs the PAR internally to pause and recover the authentication process later:
- Storing Requests using PAR: Abblix OIDC Server leverages the PAR storage to remember the initial authorization request and generates a unique identifier for it.
- Redirect with
request_uri
: In directing the user to the login UI, Abblix OIDC Server appends arequest_uri
parameter containing the unique ID of the stored request. This ensures that the user's entry point into the authentication UI is directly linked back to their original authorization request. - Handling Successful Authentication: After the user enters their credentials and is authenticated, the login UI is responsible to redirect the user back to the authorization endpoint, including the
request_uri
parameter provided. The server retrieves the stored request details using this ID, which allows the authorization flow to resume seamlessly.
Abblix OIDC Server avoids providing the full URI for redirection back from the login page to mitigate risks associated with open redirect vulnerabilities.
Additionally, using a request_uri
value instead of a full redirection URI simplifies the URLs involved, enhancing both security and usability.
Implementing the Login UI
Creating a responsive and secure login UI is crucial for a seamless user experience.
While MVC is used here for simplicity, in real-world scenarios, especially for applications requiring dynamic and responsive UIs, consider using modern SPA frameworks like React or Vue.
They offer more flexibility, better state management, and enhanced user experience compared to traditional MVC applications for complex interactive web applications.
Here's how to implement such a login interface using an MVC Controller and View in the OpenIDProviderApp
:
- Create a new controller named
AuthController.cs
underControllers
directory. This controller will manage the authentication processes, including displaying and processing the login form. - Create a view named
Login.cshtml
under theViews/Auth
directory. This view will contain the HTML form where users can input their credentials. - Upon form submission, the
AuthController
should validate the credentials and then perform the redirect with therequest_uri
, as specified by the PAR mechanism to resume the OpenID Connect flow.
Create an Authentication Controller
File: Controllers/AuthController.cs
First, let's set up a controller that manages authentication requests. Navigate to the Controllers
folder in your OpenIDProviderApp
project and introduce a new Controller class named AuthController
.
public class AuthController : Controller
{
}
Create the Login Action
The Login
action in the AuthController
manages the first step in the authentication process by presenting the login form to the user.
This method also utilizes a parameter named request_uri
which is essential to resume the OpenID Connect flow later.
Add the code of the action as shown below:
// GET: Auth/Login
public IActionResult Login([FromQuery(Name = "request_uri")] string requestUri)
{
// Return a view with login/password inputs and sign-in button
return View(new { requestUri });
}
The request_uri
parameter holds the identifier for the initial authorization request stored in the PAR storage.
This identifier is crucial because it enables the OpenID Connect provider to retrieve and continue the original authorization request after the user successfully logs in.
When a user needs to access a resource that requires authentication, the server securely stores the authorization request and generates a unique identifier (request_uri
) for this stored request.
The user is then redirected to the login page with this request_uri
included as a query parameter.
The action method captures this request_uri
from the query parameters when the user is redirected to the login page.
It then passes this value to the View, which is crucial for maintaining state between the login form and the authorization process.
The login view receives the request_uri
as part of its model data, which it includes in a hidden form field when submitting the login credentials.
This process ensures the request_uri
is preserved throughout the user session and post-authentication process, allowing the server to fetch and continue the original request seamlessly.
This mechanism ensures the authorization flow is securely paused and resumed later, maintaining the integrity and continuity of the user's authentication experience.
Create the Login View
File: Views/Auth/Login.cshtml
Prepare to create a login form view where users will submit their credentials. In the Views
folder of your OpenIDProviderApp
project, create a new folder named Auth
that corresponds with the AuthController
. Inside this folder, create a view named Login.cshtml
, designed to work with the Login
action previously defined in your AuthController
.
Structure the Login Form:
Use HTML to construct a form that includes input fields for email and password, as well as a submit button. This form will handle user inputs and submit them to your server for authentication.
<!-- Login form designed for user authentication -->
<form asp-action="Login" method="post">
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
<div>
<!-- Display validation errors here -->
@Html.ValidationSummary(true, "", new { @class = "text-danger" })
</div>
<div>
<!-- Hidden field to maintain request_uri during the login process -->
<input type="hidden" id="requestUri" name="requestUri" value="@Model.requestUri"/>
<button type="submit">Login</button>
</div>
</form>
Explanation:
- The form uses the
asp-action="Login"
tag helper to specify which action method on the server it should call when submitted. This ensures the form data is sent to theLogin
POST method inAuthController
. - Input fields for
email
andpassword
are marked withrequired
, making sure users cannot submit the form without filling out these fields. - The
@Html.ValidationSummary
helper method is used to display any validation errors that occur during the login process, enhancing user feedback. - A hidden input field named
requestUri
maintains the continuity of the login process by preserving therequest_uri
value across the form submission. This is critical for the OpenID Connect flow, ensuring that after successful authentication, the user can be redirected back to the originally requested resource or action.
Implement a test storage for user accounts
File: TestUserStorage.cs
The Abblix OIDC Server provides an interface named IUserInfoProvider
, which serves as a contract between Abblix OIDC Server's core functionalities and your application's user data storage.
This interface mandates the implementation of a method, GetUserInfoAsync
, which asynchronously fetches a user's claims based on a specified subject identifier.
These claims can include simple and structured values as requested by the client application.
Create a class that implements the IUserInfoProvider
interface in your OpenIDProviderApp
project. This class will be responsible for retrieving user information based on the subject identifier and the requested claims from the internal list of users:
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using Abblix.Jwt;
using Abblix.Oidc.Server.Features.UserInfo;
/// <summary>
/// Represents user information, including subject identifier and profile attributes like name and email.
/// </summary>
public record UserInfo(string Subject, string Name, string Email, string Password);
/// <summary>
/// Provides a test storage implementation for user information, simulating a database of users.
/// </summary>
public class TestUserStorage(params UserInfo[] users) : IUserInfoProvider
{
/// <summary>
/// Asynchronously retrieves user information based on a subject identifier and a collection of requested claims.
/// </summary>
public Task<JsonObject?> GetUserInfoAsync(string subject, IEnumerable<string> requestedClaims)
{
var userInfo = GetUserInfo(subject, requestedClaims);
return Task.FromResult(userInfo);
}
/// <summary>
/// Retrieves user information for a specific subject with respect to requested claims.
/// </summary>
/// <param name="subject">The subject identifier for which user information is requested.</param>
/// <param name="requestedClaims">The claims that determine which information to include in the response.</param>
/// <returns>A <see cref="JsonObject"/> containing the requested user information, or null if no user matches the subject.</returns>
private JsonObject? GetUserInfo(string subject, IEnumerable<string> requestedClaims)
{
var user = users.FirstOrDefault(user => user.Subject == subject);
if (user == null)
{
return null;
}
var result = new JsonObject();
foreach (var claim in requestedClaims)
{
switch (claim)
{
case IanaClaimTypes.Sub:
result.Add(claim, user.Subject);
break;
case IanaClaimTypes.Email:
result.Add(claim, user.Email);
break;
case IanaClaimTypes.Name:
result.Add(claim, user.Name);
break;
}
}
return result;
}
/// <summary>
/// Attempts to authenticate a user based on their email and password.
/// </summary>
/// <param name="email">The email of the user attempting to authenticate.</param>
/// <param name="password">The password provided for authentication.</param>
/// <param name="subject">When this method returns, contains the subject identifier of the authenticated user if the return value is true; otherwise, null.</param>
/// <returns>true if the authentication is successful; otherwise, false.</returns>
public bool TryAuthenticate(
string email,
string password,
[NotNullWhen(true)] out string? subject)
{
foreach (var user in users)
{
if (user.Email == email && user.Password == password)
{
subject = user.Subject;
return true;
}
}
subject = null;
return false;
}
}
This simplified implementation uses in-memory user accounts for demonstration purposes. This is an deliberate choice to simplify the example.
In a real application, you would modify this code to connect to a database or another service that dynamically retrieves user accounts, enhancing both security and scalability.
Security Note on Storing Passwords
It is important to understand that in real-world production environments, storing raw passwords is considered a bad practice due to the high risk of security breaches.
For our test sample, we use hard-coded user credentials only for simplicity.
However, in real scenarios, if a user database is compromised, raw passwords could be accessed directly by unauthorized parties, leading to severe security issues and potential data breaches.
Best Practices for Storing Passwords
- Use Salted Hashing: Always store passwords as hashes, not plain text. Hashing converts the original password into a different string. Before hashing, a salt-a random string-is added to each password. This ensures that even identical passwords will have unique hash values.
- Utilize Strong Hash Functions: Use robust and computationally demanding hash functions designed for secure password storage, such as PBKDF2, Bcrypt, or Argon2. These functions are designed to be resistant to brute force attacks and make it computationally challenging to derive the original password from the hash.
- Regularly Update Hashing Strategies: As technology advances and computational power increases, it is essential to periodically review and update your hashing strategies to safeguard against new threats.
- Secure Password Handling Policies: Implement policies that promote or enforce the use of strong, unique passwords among users. This reduces the risk of attacks succeeding.
Adhering to these best practices significantly improves the security of your authentication systems and safeguards user data from potential threats.
Register the Implementation in the Dependency Injection
File: Program.cs
To integrate your TestUserStorage
class as an implementation of IUserInfoProvider
within your application, follow these steps to ensure it is correctly configured and accessible throughout your application, including the internal components of the Abblix OIDC framework.
var builder = WebApplication.CreateBuilder(args);
// Add the TestUserStorage as a singleton service in the DI container.
var userInfoStorage = new TestUserStorage(
new UserInfo(
Subject: "1234567890",
Name: "John Doe",
Email: "john.doe@example.com",
Password: "Jd!2024$3cur3")
);
builder.Services.AddSingleton(userInfoStorage);
// Use AddAlias to register TestUserStorage also as an implementation of IUserInfoProvider.
builder.Services.AddAlias<IUserInfoProvider, TestUserStorage>();
The AddAlias
method is part of the Abblix.DependencyInjection package. To use this method, ensure you include the appropriate namespace in your file:
using Abblix.DependencyInjection;
This method registers TestUserStorage
not only as a service in its own right but also as the implementation for IUserInfoProvider
.
This setup ensures that the single instance of TestUserStorage
exists in the application.
And even when the IUserInfoProvider
is requested, the same instance of TestUserStorage
is used, maintaining consistency and state where necessary.
In the code snippet above, there is one user account defined with the email john.doe@example.com
and the password Jd!2024$3cur3
. You can use it for initial testing.
Of course you can also expand this setup later to include more user accounts or integrate it with a database for a more dynamic approach.
Handle Authentication in the AuthController
File: Controllers/AuthController.cs
To manage the login form submissions, add a new method named Login
designed to handle POST requests in the AuthController
. Since a GET method for login already exists, this new POST method will be clearly distinguished by using the [HttpPost] attribute. This attribute ensures that the method processes form submissions rather than initial page requests.
// Existing GET: Auth/Login method is already defined here
// POST: Auth/Login
[HttpPost]
public async Task<IActionResult> Login(
[FromServices] IAuthSessionService authService,
[FromServices] ISessionIdGenerator sessionIdGenerator,
[FromServices] TestUserStorage userStorage,
[FromForm] string email,
[FromForm] string password,
[FromForm] string requestUri)
{
// Attempt to authenticate the user with provided credentials
if (!userStorage.TryAuthenticate(email, password, out var subject))
{
// Return an error message to the view to inform the user
ModelState.AddModelError("", "Invalid username or password");
return View(new { requestUri });
}
// If authentication is successful, create a new authentication session
var authSession = new AuthSession(
subject,
sessionIdGenerator.GenerateSessionId(),
DateTimeOffset.UtcNow,
CookieAuthenticationDefaults.AuthenticationScheme);
// Sign in the user using the authentication service
await authService.SignInAsync(authSession);
// Redirect the user to the authorization endpoint URL, recovering the OIDC flow
return Redirect($"/connect/authorize?request_uri={HttpUtility.UrlEncode(requestUri)}");
}
This process starts with authenticating the user using the TryAuthenticate
function. If the credentials are incorrect, an error message is displayed on the login page.
If authentication succeeds, it triggers the creation of a new AuthSession
and formal login through authService.SignInAsync()
.
Finally, the user is redirected to the authorization endpoint, reinstating the OpenID Connect flow and facilitating token issuance.
This method effectively demonstrates very basic credential verification.
In a production environment, it would need enhancement to ensure secure and efficient handling of user credentials according to best cybersecurity practices.
However, detailing the implementation of such a system is beyond the scope of this guide.
Our current focus is to provide a foundational understanding and implementation of authentication workflows using OpenID Connect in a controlled test environment.
Configuring a Test Client Application
Ensuring your OpenID Connect server operates as expected is critical to success.
This phase involves a thorough testing process to validate the entire authentication flow - from user authentication and authorization to token issuance
and access to protected resources. Having established TestClientApp
, we now proceed to turn it into a fully-functional OpenID Connect client.
Add Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet Package
To integrate the necessary NuGet package using the command line, follow these steps.
Navigate to your project directory for TestClientApp
. Run the following command to install the Microsoft.AspNetCore.Authentication.OpenIdConnect
package:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
This command will download and install the Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet package into your TestClientApp
project, enabling it to use OpenID Connect for authentication.
This is essential for setting up the OpenID Connect client capabilities in your ASP.NET application.
Establish the Authentication Schemes
Before we dive into the specifics of TestClientApp
, it's crucial to understand the fundamental concept of Authentication Schemes in ASP.NET Core MVC and their operational roles.
Understanding the Concept of Authentication Scheme
An Authentication Scheme in ASP.NET Core MVC is a named configuration that defines the mechanics of authentication for a specific context.
Each scheme is capable of performing a variety of operations, including:
- Challenge: Initiates authentication, typically by redirecting to a login page or challenging an API call.
- Sign-in: Manages the process of establishing an authenticated session after a user is authenticated.
- Sign-out: Handles the termination of an authenticated session, usually by clearing cookies or tokens.
- Authenticate: Responsible for validating authentication data (like cookies or tokens) in requests and assigning a user identity based on that data.
A scheme may implement these operations itself or delegate them to another scheme, creating a flexible architecture.
For instance, the cookie-based scheme handles sign-in and sign-out operations directly, maintaining the session state after initial authentication.
However, for initiating authentication - known as the challenge operation - it relies on the OpenID Connect scheme.
This delegation leverages the strengths of OpenID Connect for secure initial authentication, while the cookie scheme efficiently manages session persistence.
In our example, we employ this approach, most fully capitalizing on the potential of each scheme.
Cookie Authentication
Role: Cookie Authentication acts as the primary authentication scheme in TestClientApp
, essential for managing the user session after the user has been authenticated
by an external provider such as OpenID Connect.
How It Works: After successful authentication via OpenID Connect, TestClientApp
issues a session cookie containing the user's encrypted identity information.
This cookie authenticates subsequent requests from the user's browser, maintaining the session without continuous re-authentication.
OpenID Connect Authentication
Role: This serves as the secondary or external authentication scheme in TestClientApp
, designed specifically for authenticating users against the OpenID Connect provider (OpenIDProviderApp
).
How It Works: When a user attempts to access a protected resource without an active session, this scheme redirects them to the OpenIDProviderApp
for authentication.
After successful authentication, the user is redirected back with an authorization code that TestClientApp
exchanges for an identity token and possibly an access token to establish and manage the user session with a cookie.
Inter-Scheme Delegation
In our setup, while the Cookie Authentication scheme manages the sign-in and sign-out processes, it delegates the challenge operation to the OpenID Connect Authentication scheme.
This inter-scheme delegation allows TestClientApp
to leverage OpenID Connect for handling initial user authentication requests and redirects, utilizing the external authentication mechanisms provided by OpenIDProviderApp
.
Integrating the Authentication Schemes
File: Program.cs
Add both authentication schemes to your test client application and make them configured to work together:
- Configure both authentication schemes from the configuration:
var configuration = builder.Configuration;
builder.Services
.AddAuthentication(options => configuration.Bind("Authentication", options))
.AddCookie()
.AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
This configuration establishes Cookie Authentication as the primary means of maintaining user sessions within TestClientApp
, while OpenID Connect Authentication handles initial user authentication through OpenIDProviderApp
.
The seamless cooperation between these schemes provides a secure and user-friendly authentication flow, leveraging both cookie-based sessions and OpenID Connect's federated identity capabilities.
Configure the Authentication
It's time to properly set up your TestClientApp
to authenticate using the OpenID Connect provider.
Insert the following configuration settings into the appsettings.Development.json
file as detailed below:
"Authentication": {
"DefaultScheme": "Cookies",
"DefaultChallengeScheme": "OpenIdConnect"
},
"OpenIdConnect": {
"SignInScheme": "Cookies",
"SignOutScheme": "Cookies",
"Authority": "https://localhost:5001",
"ClientId": "test_client",
"ClientSecret": "secret",
"SaveTokens": true,
"Scope": ["openid", "profile", "email"],
"MapInboundClaims": false,
"ResponseType": "code",
"ResponseMode": "query",
"UsePkce": true,
"GetClaimsFromUserInfoEndpoint": true
}
Let's break down these settings.
Authentication Section
DefaultScheme
: Specifies the primary method of authentication. Set to"Cookies"
, it indicates that the application uses cookie-based authentication by default to handle user sign-ins and maintain session state.DefaultChallengeScheme
: This specifies the scheme used when the application needs to actively challenge a user for authentication. By setting this to"OpenIdConnect"
, the application is directed to use OpenID Connect whenever it encounters a scenario requiring user authentication without a valid cookie.
OpenIdConnect Section
SignInScheme
&SignOutScheme
: Both set to"Cookies"
, these settings govern how the application handles user sign-ins and sign-outs, respectively, linking the OpenID Connect authentication process to cookie-based session management.Authority
: Defines the URL of the OpenID Connect provider, in this case,https://localhost:5001
. This is where the application sends authentication and token requests.ClientId
&ClientSecret
: TheClientId
is a unique identifier for the application registered with the OpenID Connect provider, while theClientSecret
is a secret key used to authenticate the client with the provider, enhancing security. It's crucial to keep the client secret secure, especially in production environments.SaveTokens
: This setting, when enabled, instructs the application to save the tokens obtained during the authentication process, which may include identity, access, and refresh tokens. These tokens are useful for making API requests on behalf of the user.Scope
: This array specifies the permissions or scopes requested by the application, such as"openid"
,"profile"
, and"email"
. These scopes determine the extent of access to the user's information allowed by the application.MapInboundClaims
: Setting this tofalse
avoids the automatic conversion of JWT claims into Microsoft's proprietary claim types, allowing the application to use the original claims issued by the OpenID Connect provider.ResponseType
: Specifies the type of response the client expects from the OpenID Connect provider. Setting"code"
indicates that the Authorization Code flow is used.ResponseMode
: Defines how the authorization response is returned to the client."query"
means that the authorization code will be included in the query string of the redirect URI.UsePkce
: Indicates whether Proof Key for Code Exchange (PKCE) should be used. Setting this totrue
enhances the security of the Authorization Code flow.GetClaimsFromUserInfoEndpoint
: Instructs the application to retrieve additional claims about the user from the UserInfo endpoint, supplementing those provided in the ID token.
Implementing an Index Page to Display User Claims
To effectively display user claims in your TestClientApp
as a result of successful authentication, you will need to configure the appropriate controller action and set up a view.
Secure the Index Page with User Authentication
File: Controllers/HomeController.cs
Ensure that only authenticated users can access the Index
page by applying the [Authorize]
attribute to the Index
action method in the HomeController
.
Update the Method as Follows:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
public class HomeController : Controller
{
[Authorize]
public IActionResult Index()
{
// Retrieve and pass the user's claims to the view
return View(User.Claims);
}
}
The [Authorize]
attribute ensures that the action method can only be accessed by authenticated users. If a user attempts to access it without being authenticated, the system automatically issues a challenge, typically redirecting the user to a login page. If the user is authenticated, their claims are retrieved from User.Claims
and passed to the Index
view. This allows the application to display personalized information based on the user's identity and permissions. If authentication is not verified, the [Authorize]
attribute prompts the user to log in, securing access to the page.
Update the Index View to Display User Claims
File: Views/Home/Index.cshtml
Modify the Index
view located in the Views/Home
directory to dynamically display user claims, providing a straightforward visualization of the authenticated user's data:
@model IEnumerable<System.Security.Claims.Claim>
<h2>User Claims</h2>
@if (Model.Any())
{
<ul>
@foreach (var claim in Model)
{
<li>@claim.Type: @claim.Value</li>
}
</ul>
}
else
{
<p>No claims available. Are you authenticated?</p>
}
This view is designed to receive an IEnumerable<Claim>
as its model, which it uses to display each claim in a list format. It first checks if any claims are present. If claims exist, it lists each one, showing the type and value of the claim. If no claims are found, it displays a message querying if the user is authenticated. This serves as a direct indication that no user data has been retrieved, either due to lack of authentication or an issue in the claim retrieval process.
Summary
By configuring a controller action to verify authentication and retrieve claims, along with a view that displays these claims,
your TestClientApp
effectively provides insightful feedback to users regarding their authentication status and associated claims.
This setup enhances user interaction by transparently displaying authentication details.
Testing Your Applications
To test the setup of your OpenID Connect provider and client applications, follow these steps to run both applications simultaneously.
Running the Applications
- Open two separate command prompts or terminal instances for
OpenIDProviderApp
andTestClientApp
. - In each terminal, navigate to the respective project directory of each application.
- Run the following command in both terminals:
This command starts each application with HTTPS enabled, which is necessary for OpenID Connect operations.dotnet run -lp https
Testing the Authentication Flow
- Once both applications are running, open a web browser in incognito mode and navigate to
https://localhost:5002
, corresponding toTestClientApp
. This approach ensures a clean environment, free from traces of previous sessions such as cookie files. - Accessing this URL initiates a redirect through the Authorization Endpoint to the login page at
https://localhost:5001/Auth/Login
for user authentication. - You will be prompted to log in using the credentials configured in
OpenIDProviderApp
. Enter the emailjohn.doe@example.com
and the passwordJd!2024$3cur3
, as specified earlier. - After a successful login,
OpenIDProviderApp
will redirect you back to the Authorization Endpoint to continue the OpenID Connect authorization process and then return you toTestClientApp
. - On
TestClientApp
, you will complete the authentication process and arrive at theIndex
page, which displays a list of user claims. This verifies that the user's authenticated state is recognized, confirming that the OpenID Connect authentication flow is operating correctly.
Handling HTTPS Certificate Trust Issues
When developing and testing web applications locally, especially those configured to run over HTTPS, you may encounter browser warnings indicating that the SSL certificate is not trusted. This issue arises because the development certificates used by ASP.NET Core are self-signed and not issued by a recognized Certificate Authority (CA). Here's how you can address these warnings:
Trusting the ASP.NET Core Development Certificate
To eliminate these warnings and ensure a smooth development experience, trust the ASP.NET Core development certificate on your machine. Run the following command in your command line or terminal:
dotnet dev-certs https --trust
This command updates your system to trust the certificate used by ASP.NET Core during development. After running this command, restart your browsers to ensure the changes take effect.
Special Note for Chrome Users
Even after trusting the development certificate, some browsers like Chrome might still restrict access to sites using localhost for security reasons. If you encounter an error in Chrome stating that your connection is not private, you can bypass this by:
- Clicking anywhere on the error page and typing
thisisunsafe
orbadidea
, depending on the Chrome version. These keystrokes act as bypass commands in Chrome, allowing you to proceed to your localhost site.
It's important to use these bypasses sparingly and only in development scenarios, as they could mask genuine security issues in a production environment.
Observing OpenID Connect in Action
This testing phase offers a hands-on opportunity to see OpenID Connect in action within your client applications.
It covers the full cycle from initiating user authentication with the OpenID Provider (OpenIDProviderApp
), through successful login, and back to the client application (TestClientApp
) where the user's authentication information is used.
This practical demonstration showcases how OpenID Connect can be effectively implemented in modern web development, emphasizing secure user authentication and data utilization.
Access the Complete Solution on GitHub
If you encounter any issues or discrepancies while following this guide, or if you wish to verify your setup against a working model, the final state of the getting started solution is available on GitHub. You can clone the repository from Abblix/Oidc.Server.GettingStarted to access a fully implemented version of the OpenID Connect provider and client application as described in this guide. This resource is invaluable for troubleshooting, comparing your code, and understanding the complete implementation in context. It also serves as a quick reference to ensure that all configurations and code structures have been correctly followed and implemented.
Conclusion
Congratulations on completing this guide!
You have made significant progress in understanding and implementing an OpenID Connect provider with ASP.NET MVC, using the Abblix OIDC Server solution.
You have successfully configured two applications: OpenIDProviderApp
as the OpenID Connect provider and TestClientApp
as the client or Relying Party.
Additionally, you have set up the essential services required for a secure OIDC flow.
What You've Accomplished
- You configured and set up an OpenID Connect provider.
- You established a client application that can successfully authenticate users and handle their data.
- Through a test scenario, you demonstrated user authentication and how to access and display claims in a client application.
By following this guide, you've laid a strong foundation for implementing robust authentication in your web applications using OpenID Connect and ASP.NET MVC.
This setup not only covers the basic configuration but also allows you to dive deeper into the details of managing user sessions, handling tokens, and ensuring user consent.
Next Steps to Consider
- Apply the concepts learned to broaden the functionalities of your OpenID Connect provider. Try out different grant types, incorporate additional security measures, and tailor the user experience to better meet your needs.
- Use this foundation to experiment with new ideas and solutions in authentication technology.
- Continuously embrace best practices for maintaining a secure environment, such as securing secret storage, updating dependencies regularly, and adhering to the latest security protocols.
- Join platforms like GitHub, Stack Overflow, and the ASP.NET Core community forums. These platforms offer support, facilitate discussions, and help you discover new ideas and techniques.
- Remember that building secure and efficient web applications is a continuous journey of learning, testing, and adapting. Stay curious and proactive in your professional growth.
Embrace the opportunities that lie ahead, and continue to innovate and secure your applications with confidence. Your journey into the world of OpenID Connect and ASP.NET MVC has just begun. Stay tuned.