Question

I am modifying an existing system that uses SharePoint 2013 with Forms-Based Authentication (FBA) to allow users to self-service creation of accounts and management of the accounts. One of the things they can manage is role membership; effectively, they are given a role access code which they supply to the system, and it adds them to the role, allowing them to access sites which the role has been granted access to. Part of the solution is a custom login screen to help support all of this and make it more user friendly. All of this is working relatively well.

The task I am working on it adding support for external authentication providers rather than the forms-based authentication - this is a business requirement. There is plenty of online information (e. g. http://blogs.msdn.com/b/mvpawardprogram/archive/2011/06/17/mvps-for-sharepoint-2010-using-azure-acs-v2-to-authenticate-external-systems-users.aspx) on how to add Azure ACS as an identity provider and that's working. The issue is now how to add the self-service role authorization piece.

I've gotten simple claims augmentation working fine following the guide at http://www.microsoft.com/en-us/download/details.aspx?id=27569. The problem I'm hitting is the "common" issue of the in-box STS doing caching on the token, so when a user's role membership is changed, they get the old cached token still. My claims augmentation code isn't called (of course not - why would it be when the STS is using a cached ticket), so the change in membership isn't being seen.

This is pretty much the same kind of issue discussed at check permissions showing incorrect information and http://www.shillier.com/archive/2010/10/25/authorization-failures-with-claims-based-authentication-in-sharepoint-2010.aspx, with the common answer of changing the token lifetime given. I have a few issues with and questions about this.

  1. In the external claims scenario, what is the lifetime applied? It's not a Windows token, and it's not an FBA token, so neither WindowsTokenLifetime nor FormsTokenLifetime seem appropriate, although those are the two everyone says to change.

  2. I don't want the token to expire for the end user in an unreasonably short time, I just want the STS to expire the cached version or ideally never cache it at all (I know there's a performance issue there - I don't care, for our scale it won't matter). It seems like those two things are coupled, though. Is there a way to decouple them?

  3. If I can't decouple, is there a way for the login screen to indicate to the system that I want the claim to be re-augmented? Can I just to it myself by modifying the current user identity? That's going behind the system's back of course so I don't want to do that if I can help it.

  4. All of this seems to ultimately come down to the caching in the OOB STS since all of the other moving parts seem to be right. Is the "right" way to avoid this to create a custom STS that doesn't have the caching behavior and register it as an identity provider? (If I have a custom STS it seems like I wouldn't bother with claims augmentation at that point, I'd just issue all the proper claims in the first place.) That seems like a substantial amount of work though (although I know there are samples including http://archive.msdn.microsoft.com/SelfSTS), especially since it seems like I've have to hook up the relying party connection to Azure ACS then as part of the process, where the OOB STS does that "magically" with a few lines of PowerShell and some effort.

  5. Or, do I need to abandon claims and simply change the FBA side? That feels like a horrible way to go since now I need to hook up all of the OpenID stuff in my authentication code for FBA, and I'm moving backwards away from claims when I know I should be moving to claims.

A delay of say 60 minutes is simply not acceptable when in the FBA model I can make the change immediately by forcing the user to sign in again, which gets the new role membership.

Was it helpful?

Solution

OK, I got this working (yay!), but man, it was tough.

Basically, you need to force the token to be re-issued from the STS, which forces the claims augmentation code to run again. In my case, the business requirements were a little more complicated than that, because I needed to actually only have one of the claims possible, but that's outside the immediate scope of this question.

Essentially, I went off of the code mentioned at https://stackoverflow.com/questions/8070456/re-calculate-claims-for-sharepoint-2010-user-while-the-user-is-still-logged-in/8185646#8185646 . One thing I had to do was remove the e.ReissueCookie since that completely broke authentication for me (the user was logged out by that action). In my case, I didn't want to do it through a special page, so I used a user-specific value in the Application collection that I immediately clear out after acting on it. The relevant code in my event receiver that adds and removes the code in global.asax follows:

        private readonly string AsaxText = @"
<%@ Assembly Name=""" + System.Reflection.Assembly.GetAssembly(typeof(ClaimSupport)).FullName + @"""%>
<script runat='server'> 
    void SessionAuthentication_SessionSecurityTokenReceived(object sender, Microsoft.IdentityModel.Web.SessionSecurityTokenReceivedEventArgs e) { 
        var application = HttpContext.Current.Application;
        if (application == null) {
            return;
        }
        var key = Company.SharePoint.Authentication.Data.ClaimSupport.ApplicationEventKey(e.SessionToken.ClaimsPrincipal.Identity.Name);
        var applicationValue = application[key];
        if (applicationValue == null) { 
            return;
        }
        var applicationString = applicationValue.ToString();
        if (string.IsNullOrWhiteSpace(applicationString)) {
            return;
        }
        application[key] = null;
        var sam = sender as Microsoft.IdentityModel.Web.SessionAuthenticationModule; 
        var logonWindow = Microsoft.SharePoint.Administration.Claims.SPSecurityTokenServiceManager.Local.LogonTokenCacheExpirationWindow; 
        var newValidTo = System.DateTime.UtcNow.Add(logonWindow);
        var currentPrincipal = e.SessionToken.ClaimsPrincipal;
        var claimsIdentity = (Microsoft.IdentityModel.Claims.IClaimsIdentity)currentPrincipal.Identity;

        var heartbeatClaim = GetHeartbeatClaim(claimsIdentity);
        var issuer = heartbeatClaim.Issuer;
        var originalIssuer = heartbeatClaim.OriginalIssuer;
        RemoveExistingEventClaims(claimsIdentity);
        AddEventClaim(claimsIdentity, applicationString, issuer, originalIssuer);

        e.SessionToken = sam.CreateSessionSecurityToken( 
            currentPrincipal, 
            e.SessionToken.Context, 
            e.SessionToken.ValidFrom, 
            newValidTo, 
            e.SessionToken.IsPersistent); 
        //e.ReissueCookie = true; - commented out because it broke things for me, but kept for reference
    } 

    private Microsoft.IdentityModel.Claims.Claim GetHeartbeatClaim(Microsoft.IdentityModel.Claims.IClaimsIdentity claimsIdentity) {
        var heartbeatClaim = (from c in claimsIdentity.Claims
                              where
                                  (c.ClaimType == Company.SharePoint.Authentication.Data.ClaimSupport.EventClaimType)
                              &&
                                  (c.Value == Company.SharePoint.Authentication.Data.ClaimSupport.HeartbeatClaimValue)
                              select c).FirstOrDefault();
        return heartbeatClaim;
    }

    private void AddEventClaim(Microsoft.IdentityModel.Claims.IClaimsIdentity claimsIdentity, string additionalEvent, string issuer, string originalIssuer) {
        var eventClaim = new Microsoft.IdentityModel.Claims.Claim(Company.SharePoint.Authentication.Data.ClaimSupport.EventClaimType, additionalEvent, HynesITe.SharePoint.Authentication.Data.ClaimSupport.EventClaimValueType, issuer, originalIssuer);
        claimsIdentity.Claims.Add(eventClaim);
    }

    private static void RemoveExistingEventClaims(Microsoft.IdentityModel.Claims.IClaimsIdentity claimsIdentity) {
        var currentClaims = (from c in claimsIdentity.Claims
                             where
                                 (c.ClaimType == HynesITe.SharePoint.Authentication.Data.ClaimSupport.EventClaimType)
                             &&
                                 (c.Value != HynesITe.SharePoint.Authentication.Data.ClaimSupport.HeartbeatClaimValue)
                             select c).ToList();
        foreach (var claim in currentClaims) {
            claimsIdentity.Claims.Remove(claim);
        }
    }
</script> 
";

        private void AddGlobalAsax(SPFeatureReceiverProperties properties) {
            var webApp = properties.Feature.Parent as SPWebApplication;
            if (webApp == null) {
                throw new SPException("Cannot add global.asax entries.");
            }
            var zones = Enum.GetValues(typeof(SPUrlZone)).Cast<SPUrlZone>().ToArray();
            var paths =
                zones.Select(z => Path.Combine(webApp.GetIisSettingsWithFallback(z).Path.ToString(), "global.asax"))
                    .Distinct().Where(File.Exists).ToArray();
            var globalAsaxFiles = new List<string>();
            globalAsaxFiles.AddRange(paths);
            foreach (var asax in from asax in globalAsaxFiles
                                 let contents = File.ReadAllText(asax)
                                 where !contents.Contains(AsaxText)
                                 select asax) {
                File.AppendAllText(asax, AsaxText);
            }
        }

        private void RemoveGlobalAsax(SPFeatureReceiverProperties properties) {
            var webApp = properties.Feature.Parent as SPWebApplication;
            if (webApp == null) {
                throw new SPException("Cannot add global.asax entries.");
            }
            var zones = Enum.GetValues(typeof(SPUrlZone)).Cast<SPUrlZone>().ToArray();
            var paths =
                zones.Select(z => Path.Combine(webApp.GetIisSettingsWithFallback(z).Path.ToString(), "global.asax"))
                    .Distinct().Where(File.Exists).ToArray();
            var globalAsaxFiles = new List<string>();
            globalAsaxFiles.AddRange(paths);
            foreach (var asax in globalAsaxFiles) {
                var contents = File.ReadAllText(asax);
                if (contents.Contains(AsaxText)) {
                    var replaced = contents.Replace(AsaxText, string.Empty);
                    File.WriteAllText(asax, replaced);
                }
            }
        }

        public override void FeatureActivated(SPFeatureReceiverProperties properties) {
            // other stuff
            AddGlobalAsax(properties);
            // other stuff
        }

        public override void FeatureDeactivating(SPFeatureReceiverProperties properties) {
            // other stuff
            RemoveGlobalAsax(properties);
            // other stuff
        }

The ClaimSupport assembly holds some miscellaneous shared code:

namespace HynesITe.SharePoint.Authentication.Data {
    public static class ClaimSupport {
        public static string EventClaimType {
            get {
                return "http://schema.Company.com/events";
            }
        }

        public static string EventClaimValueType {
            get {
                return Microsoft.IdentityModel.Claims.ClaimValueTypes.String;
            }
        }

        public static string ApplicationEventKey(string username) {
            return username + ":CurrentEvent";
        }

        public static string ApplicationIssuerKey(string username) {
            return username + ":Issuer";
        }

        public static string ApplicationOriginalIssuerKey(string username) {
            return username + ":OriginalIssuer";
        }

        public static string HeartbeatClaimValue {
            get {
                return "[heartbeat]";
            }
        }
    }
}

One thing that's really important here is that if you are manipulating claims, as I am here, and you expect those claims to be recognized by SharePoint, you need to match the issuer information that SharePoint would use with an SPClaim, which is why the "heartbeat" claim. In most cases you won't care about any of that - the claims augmenter will do the right thing - but here, I need to only have one of the possible set of claims, so I had to manipulate directly in this code instead of in the claims augmentation code.

Also I know you can import namespaces to global.asax but I'm concerned about other code or user manipulations of that file, so I made the changes as independent as possible.

Licensed under: CC-BY-SA with attribution
Not affiliated with sharepoint.stackexchange
scroll top