asp.net-coreopenid-connectmicrosoft-entra-idmicrosoft-identity-web

ASP.NET Core 9 + Entra ID Front-channel logout not working


I have an ASP.NET Core 9 app which is integrated via Azure Entra ID.

I'm following this tutorial:

https://learn.microsoft.com/en-us/entra/identity-platform/scenario-web-app-sign-user-sign-in?tabs=aspnetcore

In Azure Entra ID the "Front-channel logout URL" is configurated to

https://localhost:7114/signout-oidc

Entra ID is injected as shown here:

public static void InjectEntraID(
        this WebApplicationBuilder builder,
        IConfigurationRoot config,
        [CallerMemberName] string caller = "")
{
    IEnumerable<string>? initialScopes = builder
            .Configuration["DownstreamApi:Scopes"]
            ?.Split(' ');

    builder
        .Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration, "EntraID")
        .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
        .AddDownstreamApi(
                "DownstreamApi",
                builder.Configuration.GetSection("DownstreamApi")
            )
        .AddInMemoryTokenCaches();
}

public static void InjectAspNet(
    this WebApplicationBuilder builder,
    IConfigurationRoot config,
    [CallerMemberName] string caller = "")
{
    // Add services to the container.
    builder
        .Services.AddRazorPages()
            .AddMvcOptions(options =>
            {
                var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .Build();
                options.Filters.Add(new AuthorizeFilter(policy));
            })
            .AddMicrosoftIdentityUI()
            .AddMvcLocalization()
            .AddViewLocalization()
            .AddDataAnnotationsLocalization();

    builder.Services.AddSignalR(e =>
        {
            e.EnableDetailedErrors = true;
            e.MaximumReceiveMessageSize = 102400000;
        });
}

The problem is that the token/cookie is still valid after sign out.

To reproduce: make an ajax call to a method which uses user identity, create CURL from the request and import it in a tool like PostMan.

Sample:

curl --location 'https://localhost:7114/XXX?handler=AllFiles&sessionId=75dd1c26-19c3-44c6-ad2f-b548a959e042&sessionLang=en-US&instance=dev&searchQuery=' \
--header 'accept: application/json, text/javascript, */*; q=0.01' \
--header 'accept-language: en-US,en;q=0.9' \
--header 'priority: u=1, i' \
--header 'referer: https://localhost:7114/XXX?instance=dev&culture=en-US&sessionId=75dd1c26-19c3-44c6-ad2f-b548a959e042' \
--header 'requestverificationtoken: CfDJ8GQXXXXXXXXXX.......' \
--header 'sec-ch-ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' \
--header 'sec-ch-ua-mobile: ?0' \
--header 'sec-ch-ua-platform: "macOS"' \
--header 'sec-fetch-dest: empty' \
--header 'sec-fetch-mode: cors' \
--header 'sec-fetch-site: same-origin' \
--header 'Cookie: acknowledged-Terms of Use-1.0=true; .AspNetCore.Cookies=chunks-2; .AspNetCore.CookiesC1=CfDoYbp.........; .AspNetCore.CookiesC2=7LEbP2J.........'

I could validate the remote call from Azure and signout-callback-oidc call after logout via client side network and ASP.NET Core app logs in terminal:

curl 'https://localhost:7114/signout-callback-oidc?state=CfDJ8GQMaRK5om......' \
  -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
  -H 'accept-language: en-US,en;q=0.9' \
  -b 'acknowledged-Terms of Use-1.0=true' \
  -H 'priority: u=0, i' \
  -H 'referer: https://login.microsoftonline.com/' \
  -H 'sec-ch-ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  -H 'sec-fetch-dest: document' \
  -H 'sec-fetch-mode: navigate' \
  -H 'sec-fetch-site: cross-site' \
  -H 'upgrade-insecure-requests: 1' \
  -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'

My expectation is that based on documentation the token should be removed from cache in server side and be invalid by remote logout, and no one can use the cookie to make a successful request with answer.


Solution

  • Finally I found a solution, it should be implemented with a Middleware like bellow:

    public class TokenValidationMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IStorageBroker<EntraIDTokenCache, string> _storageBrokerEntraIDTokenCache;
    
        public TokenValidationMiddleware(RequestDelegate next, IStorageBroker<EntraIDTokenCache, string> storageBrokerEntraIDTokenCache)
        {
            _next = next;
            _storageBrokerEntraIDTokenCache = storageBrokerEntraIDTokenCache;
    
            ArgumentNullException.ThrowIfNull(storageBrokerEntraIDTokenCache, nameof(storageBrokerEntraIDTokenCache));
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
            var user = context.User;
    
            // Get authentication properties from the cookie
            var authResult = await context.AuthenticateAsync();
    
            if (IsAuthenticated(authResult))
            {
                var uid = user.FindFirst("uid")?.Value;
                var utid = user.FindFirst("utid")?.Value;
                var tokenKey = $"{uid}.{utid}";
                var tokenCache = await _storageBrokerEntraIDTokenCache.SelectByIdAsync(tokenKey, tokenKey);
                if (tokenCache == null)
                {
                    // Token not found in cache, sign out the user
                    await context.SignOutAsync();
                    context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    await context.Response.WriteAsync("Unauthorized: Token is invalid.");
                    return;
                }
            }
    
            await _next(context);
        }
    
        private bool IsAuthenticated(AuthenticateResult? authResult)
        {
            if (authResult == null || !authResult.Succeeded || authResult.Properties == null)
                return false;
    
            return true;
        }
    }
    
    

    If the Front-channel logout is configured correctly in Entra ID, the user token will be removed from Distributed Cache provider (In my case CosmosDB) and by every new coming request from user the TokenValidationMiddleware will check if it is still existing in cache provider, if not then it's not valid anymore!