Python / PySAML2

Requirements

The cookbook uses the PySAML2 library and requires Python >= 3.9.

Runtime Example

Included in the repository is a dockerized application for hands-on learners. It starts an Identity Provider and one Service Provider per example.

Clone the Repository

git clone https://gitlab.code.rit.edu/systems-public/saml-cookbook.git

Start Containers

The examples requires docker and docker-compose to run. You can download these by following instructions from the official Docker website.

After dependencies have been installed, navigate to the ./saml-cookbook/docker folder in a terminal and run the following command.

# Get a coffee, this is going to take a while
docker-compose build && docker-compose up

You can now view the example at https://saml-cookbook.localtest.me/. If you’re having issues verify that the command completed without errors and that localhost:443 is forwarding traffic to your docker containers.

All the code can be found at ./saml-cookbook/docker/<example>/.


Integration and Pre-Requisites

Install the pysaml2 library with the package manager of your choice (pip, poetry, uv). Ensure you have the xmlsec1 binary somewhere on your system executable by the process running Python and you’re good to go.

Extract IdP Settings

Find the IdP metadata. RIT’s IdP metadata can be found here.

You’ll need the entity id, the signing certificate, and the single sign-on service url. All the examples in this cookbook default to using RIT’s SAML IdP metadata.

Configuration

Note

If you’re confused about any of the terms, refer to the glossary.

Selecting an Entity Id

An entity id must be a URI you own. An example of a good entity id is $serviceUrl + /saml2, or the URL of your metadata.

Do not use bare words like widgets or URIs owned by other entities like google.com/widgets.

Create a Keypair

You will need a public and private keypair to encrypt and sign communication between the IdP and your service. You must generate a separate keypair every time you create a new entity id. Included is a one-line example to show how we could easily create a keypair for our service.

openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
    -keyout "service.key" -out "service.crt" -subj "/CN=widgets.rit.edu"

Create Your Settings

Refer to the official documentation for an expanded list of options.

Save this alongside your application or integrate it with an existing settings file. These settings configure how you send requests to the IdP and how you parse responses.

Modify the settings in the SP Section and IdP Section to get started.

Variable Settings
 1# SP Section
 2# base url of your site
 3BASE_URL = "https://widgets.rit.edu/"
 4# your generated entity id
 5SP_ENTITY_ID = f"{BASE_URL}saml2/"
 6# path to a base64 encoded private key (PEM) for the SP
 7SP_KEY_PATH = "/abs/path/to/service.key"
 8# path to a base64 encoded certificate (PEM) for the SP
 9SP_CERT_PATH = "/abs/path/to/service.crt"
10# End SP Section
11
12# IdP Section
13# single-sign-on url for your IdP
14IDP_SSO_URL = "https://shibboleth.main.ad.rit.edu/idp/profile/SAML2/Redirect/SSO"
15# Path to the metadata downloaded from your IdP.
16# You can download RIT's metadata at https://shibboleth.main.ad.rit.edu/idp/shibboleth
17IDP_METADATA_PATH = "/abs/path/to/idp.xml"
18# End IdP Section
Common Settings
 1CONFIG = {    
 2    "entityid": SP_ENTITY_ID,
 3    # Name of your service in metadata
 4    "name": "Python SP Example",
 5    "service": {
 6        "sp": {
 7            # Allow a user to initiate login from the IdP
 8            "allow_unsolicited": True,
 9            "authn_requests_signed": False,
10            "endpoints": {
11                # Callback service that parses SAML attributes
12                "assertion_consumer_service": [f"{BASE_URL}saml2/acs"],
13                # Service the app requests when logging in
14                "single_sign_on_service": [IDP_SSO_URL]
15            },            
16        },        
17    },
18    "metadata": {
19        "local": [
20            IDP_METADATA_PATH
21        ]
22    },
23    "allow_unknown_attributes": True,
24    "metadata_key_usage" : "both",
25    # Keypair used to sign xml payloads
26    "key_file": SP_KEY_PATH,
27    "cert_file": SP_CERT_PATH,
28    # Keypair used to encrypt xml payloads
29    "encryption_keypairs": [
30        {
31            "key_file": SP_KEY_PATH,
32            "cert_file": SP_CERT_PATH,
33        },
34    ],
35    # Path the xmlsec binary, this will likely change depending on your
36    # base image
37    "xmlsec_binary": "/usr/bin/xmlsec1",    
38    "delete_tmpfiles": True,       
39}

Integrating SAML

Once your settings are taken care of you can begin integration. To integrate with a production IdP, you will need to exchange metadata and define the attributes you need for your service.

An example list of attributes you may need is:

  • User name (uid)

  • First name (givenName)

  • Last name (sn)

  • email (mail)

Create the Client

Create a global client that’s initialized when the app starts.

1saml_client = Saml2Client(config_file=f"{DIR}/sp_conf.py")

Generating Metadata

The IdP will require a copy of the metadata produced at this endpoint to register your service. The PySAML2 library does not have an easy way to generate metadata, but we can call an included script and cache the result. It’s not necessary to expose service metadata at an endpoint, either, you may choose to generate the metadata and give it to the IdP on-demand.

 1cached_metadata = None
 2@bp.route("/saml2/metadata")
 3def metadata():
 4    global cached_metadata
 5    if not cached_metadata:
 6        cmd = subprocess.run(
 7            ["make_metadata", os.path.join(DIR, "sp_conf.py")], 
 8            stdout=subprocess.PIPE, 
 9            check=True, 
10            text=True
11        )
12
13        metadata = ET.XML(cmd.stdout)
14        ET.indent(metadata)
15        cached_metadata = ET.tostring(metadata, encoding='unicode')
16
17    response = make_response(cached_metadata)
18    response.headers['Content-Type'] = 'text/xml'
19    return response

Creating the AuthnRequest

The Saml2Client::prepare_for_authentication function handles building an authentication request for the IdP.

 1@bp.route("/saml2/login")
 2def login():
 3    _, info = saml_client.prepare_for_authenticate()
 4    headers = dict(info['headers'])
 5    # Remove Location header and create a redirect response with it
 6    response = redirect(headers.pop('Location'), code=302)
 7    # Copy the rest of the SAML headers to the response
 8    for name, value in headers.items():
 9        response.headers[name] = value
10
11    return response

Parsing the Response from the IdP

The ACS endpoint extracts and operates on a payload set by the IdP. This is handled by the Saml2Client::parse_authn_request_response method. Any errors should be shown to the user and must prevent further processing of the request.

 1@bp.route("/saml2/acs", methods=['POST'])
 2def acs():
 3    authn_response = saml_client.parse_authn_request_response(
 4        request.form['SAMLResponse'],
 5        saml2.entity.BINDING_HTTP_POST,
 6    )
 7    if not authn_response:
 8        return "Bad SAMLResponse", 500
 9
10    session['saml_identity'] = authn_response.get_identity()
11    return redirect(url_for('saml.index'))  

Extracting Attributes

After successfully parsing the IdP payload, the ACS can then extract attributes. These are returned as a dictionary of lists and may be mapped to an alias such as mail, uid, givenName or may use an oid such as 0.9.2342.19200300.100.1.1.

The above example includes processing attributes, and the relevant lines have been highlighted.

1authn_response = saml_client.parse_authn_request_response(
2    request.form['SAMLResponse'],
3    saml2.entity.BINDING_HTTP_POST,
4)
5if not authn_response:
6    return "Bad SAMLResponse", 500
7
8session['saml_identity'] = authn_response.get_identity()