Salesforce SSO with ADFS 2.0

In this post I’ll share some recent practical experiences implementing Federated SSO between Salesforce and Active Directory Federation Services 2.0 (ADFS 2.0 for brevity). For detailed configuration and theoretical information on this subject please refer to the excellent resources below.

To set the scene – the “deployment view” schematic below shows the building blocks of a complex implementation covering most if not all possible access scenarios including portals, public web access, mobile user agents etc.


Deployment Considerations

– Salesforce
Two points to make here, firstly to emphasise the fact that all communication is routed through the browser running on the local machine (SAML 2.0 Browser Post Profile), there is no direct communication between Salesforce and ADFS proxy etc.. The second point being the importance of the introduction of a My Domain to the org, this is required for service provider initiated SSO and is necessary for mobile apps, Chatter desktop, Salesforce for Outlook and deep-links to function correctly with SSO.

– ADFS 2.0 Deployment Topology
Ideally, the ADFS configuration database should be deployed on a fault-tolerant, load-balanced SQL Server cluster – avoid the Windows internal database if you’re looking at a high-availability design. This is particularly advisable if your organisation has this SQL Server infrastructure in place. The ADFS 2.0 server role can also be deployed in a Federation Server Farm configuration, with active servers load balanced on a single virtual IP and connected to the shared configuration database. There is huge flexibility here in terms of redundancy and load-balancing configurations. The ADFS servers and configuration database host should be deployed within the corporate network, not the DMZ.

In a multi-org deployment, to workaround the unique service provider certificate limitation preventing more than one Salesforce org as a service provider in ADFS follow the instructions here.

– ADFS Proxy
There are other options in terms of routing inbound traffic through the DMZ, however ADFS proxy is a popular, free and secure option. A farm configuration should be deployed, with multiple (at least 2) load balanced servers behind a virrtual IP.

– Public DNS
In SAML terms a Salesforce org can be Service Provider to one (and only one) Identity Provider. In defining the single set of settings, the Identity Provider login URL must be entered. This url has to be accessible from the local machine of all prospective users, those who are connected to the corporate network and potentially those who aren’t. In order to support external users the Identity Provider host name must be resolvable by the public DNS to a secure server presenting a certificate signed by a root CA. An internal CA signed certificate is acceptable if there are no external access scenarios to consider and all SSO attempts will be made by users connected to the corporate network via cable or VPN.

– Internal DNS
Internal users must resolve the ADFS url (Identity Provider login URL) directly to the internal ADFS server host, whilst external users will resolve via public DNS registry. The internal override is key, this is necessary to ensure that Windows Integrated Authentication takes place, plus avoids an expensive routing via the public internet. Ideally this can be achieved simply via the internal DNS. In cases where internal users connect externally from the same machine (outside of a VPN connection), a localhost file entry would be problematic.

– ADFS Claim Rules (Transform)
Defined rules can (and should) be exported to a file, as a backup and convenience when recreating Relying Party Trusts within ADFS). The link below provides the Powershell instructions for this.

Example rules below. Where possible use the claim rule templates (attribute population from AD, comditional static attribute population based on security group membership etc. the claim rule language is seemingly undocumented.

[sourcecode language=”text”]
@RuleTemplate = "LdapClaims"
@RuleName = "Send UPN as Name ID"
c:[Type == "", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = (""), query = ";userPrincipalName;{0}", param = c.Value);

@RuleTemplate = "LdapClaims"
@RuleName = "Send Email Addresses as User.Email"
c:[Type == "", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = ("User.Email"), query = ";mail;{0}", param = c.Value);

@RuleTemplate = "LdapClaims"
@RuleName = "Send Surname as User.LastName"
c:[Type == "", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = ("User.LastName"), query = ";sn;{0}", param = c.Value);

@RuleTemplate = "LdapClaims"
@RuleName = "Send UPN as User.Username"
c:[Type == "", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = ("User.username"), query = ";userPrincipalName;{0}", param = c.Value);

@RuleTemplate = "EmitGroupClaims"
@RuleName = "Send User.ProfileId for Salesforce Standard Users"
c:[Type == "", Value == "S-1-5-21-1591777566-3669593721-1616705233-599", Issuer == "AD AUTHORITY"]
=> issue(Type = "User.ProfileId", Value = "00fE0000000ryDa", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);

@RuleTemplate = "EmitGroupClaims"
@RuleName = "Send User.ProfileId for Salesforce Chatter Users"
c:[Type == "", Value == "S-1-5-21-1591777566-3669593721-1616705233-1999", Issuer == "AD AUTHORITY"]
=> issue(Type = "User.ProfileId", Value = "00fE0000000EeCs", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);

@RuleTemplate = "EmitGroupClaims"
@RuleName = "Send 809 as User.Department"
c:[Type == "", Value == "S-1-5-21-1591777566-3669593721-1616705299-9115", Issuer == "AD AUTHORITY"]
=> issue(Type = "User.Department", Value = "809", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);

@RuleTemplate = "LdapClaims"
@RuleName = "Send co as User.Country"
c:[Type == "", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = ("User.Country"), query = ";co;{0}", param = c.Value);

– ADFS Claim Rules (Authorisation)
Particularly in just-in-time (JIT) user provisioning scenarios having control over who can access Salesforce via ADFS SSO can be important. In such cases one approach can be to create an AD securtity group [Salesforce Users] and add all valid AD principles. An authorisation claim rule can be added (after removing the permit all default) to restrict access to the just the group members. Note with service provider initiated SSO, i.e. the user hits the my domain first, for non-privileged users the end result will be the display of the SAML login error page, this may not be the ideal experience. For identity provider initiated SSO, i.e. the user hits ADFS first, then an ADFS error page is displayed.

– JIT provisioning
Salesforce mandates that users provisioned via a SAML assertion must have an initial user profile Id specified in an attribute within the assertion. One approach to this requirement is to use Active Directory security groups to group subsets of the user population where the initial user profile assignment is common, i.e. [Salesforce Standard Users], [Salesforce Chatter Users] etc. A best practice with this approach is to keep the assigned profile as restricted as is feasible until an Administrator can refine the assignment with more intelligence. It would be feasible to use a custom attribute store, complex custom claim rules etc. to achieve a smarter initial profile assignment however I’ve yet to experiment too much with either.

As neither Active Directory or ADFS has any knowledge whether the user has been provisioned or not in Salesforce, it must send user-provisioning related attributes in all cases. There is an issue with this in respect to user profile. For example, user is a member of the [Salesforce Standard Users] group and is initially created with a standard user profile, subsequently another user updates the profile to system administrator – so far so good – however the next time the user accessess Salesforce via SSO, the user profile id in the SAML assertion is used to update the user back to the standard user profile – far from ideal. One approach to avoiding this is to add an Apex Trigger to the User object whch suppresses the attempted profile update, example below.

[sourcecode language=”java”]
private void processSSOUpdate(User[] updatedRecords, Map<Id, User> oldMap){
ADFS has no knowledge of whether a user exists in Salesforce or not and as such must send the user profile id in all cases.
The user profile must only be used in the insert case, this trigger prevents reset of user profile changes made in Salesforce.

Note.The SSO process invokes an update on the User record on every login.
if (UserInfo.getName()==’Automated Process’ || (Test.isRunningTest() && UserTestHelper.isSSOTest)){
for (User u : updatedRecords){
u.ProfileId=oldMap.get(u.Id).ProfileId; //& override any attempt to reset the user profile.

Apex Trigger script can also be used to add default Chatter group membership, translate locale settings etc.

– JIT de-provisioning
In cases where SSO is enforced via login policy, deactivating the AD account prevents access to Salesforce. However in a mixed-mode scheme where users can also login using Salesforce authentication, the leavers process must involve manual deactivation of the Salesforce user. Alternatively, an integration tool could be used to query the Active Directory via an LDAP connector and apply User record deactivations to Salesforce. It may be the case that the automated logic must be two-phased, first attempt deactivation if this fails (active assignment rules associated with the user etc..) then update the user to a No Access user profile, with login hours set in a way to lock-out access.

– Browser Compatibility (seamless SSO authentication)
Browser compatibility with Windows Integration Authentication is mixed:

Firefox – not ok (requires this configuration change:
Chrome – ok
IE – requires the ADFS host to be added to the Trusted Site Zone. This can be achieved via System Management Tools and pushed out across the enterprise.

– Portals
Although the Single Sign-on Implementation Guide states otherwise, I have noticed that service-provider initiated SSO does work for portals users, i.e. portal user hits the my domain, is redirected to ADFS to log in and then is returned to portal in an authenticated state via the site url. This may be an anomaly.

– Login Policy
It is possible to enforce SSO at the org level, seemingly preventing standard Salesforce authentication to take place. A very good thing where appropriate, in security terms. I have noticed that you can still log in with Salesforce credentials using links as below.

– Chatter Emails
If a My Domain is introduced to the Salesforce org to support service-provider initiated SSO, it should be noted that links embedded within automated email messages will incorporate the my domain. This could be a real problem where Customers are collaborating in Chatter Customer Groups – clicking on the links (in a pre-authenticated state) will take the customer (or partner) to the identity provider login page.

Update – Troubleshooting
1) CRL check issue. If your ADFS server can’t connect to the internet you may get errors (check the event log) relating to Certificate Revocation List checks failing. To address this open Powershell (as Administrator), then use the commands below.
[sourcecode language=”powershell”]
Add-PSSnapin Microsoft.Adfs.PowerShell
Set-ADFSRelyingPartyTrust -TargetName "YourRelyingPartyDisplayName" -SigningCertificateRevocationCheck None

2) Error MSIS7004. Make sure the ADFS service account (Network Service by default) has permissions to the ADFS certificate (right-click certificate in the certificates MMC snapin, find Manage Private Keys option, check permissions).

Update 2 – Sandbox suffixes in Claim Rules
The second Claim Rule below shows how you can handle sandbox suffixes for usernames where you’re not using a dedicated test AD.

@RuleTemplate = “LdapClaims”
@RuleName = “Send Email addresses as Email”
c:[Type == “;, Issuer == “AD AUTHORITY”]
=> issue(store = “Active Directory”, types = (“User.Email”), query = “;mail;{0}”, param = c.Value);

@RuleName = “Send Email address with suffix as User.Username”
c:[Type == “User.Email”]
=> issue(Type = “User.Username”, Value = c.Value + “.test”);

%d bloggers like this: