Introduction

Why another book? Because in the last few months I found it very difficult to learn how to use SAML properly. Even though there are excelleant Python module and projects available. So, this is my try to write down my learning. I hope someone else will also be able to learn from this document.

This is a rolling relase book. Means the more I will learn, I will update update the book accordingly.

This book (and my learning) is possible thanks to the hand holding and guidance from Ivan Kanakarakis & Giuseppe De Marco.

How to get help about examples in this book?

If you want to talk to me, then come to #learnandteach IRC channel on Libera.chat server. If you want know more about pySAML2 module or anything else in the SAML land, maybe join the idpy slack and ask there.

You can also find me on Mastodon or on Twitter.

Security Assertion Markup Language (SAML)

It is a set XML based protocol messages + bindings + profiles. You can find more details in general at the wikipedia page.

To learn about SAML, we will have to learn about Identity Provider (IdP), service provider (SP) and the user . Generally the IdP is provided by some enterprise or organization (say your university or Google), and SP is the application written by you which will use SAML to authenticate users using those IdPs. The user will do various steps using the Broser (in most cases).

Example flow

Here is an example flow.

Example flow

At first the user tries to access some resource using the brower, (1) the SP checks if it is restricted access or not. Say it is only for logged in users, then SP will show a screen to the user (or directly redirect the user) and then redirect the user to do Single Sign-On (SSO) on the IdP (2). After the user logs in, the IdP respons (4) with XHTML form which automatically does HTTP POST request from the browser (5). The SP then varifies the SAML XML (assertion) and treats the user as logged in. In the final step (6) the SP finally redirects the user to the initial resource.

To know about how do they look like, please read (use Google to translate) docs.italia.it.

More examples can be found at samltool.com.

TLS setup using mkcert

To do development (and also for final production deployment) of SAML applications, we will need proper TLS ceritifcates. To do the same on the local development system, we will use the excellent mkcert from Filippo Valsorda.

Install nss-tools package for your system

For Fedora, I installed it via dnf.

$ sudo dnf install nss-tools -y

or in Debian/Ubuntu

$ apt install libnss-tools

Getting mkcert

Then we get the latest release from the github release page.

$ wget https://github.com/FiloSottile/mkcert/releases/download/v1.4.3/mkcert-v1.4.4-linux-amd64
$ mv mkcert-v1.4.4-linux-amd64 ~/bin/mkcert
$ chmod +x ~/bin/mkcert

Setting up the local CA

$ mkcert -install
Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️
The local CA is now installed in the Firefox trust store (requires browser restart)! 🦊

This will create two important files inside of your user home directory.

$ ls -l .local/share/mkcert/
.r--------@ 2.5k kdas 20 Dec 12:14 rootCA-key.pem
.rw-r--r--@ 1.8k kdas 20 Dec 12:14 rootCA.pem

The rootCA-key.pem is an important file and it can allow people to decrypt traffic from your system. Do not share or randomly copy it around.

Setting up certificate for local development

$ mkcert localhost 127.0.0.1 ::1

Created a new certificate valid for the following names 📜
 - "localhost"
 - "127.0.0.1"
 - "::1"

The certificate is at "./localhost+2.pem" and the key at "./localhost+2-key.pem" ✅

It will expire on 20 March 2024 🗓

We are going to use these two files (the certificate & the key file) in the coming chapters.

pySAML2

pySAML2 is a pure Python implementation of SAML2.0. Roland Hedberg is the original author, and now he maintains the project along with the other team members of Identity Python project.

For the rest of the examples in this book, I will be using Python3.10 in a virtual environment (on Fedora). But, you should be able to follow along in any modern Linux distribution (or on Mac or in WSL).

Creating our own service provider (SP)

We will start with creating a new project directory for our SP. We will also need a virtual environment for the same.

$ mkdir sp
$ cd sp
$ python3 -m venv .venv
$ source .venv/bin/activate
$ python3 -m pip install wheel

We are going to use Flask as the web framework for SP. Let us install a few other packages too.

$ python3 -m pip install flask ipdb pysaml2

Then copy over the certificate & the key file from home directory (we created them in the TLS chapter).

$ cp ~/localhost+2.pem ./backend.pem
$ cp ~/localhost+2-key.pem ./backend.key

Create test IdP

We will use the example provided by pysaml2. For that we will clone the repository, create a virtualenv for the same, install all required packages, and also copy over the key & certificate we created before.

$ git clone https://github.com/IdentityPython/pysaml2/
$ cd pysaml2/examples/idp2
$ python3 -m venv .venv
$ source .venv/bin/activate
$ python3 -m pip install wheel
$ cd ../..
$ python3 -m pip install .
$ python3 -m pip install mako cherrypy
$ cp ~/localhost+2.pem ./server.pem
$ cp ~/localhost+2-key.pem ./server.key
$ cp idp_conf.py.example idp_conf.py

In the last command, we copied the example IdP configuration. We will make three small modifications in this file. First, we mentioned that we want to provide signed response and also changed the server key & certificate files path to use the key & certificate we generated. Also the SP metadata file path is now commented.

--- idp_conf.py.example	2022-07-15 08:31:54.442424007 +0200
+++ idp_conf.py	2022-07-21 14:06:34.328494860 +0200
@@ -69,6 +69,8 @@
         },
         "idp": {
             "name": "Rolands IdP",
+            "sign_response": True,
+            "sign_assertion": True,
             "endpoints": {
                 "single_sign_on_service": [
                     ("%s/sso/redirect" % BASE, BINDING_HTTP_REDIRECT),
@@ -111,10 +113,10 @@
         },
     },
     "debug": 1,
-    "key_file": full_path("pki/mykey.pem"),
-    "cert_file": full_path("pki/mycert.pem"),
+    "key_file": full_path("./server.key"),
+    "cert_file": full_path("./server.pem"),
     "metadata": {
-        "local": [full_path("../sp-wsgi/sp.xml")],
+        "local": [full_path("./sp.xml")],
     },
     "organization": {
         "display_name": "Rolands Identiteter",

Next, we will have to regenerate the metadata for the IdP.

$ make_metadata.py idp_conf.py > idp.xml
[2022-07-20 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-20 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-20 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: {'default': {'lifetime': {'minutes': 15}, 'attribute_restrictions': None, 'name_form': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', 'entity_categories': None}}

Every time we make any change to the configuration, we will have to regenerate the metadata. Same goes for the SP.

Service provider configuration

The configuration of the pysaml2 is the core part of the whole learning process. And I should admit that my brain totally broke when I looked at these at first. Even though the documentation explains each configuration value, it was still a lot. Also this is the first time I had to configure a library/module to use. Before now I always to configure applications, but never a library at this detailed level.

After the first few failed attempts, Ivan showed me an easier way. Instead of writing the configuration from scratch, I started with an example configuration from the SATOSA project.

I am also using the code from Satosa, that way I can very easily parse the configuration from the YAML file and use it.

saml2_backend.yaml

---
module: satosa.backends.saml2.SAMLBackend
name: Saml2
config:
  idp_blacklist_file: /path/to/denylist.json

  acr_mapping:
    "": default-LoA
    "https://accounts.google.com": LoA1

  # disco_srv must be defined if there is more than one IdP in the metadata specified above
  disco_srv: http://disco.example.com

  entityid_endpoint: true
  mirror_force_authn: no
  memorize_idp: no
  use_memorized_idp_when_force_authn: no
  send_requester_id: no
  enable_metadata_reload: no

  sp_config:
    name: "Demo SP written in Python"
    description: "Our amazing SP"
    key_file: backend.key
    cert_file: backend.crt
    organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'}
    contact_person:
    - {contact_type: technical, email_address: technical@example.com, given_name: Technical}
    - {contact_type: support, email_address: support@example.com, given_name: Support}

    metadata:
      local: [idp.xml]

    entityid: http://localhost:5000/proxy_saml2_backend.xml
    accepted_time_diff: 60
    service:
      sp:
        ui_info:
          display_name:
            - lang: en
              text: "SP Display Name"
          description:
            - lang: en
              text: "SP Description"
          information_url:
            - lang: en
              text: "http://sp.information.url/"
          privacy_statement_url:
            - lang: en
              text: "http://sp.privacy.url/"
          keywords:
            - lang: se
              text: ["Satosa", "SP-SE"]
            - lang: en
              text: ["Satosa", "SP-EN"]
          logo:
            text: "http://sp.logo.url/"
            width: "100"
            height: "100"
        authn_requests_signed: true
        want_response_signed: true
        want_assertion_signed: true
        allow_unknown_attributes: true
        allow_unsolicited: true
        endpoints:
          assertion_consumer_service:
            - [http://localhost:5000/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
            - [http://localhost:5000/acs/redirect, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
          discovery_response:
          - [<base_url>/<name>/disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol']
        name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
        # A name_id_format of 'None' will cause the authentication request to not
        # include a Format attribute in the NameIDPolicy.
        # name_id_format: 'None'
        name_id_format_allow_create: true

We are interested only the values in the sp_config dictionary here. We mentioned the key file and the certificate file. We also defined the URL for callback (via POST or redirect ) in the endpoints as assertion_consumer_service.

The meatadata contains various service related metadata, in our case we are mentioning an idp.xml file. Let us copy over this file from our test IdP.

$ cp ../../pysaml2/example/idp2/idp.xml .

The flask application (SP) will run locally with default flask app port 5000. So, we will have to define the routes for all the URLs mentioned here.

We will need the satosa installed in the virtual environment so that we can reuse that code.

$ python3 -m pip install satosa

app.py

Next, is our main Flask application. Please open the current code in a browser tab. We will go through this code and try to understand it better.

We are first reading the YAMl file for configuration values and then creating a configuration object.

with open("./saml2_backend.yaml") as fobj:
    sp_config = SPConfig().load(yaml.load(fobj, SafeLoader)["config"]["sp_config"])


sp = Saml2Client(sp_config)

Next, we have a rndstr function to create random string.

def rndstr(size=16, alphabet=""):
    """
    Returns a string of random ascii characters or digits
    :type size: int
    :type alphabet: str
    :param size: The length of the string
    :param alphabet: A string with characters.
    :return: string
    """
    rng = random.SystemRandom()
    if not alphabet:
        alphabet = string.ascii_letters[0:52] + string.digits
    return type(alphabet)().join(rng.choice(alphabet) for _ in range(size))

We also have a function to find the IdP's entity ID, called get_idp_entity_id. In our code it is using the global object sp.

def get_idp_entity_id():
    """
    Finds the entity_id for the IDP
    :return: the entity_id of the idp
    """

    idps = sp.metadata.identity_providers()
    only_idp = idps[0]
    entity_id = only_idp

    return entity_id

We have our index page route as hello_world function. This currently shows a login link.

@app.route("/")
def hello_world():
    return '<p><a href="/login/">Login</a></p>'

We also need a /metadata/ endpoint, this will help us to generate the metadata (XML format) for the SP. We need to pass this metadata information to our IdP before hand. We are using the create_metadata_string function from the pysaml2 module.

@app.route("/metadata/")
def metadata():
    metadata_string = create_metadata_string(
        None, sp.config, 4, None, None, None, None, None
    ).decode("utf-8")
    return Response(metadata_string, mimetype="text/xml")

We will come back to this metadata later in the chapter/book. Meanwhile, we look into the most complex function in our code (yet), the /login/ route.

@app.route("/login/")
def login():
    try:
        acs_endp, response_binding = sp.config.getattr("endpoints", "sp")[
            "assertion_consumer_service"
        ][0]
        relay_state = rndstr()
        entity_id = get_idp_entity_id()
        req_id, binding, http_args = sp.prepare_for_negotiated_authenticate(
            entityid=entity_id,
            response_binding=response_binding,
            relay_state=relay_state,
        )
        if binding == BINDING_HTTP_REDIRECT:
            headers = dict(http_args["headers"])
            return redirect(str(headers["Location"]), code=303)

        return Response(http_args["data"], headers=http_args["headers"])
    except Exception as e:
        print(e)

Here, first we get the response_binding value from our configuration, the default value for this is BINDING_HTTP_POST, that is 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'. You will also notice that the function again returns a binding value. The function itself checks for available options from IdP, and returns the correct binding back. For our test IdP it actually returns BINDING_HTTP_REDIRECT, so our code calls redirect function with status code 303. The Location header value contains all required SAML data in encoded format.

This is what it looks like:

{'data': [],
 'headers': [('Location',
              'https://localhost:8088/sso/redirect?SAMLRequest=nVdZj9rYEn7vX4HII%2Bp4YzOazuh4xRgb78Z%2BGXnDC8YGH%2B%2B%2FftzdSTqJckdzBwmJqlPL91XVqSP%2B%2BLO%2F5bM2qmBaFi9z7DM6%2F%2FPL0x8FRHegqZNCix5NBOvZZFXA3aR%2BmTdVsSs9mE6id4vgrg52OpCOO%2FwzurtXZV0GZT7%2F7oD9s4MHYVTVU%2B4PD%2FxlntT1fYcgXdd97ojPZRUjOIqiCEoik1EI0%2FjTfCYwL%2FM0fKblaISbwh1q806mZNLNZ9Y3NlOCyQ7CJhIKWHtFPalQHH9GN884amD4jiB22Nadz5iJYlp49ZvXa3I4Zc%2FLwMuTEta7LbrdIhCWSBWFaRUF9XymfCVKpUWYFvE%2Fk%2FTfjeBubxjKs3LSjfkMfCNOlwVsblGlR1WbBpGpHb%2Fz%2F0CweqXvBRC5T9Jb9jYNo0qeUr3MmehWznRl1lVpXUfFLC1mylAnU02%2FTI3Edm8VqGZcWd28%2Bp%2BRvmqmol7eTHdRUaf1MP%2FyezhTr%2FvhL%2Bjdcvwv3wuuURF%2BntrzB%2FKR8zU%2FvtPTeKptU0UzIXyZf5ew%2BcdxFArFpXyXaa8oi3RKlY5vHZGiiUs4A3lcTgST2%2F8YDwzB0NfxeI764DnAlsWnOfJL%2Fn8Z6adBq6D3DBMP%2BxZMiy5RFRVBNDM14WX%2B6bcj%2BG5qVF4BX0sJf5H%2FPwRR0UZ5eY%2FCZ%2FiNyDcw%2Fz7i76uD%2FA4mk8bTdfgvtfqxTu9RLC9voi%2Fb6%2BZGW7wRrvsNlWS2Lmx0zeTPegtf3jH8aP2u%2BV7nr%2FKvU%2FK9qe9Ogo%2FWnB7eKJDd95ZYDf7ysBFpRJOWZge5QVizqGKKEVJ1anvmdV32F55LEKv04Q0Lc%2FNUEWxuUhcsoQQhkLZxKpWSjnjtIs0RJTkiSHURo6LnL01ubOP7xipCdM%2FYe58G8nUBkScXD1cNeoTQjkWtR%2Ff3fiGQJbdVXZXVUl0A9GD5jrPdbFqH6x0Hi629NpKjXYYPI3TTp7ErWaItYEDl0ul6zu93z1hyvR0Z3GZjr1fnA5lrTtF2toaKx%2Fahk7cer9wq89bZpS3IJxS1zDQOJT0iWSRb19eztTawTeZhjkQ9KLLmiD0kuYDblqFnQ%2F8mFzhaqUeEgxxdt%2BqTTpy0u6LeZBQ8CvuoYIJNS%2BDl5aMDP1T8rQtiNHy05LxCScarvQ%2BJfl1xl%2Bku19EXSRBYj6FpUNMx6AQKxIIGjkR475wwcU2CRolQLI6dFzodozoHsXSFpA1koLJHSgVd7MB%2Bn4GAimWLAqXB2Vjl4NYQ0tTVPbu5z5Odb1vNJDOqwXYSveQBZrJ01xUeriUBSkEflxOfn6ISajPZZ47dX30cSzx7GYtsjQV4kvg0xTpn6S4ZQSerDmOpKsPQq9rD5Two1Pg%2FxaKWZ8YQBolhx%2Bm7lBgVlbHyVYdOv191xFddZ49sIdHmO%2FZEiny7ztzzARV4LQ9vFvQJqnbt1SS%2F4tHu7i3PnLOWSwbojvEb3iNDh5XLc6PK962Dc9DjyTGkV18xa61vc3c%2FBaVOWKPHc1BgJ07DtYtjNpUAytP6g9cFn2BUlgKqCcBSoJgOvJ6LoJx6pzIh3NS26njr4NCrVedb10tWEvQhzxEd1MLdHGQ3XTlRiYar9pD2W9xoVfNkZ%2B2Fb%2BPONEbGshKBw5qLqIIFRykx4nhDwrdieTXgGtMP%2BxMpp%2FUtpYvuxkCKXtnxVi7iJr9dRLzcVpY5tMMQrGxSAYj%2BQE16uyKOG1oxlIcqKZsTbg%2FN4mIf1z69ujAqeZ%2FmA1c8es2g9qZAyQVLXFV3GdNJdHUc092v0lPdg3tVn%2FNlnFDj2DpGhx6GZdMnY7dJy5C0eWHvnvoizJYx9AX8sQ8ieZvs7zVw7Q1dcEa6n9bLPshvyxQxdY8S2j0ukcTx6iC80d2dMZZuRnfKG2zkff5kTOOxCW8iMzx8EEsUAHwWZhQqgbf5DZmOpZBOZac7w5UMMF7nf6%2BbLMMAkYrjiopZjlKD6QRc3s8klmeAHVPq2IeLx7UqO00EbF6IFIY6vfToOpQB8M1WY9mDAbI4zn%2BYZ31P7bdgwrFnwe8%2B7O%2FuJxPHPAXoFXtoOLklM5nhfR%2B11jnRdebp8CBL%2FcEyaCrBlaiIK22IvXW4jBbZKWu0dbDRaz6iUa5BrqOCkTiaINbYaCyBCeFePmD2gin5UwYvkmX7D23ZuduUp7bn9Exh8sN83Fp6URN0uCzZgPEP25OxPRd8IeVw9IlNVbjp8IAZTgz6mq0BgRH0SsU94lAddHyAWrhVWJXg7kyHkA8VQffa9noC55bUyP7Sc0t%2BPSZiY4XjyCq5iyZ7oTJDgQqVI48LFwWnFR7WxuoRItPGxbZHJx%2F0nhhLSk1Ryn5IGdoNcqfTC0o8OE1wnPa2yHgafTkZ6wt39Kh6BHXvX%2FNrcuJSzI2YR8M1eIIj8ma9ykw7txfrfr2UeLeRPfmBysdpTyzrq5sCVLFWUMAGRljlp8KD%2BVqu1I1zuxAufbyITYt6Qz2Wh8oBAYdICgUYnRX05JrFDWgj9QCglrl60yUcGVv4WcGxVtZ988Ce96vkXsXn%2Fuotxa23MrFKHmvRGSkDC9VsRDL%2FNG0NMjx5NG%2BobrCMvz0Wv27%2FD%2B37C4H89Hr8%2FL68yT%2F%2F%2F%2Fjy9Dc%3D&RelayState=w55JXDjMjii6zPmk&SigAlg=http%3A%2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1&Signature=jdwo4NH3Hna4v3GRkKF55H%2Ftlv3OOxfDMkyxQJ%2BauR3bQ8aHiYerpBNITVzSyQFqXcv8Cj2uBeeG%2BEBbVuXsW1fx0LHtJuJw03NxuDnxGZnGaAg8XrEBbwOOCtVat7irAIzG9EanDyslAs1YxM4VQtaoXqlBVDr0ZqVzfWHtNAByfEDJAgR%2B7XMlUkZ8iwLDneT9vAnIj%2BZFsnAl%2BphJMOzoK2zR%2BFw81WndrwSbEdOMSTeONOpckMrQwOP2GGx9T5jehf3e2CikZFN03MAhuY1lP0tlUSdP3nQqPweh2aBD1MlC5mYlijZrmZ1L79fPcRY9UgWXEouw84B%2BmPe0Gg%3D%3D')],
 'method': 'GET',
 'status': 303,
 'url': 'https://localhost:8088/sso/redirect'}

The last two routes are for step 5 of our flow diagram, in this step we validate the response we receive.

@app.route("/acs/post", methods=["POST"])
def acs_post():
    outstanding_queries = {}
    binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
    authn_response = sp.parse_authn_request_response(
        request.form["SAMLResponse"], binding, outstanding=outstanding_queries
    )
    #ipdb.set_trace()
    return str(authn_response.ava)

@app.route("/acs/redirect", methods=["GET"])
def acs_redirect():
    outstanding_queries = {}
    binding = BINDING_HTTP_REDIRECT
    authn_response = sp.parse_authn_request_response(
        request.form["SAMLResponse"], binding, outstanding=outstanding_queries
    )
    return str(authn_response.ava)

The sp.parse_authn_request_response validates the response, in future we will put this function inside of a try-except block to handle any failure or to deal with bad response. These functions are supposed to redirect the user (after marking them as logged in user) to the actual resource. But, for now we will just print all the validated information we received from the IdP.

Starting the SP Flask application

Do the following in the same directory of SP (remember to activate the virtual environment first).

$ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)

Right now the server is listening on port 5000.

Now, let us move to the test IdP terminal, keep this application running.

Starting the test IdP

We will download the SP metadata first.

$ curl http://localhost:5000/metadata/ -o sp.xml

Update the idp_conf.py file with the metadata file to point to the new XML file.

    "metadata": {
        "local": [full_path("./sp.xml")],
    },

Regenerate the idp.xml and copy it over to SP, and then restart SP Flask application.

$ make_metadata.py idp_conf.py > idp.xml
[2022-07-21 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-21 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-21 13:37:20,260] [DEBUG] [saml2.assertion.__init__] policy restrictions: {'default': {'lifetime': {'minutes': 15}, 'attribute_restrictions': None, 'name_form': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', 'entity_categories': None}}
$ cp idp.xml ../samp-examples/sp/

Now, let us start the IdP.

$ python idp.py idp_conf
[2022-07-21 10:42:25,773] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-21 10:42:25,773] [DEBUG] [saml2.assertion.__init__] policy restrictions: None
[2022-07-21 10:42:25,773] [DEBUG] [saml2.assertion.__init__] policy restrictions: {'default': {'lifetime': {'minutes': 15}, 'attribute_restrictions': None, 'name_form': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', 'entity_categories': None}}
[2022-07-21 10:42:25,843] [INFO] [saml2.idp.<module>] Server starting
IDP listening on localhost:8088

On the browser

We will use Firefox as the browser for this step, you can use any browser of your choice. But, we will install saml-tracer plugin so that we can see the SAML request and response objects nicely in our browser.

After starting the tracer plugin, if we open the SP URL http://localhost:5000/ we will see the following.

Firefox & tracer

Next, we can click and login. The username/password combinations are mentioned in the idp.py file. We are using testuser/qwerty combination.

Firefox & tracer, full flow

We can all the claims released by the IdP at the last screen.

If we click on the SAML request in the tracer we can see the actual request.

SAML request

Same about SAML response.

SAML response

This where the tracer becomes so useful :) I personally found it super useful to see the actual SAML request/response and go through the various values inside of those.

Making the SP useful

The SAML request

Let us dig more into a SAML request. Here is one from our example in the last chapter.

<ns0:AuthnRequest xmlns:ns0="urn:oasis:names:tc:SAML:2.0:protocol"
                  xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion"
                  xmlns:ns2="http://www.w3.org/2000/09/xmldsig#"
                  ID="id-JUwmMTjY6T9RTAN7S"
                  Version="2.0"
                  IssueInstant="2022-07-21T09:56:57Z"
                  Destination="https://localhost:8088/sso/redirect"
                  ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                  AssertionConsumerServiceURL="http://localhost:5000/acs/post"
                  ProviderName="Demo SP written in Python"
                  >
    <ns1:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://localhost:5000/proxy_saml2_backend.xml</ns1:Issuer>
    <ns2:Signature Id="Signature1">
        <ns2:SignedInfo>
            <ns2:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            <ns2:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
            <ns2:Reference URI="#id-JUwmMTjY6T9RTAN7S">
                <ns2:Transforms>
                    <ns2:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                    <ns2:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                </ns2:Transforms>
                <ns2:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                <ns2:DigestValue>knF1koHdOZhADoSsakgYkYNkmDM=</ns3:DigestValue>
            </ns2:Reference>
        </ns2:SignedInfo>
        <ns2:SignatureValue>M1vZtdwkW8KBhNgBsw/8kMDWNcXIeMBtuUid6+r9zqE8647izXpHyn3cYQ4ORJR6
nDG496u7ZeRuqobbhpgZfwHU9c4obov0ilOaBJomxrfggwCYF+zExaIL9DzSph90
H1lkJG1UUiRuwK9OtSZeov2LjCD0A8H3YZFm/i3AwZYYavO0Nf6+cJ9bvrNLfcvV
aXCbeScPTsJJ/ju3l3BCQl2qozE1Bh8w+so96EmUx/OE9yIDMf2qKZP6N7bSHTaI
IeUbZ+xbHfs1ZkRLcrtwr7W48C2/kzSyXEcVGq5JVtBcZKh4WkmgMuKUkvJ0k5B6
KcRoRlIkK1CNlD4pk1pHuQ==</ns2:SignatureValue>
        <ns2:KeyInfo>
            <ns2:X509Data>
                <ns2:X509Certificate>MIIEaDCCAtCgAwIBAgIRAL3dpwYdhZU3C03dKnLwadYwDQYJKoZIhvcNAQELBQAwgYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwna2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMTcwNQYDVQQDDC5ta2NlcnQga2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMB4XDTIyMDEzMDE4MDQ0N1oXDTI0MDQzMDE3MDQ0N1owWzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdrZGFzQGxvY2FsaG9zdC5sb2NhbGRvbWFpbiAoS3VzaGFsIERhcykwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDds7tWQYa6cJxQrwbVkfjo3CJll/SAtIpUyNZi5Yeo0d5vJix82TvQUOWjvfGvgwUTzDVVhIF1ufKQA+FBPg/YayhGvKokTs61SJHO9NitmiCnwmDsBC5Wg8NngulmfK2o8rVUyvyyc5W9PA/Sq0UC853L7CPTPqQMP7O2Wyu+fWL6bC5fDQ9pbWV2PaC6D0W7n09+E3kQZ4gChekYYUZH5iOtxAprtXl4ghBzzvYTw0Jy4uxhzw7iod9WGIHZOxndj4gsbI2qHceN8hHptAZW7CnFTiHGSSHclm4i/USaBIvH2M93LkY/GTwpYzgMmTwOlu1zGbGOTMpM7dmKDyqbAgMBAAGjdjB0MA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBQzxd+qkrowRKAElnKB10YxMqww0DAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAC5EJuFNv9jNDGbb0V6l3wwUOJq9oSqED0iMs5KPK5Ryga6d4e+jOjuR6c7StGeC0Fu/kzP1920h/VzuRE31IdHNJ1W+DoGOjsfMVWbqR4wZ8iGB8XiXB1NqUqmvC+t3Cd4oEcDbJ8OT8XnGnMlszb37rnZiyqsj23yS6EtA313C5Q2a3JrJS2ysRd8PEQ3FpDw/9qQ/0HR8kOAXv9R9xfxF4G6zhKuVdzzEPlZ0hHIrUdIBdPLG2IfP2CPGstT5qd/6T118LYlySx3zoBQi0BWqMj0wyNwSC+BKJYucL2rZKDaRCfOT6fFLaBtzAtxbklkhOFi1ZeDquFu2h2/N765jUWlW+6x64MGZuNaNq0NLWzE4tkZiA0PV5sI1yDI5lOnasl6NrQ7Ymf3ZCLfKuv0aytzoJrYAcF/MPBADSEIShkjguAveQJAsRjZSuwhF9gV2XP21vNSbUJEXH5hprgXxka4K8a5U1rNztKYzBT1dQjz/jbOykw9dOaCGTQZc4g==</ns2:X509Certificate>
            </ns2:X509Data>
        </ns2:KeyInfo>
    </ns2:Signature>
</ns0:AuthnRequest>

The request is signed. And AssertionConsumerServiceURL contains the SP route (URL) where the response will be POST back from the IdP via the browser.

The SAML response

<ns0:Response xmlns:ns0="urn:oasis:names:tc:SAML:2.0:protocol"
              xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion"
              xmlns:ns2="http://www.w3.org/2000/09/xmldsig#"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              ID="id-2AtrGBfajnH62i1VZ"
              InResponseTo="id-ew6aM7l0xFh07xbCQ"
              Version="2.0"
              IssueInstant="2022-07-21T12:07:57Z"
              Destination="http://localhost:5000/acs/post"
              >
    <ns1:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://localhost:8088/idp.xml</ns1:Issuer>
    <ns2:Signature Id="Signature1">
        <ns2:SignedInfo>
            <ns2:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            <ns2:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
            <ns2:Reference URI="#id-2AtrGBfajnH62i1VZ">
                <ns2:Transforms>
                    <ns2:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                    <ns2:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                </ns2:Transforms>
                <ns2:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                <ns2:DigestValue>G6ZNgH2RvTLXJzv3jZ/SvQ25E04=</ns2:DigestValue>
            </ns2:Reference>
        </ns2:SignedInfo>
        <ns2:SignatureValue>c6Y+YJ8IGIT3lzDLxm3kCoxst4/p2k1MMl/oU8qUc0qnmt3D+hWvd+3vVyp4NaRJ
prYlqh3eqN5a0Ln4/LcQRQV/Rg5CQ8bkhQ5e+jgFjyHRVtOtvb20Vyb3q5Kyx/Eg
Vk2jRGMpAollVk4+5+mp46MY4F7+s1suhY0/wDwbo6Djl/L38K/LXhF1qQ3gxs2n
m63oMktW3bSomIiDGzLEEMqgbhYWjisTN9YsIK+WwQO3mJ/3bUF7baljt40hCwQA
QhIJP/rgOoyVzJj8eSmMGbZJ+r9gVNSGBybFhs2yJj5+PJA1JvtJJs340ujEXkZf
29GI0vG7PcOWY03wj6PfMw==</ns2:SignatureValue>
        <ns2:KeyInfo>
            <ns2:X509Data>
                <ns2:X509Certificate>MIIEaDCCAtCgAwIBAgIRAL3dpwYdhZU3C03dKnLwadYwDQYJKoZIhvcNAQELBQAwgYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwna2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMTcwNQYDVQQDDC5ta2NlcnQga2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMB4XDTIyMDEzMDE4MDQ0N1oXDTI0MDQzMDE3MDQ0N1owWzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdrZGFzQGxvY2FsaG9zdC5sb2NhbGRvbWFpbiAoS3VzaGFsIERhcykwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDds7tWQYa6cJxQrwbVkfjo3CJll/SAtIpUyNZi5Yeo0d5vJix82TvQUOWjvfGvgwUTzDVVhIF1ufKQA+FBPg/YayhGvKokTs61SJHO9NitmiCnwmDsBC5Wg8NngulmfK2o8rVUyvyyc5W9PA/Sq0UC853L7CPTPqQMP7O2Wyu+fWL6bC5fDQ9pbWV2PaC6D0W7n09+E3kQZ4gChekYYUZH5iOtxAprtXl4ghBzzvYTw0Jy4uxhzw7iod9WGIHZOxndj4gsbI2qHceN8hHptAZW7CnFTiHGSSHclm4i/USaBIvH2M93LkY/GTwpYzgMmTwOlu1zGbGOTMpM7dmKDyqbAgMBAAGjdjB0MA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBQzxd+qkrowRKAElnKB10YxMqww0DAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAC5EJuFNv9jNDGbb0V6l3wwUOJq9oSqED0iMs5KPK5Ryga6d4e+jOjuR6c7StGeC0Fu/kzP1920h/VzuRE31IdHNJ1W+DoGOjsfMVWbqR4wZ8iGB8XiXB1NqUqmvC+t3Cd4oEcDbJ8OT8XnGnMlszb37rnZiyqsj23yS6EtA313C5Q2a3JrJS2ysRd8PEQ3FpDw/9qQ/0HR8kOAXv9R9xfxF4G6zhKuVdzzEPlZ0hHIrUdIBdPLG2IfP2CPGstT5qd/6T118LYlySx3zoBQi0BWqMj0wyNwSC+BKJYucL2rZKDaRCfOT6fFLaBtzAtxbklkhOFi1ZeDquFu2h2/N765jUWlW+6x64MGZuNaNq0NLWzE4tkZiA0PV5sI1yDI5lOnasl6NrQ7Ymf3ZCLfKuv0aytzoJrYAcF/MPBADSEIShkjguAveQJAsRjZSuwhF9gV2XP21vNSbUJEXH5hprgXxka4K8a5U1rNztKYzBT1dQjz/jbOykw9dOaCGTQZc4g==</ns2:X509Certificate>
            </ns2:X509Data>
        </ns2:KeyInfo>
    </ns2:Signature>
    <ns0:Status>
        <ns0:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
    </ns0:Status>
    <ns1:Assertion Version="2.0"
                   ID="id-57PKtFAHtjXCPVdW0"
                   IssueInstant="2022-07-21T12:07:57Z"
                   >
        <ns1:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://localhost:8088/idp.xml</ns1:Issuer>
        <ns2:Signature Id="Signature2">
            <ns2:SignedInfo>
                <ns2:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                <ns2:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
                <ns2:Reference URI="#id-57PKtFAHtjXCPVdW0">
                    <ns2:Transforms>
                        <ns2:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                        <ns2:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                    </ns2:Transforms>
                    <ns2:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                    <ns2:DigestValue>2xtlJf6NK9dDj+rys0jTIqvmfdA=</ns2:DigestValue>
                </ns2:Reference>
            </ns2:SignedInfo>
            <ns2:SignatureValue>U4dGSI7dvOeGcekCSv16KxpwhYJvvk2lroJgquA17fMKHH4uImc87Kse41aH/NUY
FufdAcAFhW1EZNIZ8/mj3i8jhD4maJREp+N78/NO9HFCgdRnPpgPR1/XU/Eqkl/C
YhFU4tnfpMO35r4/PTWh0fhs9SLUI94j46G8GtwzqBLCXwk6++tCetsMWEQbcyIs
6aN383aUw+r0VcJQ1yfv1Qtl8sN370bW+FK+h8oa7IQZIUuOn+uuOaHpxwHryBAs
q0RdpDbXdK4oIhXmkJfhlyEoiRdWXngKPMXqme4HOlr4+pH5odlh82YB32JmlKw7
BsQ+JBB5Cy7O/f1Q30mvtQ==</ns2:SignatureValue>
            <ns2:KeyInfo>
                <ns2:X509Data>
                    <ns2:X509Certificate>MIIEaDCCAtCgAwIBAgIRAL3dpwYdhZU3C03dKnLwadYwDQYJKoZIhvcNAQELBQAwgYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwna2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMTcwNQYDVQQDDC5ta2NlcnQga2Rhc0Bsb2NhbGhvc3QubG9jYWxkb21haW4gKEt1c2hhbCBEYXMpMB4XDTIyMDEzMDE4MDQ0N1oXDTI0MDQzMDE3MDQ0N1owWzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdrZGFzQGxvY2FsaG9zdC5sb2NhbGRvbWFpbiAoS3VzaGFsIERhcykwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDds7tWQYa6cJxQrwbVkfjo3CJll/SAtIpUyNZi5Yeo0d5vJix82TvQUOWjvfGvgwUTzDVVhIF1ufKQA+FBPg/YayhGvKokTs61SJHO9NitmiCnwmDsBC5Wg8NngulmfK2o8rVUyvyyc5W9PA/Sq0UC853L7CPTPqQMP7O2Wyu+fWL6bC5fDQ9pbWV2PaC6D0W7n09+E3kQZ4gChekYYUZH5iOtxAprtXl4ghBzzvYTw0Jy4uxhzw7iod9WGIHZOxndj4gsbI2qHceN8hHptAZW7CnFTiHGSSHclm4i/USaBIvH2M93LkY/GTwpYzgMmTwOlu1zGbGOTMpM7dmKDyqbAgMBAAGjdjB0MA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBQzxd+qkrowRKAElnKB10YxMqww0DAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAC5EJuFNv9jNDGbb0V6l3wwUOJq9oSqED0iMs5KPK5Ryga6d4e+jOjuR6c7StGeC0Fu/kzP1920h/VzuRE31IdHNJ1W+DoGOjsfMVWbqR4wZ8iGB8XiXB1NqUqmvC+t3Cd4oEcDbJ8OT8XnGnMlszb37rnZiyqsj23yS6EtA313C5Q2a3JrJS2ysRd8PEQ3FpDw/9qQ/0HR8kOAXv9R9xfxF4G6zhKuVdzzEPlZ0hHIrUdIBdPLG2IfP2CPGstT5qd/6T118LYlySx3zoBQi0BWqMj0wyNwSC+BKJYucL2rZKDaRCfOT6fFLaBtzAtxbklkhOFi1ZeDquFu2h2/N765jUWlW+6x64MGZuNaNq0NLWzE4tkZiA0PV5sI1yDI5lOnasl6NrQ7Ymf3ZCLfKuv0aytzoJrYAcF/MPBADSEIShkjguAveQJAsRjZSuwhF9gV2XP21vNSbUJEXH5hprgXxka4K8a5U1rNztKYzBT1dQjz/jbOykw9dOaCGTQZc4g==</ns2:X509Certificate>
                </ns2:X509Data>
            </ns2:KeyInfo>
        </ns2:Signature>
        <ns1:Subject>
            <ns1:NameID NameQualifier="https://localhost:8088/idp.xml"
                        SPNameQualifier="http://localhost:5000/proxy_saml2_backend.xml"
                        Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
                        >615abcec23b7a4165907c61172ce29ea5eeecfa100f4428e61388329a08c9e87</ns1:NameID>
            <ns1:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
                <ns1:SubjectConfirmationData NotOnOrAfter="2022-07-21T12:22:57Z"
                                             Recipient="http://localhost:5000/acs/post"
                                             InResponseTo="id-ew6aM7l0xFh07xbCQ"
                                             />
            </ns1:SubjectConfirmation>
        </ns1:Subject>
        <ns1:Conditions NotBefore="2022-07-21T12:07:57Z"
                        NotOnOrAfter="2022-07-21T12:22:57Z"
                        >
            <ns1:AudienceRestriction>
                <ns1:Audience>http://localhost:5000/proxy_saml2_backend.xml</ns1:Audience>
            </ns1:AudienceRestriction>
        </ns1:Conditions>
        <ns1:AuthnStatement AuthnInstant="2022-07-21T12:07:57Z"
                            SessionIndex="id-8XXMIpMS8VVJubvRo"
                            >
            <ns1:AuthnContext>
                <ns1:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</ns1:AuthnContextClassRef>
                <ns1:AuthenticatingAuthority>https://localhost:8088</ns1:AuthenticatingAuthority>
            </ns1:AuthnContext>
        </ns1:AuthnStatement>
        <ns1:AttributeStatement>
            <ns1:Attribute Name="urn:oid:2.5.4.4"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="sn"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >Testsson</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.42"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="givenName"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >Test</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="eduPersonAffiliation"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >student</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.9"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="eduPersonScopedAffiliation"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >student@example.com</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="eduPersonPrincipalName"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >test@example.com</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:0.9.2342.19200300.100.1.1"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="uid"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >testuser</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.10"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="eduPersonTargetedID"
                           >
                <ns1:AttributeValue>
                    <ns1:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">one!for!all</ns1:NameID>
                </ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.6"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="c"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >SE</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.10"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="o"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >Example Co.</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.11"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="ou"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >IT</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.43"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="initials"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >P</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:0.9.2342.19200300.100.1.43"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="co"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >co</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:0.9.2342.19200300.100.1.3"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="mail"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >mail</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.2428.90.1.6"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="noreduorgacronym"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >noreduorgacronym</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.25178.1.2.9"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="schacHomeOrganization"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >example.com</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.2.840.113549.1.9.1.1"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="email"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >test@example.com</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.16.840.1.113730.3.1.241"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="displayName"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >Test Testsson</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="labeledURL"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >http://www.example.com/test My homepage</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:1.3.6.1.4.1.2428.90.1.5"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="norEduPersonNIN"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >SE199012315555</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.16"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="postaladdress"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >postaladdress</ns1:AttributeValue>
            </ns1:Attribute>
            <ns1:Attribute Name="urn:oid:2.5.4.3"
                           NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
                           FriendlyName="cn"
                           >
                <ns1:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
                                    xsi:type="xs:string"
                                    >cn</ns1:AttributeValue>
            </ns1:Attribute>
        </ns1:AttributeStatement>
    </ns1:Assertion>
</ns0:Response>

A big XML :) We can see both the response and the attributes are signed. By default our test IdP released all the attributes it had. You can see the different maps available in the source.

You can search more to find details about these attributes, here are a few links:

Now a Service Provider using Flask with users and authentication

Our last example was purely a demo example, something which is nice to start learning. But, we need more things to be production ready.

So, in our virtual environment we need some more packages.

python3 -m pip install flask-sqlalchemy flask-login flask-migrate flask ipdb pysaml2

If you are new to Flask, then I would suggest to first look at the Flask Mega Tutorial. That tutorial should explain you all the basics we need for our codebase.

To make things easier, feel free to clone the saml-examples repository, and then look at the sp2 directory.

All of our application spefic code is now at app directory & all database migrations are in migrations directory. You will have to copy over the backend.key, backend.crt & idp.xml in the same directory to make it working.

app/models.py

In this file we are declaring our database model. Now I am just using the email address for every user. Based on your need you can add as much details as you want in the model.

from flask_login import UserMixin
from app import db

class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(250), index=True, unique=True)

    def __repr__(self):
        return '<User {}>'.format(self.email)

app/templates

In this directory we have various HTML templates. The one we should look at is profile.html.

{% extends "base.html" %}

{% block content %}
<h1>
  Hello {{ current_user.email }}
</h1>

{% endblock %}

This page assumes there is a logged in user, and we can access the email address by {{ current_user.email }}.

app/init.py

This is the primary file for our application.

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"
app.config[
    "SECRET_KEY"
] = "192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf"

db = SQLAlchemy(app)
migrate = Migrate(app, db)

login_manager = LoginManager()
login_manager.login_view = "login"
login_manager.init_app(app)

from .models import User

We are creating the Flask app variable with required configrutions, like database path, and a static SECRET_KEY for our development purpose. Remember to change these values with proper ones for production.

We are also creating the db instance, and also the migrate to make sure that we have database migrations. The login_manager is the piece of code which will deal with user authentication related steps. We are marking our login function as the view to be used when the user needs to login. At the last line we are importing the User we defined in our models.py.

@login_manager.user_loader
def load_user(user_id):
    # since the user_id is just the primary key of our user table, use it in the query for the user
    return User.query.get(int(user_id))

Next, we have a load_user function as defined by the flask_login.

@app.route("/")
def index():
    return render_template("index.html")

We also have a nicer index page, where we are rendering the index.html template.

@app.route("/profile/", methods=["GET"])
@login_required
def profile():
    return render_template("profile.html")

@app.route("/logout/", methods=["GET"])
def logout():
    logout_user()
    return redirect(url_for("index"))

We also added two new routes for our application. /profile is the page which only authenticated users can view, that is why we used the @login_required decorator there (from flask_login module). And next is the /logout to make sure that the user can logout.

@app.route("/acs/post", methods=["POST"])
def acs_post():
    outstanding_queries = {}
    binding = BINDING_HTTP_POST
    try:
        authn_response = sp.parse_authn_request_response(
            request.form["SAMLResponse"], binding, outstanding=outstanding_queries
        )
    except:
        return render_template("error.html"), 500
    # ipdb.set_trace()
    email = authn_response.ava["email"][0]
    # Now check if an user exists, or add one
    user = User.query.filter_by(email=email).first()

    if not user:
        user = User(email=email)
        db.session.add(user)
        db.session.commit()
    login_user(user, remember=True)
    return redirect(url_for("profile"))

The /acs/post route got the biggest changes. After validating the SAML respose, we are extracting the user email addresss, and first checking if an user account is already there or not. If no such user, then we create one and save the database using db.session.commit(). Then next step is to call login_user. And finally we redirect the logged in user to the /profile view.

Example of Flask SP on localhost

We can see it live above, we can also see because we are logged in to the IdP, when the user logsout and then again type in /profile, the application goes to IdP and then logs in (as user is already logged in there) and comes back to the /profile view.

What next?

I will be pushing the chapters on Django soon.

SATOSA

SATOSA is a proxy written in Python which helps for translating between different authentication protocols such as SAML2, OpenID Connect and OAuth2.

We will slowly learn about SATOSA in details in the coming chapters. For now we will learn how to configure one SATOSA instance to work with our example IdP and service provider.

SP ---> SATOSA FRONTEND <--->SATOSA BACKEND <-----> IdP

Setting up SATOSA

$ git clone https://github.com/IdentityPython/satosa.git
$ cd satosa
$ python3 -m venv .venv
$ source .venv/bin/activate
$ python3 -m pip install wheel
$ python3 -m pip install -e .
$ cd example/
$ cp internal_attributes.yaml.example internal_attributes.yaml
$ cp proxy_conf.yaml.example proxy_conf.yaml
$ cp ~/localhost+2.pem ./proxy.crt
$ cp ~/localhost+2.pem ./backend.crt
$ cp ~/localhost+2-key.pem ./backend.key
$ cp ~/localhost+2-key.pem ./proxy.key

Here we are using the same key/certificate for both the proxy frontend and backend.

Update the proxy_conf.yaml file first.

--- proxy_conf.yaml.example	2023-11-27 12:48:53.272742476 +0100
+++ proxy_conf.yaml	2023-11-21 16:15:21.878249924 +0100
@@ -1,7 +1,7 @@
-BASE: https://example.com
+BASE: https://localhost:8010
 
 COOKIE_STATE_NAME: "SATOSA_STATE"
-CONTEXT_STATE_DELETE: yes
+CONTEXT_STATE_DELETE: false
 STATE_ENCRYPTION_KEY: "asdASD123"
 
 cookies_samesite_compat:
@@ -15,8 +15,8 @@
 FRONTEND_MODULES:
   - "plugins/frontends/saml2_frontend.yaml"
 
-MICRO_SERVICES:
-  - "plugins/microservices/static_attributes.yaml"
+MICRO_SERVICES: []
+#  - "plugins/microservices/static_attributes.yaml"
 
 LOGGING:
   version: 1

We are mentioning that we will be using port 8010 along with TLS for the example server.

We are also using both SAML frontend and backend. This is mentioned via the following in the proxy_conf.yaml file. There can be more than one frontend and backend.

BACKEND_MODULES:
  - "plugins/backends/saml2_backend.yaml"

FRONTEND_MODULES:
  - "plugins/frontends/saml2_frontend.yaml"

We need to setup next frontend and backend.

$ cd plugins/backends/
$ cp saml2_backend.yaml.example saml2_backend.yaml
diff -Naur saml2_backend.yaml.example saml2_backend.yaml
--- saml2_backend.yaml.example	2023-11-21 16:06:31.650318811 +0100
+++ saml2_backend.yaml	2023-11-21 16:26:37.869130158 +0100
@@ -1,14 +1,14 @@
 module: satosa.backends.saml2.SAMLBackend
 name: Saml2
 config:
-  idp_blacklist_file: /path/to/blacklist.json
+  #idp_blacklist_file: /path/to/blacklist.json
 
-  acr_mapping:
-    "": default-LoA
-    "https://accounts.google.com": LoA1
+  # acr_mapping:
+  #   "": default-LoA
+  #   "https://accounts.google.com": LoA1
 
   # disco_srv must be defined if there is more than one IdP in the metadata specified above
-  disco_srv: http://disco.example.com
+  #disco_srv: http://disco.example.com
 
   entityid_endpoint: true
   mirror_force_authn: no
@@ -80,8 +80,9 @@
             text: "http://sp.logo.url/"
             width: "100"
             height: "100"
-        authn_requests_signed: true
-        want_response_signed: true
+        authn_requests_signed: false
+        want_response_signed: false
+        want_assertions_or_response_signed: true
         allow_unsolicited: true
         endpoints:
           assertion_consumer_service:

And the same for the frontend:

$ cd ../frontends/
$ cp saml2_frontend.yaml.example saml2_frontend.yaml

And update it with the following configuration.

--- saml2_frontend.yaml.example	2023-11-27 12:41:56.187392054 +0100
+++ saml2_frontend.yaml	2023-11-21 16:20:25.274196172 +0100
@@ -49,8 +49,8 @@
               "remd:contactType": "http://refeds.org/metadata/contactType/security",
             },
         }
-    key_file: frontend.key
-    cert_file: frontend.crt
+    key_file: proxy.key
+    cert_file: proxy.crt
     metadata:
       local: [sp.xml]

Next we will have to generate the metadata for the SATOSA proxy and then start the proxy server itself.

$ cd ../..
$ satosa-saml-metadata proxy_conf.yaml proxy.key proxy.crt
$ gunicorn -b 0.0.0.0:8010 satosa.wsgi:app --keyfile proxy.key --certfile proxy.crt

Updating the IdP with proxy's metadata

Now we have to update the IdP with the proxy's metadata as service provider. We can either link or copy the backend.xml file for this.

$ ln -s ../../../satosa/example/backend.xml

And then updated the idp_conf.py file.

--- idp_conf.py.example	2023-11-21 14:41:57.200660142 +0100
+++ idp_conf.py	2023-11-21 16:30:32.719075149 +0100
@@ -2,15 +2,13 @@
 # -*- coding: utf-8 -*-
 import os.path
 
+from saml2 import BINDING_HTTP_REDIRECT, BINDING_URI
 from saml2 import BINDING_HTTP_ARTIFACT
 from saml2 import BINDING_HTTP_POST
-from saml2 import BINDING_HTTP_REDIRECT
 from saml2 import BINDING_SOAP
-from saml2 import BINDING_URI
 from saml2.saml import NAME_FORMAT_URI
-from saml2.saml import NAMEID_FORMAT_PERSISTENT
 from saml2.saml import NAMEID_FORMAT_TRANSIENT
-
+from saml2.saml import NAMEID_FORMAT_PERSISTENT
 
 try:
     from saml2.sigver import get_xmlsec_binary
@@ -71,6 +69,8 @@
         },
         "idp": {
             "name": "Rolands IdP",
+            "sign_response": True,
+            "sign_assertion": True,
             "endpoints": {
                 "single_sign_on_service": [
                     ("%s/sso/redirect" % BASE, BINDING_HTTP_REDIRECT),
@@ -113,10 +113,10 @@
         },
     },
     "debug": 1,
-    "key_file": full_path("pki/mykey.pem"),
-    "cert_file": full_path("pki/mycert.pem"),
+    "key_file": full_path("./server.key"),
+    "cert_file": full_path("./server.pem"),
     "metadata": {
-        "local": [full_path("../sp-wsgi/sp.xml")],
+        "local": [full_path("./backend.xml")],
     },
     "organization": {
         "display_name": "Rolands Identiteter",

We can then start the IdP server on port 8088.

$ python idp.py idp_conf

Setting up the service provider

To make things easier, feel free to clone the saml-examples repository, and then look at the sp3 directory.

We copied over the frontend.xml as the metadata from the proxy service, this will be used as IdP's metadata in the Flask based service provider.

--- ../sp2/saml2_backend.yaml	2022-07-21 14:02:10.464265209 +0200
+++ saml2_backend.yaml	2023-11-21 16:32:31.219046414 +0100
@@ -29,7 +29,7 @@
     - {contact_type: support, email_address: support@example.com, given_name: Support}
 
     metadata:
-      local: [idp.xml]
+      local: [frontend.xml]
 
     entityid: http://localhost:5000/proxy_saml2_backend.xml
     accepted_time_diff: 60

We can start the service next.

$ flask run

Then we can login to the server. If you notice the address bar, you can see that we fist went to the SATOSA proxy and then to the IdP server.

SAML to SAML