cancel
Showing results for 
Search instead for 
Did you mean: 
cancel
1822
Views
5
Helpful
0
Comments
Qi Li
Cisco Employee
Cisco Employee

Co-Author: @Santiago Ahumada 

Introduction

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. 

NSO DUO SSO PoC Design (1).png

From the diagram above, one can realize there are different roles for each of the components. The roles are defined as below:

  • Service Provider - NSO
  • User Agent - User that is requesting access to the NSO resource
  • Identity Provider – DUO
  • Authentication Source
    • Active Directory (We use this method in our guide)
    • Cloud-based SAML2.0 solution

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 and LOGOUT_PATH are the paths used to handle login/logout requests.
  • ACS_PATH is the path towards ACS after success or failed authentication on the IDP side.
  • METADATA_PATH is the path that stores NSO Service Provider Metadata that can be used for automatic metadata parsing from the IDP side.

 

 

LOGIN_PATH = "/saml/login/"
LOGOUT_PATH = "/saml/logout/"
ACS_PATH = "/saml/acs/"
METADATA_PATH = "/saml/metadata/"

 

 

Code Example

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:

Develop with the “cisco-nso-saml2-auth”

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: 

  • authenticate - The main SSO authentication script used to handle various requests from different paths
  • saml2_auth_utils.py – Utility used by the “authenticate” script. For example, XML parser.

There are a few frequently used functions and classes that one needs to manipulate to make things work

  • “authenticate”
    • lookup_config() – Retrieve the config stored within NCS
    • login(saml_cfg, args) - Handle LOGIN_PATH by login a user
    • metadata(saml_cfg) – Handle METADATA_PATH when exchange metadata with IDP
    • acs(saml_cfg, args) – Handle ACS_PATH by validate SAMLResponse
  • saml2_auth_utils.py
    • Class AuthnRequest(XmlTemplate) - Generates the authentication request XML object needed for login()
    • Class ResponseParser(XmlParser) - Generates an object with the parsed SAML response data
    • Class XmlParser – Parses a possibly encrypted and signed SAML response or assertion

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. 

Use Logging for Troubleshooting

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. 

Sample Code Modification

In this chapter, we will show the modification we have done to connect the NSO SSO with DUO. 

 

Playing with the “authenticate” - Incompatible IssueInstant Format

The first issue when NSO SSO tries to communicate with Cisco DUO is the "IssueInstant" format is not understood by the DUO. 

QiLi_2-1724702096095.png

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. 

Playing with the “authenticate” - IDP cert input validation

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()

 

 

 

 

Playing with the “authenticate” – Fixing groups, gids,uid, gid,homedir missing in “saml:AttributeStatement”

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'")

 

 

 

 

Give “authenticate” more utility in “saml2_auth_utils.py” – Adding Assertion Encryption Feature

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: 

  • In the __init__ function from XmlParser:

 

 

 

 

def __init__(self, xml_string: str, certificate: Optional[X509], pkey_string: Optional[str])

 

 

 

 

  • In the authenticate script when initiating the ResponseParser in the "acs(saml_cfg, args)" function:

 

 

 

 

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)

 

 

 

 

Give “authenticate” more utility in “saml2_auth_utils.py” – Adding Assertion Signing Validation Feature

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).

Guide and Demo Video

 

Getting Started

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: