3

On Sitecore 9.0.1 and SXA 1.6 I'm trying to get federated authentication to work for website visitors using OWIN WSFed following this guide in a multi-site SXA solution.

I've gotten everything to work using a page with a controller rendering pointing to an action with the [Authorize] attribute, but that's not quite ideal and in stead I want to use sign-in links described in the linked chapter above.

The problem I'm facing is the link is generated correctly (/identity/externallogin?authenticationType=[etc]), but when I click on it I get an IIS 404 page, leading me to believe OWIN is not actually listening here. I've copied the HandleLoginLink class into my own solution so I can debug it so I can verify OWIN is, indeed, listening at this path. But the way it is implemented OWIN only listens for a path and not a complete URL so it should work for all my sites right? Well...

Now this link is generated and presented on mySite, so the complete link when clicked is http://www-local.mysite.sc/identity/externallogin?authenticationType=[etc]. When I however change the link to include the hostname of another site, http://www-local.othersite.sc/identity/externallogin?authenticationType= and it points to the url of another configured site, suddenly I do enter the OWIN middleware! It returns a 401 but that's expected because I haven't configured fedauth on that site and, moreover, a couple of querystring parameters still reference mySite, but at least something is answering.

Now for my question: Why is OWIN only listening on the sites that don't have a MapEntry in the IdentityProvidersPerSite config?

Here's my config:

<federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
  <identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
    <mapEntry name="ADFS" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
      <sites hint="list">
        <site>MySite</site>
      </sites>
      <identityProviders hint="list:AddIdentityProvider">
        <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='ADFS']" />
      </identityProviders>
      <externalUserBuilder type="Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder, Sitecore.Owin.Authentication">
        <param desc="isPersistentUser">false</param>
      </externalUserBuilder>
    </mapEntry>
  </identityProvidersPerSites>

  <identityProviders hint="list:AddIdentityProvider">
    <identityProvider id="ADFS" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
      <param desc="name">$(id)</param>
      <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
      <caption>Log in with DigiD</caption>
      <icon>/sitecore/shell/themes/standard/Custom/24x24/msazure.png</icon>
      <domain>sitecore</domain>
      <transformations hint="list:AddTransformation">
        <!--don't need this anymore, all shared transformations will be done automatically-->
        <!--<transformation name="set idp claim" ref="federatedAuthentication/sharedTransformations/setIdpClaim" />-->
      </transformations>
    </identityProvider>
  </identityProviders>

  <sharedTransformations hint="list:AddSharedClaimsTransformation">
    <setIdpClaim name="setIdpClaim" type="Sitecore.Owin.Authentication.Services.SetIdpClaimTransform, Sitecore.Owin.Authentication"/>
  </sharedTransformations>

  <propertyInitializer type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
    <maps hint="list">
    </maps>
  </propertyInitializer>
</federatedAuthentication>

<settings>
  <setting name="FederatedAuthentication.Enabled">
    <patch:attribute name="value">true</patch:attribute>
  </setting>
</settings>

<services>
  <register serviceType="Sitecore.Abstractions.BaseAuthenticationManager, Sitecore.Kernel"
            implementationType="Sitecore.Owin.Authentication.Security.AuthenticationManager, Sitecore.Owin.Authentication"
            lifetime="Singleton" />
  <register serviceType="Sitecore.Abstractions.BaseTicketManager, Sitecore.Kernel"
            implementationType="Sitecore.Owin.Authentication.Security.TicketManager, Sitecore.Owin.Authentication"
            lifetime="Singleton" />
  <register serviceType="Sitecore.Abstractions.BasePreviewManager, Sitecore.Kernel"
            implementationType="Sitecore.Owin.Authentication.Publishing.PreviewManager, Sitecore.Owin.Authentication"
            lifetime="Singleton" />
</services>

<pipelines>
  <owin.identityProviders>
    <processor type="Foundation.Authentication.Pipelines.AdfsIdentityProvidersProcessor, Foundation.Authentication" resolve="true">
      <metadataAddress>https://acc.sts.company.nl/FederationMetadata/2007-06/federationmetadata.xml</metadataAddress>
      <sites hint="raw:AddSite">
        <mysite debug="true"> <!--Specify debug to use manual wsfederation metadata for ACC-->
          <name>MySite</name> 
          <hostname>www-local.mysite.sc</hostname>
          <wtrealm>urn:acc-sitecore.company.nl</wtrealm>
          <wreply>http://www-local.mysite.sc/login</wreply>
          <sessionExpiration>00:15:00</sessionExpiration>
        </mysite>
      </sites>
    </processor>
  </owin.identityProviders>

EDIT: I figured my code for the IdentityProviderProcessor wouldn't be amiss too:

public class AdfsIdentityProvidersProcessor : IdentityProvidersProcessor
{
    public string MetadataAddress { get; set; }

    protected override string IdentityProviderName => "ADFS";

    private List<SiteConfiguration> Sites = new List<SiteConfiguration>();

    public AdfsIdentityProvidersProcessor(FederatedAuthenticationConfiguration federatedAuthenticationConfiguration)
        : base(federatedAuthenticationConfiguration)
    {
    }

    protected override void ProcessCore(IdentityProvidersArgs args)
    {
        ConfigureAuth(args.App);
    }

    private void ConfigureAuth(IAppBuilder app)
    {
        foreach (var site in Sites)
        {
            app.MapWhen(ctx => ctx.Request.Host.Value.Equals(site.Hostname), siteApp =>
            {
                siteApp.UseWsFederationAuthentication(CreateWsFederationAuthenticationOptions(site));
            });
        }
    }

    private WsFederationAuthenticationOptions CreateWsFederationAuthenticationOptions(SiteConfiguration siteConfig)
    {
        var options = new WsFederationAuthenticationOptions
        {
            AuthenticationType = WsFederationAuthenticationDefaults.AuthenticationType,
            Wtrealm = siteConfig.Wtrealm,
            Wreply = siteConfig.Wreply,
            MetadataAddress = MetadataAddress,
            UseTokenLifetime = true,
            SignInAsAuthenticationType = GetAuthenticationType(),
            Notifications = new WsFederationAuthenticationNotifications
            {
                SecurityTokenValidated = (context) =>
                {
                    context.AuthenticationTicket.Identity.ApplyClaimsTransformations(new TransformationContext(FederatedAuthenticationConfiguration, GetIdentityProvider()));
                    return Task.CompletedTask;
                }
            }
        };

        if (siteConfig.Debug)
        {
            options.Configuration = new Microsoft.IdentityModel.Protocols.WsFederationConfiguration
            {
                Issuer = "http://acc.sts.company.nl/adfs/services/trust",
                TokenEndpoint = "https://acc.sts.company.nl/adfs/ls/"
            };
            var cert = new X509Certificate2(Convert.FromBase64String("[certificate]"));
            var key = new X509SecurityKey(cert);
            options.Configuration.SigningKeys.Add(key);
        }

        return options;
    }

    public void AddSite(string key, XmlNode node)
    {
        AddSite(node);
    }

    public void AddSite(XmlNode node)
    {
        Assert.IsTrue(XmlUtil.HasChildNode("wtrealm", node), $"ADFS site config has no wtrealm node: {node.OuterXml}");
        Assert.IsTrue(XmlUtil.HasChildNode("hostname", node), $"ADFS site config has no hostname node: {node.OuterXml}");
        var expiration = new TimeSpan(0, 15, 0);
        if (XmlUtil.HasChildNode("sessionExpiration", node))
        {
            if (TimeSpan.TryParse(XmlUtil.GetChildValue("sessionExpiration", node), out var newExpiration))
            {
                expiration = newExpiration;
            }
            else
            {
                Log.Warn("sessionExpiration failed to parse. Please use hh:mm:ss format", this);
            }
        }
        Sites.Add(new SiteConfiguration
        {
            Name = XmlUtil.GetChildValue("name", node),
            Hostname = XmlUtil.GetChildValue("hostname", node),
            Wtrealm = XmlUtil.GetChildValue("wtrealm", node),
            Wreply = XmlUtil.GetChildValue("wreply", node),
            Debug = XmlUtil.GetAttribute("debug", node, "false") == "true",
            ExpirationTime = expiration
        });
    }
}

And the part that generates the login link:

public class AdfsAuthenticationController : StandardController
{
    public ActionResult AuthenticationTest()
    {
        return View("~/Views/Authentication/AuthenticationTest.cshtml");
    }

    public override ActionResult Index()
    {
        var url = "/";
        if (!string.IsNullOrEmpty(Request.QueryString?["ReturnUrl"]))
        {
            url = Request.QueryString["ReturnUrl"];
        }

        var corePipelineManager = DependencyResolver.Current.GetService<BaseCorePipelineManager>();
        var args = new GetSignInUrlInfoArgs(Context.Site.Name, url);
        GetSignInUrlInfoPipeline.Run(corePipelineManager, args);

        return View("~/Views/Authentication/LoginLinks.cshtml", args.Result);
    }
}

And its view (yeah I verbatim copied some stuff, but hey we never got ahead by re-inventing the wheel over and over right)

@using Sitecore
@using Sitecore.Mvc

@model IEnumerable<Sitecore.Data.SignInUrlInfo>

<div id="loginButtons">
    @foreach (var signIn in Model)
    {
        using (Html.BeginForm(null, null, FormMethod.Post, new { @action = signIn.Href }))
        {
            <button type="submit">
                @signIn.Caption
            </button>
        }
    }
</div>
2
  • Did you check blog.nikkipunjabi.com/2018/03/… this might help Nov 2, 2018 at 9:56
  • Hm that seems to cover what I've got working mostly and I don't really see anything new that might explain my problem with the login links and OWIN listening at the /identity/ path. Thanks for helping though! Nov 2, 2018 at 10:37

3 Answers 3

4

There a few reasons why you are facing these issues:

  1. You are using the [Authorize] Attribute
  2. You are doing an own mapping
  3. Sitecore, Identity & Owin do work a little bit different that you would expect

The way Sitecore, Owin & Identity works

Sitecore has created a piece of pipelines and stagemarkers which are run during initialization of Sitecore. These stagemarkers basically “decide” when a certain piece of middleware (which is being injected by the pipelines) should be run. When following the general guidelines of creating an own identity provider you don’t have to use the “MapWhen” functions, as Sitecore and their pipelines will handle this for the largest part. Indeed, the largest part, as it doesn’t handle every use case. It does handle every explicit login request, not every implicit login request

The explicit login process

The signin link that you use omits Owin, it just generated the link /identity/externalcallback?...... with a bunch of information, including an encrypted returnUrl (as part of the OWIN context).

public virtual SignInUrlInfo GetLoginLink(string siteName, IdentityProvider identityProvider, string returnUrl, AuthenticationSource authenticationSource)
{
    Assert.ArgumentNotNullOrEmpty(siteName, "siteName");
    Assert.ArgumentNotNull(identityProvider, "identityProvider");
    NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(string.Empty);
    nameValueCollection["ReturnUrl"] = returnUrl;
    nameValueCollection["sc_site"] = siteName;
    nameValueCollection["authenticationSource"] = authenticationSource.ToString();
    string value = Settings.IdentityProcessingPathPrefix().EnsureTrailingSlash() + "externallogincallback" + "?" + nameValueCollection;
    nameValueCollection = HttpUtility.ParseQueryString(string.Empty);
    nameValueCollection["authenticationType"] = identityProvider.Name;
    nameValueCollection["ReturnUrl"] = value;
    nameValueCollection["sc_site"] = siteName;
    string href = Settings.IdentityProcessingPathPrefix().EnsureTrailingSlash() + "externallogin" + "?" + nameValueCollection;
    return new SignInUrlInfo(identityProvider.Name, href, identityProvider.Caption, identityProvider.Icon);
}

There is no OWIN involved in here, only some artificial creation of the login url and the url you came from, as part of the OWIN context. A returnUrl is being added as well, this is the actual “identity/externallogincallback….” To which the identityprovider should redirect you to. The place where the magic happens, is the is in this /identity/externallogincallback path. It took me quite some time to figure out where it was injected, but it all happens in the “handleloginlink” processor (which is registred as middleware using OWIN):

<processor type="Sitecore.Owin.Authentication.Pipelines.Initialize.HandleLoginLink, Sitecore.Owin.Authentication" resolve="true" patch:source="Sitecore.Owin.Authentication.config"/>

In this processor, two events are handled: the route to the identity provider and the route from the identity provider. The to-path does an actual implicit authentication challenge, creates the returnurl as an encrypted owin context property and, as the code is running inside the owin context, tells the owin context which provider should be used. The from-path handles the response from the identity provider, makes sure that the externalidentity gets mapped to a (virtual) Sitecore users and makes sure that the correct redirection will be executed.

In short: the login url that will be generated is an intermediate step to trigger the “real” authentication challenge.

The implicit login challenge

When using the authorize attribute, you are triggering an implicit login. Early in the process, this authorize attribute generated an “HttpUnauthorized” event and triggers an Authentication challenge which tells OWIN to do its job – and this is exactly where all goes wrong; OWIN isn’t aware of the current Sitecore context – (probably) the last registered middleware will be used to execute the authentication and the user will directly be redirected to the identity provider – not using the /identity/externallogin and thus nog setting the right callback path and identityprovider. All of this can be solved by catching this redirect early on in the process. Based on the type of authentication middleware that you are used, there might (or might not) be a Redirect event. Make sure to catch that event and execute the following code. It unprotects the current Owin Context, checks the actual return Url and sets it to the /identity/externallogincallback url. The actual page where the user came from will be added to the Authentication Properties. This way, the “handlelogin” link route will be “injected” into the implicit authentication flow, where you are sure that all Dark Sitecore Magic will be executed.

private Task OnRedirectToIdentityProvider(RedirectToIdentityProviderNotification<WsFederationMessage, WsFederationAuthenticationOptions> context)
        {
            LogMessage(context.ProtocolMessage.Parameters, "OnRedirectToIdentityProvider");
            if (context.ProtocolMessage.IsSignInMessage)
            {
                IReadableStringCollection query = context.Request.Query;
                string wreply = string.Empty;
                if (context.ProtocolMessage.Parameters.TryGetValue("wreply", out wreply))
                {
                    // Trick to support authorize attribute. We lead the callback from the idp to Sitecores externallogincallback
                    // url before it will return to it's returnurl
                    // Multisite is not supported with this piece of code.
                    string[] stateQueryString = context.ProtocolMessage.Wctx.Split('=');
                    string protectedState = stateQueryString[1];

                    AuthenticationProperties state = context.Options.StateDataFormat.Unprotect(protectedState);
                    string[] redirectUriQueryString = state.RedirectUri.Split('?');
                    string redirectState = redirectUriQueryString[0];                   
                    if (!redirectState.Contains("/identity/externallogincallback"))
                    {
                        NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(string.Empty);
                        nameValueCollection["ReturnUrl"] = state.RedirectUri;
                        nameValueCollection["authenticationSource"] = "Default";                       
                        //nameValueCollection["sc_site"] = siteName;
                        string value = "/identity/externallogincallback" + "?" + nameValueCollection;
                        state.RedirectUri = value;
                        context.ProtocolMessage.Wctx = stateQueryString[0] + "=" + context.Options.StateDataFormat.Protect(state);
                    }
                }
            }
1

In the end, I opted to create a page with just a controller rendering on it that handles checking logged in status, returning an HttpUnauthorizedResult if you aren't, which kicks off OWIN and the WsFederation login process. The effect is the same and even gives me more freedom in handling redirecting the user back into their workflow.

public ActionResult Login()
{
    if (!Sitecore.Context.User.IsAuthenticated)
    {
        // As part of the login process, save where the user originated from as this information is lost in all the redirects
        var url = GetReturnPath();
        if (!string.IsNullOrWhiteSpace(url))
        {
            var uri = new Uri(url, UriKind.Relative);
            // Only use relative uri for security
            if (!uri.IsAbsoluteUri)
            {
                HttpContext.Session.Add(SESSION_KEY, GetReturnPath());
            }
        }

        return new HttpUnauthorizedResult();
    }

    // After login, send user back from whence they came
    var returnUrl = HttpContext.Session[SESSION_KEY] as string ?? GetReturnPath();
    HttpContext.Session.Remove(SESSION_KEY);
    if (!string.IsNullOrEmpty(returnUrl))
    {
        return Redirect(returnUrl);
    }

    return Redirect("/");
}
0

I recently got Sitecore FedAuth to with WS-Fed. You should not set the AuthenticationType to WsFederationAuthenticationDefaults.AuthenticationType. Instead call the GetAuthenticationType() method. If you do not change this the external cookie will not get set after the token is posted back from ADFS and things will fail after that.

Also in the processcore method you should only set the WsFederationAuthenticationOptions not run the app.MapWhen.. etc code Sitecore will do this for you since you have set this in identityProvidersPerSites. The code in process core should be:

protected override void ProcessCore(IdentityProvidersArgs args)
{
    args.App.UseWsFederationAuthentication(CreateWsFederationAuthenticationOptions(siteConfig));
}
3
  • Hi, ExternalCookie is getting set for me but after that it falls into an infinite loop. Any idea?
    – NewbieHere
    Oct 7, 2021 at 13:22
  • Are any cookies getting blocked (chrome network tab --> select request --> cookies tab --> check "show filtered out request cookies"? What is the URL or URLs which are in the infinite loop?
    – Jeroen
    Oct 7, 2021 at 16:24
  • I can see 2 cookies in blocked list - ASP.NET_SessionId, SC_ANALYTICS_GLOBAL_COOKIE. And by infinite loop its like on hitting the login button, it goes to the microsoftonline end point then I enter my credentials and it comes back to the login page, then goes to the microsoftonline again and so on. I will update the question I asked here - sitecore.stackexchange.com/questions/29944/… and add the screenshots for cookie and loop. Please help.
    – NewbieHere
    Oct 8, 2021 at 10:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.