420.5 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 and authorisation server

Keycloak realm

  • in the Keycloak console create realm with rupbes.test-name and default settings

Keycloak Mvc01 app

  • in the Keycloak console under rupbes.test realm we create app
    • Client ID: Mvc01
    • Access settings
      • Root URL: https://aaa.xxxxxxx.zzz:7008
      • Home URL: https://aaa.xxxxxxx.zzz:7008
      • Valid redirect URIs: signin-oidc
      • Valid post logout redirect URIs: /signout-callback-oidc
    • Capability config
      • Client authentication: ON
      • Authentication flow: [Standard flow]
    • Credetials
      • Client Authenticator: Client ID and Secret
      • Client Secret: XXXXXXXXXXXXXX (we just copied auto generated string in the clipboard)

Keycloak Mvc02 app

  • in the Keycloak console under rupbes.test realm we create app
    • Client ID: Mvc02
    • Access settings
      • Root URL: https://bbb.xxxxxxx.zzz:7201
      • Home URL: https://bbb.xxxxxxx.zzz:7201
      • Valid redirect URIs: signin-oidc
      • Valid post logout redirect URIs: /signout-callback-oidc
    • Capability config
      • Client authentication: ON
      • Authentication flow: [Standard flow]
    • Credetials
      • Client Authenticator: Client ID and Secret
      • Client Secret: YYYYYYYYYYYYYYY (we just copied auto generated string in the clipboard)

Keycloak testuser

  • in the Keycloak console under rupbes.test realm we create user
    • User name: testuser
    • Email verified: ON
    • Credetials tab
      • we set the password for the user

hosts file

  • On the developer machine add two lines into hosts-file.
127.0.0.1	AAA.XXXXXXX.ZZZ
127.0.0.1	BBB.XXXXXXX.ZZZ
  • If to use 'localhost' for both app with defferent ports
    • you start both app
    • you login into Mvc01 app
    • After logging into the Mvc02 app you will see that
      • you automatically logged out from Mvc01 app
  • to avoid such a scenario we will use different dns names

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: None
      • 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>
  </PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" 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.IdentityModel.Protocols.OpenIdConnect;

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

            builder.Services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "SSO", o =>
            {
                //o.SignInScheme = IdentityConstants.ExternalScheme;
                //o.SignOutScheme = OpenIdConnectDefaults.AuthenticationScheme; // IdentityConstants.ApplicationScheme;


                o.ClientId = "Mvc01";
                o.ClientSecret = "YYYYYYYYYYYYYYYYY"; // copy ClientSecret from the Keycloak
                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");

                // 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.Clear();
                o.Scope.Add("openid");
                o.Scope.Add("profile");
                o.Scope.Add("email");
                o.Scope.Add("phone");
                o.SaveTokens = true;
            });




            // Add services to the container.
            builder.Services.AddControllersWithViews();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                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.UseAuthentication(); // Added
            app.UseAuthorization();

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

            app.Run();
        }
    }
}
  • in the HomeController.cs-class add three methods:
    • both implementation of the DoLogout-method works:
Click to show the code
...

        public IActionResult DoLogin()
        {
            // defining  "RedirectUri" is important !!!
            var authProps = new AuthenticationProperties
            {
                RedirectUri = "/"
            };
            // do not use HttpContext.ChallengeAsync(...)-method call !!! It will not work
            return Challenge(authProps, OpenIdConnectDefaults.AuthenticationScheme);
        }

        // public async Task DoLogout()
        // {
        //    await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
        // }

        
        public IActionResult DoLogout()
        {
            return new SignOutResult(
                new[] {
                OpenIdConnectDefaults.AuthenticationScheme
                //  CookieAuthenticationDefaults.AuthenticationScheme // do not insert this line
            });
        }
        

        public async Task DoCleanUpCookie()
        {
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
...

Create MvcCore02 app

  • add a second project to the solution and repeat almost the same as for MvcCore01
    • name the project MvcCore02
  • 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"
      }
    }
  }
}
  • in Program.cs-file we change ClientId and ClientSecret
...
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "SSO", o =>
            {
                o.ClientId = "Mvc02";
                o.ClientSecret = "YYYYYYYYYYYYYYYYYYYYYYYYYY"; // copy Secret from keycloak
...

Keycloak sessions

  • start MvcCore01 app and login as testuser
    • you must goto https://aaa.xxxxxxx.zzz:7008/home/DoLogin
  • using Keycloak console goto Sessions for rupbes.test-realm
testuser   REGULAR   7/18/2025, 1:40:18 PM   7/18/2025, 1:40:18 PM	10.183.96.1    Mvc01
  • start MvcCore02 app and log in as testuser
    • you must goto https://bbb.xxxxxxx.zzz:7201/home/DoLogin
  • using Keycloak console goto Sessions for rupbes.test-realm
    • now you see two apps shown Mvc01 Mvc02
testuser  REGULAR   7/18/2025, 1:40:18 PM   7/18/2025, 1:40:18 PM	10.183.96.1    Mvc01 Mvc02
  • log out from only one app, for instance from the second app (or from the first one)
    • you must goto https://bbb.xxxxxxx.zzz:7201/home/DoLogout (or https://aaa.xxxxxxx.zzz:7008/home/DoLogoutn)
  • using Keycloak console goto Sessions for rupbes.test-realm
    • The list of Sessions is empty
      • We expected that only one session would be deleted. (!!!)

offline access

  • for the MvcCore01 app in the Program.cs add offline_access scope
          o.Scope.Add("offline_access");
  • run the both apps
  • log in into MvcCore01
    • goto https://aaa.xxxxxxx.zzz:7008/home/DoLogin
    • check the sessions. We have OFFLINE-type
testuser	OFFLINE	7/18/2025, 2:05:14 PM	7/18/2025, 2:05:14 PM	10.183.96.1	Mvc01
  • log in into MvcCore02
    • goto https://bbb.xxxxxxx.zzz:7201/home/DoLogin
    • check the sessions. Now we have two lines
testuser	REGULAR	7/18/2025, 2:09:46 PM	7/18/2025, 2:09:46 PM	10.183.96.1	Mvc02
testuser	OFFLINE	7/18/2025, 2:05:14 PM	7/18/2025, 2:05:14 PM	10.183.96.1	Mvc01
  • logout from the second app
    • goto https://bbb.xxxxxxx.zzz:7201/home/DoLogout
    • check the sessions. One line is still present:
testuser	OFFLINE	7/18/2025, 2:05:14 PM	7/18/2025, 2:05:14 PM	10.183.96.1	Mvc01
  • logout from the first app
    • goto https://aaa.xxxxxxx.zzz:7008/home/DoLogout
    • check the sessions. One line is still present (!!!)
      • we expected that the session would be deleted
testuser	OFFLINE	7/18/2025, 2:05:14 PM	7/18/2025, 2:05:14 PM	10.183.96.1	Mvc01

Modify MvcCore01 app

  • add a refernce onto Duende.AccessTokenManagement.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>
  </PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" Version="3.2.0" />
		<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.17" />
	</ItemGroup>
</Project>
  • modify Program.cs as follows
Click to show the code
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

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

            builder.Services.AddOpenIdConnectAccessTokenManagement(o =>
            {
                // o.ClientCredentialsScope = "openid profile offline_access";
                // o.ClientCredentialsResource = "OidcWebApiResource OidcWebApiIntrospectionResource";
                // o.DPoPJsonWebKey = "jwk";
            });

            builder.Services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
                 options => {
                     options.Events.OnSigningOut = async e =>
                     {
                         await e.HttpContext.RevokeRefreshTokenAsync();
                     };
                 }
            )
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "SSO", o =>
            {
                //o.SignInScheme = IdentityConstants.ExternalScheme;
                //o.SignOutScheme = OpenIdConnectDefaults.AuthenticationScheme; // IdentityConstants.ApplicationScheme;


                o.ClientId = "Mvc01";
                o.ClientSecret = "8JQRgt9U7kJKhl7HGBgQ6rhH6WlmU94p";
                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");


                /// <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 = "/"


                // 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");


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

                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;
            });


            // Add services to the container.
            builder.Services.AddControllersWithViews();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                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.UseAuthentication(); // Added
            app.UseAuthorization();

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

            app.Run();
        }
    }
}
  • after logout of the first app OFFLINE-session will be deleted in the list of Keycloak sessions

Сookies

  • We are going to reference this note in the next article (Keycloak id_token_hint issue):
    • check the browser cookies after log in to the second app
      • .AspNetCore.Cookies=chunks-2
      • .AspNetCore.CookiesC1=...
      • .AspNetCore.CookiesC2=...
    • check the browser cookies after log in to the first app (it requests offline_access-scope)
      • .AspNetCore.Cookies=chunks-3
      • .AspNetCore.CookiesC1=...
      • .AspNetCore.CookiesC2=...
      • .AspNetCore.CookiesC3=...

Security

  • take a closer look at the logout method
  • it can be like this
        public async Task DoLogout()
        {
           await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
        }
  • or it can be like this
        public IActionResult DoLogout()
        {
            return new SignOutResult(
                new[] {
                OpenIdConnectDefaults.AuthenticationScheme,
                // CookieAuthenticationDefaults.AuthenticationScheme // do not insert this line
            });
        }
  • if we use the code like below
        public IActionResult DoLogout()
        {
            return new SignOutResult(
                new[] {
                OpenIdConnectDefaults.AuthenticationScheme,
                CookieAuthenticationDefaults.AuthenticationScheme // do not insert this line
            });
        }
  • we obtain the Keycloak message: Missing parameters: id_token_hint

  • if we change the code like below

        public IActionResult DoLogout()
        {
            return new SignOutResult(
                new[] {
                // OpenIdConnectDefaults.AuthenticationScheme,
                CookieAuthenticationDefaults.AuthenticationScheme // do not insert this line
            });
        }
  • app logs out without errors
    • but the next time you log in, the application will not ask for your username and password (!!!)
      • It is the same as to login two or more times without logout. The very first time it will ask for Username and password and at the second time it will not.
    • OpenIdConnect accepts user credentials from a Keycloak session until the session expires and does not ask for username and password.
      • Bad user experience: After your first login, you will not be able to log in under a different username.
        • require remembering https://kc.rupbes.by:8445/realms/rupbes.test/account-ref
      • Security Issue: Once you log out, anyone can log into your account without a password on the same workstation.
⚠️ **GitHub.com Fallback** ⚠️