Blazor Server OIDC Logout with Keycloak Redirects but Session Not Cleared

Mohamed Moussa 0 Reputation points
2024-06-07T15:16:04.19+00:00

have a Blazor Server application configured with OpenID Connect (OIDC) authentication using Keycloak. Login works fine and I can authenticate successfully. However, when I log out, I get redirected to the Keycloak logout page but upon returning to my Blazor application, I find that I'm still logged in, even though the Keycloak session is expired.

Here are the details of my setup: Program.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<LogoutService>();

// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole();

// Configure OIDC authentication
var oidcSettings = builder.Configuration.GetSection("OIDC");

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.Events.OnSigningOut = async e =>
    {
        e.HttpContext.Response.Cookies.Delete(".AspNetCore.Cookies");
        await Task.CompletedTask;
    };
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
    options.MetadataAddress = "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration";
    options.Authority = oidcSettings["Authority"];
    options.ClientId = oidcSettings["ClientId"];
    options.ClientSecret = oidcSettings["ClientSecret"];
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = false;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.CallbackPath = new PathString("/signin-oidc");
    options.SignedOutCallbackPath = new PathString("/signout-callback-oidc");
    options.RemoteSignOutPath = new PathString("/signout-oidc");
    options.SignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
    options.MapInboundClaims = false;
    options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
    options.TokenValidationParameters.RoleClaimType = "role";

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = oidcSettings["Authority"],
        ValidateAudience = true,
        ValidAudience = oidcSettings["ClientId"],
        ValidateLifetime = true
    };

    options.Scope.Add("profile");
    options.Scope.Add("email");
});

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.AddPolicy("AnonymousPolicy", policy => policy.RequireAssertion(_ => true));
});

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Debug",
      "Microsoft.AspNetCore": "Debug"
    }
  },
  "OIDC": {
    "Authority": "https://keycloak.example.com/realms/myrealm",
    "ClientId": "myclientid",
    "ClientSecret": "myclientsecret",
    "CallbackPath": "/signin-oidc",
    "PostLogoutRedirectUri": "https://localhost:7029/",
    "SignedOutCallbackPath": "/signout-callback-oidc",
    "SignOutPath": "/signout-oidc"
  }
}

Razor Page

@page "/counter"
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Authentication.OpenIdConnect

<PageTitle>Index</PageTitle>
@inject NavigationManager Navigation
@inject IConfiguration Configuration
@inject IHttpContextAccessor HttpContextAccessor

<AuthorizeView>
    <Authorized>
        <button @onclick="Logout">Logout</button>
    </Authorized>
    <NotAuthorized>
        <button @onclick="Login">Login</button>
    </NotAuthorized>
</AuthorizeView>

@code {
    private void Login()
    {
        Navigation.NavigateTo("https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth?client_id=myclientid&redirect_uri=https://localhost:7029/signin-oidc&response_type=code&scope=openid");
    }

    private void Logout()
    {
        var authority = Configuration["OIDC:Authority"];
        var postLogoutRedirectUri = Configuration["OIDC:PostLogoutRedirectUri"];
        var logoutUrl = $"{authority}/protocol/openid-connect/logout?client_id=myclientid&post_logout_redirect_uri={Navigation.ToAbsoluteUri("/")}";
        Navigation.NavigateTo(logoutUrl, true);
    }
}

Problem Description:

Login works and redirects successfully to the application. Logging out redirects to the Keycloak logout page, but when I return to the application, I'm still logged in. The session in Keycloak is expired but the Blazor application still recognizes the user as logged in.

Microsoft Authenticator
Microsoft Authenticator
A Microsoft app for iOS and Android devices that enables authentication with two-factor verification, phone sign-in, and code generation.
7,181 questions
Blazor
Blazor
A free and open-source web framework that enables developers to create web apps using C# and HTML being developed by Microsoft.
1,595 questions
0 comments No comments
{count} votes

1 answer

Sort by: Most helpful
  1. Souleymen Hacini 0 Reputation points
    2024-06-08T00:46:47.65+00:00

    Salmu alaykoum Mohammed

    Hello

    // Service Config

    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.IdentityModel.Protocols.OpenIdConnect;
    using Microsoft.IdentityModel.JsonWebTokens;
    using Authentification.Keycloak.Extensions;
    using Microsoft.AspNetCore.Components.Authorization;
    namespace Authentification.Keycloak
    {
        public static class KeycloakConfiguration
        {
            const string authority = "http://localhost:8080/realms/OLP_Realm";
            const string clientId = "local_olp_client";
            const string policy = "MainPolicy";
            const string MS_OIDC_SCHEME = "MicrosoftOidc";
            public static IServiceCollection AddKeycloakAuthentificationService(this IServiceCollection services, ConfigurationManager configuration)
            {
                services.AddAuthentication(MS_OIDC_SCHEME)
                     .AddOpenIdConnect(MS_OIDC_SCHEME, oidcOptions =>
                     {
                         oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                         oidcOptions.Authority = authority;
                         oidcOptions.ClientId = clientId;
                         oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
                         oidcOptions.MapInboundClaims = false;
                         oidcOptions.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
                         oidcOptions.TokenValidationParameters.RoleClaimType = "role";
                         oidcOptions.SaveTokens = false;
                         oidcOptions.RequireHttpsMetadata = false;
                     }).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
                services.ConfigureCookieOidcRefresh(CookieAuthenticationDefaults.AuthenticationScheme, MS_OIDC_SCHEME);
                services.AddAuthorization();
                services.AddCascadingAuthenticationState();
                services.AddScoped<AuthenticationStateProvider, PersistingAuthenticationStateProvider>();
                return services;
            }
        }
    }
    

    Component

    @using Microsoft.AspNetCore.Authentication.Cookies
    @using Microsoft.AspNetCore.Authentication.OpenIdConnect
    @using Microsoft.AspNetCore.Components.Authorization
    @implements IDisposable
    @inject NavigationManager NavigationManager
    <div class="nav-item px-3">
        <AuthorizeView>
            <Authorized>
                <a class="nav-link" href="authentication/logout">
                    <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Logout
                </a>
            </Authorized>
            <NotAuthorized>
                <a class="nav-link" href="authentication/login">
                    <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
                </a>
            </NotAuthorized>
        </AuthorizeView>
    </div>
    @code {
        private string? currentUrl;
        protected override void OnInitialized()
        {
            currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
            NavigationManager.LocationChanged += OnLocationChanged;
        }
        private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
        {
            currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
            StateHasChanged();
        }
        public void Dispose()
        {
            NavigationManager.LocationChanged -= OnLocationChanged;
        }
    }
    

    // Class Handler

    using Microsoft.AspNetCore.Authentication;
    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.AspNetCore.Authentication.OpenIdConnect;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Components;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Routing;
    using System.Xml.Linq;
    namespace Authentification.Keycloak.Extensions;
    public static class LoginLogoutEndpointRouteBuilderExtensions
    {
        public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints)
        {
            var group = endpoints.MapGroup("");
            group.MapGet("/login", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl)))
                .AllowAnonymous();
            // Sign out of the Cookie and OIDC handlers. If you do not sign out with the OIDC handler,
            // the user will automatically be signed back in the next time they visit a page that requires authentication
            // without being able to choose another account.
          // group.MapGet("/logout", (string? returnUrl) => TypedResults.SignOut(GetAuthProperties(returnUrl), [CookieAuthenticationDefaults.AuthenticationScheme, "MicrosoftOidc"]));
            group.MapGet("/logout", async (HttpContext context, string? returnUrl) =>
            {
                // Retrieve the authentication result for the current user
                var result = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                if (result?.Principal != null)
                {
                    // Retrieve the ID token from the properties
                    var idToken = result.Properties.GetTokenValue("id_token");
                    if (idToken != null)
                    {
                        // Create the logout URL with the id_token_hint
                        var logoutUri = $"http://localhost:8080/auth/realms/OLP_Realm/protocol/openid-connect/logout?redirect_uri={returnUrl}";
                        // Sign out from both the local session and the OIDC provider
                        await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                        await context.SignOutAsync("MicrosoftOidc", new AuthenticationProperties { RedirectUri = logoutUri });
                        return Results.Redirect(logoutUri);
                    }
                }
                // If no ID token or principal, fallback to local sign out and redirect
                await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                return Results.Redirect(returnUrl ?? "/");
            });
            return group;
        }
        private static AuthenticationProperties GetAuthProperties(string? returnUrl)
        {
            // TODO: Use HttpContext.Request.PathBase instead.
            const string pathBase = "/";
            // Prevent open redirects.
            if (string.IsNullOrEmpty(returnUrl))
            {
                returnUrl = pathBase;
            }
            else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative))
            {
                returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery;
            }
            else if (returnUrl[0] != '/')
            {
                returnUrl = $"{pathBase}{returnUrl}";
            }
            return new AuthenticationProperties { RedirectUri = returnUrl };
        }
    }
    

    // App builder

    app.MapGroup("/authentication").MapLoginAndLogout();
    
    0 comments No comments

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.