How to persist claims added to identity authenticated by WS-Federation post-authentication

23 views Asked by At

This might seem on the surface like a dozen other questions about persisting claims but as far as I can tell it's actually unusual enough that nothing I've read online seems to address it.

My web app (.NET 7) supports two different authentication methods. Most users are required to login using our remote authentication provider, ADFS, which uses WS-Federation (in our case). However, small parts of the app are accessible to users who authenticate by a different means, entering an ID and password into a form which are then authenticated by a web service.

To achieve this, my program.cs contains code for two authentication schemes:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme;
}
).AddCookie(options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(Convert.ToInt32(builder.Configuration["AppConfiguration:AuthenticationCookieExpiryMinutes"]));
    options.SlidingExpiration = true;
    options.Cookie.Name = builder.Configuration["AppConfiguration:AuthenticationCookieName"];
})
.AddWsFederation(options =>
{
    options.Wtrealm = builder.Configuration["AppConfiguration:WtRealm"];
    options.MetadataAddress = builder.Configuration["AppConfiguration:WsFederationMetadataAddress"];
    options.Events = new WsFederationEvents()
    {
        OnRemoteFailure = async context =>
        {
            var authenticationHelper = context.HttpContext.RequestServices.GetRequiredService<IAuthenticationHelper>();
            await authenticationHelper.OnAuthenticationFailedAsync(context);
        },
        OnSecurityTokenValidated = async context =>
        {
            var claimsTransformation = context.HttpContext.RequestServices.GetRequiredService<IClaimsTransformation>();
            await claimsTransformation.TransformAsync(context.Principal);
        }
    };
});

I can then decorate my controller actions with AuthorizeAttributes that indicate which schemes are allowed for accessing a particular page. For example:

[Authorize(AuthenticationSchemes = WsFederationDefaults.AuthenticationScheme)]

ensures that the action will only be processed if the user has authenticated using ADFS. For actions which are also accessible by users of our in-house authentication method, I omit the AuthenticationSchemes parameter, and some custom middleware that I've written directs users to a page where they must choose how to authenticate. Depending on which option they select, they're either redirected into the ADFS authentication flow or taken to the form where they can enter their ID and password.

This all works really well. However, what I'm now trying to do is add custom claims to the ClaimsIdentity after the user is already authenticated, in response to an action that they take in the UI. So, at least for now while I try and figure this out, I've got some code in my controller like this:

[HttpPost]
public async Task<IActionResult> AddTestClaim(TestClaimsViewModel model)
{
    if (ModelState.IsValid)
    {   
        string? authenticationType = HttpContext.User.Identities.First().AuthenticationType;
        var identity = new ClaimsIdentity(authenticationType);
        var claimsPrincipal = new ClaimsPrincipal(identity);
        identity.AddClaim(new Claim("TestClaim", model.TestClaimValue));
        
        await HttpContext.SignInAsync(claimsPrincipal);
        return RedirectToAction("Index", "Home");
    }

    return View(model);
}

As I understand it, the call to SignInAsync is required in order to persist the new claims, and this is supported by what I see when I step through the code. There is a problem, though.

When the RedirectToAction method is called it hits the Index action in the Home controller, which is decorated with [Authorize(AuthenticationSchemes = WsFederationDefaults.AuthenticationScheme)]. At that point, as far as I can tell, a call is made to the remote authentication provider (ADFS), which returns a new ClaimsPrinciapl (or, at least, a new ClaimsIdentity) that has the default set of claims, wiping out my custom claims. I'm not 100% sure why ADFS gets called at this point, but I presume it's because the principal is now authenticated with the default authentication scheme, Cookies, whereas the action requires authentication with WS-Federation.

I thought this might be a quick fix. Just create my ClaimsIdentity with the correct authentication scheme so that it matches the requirements of the Authorize attribute, like so:

var identity = new ClaimsIdentity(WsFederationDefaults.AuthenticationScheme);

and I'd be good to go. Unfortunately, doing this causes an exception to be thrown:

The authentication handler registered for scheme 'WsFederation' is 'WsFederationHandler' which cannot be used for SignInAsync. The registered sign-in schemes are: Cookies.

I can't find any information about this specific error online, but I interpret it to mean that the SignInAsync function basically can't be used with the WS-Federation authentication scheme because it doesn't have a handler for signing in.

So basically I'm stuck. I need to persist the claims I'm adding. To persist them I need to call SignInAsync (I can't find any alternative specific to WS-Federation). Calling SignInAsync creates a ClaimsIdentity with the Cookies authentication scheme, which means that my Authorize attribute requiring the WS-Federation authentication scheme forces a call to ADFS, which wipes out my new identity with its custom claims. And I can't change the Authorize attribute because then my app's authentication mechanism won't work (I did try removing the authentication scheme parameter from the Authorize attribute as a test and the problem went away, but obviously that allows access to the page with the non-ADFS authentication method, which is wrong).

0

There are 0 answers