Co-Author: @Santiago Ahumada
The “cisco-nso-saml2-auth” is an NSO package that gives the SSO feature to the NSO via package authentication. By default, the “cisco-nso-saml2-auth” only provides the basic functionality by following the SAML OASIS Standard. However, each vendor understands the SAML OASIS Standard differently which may have different features across different vendors. Therefore, modifications on the “cisco-nso-saml2-auth” SAML generic package are required.
This tutorial will introduce the method to integrate NSO SSO to Cisco DUO as IDP via the “cisco-nso-saml2-auth” package. A similar method can also be applied to other IDP vendors by treating“cisco-nso-saml2-auth” as SAML2 Generic Package. The diagram below shows the environment we used to connect NSO SSO with Cisco DUO.
From the diagram above, one can realize there are different roles for each of the components. The roles are defined as below:
At the same time, when NSO acts as a Service Provider, the “cisco-nso-saml2-auth” package handles the following path throughout the SAML communication under the Python script - “scripts/authenticate”.
LOGIN_PATH = "/saml/login/"
LOGOUT_PATH = "/saml/logout/"
ACS_PATH = "/saml/acs/"
METADATA_PATH = "/saml/metadata/"
This article comes with two examples that benefit from the same DUO-compatible “cisco-nso-saml2-auth” packages. For each example, it will first pull the DUO-compatible “cisco-nso-saml2-auth” packages from GitHub and then start from there.
The instructions for the Example Use Case can be found in the Readme of each repository. The SSO setup method for DUO is not explained in this guide. However, one can find the setup method in the deployment guide inside each of the repositories.
For general SSO and Package Authentication setup, check the NSO Official Guides below:
Under the “cisco-nso-saml2-auth” package you will find the “python” folder is empty compared to the other NSO service packages or NED. This is due to the authentication handling being under the “scripts” folder rather than the “python” folder. There are two files inside the scripts folder:
There are a few frequently used functions and classes that one needs to manipulate to make things work
In this chapter, we will explain how we tackle the issues to make NSO SSO work with Cisco DUO. We recommend you clone and set up the Example Use Case above before reading the rest of the article. At the same time, take a quick look at the DUO-compatible “cisco-nso-saml2-auth” packages.
Before we start to explain how we connect the NSO SSO to the Cisco DUO, understanding how to troubleshoot the “cisco-nso-saml2-auth” package or all package authentication via Python VM Log is important. At the moment, the WebUI will only show “No Auth Method Available”/"noauth" when either “authenticate” or “saml2_auth_utils.py” throw an error. Luckily the logging framework has been fully set up for both “authenticate” and “saml2_auth_utils.py”. For example, “cisco-nso-saml2-auth/scripts/authenticate” has the code shown below.
# Setup logger
# assume system install
logdir = os.getenv("NCS_LOG_DIR")
if logdir is None:
# fallback local install
logdir = os.path.join(os.path.dirname(os.path.realpath(__file__)),
"../../../../../logs")
logname = os.path.join(logdir, "ncs-python-saml2-auth.log")
if not os.path.isdir(logdir):
os.mkdir(logdir)
logfmt = ("%(asctime)s.%(msecs)02d %(filename)s:%(lineno)s"
" %(levelname)s: %(message)s")
logging.basicConfig(filename=logname, filemode="a+", format=logfmt,
datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO)
logger = logging.getLogger(__name__)
The log will be printed into the Python VM log - “ncs-python-saml2-auth.log” located in the “logs” folder. By default the only log that will print is the info level logging since "level=logging.INFO" is set. However most of the default logs are created in DEBUG level. Therefore, change "level=logging.INFO" to "level=logging.DEBUG" before troubleshooting is crucial. This will enable all logger start printing in the Python VM log. At the same time, set the logging level to debug in NSO under the “python-vm logging vm-levels” in the CLI. Otherwise replace all the "logger.debug" to "logger.info" will also do the trick. Take in mind the "package reload" is needed after the code modification. At the same time, one can easily create extra logging by adding additional “logger.info” or “logger.debug” and handle the error via “logger.error” through out the code. One of the suggestions is to add "logger.error" right above each of the exceptions that are defined below. A straight forward method is to print the exception message via logger since these won’t show in the python-vm log without the logger printing it.
ERROR_BAD_SAML_CONFIG = "SAML config is wrong or incomplete"
ERROR_BAD_SAML_RESPONSE = "Bad SAMLResponse"
ERROR_BAD_RELAY_STATE = "Bad RelayState"
ERROR_BAD_METADATA_URL = "Bad metadata-url"
ERROR_UNHANDLED_URL = "Unhandled URL"
Alternatively, another way to obtain all logs from the package once for all is to start up the NSO in the foreground debug mode.
ncs --foreground -v
However, all the logs will be printed on the same terminal which might be a bit distracting when troubleshooting specific packages inside NSO.
In this chapter, we will show the modification we have done to connect the NSO SSO with DUO.
The first issue when NSO SSO tries to communicate with Cisco DUO is the "IssueInstant" format is not understood by the DUO.
To understand what is exactly sent from the NSO side, one needs to print out the content of the payload before sending it. This can be done by printing the “IssueInstant” during the Login phase inside the "login(saml_cfg, args)" function.
issue_instant = utcnow().isoformat()
logger.info(f‘issue_instant: { issue_instant }')
From the log that is printed, one can see the format of the “issue_instant” looks like “2013-10-29T09:14:03.895210+00:00” while in OASIS standard documentation, most of the examples use the format as “2013-10-29T09:38:41.341Z”. So most likely DUO is expecting this format by replacing the “+00:00” with “Z”. In this case, a quick way to get around the issue is by using the “replace('+00:00', 'Z')” function on “issue_instant” to bypass this issue.
When using metadata_url as the input source, NSO will use the metadata information provided from the IDP side. In this case, there might be some formatting issues that NSO is not expecting. In our case, when we print out the certificate with “logger.info(cert)” that was received from the IDP side, we see an extra line break between “2gumJlQ7VU=” and “</ds:X509Certificate>”. This line break causes a parsing issue when trying to digest the certification from metadata.
<ds:X509Certificate>MIIDDTCCAfWgAwIBAgIUWlDwxL
……2gumJlQ7VU=
</ds:X509Certificate>
In this case, we use “linesep” to clean up the line break from the entire certificate. However, this has a side effect. If the IDP server is deployed in Windows but the NSO is deployed on Linux, the Windows side will use CR+LF while Linux will only use LF. Then “os.linesep” won’t accurately clean up the line breaks.
cert = saml_cfg["idp"].get("signing-certificate", None)
cert=os.linesep.join([s for s in cert.splitlines() if s])
So another better method will be to use "rstrip" as below:
cert=cert.rstrip()
The original SAML package requires “groups” and “gids” as optional parameters(used for Authorization) while “uid”, ”gid” and “homedir” as mandatory parameters(used for Authentication) from the IDP side. However, some IDPs only allow sending one extra attribute like DUO. In our case, we let DUO send groups by mapping users towards specific groups that were obtained from the Active Directory. Then, obtain “gids” from the NACM config if possible(since it is not a mandatory config) and “uid”, ”gid” and “homedir” from the AAA config or PAM. Therefore, we extract the AAA and NACM config in the “lookup_config()”.
aaa_config = root.aaa.authentication.users.user
nacm_config = root.nacm.groups.group
Then fill NACM and AAA config to the “cfg” variable so we can use it later in “acs(aaa_cfg,saml_cfg, args)”.
for group_el in nacm_config:
try:
cfg["nacm"][group_el.name]={}
if group_el.gid:
cfg["nacm"][group_el.name]["gids"]=group_el.gid
else:
logger.info("gids not exist in NACM config for groups - "+str(group_el.name)+". Stop Injecting. ")
except Exception as e:
logger.info(str(e))
for user_el in aaa_config:
cfg["aaa"][user_el.name]={}
cfg["aaa"][user_el.name]["uid"]=user_el.uid
cfg["aaa"][user_el.name]["gid"]=user_el.gid
cfg["aaa"][user_el.name]["homedir"]=user_el.homedir
In “acs(aaa_cfg,saml_cfg, args)”, we check if “groups” and “gids” exist in the attributes, if not we look for them in the NACM config. However, if the user never configures “groups” and “gids” in the NACM configuration, we leave these two fields as empty. Empty means optional parameters do not exist and continue without using them.
# groups and gids are optional
if 'groups' in attrs:
logger.info("Groups is provided by IDP: "+str(attrs['groups']))
maybe_groups = f"{attrs['groups']}"
if 'gids' not in attrs:
logger.info('saml_cfg: '+str(saml_cfg))
logger.info('maybe_groups: '+str(maybe_groups))
if "gids" in saml_cfg["nacm"][maybe_groups]:
attrs['gids']=saml_cfg["nacm"][maybe_groups]["gids"]
logger.info("GIDs is provided by NACM: "+str(attrs['gids']))
else:
logger.info('GIDs attribute is not provided by the IDP and NACM. Set Empty')
attrs['gids']=""
else:
logger.info("GIDs is provided by IDP: "+str(attrs['gids']))
maybe_gids = f"{attrs['gids']}"
else:
logger.info('Groups attribute is not provided by the IDP')
maybe_groups = ""
maybe_gids = ""
logger.info("user: "+str(user_str))
For “uid”, ”gid” and “homedir”, we first check if these exist under the aaa config for specific users. If not, we look for them in PAM by checking the return value of “getpwall()”. Otherwise, we define the user as not existing in any authentication database and decline the access from the package side.
# lookup config in aaa server
if 'gid' not in attrs or 'uid' not in attrs or 'homedir' not in attrs:
usernames = [x[0] for x in getpwall()]
if user_str in saml_cfg["aaa"].keys() :
logger.info("user exist in aaa config on NSO server")
aaa_el=saml_cfg["aaa"][user_str]
if 'gid' not in attrs:
logger.info("SAMLResponse missing attributes - gid")
attrs['gid']=aaa_el["gid"]
if 'uid' not in attrs:
logger.info("SAMLResponse missing attributes - uid")
attrs['uid']=aaa_el["uid"]
if 'homedir' not in attrs:
logger.info("SAMLResponse missing attributes - homedir")
attrs['homedir']=aaa_el["homedir"]
elif user_str in usernames:
logger.info("user exist in PAM on local machine")
if 'gid' not in attrs:
logger.info("SAMLResponse missing attributes - gid")
attrs['gid']=getpwnam(user_str).pw_gid
if 'uid' not in attrs:
logger.info("SAMLResponse missing attributes - uid")
attrs['uid']=getpwnam(user_str).pw_uid
if 'homedir' not in attrs:
logger.info("SAMLResponse missing attributes - homedir")
attrs['homedir']=getpwnam(user_str).pw_dir
else:
print("reject 'permission denied'")
If the IDP encrypts the SAMLResponse assertion, then we can no longer parse the response due to encryption, and the formatting of the SAMLResponse. The SAMLResponse is now encrypted under "EncryptedAssertion/EncryptedData/CipherData/CipherValue". At the same time, the SAMLResponse inside CipherValue is encrypted by the key under "EncryptedAssertion/EncryptedData/KeyInfo/EncryptedKey/CipherData/CipherValue". Eventually, the key itself is also encrypted via the public key that was exchanged in the metadata.
<saml:EncryptedAssertion>
<xenc:EncryptedData Type="http://www.w3.org/2001/04/xmlenc#Element">
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc" />
<ds:KeyInfo>
<xenc:EncryptedKey>
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5" />
<xenc:CipherData>
<xenc:CipherValue> ...KEY...</xenc:CipherValue>
</xenc:CipherData>
</xenc:EncryptedKey>
</ds:KeyInfo>
<xenc:CipherData>
<xenc:CipherValue> ...SAMLResponse... </xenc:CipherValue>
</xenc:CipherData>
</xenc:EncryptedData>
</saml:EncryptedAssertion>
To support this use case, we can add an encryption detection and decryption utility in the "saml2_auth_utils.py". This can be done in the XmlParser class by adding an "is_encrypted()" function that checks whether the response contains a "saml:EncryptedAssertion" element:
def is_encrypted(self):
enc = self.xml_tree.xpath('/samlp:Response/saml:EncryptedAssertion',
namespaces=self.get_namespace_map())
return(bool(enc))
At the same time, implementing the decryption mechanism. In this example we used the "xmlsec" library for decryption and added the function "decrypt_response()" that takes as input the unparsed response object and the SP private key:
def decrypt_response(self, xml_tree: XmlNode, pkey_string: str) -> XmlNode:
enc_xml_tree = xml_tree.find('.//xenc:EncryptedData',
namespaces=self.get_namespace_map())
key_manager = xmlsec.KeysManager()
key = xmlsec.Key.from_memory(pkey_string, xmlsec.constants.KeyDataFormatPem)
key_manager.add_key(key)
enc_ctx = xmlsec.EncryptionContext(key_manager)
dec_xml_tree = enc_ctx.decrypt(enc_xml_tree)
After decryption, the decrypted assertion data remains encapsulated in the "saml:EncryptedAssertion" element in the xml_tree which may cause issues when parsing the data at a later stage. To fix this, we move the assertion data to "samlp:Response" and remove the "saml:EncryptedAssertion" before returning the decrypted "xml_tree".
tag2remove = xml_tree.find('.//saml:EncryptedAssertion',
namespaces=self.get_namespace_map())
temp_parent = tag2remove.getparent()
temp_parent.append(dec_xml_tree)
temp_parent.remove(tag2remove)
return xml_tree
Eventually, we add both functions in the __init__ function in XmlParser:
if self.is_encrypted():
self._logger.info("Encrypted assertion found - Attempting decryption for: "+lxml.etree.tostring(self.xml_tree,method='c14n',
exclusive=True).decode('utf-8'))
self.xml_tree = self.decrypt_response(self.xml_tree, self.pkey_string)
Additionally, since we are now using the SP private key for decryption, we need to add it as an input parameter accordingly:
def __init__(self, xml_string: str, certificate: Optional[X509], pkey_string: Optional[str])
pkey = saml_cfg["sp"].get("private-key-encryption", None)
pkey = os.linesep.join([s for s in pkey.splitlines() if s])
logger.info('pkey1 is: '+str(pkey))
response = ResponseParser(saml_response, cert, pkey)
This code change above also makes "Assertion Signing Validation" possible with a few extra code changes. The changes need to be done by modifying XmlParser to check for signature in '/samlp:Response/ds:Signature' and '/samlp:Response/saml:Assertion/ds:Signature'. Then run the validation separately. The response signature must always be validated first, even before decryption (if applicable).
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
Find answers to your questions by entering keywords or phrases in the Search bar above. New here? Use these resources to familiarize yourself with the NSO Developer community: