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