420.6 Keycloak and MVC - chempkovsky/CS82ANGULAR GitHub Wiki

Notes

  • KeyCloak can be used as authentication and authorisation server
    • Once an application is registered with KeyCloak, you can define one or more roles for that application using the KeyCloak console.
    • Once you have created a user in KeyCloak, you can assign one or more registered application roles to that user.
    • After login into MVC app you can get all the roles assigned to the current user. KeyCloak sends the roles only for the current app. (In case you registered more than one apps and assigned the roles of other apps to the current user).
    • you don't have to rely on scopes
    • In short, KeyCloak made with love.
  • KeyCloak can be used as authentication only server
    • In this case, KeyCloak will be used to authenticate the user and Microsoft Identity middleware to authorize the user.
  • In this article we show how to configure MVC app to use KeyCloak as authentication only server

Keycloak realm

Keycloak Mvc01 app

Keycloak Mvc02 app

Keycloak testuser

hosts file

Create MvcCore01 app

  • On the developer machine start Visual Studio.
    • Create new project with a template: ASP.NET Core Web App (Model-View-Controler)
      • Name: MvcCore01
      • Framework: '.Net 8.0' (or '.Net 9.0')
      • Authentication type: Individual accounts
      • Configure Https: ON
      • Do not use top-level statements: ON
      • add reference onto Microsoft.AspNetCore.Authentication.OpenIdConnect
      • your MvcCore01.csproj become as follows
Click to show the code
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnet-MvcCore01-e24e1607-7547-4c8d-b012-ac3a9386bce5</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
	<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.17" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.17" />
  </ItemGroup>

</Project>
  • modify launchSettings.json file as follows:
Click to show the code
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://AAA.XXXXXXX.ZZZ:7008",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
  • modify Program.cs as follows
Click to show the code
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using MvcCore01.Data;

//
// https://kc.rupbes.by:8445/realms/rupbes.test/account
//

namespace MvcCore01
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);


            // Add services to the container.
            var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
            builder.Services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(connectionString));
            builder.Services.AddDatabaseDeveloperPageExceptionFilter();

            builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
                .AddEntityFrameworkStores<ApplicationDbContext>()
                ;//.AddDefaultTokenProviders();

            builder.Services.AddAuthentication(options =>
            { 
                
                options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
                options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
                options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
                
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.LoginPath = "/Account/Login/";
                        options.LogoutPath = "/Account/Logout";
                    }
            )
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "SSO with Keycloak", o =>
             {
                 o.SignInScheme = IdentityConstants.ExternalScheme;
                 o.SignOutScheme = IdentityConstants.ApplicationScheme;


                 o.ClientId = "Mvc01";
                 o.ClientSecret = "XXXXXXXXXXXXXXXXXXXXXXX"; // copy Secret from keycloak console
                 o.Authority = "https://kc.rupbes.by:8445/realms/rupbes.test";

                 // default value == "/signin-oidc"
                 // Keycloak  "Valid redirect URIs"-param must be set to "/signin-oidc"
                 o.CallbackPath = new PathString("/signin-oidc");


                 // Requests received on this path will cause the handler to invoke SignOut using the SignOutScheme.
                 //o.RemoteSignOutPath = new PathString("/Home/DoLogout");              // default value


                 // default value == "/signout-callback-oidc"
                 // Keycloak  "Valid post logout redirect URIs"-param must be set to "/signout-callback-oidc"
                 //            o.SignedOutCallbackPath = new PathString("/signout-callback-oidc");
                 /// <summary>
                 /// The uri where the user agent will be redirected to after application is signed out from the identity provider.
                 /// The redirect will happen after the SignedOutCallbackPath is invoked.
                 /// </summary>
                 /// <remarks>This URI can be out of the application's domain. By default it points to the root.</remarks>
                 //             o.SignedOutRedirectUri = new PathString("/Home/DoCleanUpCookie");                       // default value


                 o.ResponseType = OpenIdConnectResponseType.Code;
                 o.UsePkce = true;

                 // o.Scope.Add("user.read");

                 // o.MapInboundClaims = false;
                 o.Scope.Clear();
                 o.Scope.Add("openid");
                 o.Scope.Add("profile");
                 o.Scope.Add("email");
                 o.Scope.Add("phone");
                 //  o.Scope.Add("offline_access");

                 o.SaveTokens = true;
             });

            builder.Services.AddControllersWithViews();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseMigrationsEndPoint();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
            app.MapRazorPages();

            app.Run();
        }
    }
}

Create MvcCore02 app

  • On the developer machine start Visual Studio.
    • Create new project with a template: ASP.NET Core Web App (Model-View-Controler)
      • Name: MvcCore01
      • Framework: '.Net 8.0' (or '.Net 9.0')
      • Authentication type: Individual accounts
      • Configure Https: ON
      • Do not use top-level statements: ON
      • add reference onto Microsoft.AspNetCore.Authentication.OpenIdConnect
      • your MvcCore01.csproj become as follows
Click to show the code
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnet-MvcCore02-41c22ddb-032a-416a-aaaa-83d1f35fd6ed</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
	<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.17" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.17" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.17" />
  </ItemGroup>

</Project>
  • modify launchSettings.json file as follows:
Click to show the code
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,  
      "applicationUrl": "https://BBB.XXXXXXX.ZZZ:7201",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
  • modify Program.cs as follows
Click to show the code
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using MvcCore02.Data;

//
// https://kc.rupbes.by:8445/realms/rupbes.test/account
//

namespace MvcCore02
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);


            // Add services to the container.
            var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
            builder.Services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(connectionString));
            builder.Services.AddDatabaseDeveloperPageExceptionFilter();

            builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
                .AddEntityFrameworkStores<ApplicationDbContext>()
                ;//.AddDefaultTokenProviders();

            builder.Services.AddAuthentication(options =>
            { 
                
                options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
                options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
                options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
                
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.LoginPath = "/Account/Login/";
                        options.LogoutPath = "/Account/Logout";
                    }
            )
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "SSO with Keycloak", o =>
             {
                 o.SignInScheme = IdentityConstants.ExternalScheme;
                 o.SignOutScheme = IdentityConstants.ApplicationScheme;


                 o.ClientId = "Mvc02";
                 o.ClientSecret = "YYYYYYYYYYYYYYYYYYYYYYY"; // copy Secret from keycloak console
                 o.Authority = "https://kc.rupbes.by:8445/realms/rupbes.test";

                 // default value == "/signin-oidc"
                 // Keycloak  "Valid redirect URIs"-param must be set to "/signin-oidc"
                 o.CallbackPath = new PathString("/signin-oidc");


                 // Requests received on this path will cause the handler to invoke SignOut using the SignOutScheme.
                 //o.RemoteSignOutPath = new PathString("/Home/DoLogout");              // default value


                 // default value == "/signout-callback-oidc"
                 // Keycloak  "Valid post logout redirect URIs"-param must be set to "/signout-callback-oidc"
                 //            o.SignedOutCallbackPath = new PathString("/signout-callback-oidc");
                 /// <summary>
                 /// The uri where the user agent will be redirected to after application is signed out from the identity provider.
                 /// The redirect will happen after the SignedOutCallbackPath is invoked.
                 /// </summary>
                 /// <remarks>This URI can be out of the application's domain. By default it points to the root.</remarks>
                 //             o.SignedOutRedirectUri = new PathString("/Home/DoCleanUpCookie");                       // default value


                 o.ResponseType = OpenIdConnectResponseType.Code;
                 o.UsePkce = true;

                 // o.Scope.Add("user.read");

                 // o.MapInboundClaims = false;
                 o.Scope.Clear();
                 o.Scope.Add("openid");
                 o.Scope.Add("profile");
                 o.Scope.Add("email");
                 o.Scope.Add("phone");
                 //  o.Scope.Add("offline_access");

                 o.SaveTokens = true;
             });

            builder.Services.AddControllersWithViews();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseMigrationsEndPoint();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
            app.MapRazorPages();

            app.Run();
        }
    }
}

After very first login

  • On the Home-page click Login-ref. It will show the login page with SSO with Keycloak-button on the right side of the page.
    • click SSO with Keycloak-button and login as testuser
    • The prompt will be shown if to register current user in the Asp.Net app. We agree.
      • The new row will be inserted in AspNetUsers. (testuser will be inserted without password)
      • The new row will be inserted in AspNetUserLogins. (Inserted row holds both AspNet UserId and Keycloak UserId)

After logout

  • After logout, the record in keycloak sessions is not deleted (!!!)
    • If you log in a second time by clicking the "SSO with Keycloak"-button, the request to enter your username and login will not be completed. (!!!)
  • The question is:
    • Hown to implement Keycloak logout
      • Until the Keycloak session expires, we have no way to log in as another Keycloak user. (by the SSO with Keycloak-button's click)
  • The easiest way is to suggest remembering https://kc.rupbes.by:8445/realms/rupbes.test/account-ref
  • Another way is to reimplement Mvc logout page
    • Add template Identiry logout item to the project
    • modify OnPost-method in the Logout.cshtml.cs-file as follows:
Click to show the code
        public async Task<IActionResult> OnPost(string returnUrl = null)
        {
            Claim? clm = this.User?.FindFirst("http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod");
            
            await _signInManager.SignOutAsync();
            _logger.LogInformation("User logged out.");
            if ("OpenIdConnect".Equals(clm?.Value, StringComparison.OrdinalIgnoreCase))
            {
                return new RedirectResult("https://kc.rupbes.by:8445/realms/rupbes.test/account");
            }
            else
            {
                if (returnUrl != null)
                {
                    return LocalRedirect(returnUrl);
                    //return new RedirectResult("https://kc.rupbes.by:8445/realms/rupbes.test/account");
                }
                else
                {
                    // This needs to be a redirect so that the browser performs a new
                    // request and the identity for the user gets updated.
                    return RedirectToPage();
                    //return new RedirectResult("https://kc.rupbes.by:8445/realms/rupbes.test/account");
                }
            }
            
        }
    }
⚠️ **GitHub.com Fallback** ⚠️