Monday, January 2, 2012

CXF and MS CRM 2011

Background
Early last year, Microsoft released a new version of their CRM product, Dynamics CRM 2011. My company has a Java product that integrates with MS CRM and we were successfully able to call the webservice that is included with CRM 2011 using SAML tokens. Everything was working great until we reconfigured CRM for on-premise authentication. In the previous version of MS CRM, the on-premise authentication used transport layer NTLM/Kerberos authentication which Java supports. With CRM 2011, however, Microsoft is using WCF 4.0 which uses message level NTLM/Kerberos based on SPNEGO WS-Trust. (Specification) We searched extensively, but could not find an implementation of this protocol for any open source Java Web Service framework. So I along with a coworker set out to implement this for CXF. After several weeks, we were successful and were able to invoke the CRM web service from Java. We submitted this functionality back to the CXF project. (JIRA) Recently, Colm O hEigeartaigh was kind enough to review the patch and integrate it into the trunk of the CXF codebase. (With some major refactoring to make it fit better) This works out of the box with the latest CXF code, but the configuration of this is far from trivial, so this blog post explains how to configure this new functionality.

CXF and MS CRM 2011
The first step in getting this all to work is to setup Java to authenticate using Kerberos with the Active Directory primary domain controller. This requires you to create a login.conf as follows:
spnego-client {
com.sun.security.auth.module.Krb5LoginModule required;
};

and a krb5.conf as follows:
[libdefaults]
default_realm = <NTdomain>
default_tkt_enctypes = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc
default_tgs_enctypes = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc
permitted_enctypes = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc

[realms]
<NTdomain> = {
kdc = <PrimaryDomainController>
default_domain = <NTdomain>
}

[domain_realm]
.<NTdomain> = <NTdomain>

Then, in my Java code, I manually configure the following the system properies so that JAAS will use these files:
System.setProperty("java.security.auth.login.config", "C:\\projects\\login.conf");
System.setProperty("java.security.krb5.conf", "C:\\projects\\krb5.conf");

CRM 2011 includes a custom policy element in the WSDL that needs to be asserted in CXF. If this is not done, CXF will error out because it doesn't have a policy provider to assert the custom policy. This custom policy can be asserted using the following classes, the first is the PolicyProvider itself:
public class XRMAuthPolicyProvider extends AbstractPolicyInterceptorProvider
{
public XRMAuthPolicyProvider()
{
super(Arrays
.asList(new QName[]{new QName("http://schemas.microsoft.com/xrm/2011/Contracts/Services",
"AuthenticationPolicy", "ms-xrm")}));
getInInterceptors().add(new XRMAuthPolicyInterceptor());
}
}

and the second is the associated CXF interceptor:
public class XRMAuthPolicyInterceptor extends AbstractSoapInterceptor
{
public XRMAuthPolicyInterceptor()
{
super(Phase.PRE_PROTOCOL);
addAfter(PolicyBasedWSS4JInInterceptor.class.getName());
addAfter(PolicyBasedWSS4JOutInterceptor.class.getName());
}

public void handleMessage(SoapMessage message) throws Fault
{
AssertionInfoMap aim = message.get(AssertionInfoMap.class);
if (null == aim)
{
return;
}
QName qname = new QName("http://schemas.microsoft.com/xrm/2011/Contracts/Services",
"AuthenticationPolicy", "ms-xrm");
Collection ais = aim.get(qname);
if (null == ais || ais.size() == 0)
{
return;
}
for (AssertionInfo ai : ais)
{
ai.setAsserted(true);
}
}
}

The policy provider can be added to web service client as follows:
   Client client = ClientProxy.getClient(port);
Bus bus = ((EndpointImpl) client.getEndpoint()).getBus();
PolicyInterceptorProviderRegistry pipr = bus
.getExtension(PolicyInterceptorProviderRegistry.class);
pipr.register(new XRMAuthPolicyProvider());

Once the policy provider is added, CXF needs to be configured to use Kerberos authentication. For my setup, I was using an Active Directory username and password, so I had to create an implementation of SpnegoClientAction that uses GSSName.NT_USER_NAME rather than the default GSSName.NT_HOSTBASED_SERVICE:
public class XRMSpnegoClientAction implements SpnegoClientAction
{
private org.apache.commons.logging.Log log = org.apache.commons.logging.LogFactory
.getLog(DefaultSpnegoClientAction.class);

protected String serviceName;
protected GSSContext secContext;
protected boolean mutualAuth;

/**
* Whether to enable mutual authentication or not.
*/
public void setMutualAuth(boolean mutualAuthentication)
{
mutualAuth = mutualAuthentication;
}

/**
* The Service Name
*/
public void setServiceName(String serviceName)
{
this.serviceName = serviceName;
}

/**
* Obtain a service ticket
*/
public byte[] run()
{
try
{
GSSManager gssManager = GSSManager.getInstance();
Oid oid = new Oid("1.3.6.1.5.5.2");

GSSName gssService = gssManager.createName(serviceName, GSSName.NT_USER_NAME);
secContext = gssManager.createContext(gssService, oid, null, GSSContext.DEFAULT_LIFETIME);

secContext.requestMutualAuth(mutualAuth);
secContext.requestCredDeleg(Boolean.FALSE);

byte[] token = new byte[0];
return secContext.initSecContext(token, 0, token.length);
}
catch (GSSException e)
{
if (log.isDebugEnabled())
{
log.debug("Error in obtaining a Kerberos token", e);
}
}

return null;
}

/**
* Get the GSSContext that was created after a service ticket was obtained
*/
public GSSContext getContext()
{
return secContext;
}
}

Then I added the following code to hook Kerberos into my web service client:
   // Active Directory domain username and password
final String username = "<username>";
final String password = "<password>";
// Kerberos service provider name, e.g. RestrictedKrbHost/<computername>
String spn = "RestrictedKrbHost/<crm_server>";
// Kerberos JAAS client as configured in login.conf
String jaasClient = "spnego-client";
// Active Directory username and password
CallbackHandler callbackHandler = new NamePasswordCallbackHandler(username, password);

client.getRequestContext().put("ws-security.kerberos.jaas.context", jaasClient);
client.getRequestContext().put("ws-security.kerberos.spn", spn);
client.getRequestContext().put("ws-security.callback-handler", callbackHandler);
client.getRequestContext().put("ws-security.spnego.client.action", new XRMSpnegoClientAction());

Once the custom policy provider is added and the Kerberos authentication is setup, the client configuration is complete.

For reference, the complete client code example is below:
   URL wsdlURL = OrganizationService.WSDL_LOCATION;

System.setProperty("java.security.auth.login.config", "C:\\projects\\login.conf");
System.setProperty("java.security.krb5.conf", "C:\\projects\\krb5.conf");

// Active Directory domain username and password
final String username = "<username>";
final String password = "<password>";
// Kerberos service provider name, e.g. RestrictedKrbHost/<computername>
String spn = "RestrictedKrbHost/<crm_server>";
// Kerberos JAAS client as configured in login.conf
String jaasClient = "spnego-client";
// Active Directory username and password
CallbackHandler callbackHandler = new NamePasswordCallbackHandler(username, password);

OrganizationService ss = new OrganizationService(wsdlURL, SERVICE_NAME);
IOrganizationService port = ss.getCustomBindingIOrganizationService();

Client client = ClientProxy.getClient(port);
Bus bus = ((EndpointImpl) client.getEndpoint()).getBus();
PolicyInterceptorProviderRegistry pipr = bus
.getExtension(PolicyInterceptorProviderRegistry.class);
pipr.register(new XRMAuthPolicyProvider());

client.getRequestContext().put("ws-security.kerberos.jaas.context", jaasClient);
client.getRequestContext().put("ws-security.kerberos.spn", spn);
client.getRequestContext().put("ws-security.callback-handler", callbackHandler);
client.getRequestContext().put("ws-security.spnego.client.action", new XRMSpnegoClientAction());

// call webservice
ColumnSet colSet = new ColumnSet();
colSet.setAllColumns(true);
colSet.setColumns(new ArrayOfstring());
QueryByAttribute qba = new QueryByAttribute();
qba.setEntityName("account");
qba.setColumnSet(colSet);
ArrayOfstring aos2 = new ArrayOfstring();
aos2.getString().add("accountid");
qba.setAttributes(aos2);
ArrayOfanyType aoat = new ArrayOfanyType();
aoat.getAnyType().add("2D08FEC1-9734-E111-9454-000C295D5DEA");
qba.setValues(aoat);
EntityCollection entityCol = port.retrieveMultiple(qba);
List props = entityCol.getEntities().getEntity().get(0)
.getAttributes().getKeyValuePairOfstringanyType();
for (KeyValuePairOfstringanyType prop : props)
{
if ("name".equals(prop.getKey()))
{
System.out.println(prop.getKey() + " = " + prop.getValue());
}
}

Since our team spent several weeks working on this example, I hope this walkthrough is helpful for other developers out there. This will not only work for CRM 2011, but any WCF webservice that uses message level NTLM/Kerberos authentication.

11 comments:

  1. Tom, could you provide more information about your IIS configuration. I acquired TGT using AS, and -commit succeeded.
    Then i have such exception:
    org.apache.cxf.interceptor.Fault: General security error (An error occurred in trying to obtain a service ticket)

    Thanks!

    ReplyDelete
  2. Sadly, I was not involved in setting up the MS CRM instance. That was handled by our netops team, so I don't have access to those details.

    ReplyDelete
  3. Hi tom,
    in your class XRMSpnegoClientAction you implements SpnegoClientAction but in wss4j SpnegoClientaction is a claas

    Can you help me to resolve the problem ?

    Thanks

    ReplyDelete
  4. You need the very latest trunk code for wss4j. If you look at trunk, it is an interface in the latest version:

    http://svn.apache.org/viewvc/webservices/wss4j/trunk/src/main/java/org/apache/ws/security/spnego/SpnegoClientAction.java?revision=1227128&view=markup

    This is all very bleeding edge, so you'll need the trunk versions of both CXF and wss4j.

    ReplyDelete
  5. This comment has been removed by the author.

    ReplyDelete
  6. Thank you, Tom!
    Nice work, helpful message.
    But unfortunately I've faced a problem:
    my (ColumnSet) colSet variable has not a method setEntityName(String), but a setEntityName(JAXBElement&<String>) instead.
    It concerns also several analogous setters.
    Wat's wrong?
    I've generated from wsdl using cxf library.
    Thank you.

    ReplyDelete
  7. Ok, I've fixed previous type mismatch problem by creating corresponding JAXBElement<T> type variables instead of T type
    using values of @XmlElementRef notations in corresponding classes of package com.microsoft.schemas.xrm._2011.contracts.
    But when running the sample, I've faced with error at the earliest stages of it.
    Namely, at the line where IOrganizationService port is being obtained (it's just line 47 in my ServiceTest.java):
    IOrganizationService port = ss.getCustomBindingIOrganizationService()
    the exception appears:
    -----------
    java.lang.NoSuchFieldError: QUALIFIED
    at org.apache.cxf.service.model.SchemaInfo.setSchema(SchemaInfo.java:146)
    at org.apache.cxf.wsdl11.SchemaUtil.extractSchema(SchemaUtil.java:136)
    at org.apache.cxf.wsdl11.SchemaUtil.getSchemas(SchemaUtil.java:81)
    at org.apache.cxf.wsdl11.SchemaUtil.getSchemas(SchemaUtil.java:65)
    ............
    at org.apache.cxf.jaxws.ServiceImpl.createPort(ServiceImpl.java:465)
    at org.apache.cxf.jaxws.ServiceImpl.getPort(ServiceImpl.java:332)
    at org.apache.cxf.jaxws.ServiceImpl.getPort(ServiceImpl.java:319)
    at javax.xml.ws.Service.getPort(Service.java:92)
    at com.microsoft.schemas.xrm._2011.contracts.OrganizationService.getCustomBindingIOrganizationService(OrganizationService.java:65)
    at com.mypackage.msdynamics.onpremise.ServiceTest.run(ServiceTest.java:47)
    -----------
    Can anyone help me and explain what goes wrong?
    Thank you.

    ReplyDelete
  8. Double check your JAXB version. The JAXB version used with the latest version of CXF is newer than what comes with the JDK 6. It takes some effort to get it all working because of this. (The JAXB jars have to be 'endorsed' to override what comes with the JDK) If that doesn't resolve the issue, it may help to look at the cxf source at the line in error--it might give you some ideas on what's wrong. This is all very experimental, so you definitely have to get your hands dirty to get it all working.

    ReplyDelete
  9. Tom, thank you.
    At least now I know where to start.

    ReplyDelete
  10. Hi, Tom.
    Thanks to your previous advice, I've solved my problem with type inconsistency and simultaneously have overcome some another problems with inappropriate libraries and cast type errors.
    But I'm stacking now because of null value of the pipr variable: my bus is occurred not to have an extension of type PolicyInterceptorProviderRegistry:

    PolicyInterceptorProviderRegistry pipr = bus
    .getExtension(PolicyInterceptorProviderRegistry.class); // -----> null

    Can you suppose, please, what's wrong?
    Any idea may be helpful.
    Thank you.

    ReplyDelete
  11. Hmm, not sure. I was always able to get the PolicyInterceptorProviderRegistry without issue. It might be a new change with the latest code or something might not be getting initialized properly. You'll have to debug through the code to see where that gets initialized in CXF--I'm not familiar with that part of the code.

    ReplyDelete