Federated Authentication in Sitecore 9
One of the great new features of Sitecore 9 is the new federated authentication system. You can plug in pretty much any OpenID provider with minimal code and configuration. In this blog I'll go over how to configure a sample OpenID Connect provider.
Configuration
There's a few different types of configuration that need to be done to get up and running. This takes a few web.config changes, a few app_config changes, and your own custom configurations.
Web.config Changes
First up is disabling forms authentication. Do this by changing the authentication mode to none:
<authentication mode="None" />
Next up you need to remove the forms authentication module:
...
<system.webServer>
<modules>
<remove name="FormsAuthentication" />
...
App_Config Changes
The app config changes need some boilerplate Sitecore configuration as well as your custom configuration for your authentication provider. Sitecore's boilderplate config can be found here: \App_Config\Include\Examples\Sitecore.Owin.Authentication.Enabler.config.example
. Basically it just turns on federated authentication and enables a few services in Sitecore.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
<sitecore role:require="Standalone or ContentDelivery or ContentManagement">
<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>
Next up we need to define our provider:
<identityProviders hint="list:AddIdentityProvider">
<identityProvider id="Crn" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
<param desc="name">$(id)</param>
<param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
<caption>Login Button Text</caption>
<icon>/sitecore/shell/themes/standard/Images/24x24/IconThatsOnTheButton</icon>
<domain>sitecore</domain> <!-- careful here - you may want to use extranet for public sites -->
<!--list of identity transfromations which are applied to the provider when a user signin-->
<transformations hint="list:AddTransformation">
<transformation name="Idp Claim" ref="federatedAuthentication/sharedTransformations/setIdpClaim" />
<transformation name="Name Identifier Claim" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
<sources hint="raw:AddSource">
<claim name="sub" />
</sources>
<targets hint="raw:AddTarget">
<claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" />
</targets>
<keepSource>true</keepSource>
</transformation>
</transformations>
</identityProvider>
</identityProviders>
<sharedTransformations>
</sharedTransformations>
The transformations can be a bit tricky and can really depend on the environment. If the Idp
claim isn't returned by your provider you will need to add it here. It's basically just the name of the provider. Sitecore provides a transform to do this:
<transformation name="Idp Claim" ref="federatedAuthentication/sharedTransformations/setIdpClaim" />
The other gotcha is the nameidentifier
claim is required by Sitecore. If it doesn't exist you will need to create it. Typically this means filling it with data from another claim:
<transformation name="Name Identifier Claim" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
<sources hint="raw:AddSource">
<claim name="sub" />
</sources>
<targets hint="raw:AddTarget">
<claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" />
</targets>
<keepSource>true</keepSource>
</transformation>
Now we need to tell Sitecore what sites it should use the provider for. In this example we're saying use it on every site but that's almost never what you want. You would typically have two entries here, one for the Content Management (Sitecore) login and a separate one for the public facing sites. Think something like Okta Verify for the content editors and Facebook login for the public site.
<federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
<identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
<mapEntry name="Crn" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
<sites hint="list">
<site>shell</site>
<site>login</site>
<site>admin</site>
<site>service</site>
<site>modules_shell</site>
<site>modules_website</site>
<site>website</site>
<site>scheduler</site>
<site>system</site>
<site>publisher</site>
</sites>
<identityProviders hint="list:AddIdentityProvider">
<identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='Crn']" />
</identityProviders>
<externalUserBuilder type="Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder, Sitecore.Owin.Authentication">
<param desc="isPersistentUser">false</param>
</externalUserBuilder>
</mapEntry>
</identityProvidersPerSites>
The tricky part here is the isPersistentUser
setting. Persistent users are basically shadow users that are created and visible in Sitecore's security. You can't actually change their info or reset their passwords though. A big downside here is that you're storing personal data like email addresses in Sitecore itself now. This can cause issues if your organization has requirements around how PII (personally identifiable information) is stored. Often times PII needs to be encrypted in transit and at rest. That would require upgrading to SQL Enterprise rather than just using SQL Standard.
If the setting is false then you don't need to worry about shadow users but you may run into issues with tracking anonymous users across sessions. The documentation isn't 100% clear on this but that's what I've heard. It may take some custom business logic to maintain that tracking.
The last part of the app_config is registering your pipeline:
<pipelines>
<owin.identityProviders>
<!-- Processors for coniguring providers. Each provider must have its own processor-->
<processor type="Site.Foundation.LoginProvider.Pipelines.IdentityProviders.CrnIdentityProvider, Site.Foundation.LoginProvider" resolve="true" />
</owin.identityProviders>
</pipelines>
Pipeline Setup
Here's an example pipeline processor:
namespace Site.Foundation.LoginProvider.Pipelines.IdentityProviders
{
public class CrnIdentityProvider : IdentityProvidersProcessor
{
public CrnIdentityProvider(
FederatedAuthenticationConfiguration federatedAuthenticationConfiguration) : base(federatedAuthenticationConfiguration)
{
}
protected override string IdentityProviderName
{
get { return "Crn"; }
}
protected override void ProcessCore(IdentityProvidersArgs args)
{
Assert.ArgumentNotNull(args, "args");
IdentityProvider identityProvider = this.GetIdentityProvider();
string authenticationType = this.GetAuthenticationType();
var scope = string.Join(" ", Settings.Default.Scope.Cast<string>()) + " openid";
args.App.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Authority = Settings.Default.EndpointUri.ToString(),
ClientId = Settings.Default.ClientId,
ClientSecret = Properties.Credentials.Default.ClientSecret,
Scope = scope,
RedirectUri = Properties.Settings.Default.RedirectUri,
AuthenticationType = authenticationType,
ResponseType = "code id_token token",
SignInAsAuthenticationType = authenticationType,
UseTokenLifetime = false,
Notifications = new OpenIdConnectAuthenticationNotifications
{
SecurityTokenValidated = notification =>
{
// the user has been validated but we need to call another
// api to get more fields (name, email address, etc) to inject
// as claims before the user is created in Sitecore.
var accessToken = notification.ProtocolMessage.AccessToken;
// call the api with the access token here
// apply any transformations from the app_config
// this adds the idp claim that's required by Sitecore
notification.AuthenticationTicket.Identity
.ApplyClaimsTransformations(new TransformationContext(this.FederatedAuthenticationConfiguration, identityProvider));
return Task.CompletedTask;
},
},
});
}
}
}
It should be pretty straightforward but the main gotchas here are more around OpenID Connect then Sitecore. The Authority
is the url to authenticate against. The ClientID
and ClientSecret
are similar to a username and password. The ResponseType
is a bit tricky though. If you need to make an API call to add aditional claims before Sitecore creates the user then you will need to make sure that it contains the token
value. Otherwise the notification.ProtocolMessage.AccessToken
field will be null.
Things you can't do in this Pipeline
What you see above is pretty much all you can do here. If you want to change cookie names or providers you will need to override another Sitecore pipeline processor. I'd suggest starting with this and see if it works before adding more. The errors that you get from problems here are very confusing and not descriptive. Oh, and they typically don't show up in any of the logs either.
Signing In vs Signing Out
The main trick here is that you have to request the login url from Sitecore and do a POST to it. If your site is set up to login via links like <a href='/account/login'>Log In</a>
then you've got some fixing to do.
Sample code to get the login url:
namespace Site.Foundation.LoginProvider.Services
{
public class LoginService : ILoginService
{
private readonly BaseCorePipelineManager _pipelineManager;
public LoginService(BaseCorePipelineManager pipelineManager)
{
_pipelineManager = pipelineManager;
}
public string GetLoginUrl(string returnUrl)
{
var args = new GetSignInUrlInfoArgs("CFACOM", returnUrl);
GetSignInUrlInfoPipeline.Run(_pipelineManager, args);
return args.Result.First().Href;
}
}
}
Your login link will now look something more like this:
using (Html.BeginForm(null, null, FormMethod.Post, new { action = Model.SignInUrl }))
{
<button type="submit">
Login
</button>
}
Logging out uses the fairly standard owin method:
owinContext.Authentication.SignOut(new AuthenticationProperties { RedirectUri = redirectUri });