dotnet/systemweb-adapters

Guidance on various IdentityModel implementations, and Session sharing for Blazor

zachrybaker opened this issue · 1 comments

Summary

For a few reasons, session synchronization between the ASP.NET web forms app and a Blazor server app gaps to mind. Like sharing the session id, for starters. Or sharing other pieces of information such as claims.

I've confirmed that the RemoteAppAuthenticationHttpHandler doesn't have utility to addressing problems I hoped that it would, as in my experience it has no visibility into the claims collection for a Blazor app at the time it executes. Having the claims collection is a very useful feature that could be leveraged, perhaps...

For someone that hasn't been read-in on the various states of change over the last twenty years to the Identity model transitions that Microsoft has made, including the various namespaces and implementations, there can be a nice time waste sorting that out. Sometimes it is as much about what NOT to do as what to do. I wanted to describe a gap and what I eventually pieced together to bridge the gap.

Motivation and goals

This dovetails into the differences between the Microsoft.IdentityModel and the System.Security.Claims implementations, whether you really need to move to OWIN to get claims, etc.

Perhaps getting the webforms app to use OWIN is the prescribed answer, but this does not appear to be a small matter for systems still using ASP.NET Membership back end.

Given that Blazor Server apps are not well-supported (yet) with the adapters - at least for session interop due to limitations in bridging the gap between HTTPContexts and circuits - I was hoping to add the aspnet session id or other identifier as a claim (userdata) so that I could pull this out of the ClaimsIdentity on the Blazor side to enable a pubsub scenario to share session data by way of Redis and a modified Redis session provider that was cooperative with the scheme.

I believed that if someone could easily write their own RemoteAppAuthenticationHttpHandler, maybe the below code in fact, if the IHttpHandler was DI'ed for the RemoteAppAuthenticationModule. Maybe, maybe not.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Web;

/// <summary>
/// HTTP handler for serving requests to remote authenticaiton endpoint.
/// </summary>
public class CanopyRemoteAppAuthenticationHttpHandler : IHttpHandler
{
    public bool IsReusable => true;

    public void ProcessRequest(HttpContext context)
    {
        if (context is null)
        {
            return;
        }

        // Setting 401 signals to other components (such as OWIN auth handlers) that authentication is required.
        // Those components can make updates, as needed, based on the specific auth process being used (such as
        // changing to a 302 status code or adding WWW-Authenticate or Location headers.
        context.Response.StatusCode = 401;


        if (context.User.Identity != null)
        {
            if ((context.User is Microsoft.IdentityModel.Claims.ClaimsPrincipal) &&
                ((Microsoft.IdentityModel.Claims.ClaimsIdentity)HttpContext.Current.User.Identity).IsAuthenticated)
            {
                var identity = (Microsoft.IdentityModel.Claims.ClaimsIdentity)HttpContext.Current.User.Identity;

                context.Response.StatusCode = 200;
                context.Response.ContentType = "application/octet-stream";
                
                var claims = identity.Claims.Select(x => new System.Security.Claims.Claim(x.ClaimType, x.Value)).ToList();
                var claimsPrincipal = new ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(HttpContext.Current.User.Identity, claims));
               
                using (var writer = new BinaryWriter(context.Response.OutputStream))
                {
                    claimsPrincipal.WriteTo(writer);
                }
            }
            // If a user is logged in (using ASP.NET's usual authentication mechanisms), return that claims principal.
            else if (context.User.Identity is System.Security.Principal.IIdentity 
                && context.User.Identity.IsAuthenticated)
            {
                context.Response.StatusCode = 200;
                context.Response.ContentType = "application/octet-stream";
                var claimsPrincipal = context.User as ClaimsPrincipal ?? new ClaimsPrincipal(context.User.Identity);
                using( var writer = new BinaryWriter(context.Response.OutputStream))
                { 
                    claimsPrincipal.WriteTo(writer);
                }
            }
        }

        context.ApplicationInstance.CompleteRequest();
    }
}

I have solved the problem, but wish to document it for others who could benefit from an explanation for the scenario I wished to recreate.

The short version is this: integrate System.IdentityModel into your asp.net web forms application and in your forms-based authentication use the FederatedAuthentication.SessionAuthenticationModule to set the auth cookie rather than the traditional FormsAuthenticationModule-based approach. And when you do so, use claims. It turns out that when you do this - when you use claims - you can create a Blazor CircuitHandler to intercept the authentication state change on that side, and do what you need to with it. So if you want to share a session, you can by passing it as a claim. Hacky but it works.

Avoid the Microsoft.Identity namespace and any blogs referencing it. Dead in the water on the Blazor side. Avoid trying to not use claims - shoving data into the UserData field of the traditional forms authentication cookie will only help you on the web forms side, it will not result in an identity that appears authenticated on the Blazor side. Just take it to claims, at least. But you can stop there - no need to migrate to OWIN to get claims.

ASP.NET web forms side:

The ASP.NET side of this equation is captured really well by this blog post by Martin Willey but in case it goes away, here's the pertinent bits:

Login Page:

using System.IdentityModel.Services;
using System.IdentityModel.Tokens;

... set your cookie like so, not using the traditional FormsAuthentication module, but instead the sessionAuthenticationModule:
 var claims = new List<Claim>();
 claims.Add(new Claim(ClaimTypes.Name, uin));
 claims.Add(new Claim(ClaimTypes.Actor, famisID));
 claims.Add(new Claim(ClaimTypes.Sid, Session.SessionID));
 claims.Add(new Claim(ClaimTypes.Role, "user"));
 //needs an authentication issuer otherwise not authenticated
 var claimsIdentity = new ClaimsIdentity(claims, "Forms");
 var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
 var sessionAuthenticationModule = FederatedAuthentication.SessionAuthenticationModule;
 var token = new SessionSecurityToken(claimsPrincipal);
 sessionAuthenticationModule.WriteSessionTokenToCookie(token);

Web.config:
add a configSections node in the root of your configuration:

<configSections>
	<section name="system.identityModel" type="System.IdentityModel.Configuration.SystemIdentityModelSection, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />
	   <section name="system.identityModel.services" type="System.IdentityModel.Services.Configuration.SystemIdentityModelServicesSection, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />
</configSections>

Your forms authiction node should be able to stay as it has always been. In <system.web> add an the httpModule:

<httpModules>
 <add name="SessionAuthenticationModule" type="System.IdentityModel.Services.SessionAuthenticationModule, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>
</httpModules>

in <system.webServer> add this module:

<add name="SessionAuthenticationModule" type="System.IdentityModel.Services.SessionAuthenticationModule, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>

lastly, in the root of your perhaps at the bottom add this:

Blazor Side:

As for the blazor side, follow the directions as prescribed to implement a CircuitHandler to intercept the authentication state change. The UserService pattern should be useful to you. If you make this service a cascading parameter, or use the event pattern, you can handle changes as that authentication state changes.

I hope this helps people. The documentation on Blazor session state seems to be rather disjointed and in need of cleanup as it stands at this moment, and the documentation of how to handle auth and session interop between web forms and blazor could do well to spell out this recipe, IMO. Not that people should necessarily shove session ID in a claim - a little hacky - but until session handling is better supported and spelled out in blazor for this scenario, it works for me.

From here I plan to modify an ASP.NET session state provider (if necessary) as well as a Blazor session service that will pubsub with Redis to share session state across apps.