Introduction to SAML

For an in-depth explanation of SAML, we recommend reading the first section of our Article on SAML integration with Streamlit, which covers the protocol and potential vulnerabilities in Learn how SAML works, and how to use it in your Streamlit application.

SAML (Security Assertion Markup Language) enables users to share a common identity defined by an Identity Provider (IdP), the enterprise authentication server, across multiple service providers (SP), like your application. It facilitates single sign-on (SSO) experiences by securely transferring user identity information between the IdP and SP. This protocol is widely used in enterprise environments due to its flexibility, robust security features, and cross-platform compatibility.

SAML Protocol Flow

SAML Auth Flow

Key Protocol Elements:

  • Identity Provider (IdP): Authenticates users and provides identity information
  • Service Provider (SP): The application relying on IdP for user authentication
  • SAML Request: Authentication request sent from SP to IdP
  • SAML Response: Authentication results returned from IdP to SP
  • Assertion: Signed XML statement containing user identity and authentication details
  • Assertion Consumer Service (ACS): SP endpoint for processing SAML responses

Implementing SAML in a Dash Application

⚠️ Important Security Notice This example is for educational purposes only. The implementation:

  • Omits critical security features
  • Should not be used in production environments
  • Demonstrates core SAML integration concepts

Need a production-ready SAML implementation for your Dash application? Contact us. We offer managed authentication solutions that prioritize security and reliability.

Understanding Dash

Dash is a Python framework built on top of Flask, designed for creating interactive, data-driven web applications. While its primary focus is on data visualization, Dash’s versatility extends far beyond, making it an excellent choice for a wide range of web applications, including those requiring advanced features like SAML integration.

Key Components of Dash to know for SAML

  1. Flask Foundation

Dash leverages Flask’s robust web server capabilities and routing system, providing a solid backend for web applications. This Flask backend will allow us to have the necessary routes to implement all parts of SAML.

  1. The Stateless Nature of Dash

Dash, like Flask, operates on a stateless model, which has significant implications for application design and scalability. With the stateless approach:

  • The server processes each request independently, without inherent memory of previous interactions.
  • It can easily handle multiple users across distributed server instances.
  • It simplifies server-side logic and reduces server memory usage.
  1. Managing State in Dash:
    • For applications requiring user-specific data (like authentication):
      • Use client-side storage (e.g., cookies, local storage) for short-term data.
      • Implement server-side session management using tools like Flask-Session or database-backed solutions.
      • Utilize external state management systems for more complex state requirements.

The implications for SAML Integration are simple: we need a way to store the connected user’s information and share it with all requests. For this, we can leverage browser features like Cookies, which are pieces of information that will be attached to all requests made to the Dash server. This allows the server to retrieve which user is being served and act on that information.

SAML Implementation Steps

Setting Up SAML Metadata

The metadata configuration is crucial for establishing secure communication between the Service Provider (SP) and Identity Provider (IdP). For the login process, we need two key pieces of information:

  1. The URL of the IdP’s Single Sign-On (SSO) service and where to redirect the user once authenticated.
  2. The IdP’s X.509 certificate, which is used to verify the digital signature of the SAML response.

Additionally, the SP needs its own certificate to sign SAML requests. These configurations ensure the authenticity and integrity of the SAML messages exchanged between the SP and IdP. You can typically find this information in your Identity Provider’s platform settings. In this example, we’re using Auth0 as our IdP, but the process is similar for other SAML-compliant providers. Several security measures can be implemented here; for more details on the available options, refer to Python3-SAML.

# AUTH0 configuration
AUTH0_CLIENT_ID = "[**YOUR_CLIENT_ID**]"
AUTH0_ENTITY_ID = "[**YOUR_ENTITY_ID**]"

def read_cert_from_file(filename):
    """Helper to load the certificate needed to validate the signature"""
    with open(filename, 'r') as cert_file:
        return cert_file.read().strip()

def get_saml_settings():
    return {
        "strict": True,
        "debug": True,
        "sp": {
            "entityId": f"{AUTH_PROXY_SERVER_URL}/metadata",
            "assertionConsumerService": {
                "url": f"{AUTH_PROXY_SERVER_URL}/acs",
                "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
            },
            "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
        },
        "idp": {
            "entityId": f"urn:{AUTH0_ENTITY_ID}",
            "singleSignOnService": {
                "url": f"https://{AUTH0_ENTITY_ID}/samlp/{AUTH0_CLIENT_ID}",
                "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
            },
            "x509cert": read_cert_from_file('./key.pem')
        }
    }

Custom Route in Dash

Since Dash is built around a Flask server, we have the possiblity to define custom routes on our server, let’s see how this is done. First, we can initialize a Flask server, being a simple REST API, register all our routes needed for SAML, and then pass this server to the Dash framework


# Define a Flask server
server = Flask(__name__)

# Register custom route
@server.route('/login')
def login():
    ...

# Build a Dash app around our previously declared server
app = Dash(
  __name__,
  use_pages=True,
  server=server 
)

# Build the Dash application
app.layout = html.Div([
    html.H1("Dash App with Flask Routes"),
])

Let’s start by implementing the required route for our SAML Authentication flow. We can focus on building a REST API, first and then we can build the Dash UI around it by simply supplying the Flask server like we just saw.

To implements the SAML protocol for our Service Provider. Our Flask server needs to handle four critical responsibilities:

  1. Accept login requests from the Dash application
  2. Generate and send SAML requests to the Identity Provider (IdP)
  3. Process incoming SAML responses
  4. Store the user information

We’ll use python3-saml to implement the SAML protocol in our Service Provider. While Flask SAML2 and PySAML2 are other excellent options, they obfuscate a big part of the process which makes them less interesting for this article.

"""
IMPORTANT: This is a simplified example for demonstration purposes only. This code lacks several crucial security features.
"""
from flask import Flask, request, redirect, session, jsonify, url_for
from onelogin.saml2.auth import OneLogin_Saml2_Auth
import requests
from urllib.parse import urlparse


server = Flask(__name__)
server.config['SECRET_KEY'] = os.urandom(24)


## Config
# App configuration
APP_URL = "http://localhost:8050"

# Cookie configuration
COOKIE_NAME = 'auth_data'
COOKIE_MAX_AGE = 3600  # 1 hour

@server.route('/metadata')
def metadata():
    """Allow the IdP to recognize our Service Prodiver"""
    raise NotImplementedError

@app.route('/login')
def login():
    """ Initiate a SAML request """
    raise NotImplementedError

# Need to match the url of `assertionConsumerService` from the `get_saml_settings` function
@app.route('/acs', methods=['POST'])
def acs():
    """ Assertion Consumer Service - Process the SAML response """
    raise NotImplementedError

Setting Up SAML Metadata Endpoint

First let’s configure SAML, which requires careful setup to ensure proper communication between our Service Provider and the Identity Provider. For this, we need to implement two key components:

  1. The /metadata endpoint that allows the IdP to recognize our Service Provider
  2. The get_settings() function, which returns our SAML configuration, to build the request and process the response with.
@app.route('/metadata')
def metadata():
    """ SP Metadata endpoint """
    auth = OneLogin_Saml2_Auth(prepare_flask_request(), get_saml_settings())
    settings = auth.get_settings()
    metadata = settings.get_sp_metadata()
    errors = settings.validate_metadata(metadata)

    if len(errors) == 0:
        return metadata, 200, {'Content-Type': 'text/xml'}

    return "Error: " + ', '.join(errors), 400

Managing the Login Process

When users initiate login from Dash, we will redirect them to /login route. The corresponding endpoint will transform the standard request into a SAML request and redirect users to the Identity Provider for authentication. Using python3-saml, this part is as easy as processing the settings previously stated and redirecting the user to the IdP.

@app.route('/login')
def login():
    req = prepare_flask_request()
    auth = OneLogin_Saml2_Auth(req, get_saml_settings())
    return redirect(auth.login())

At this point, the user is sent to the Identity Provider, and if they aren’t already connected, the IdP will challenge the user by asking for its credentials. Once the IdP has identified the user, it prepares a POST request, which will be sent to our Flask server by redirecting the user. More precisely, the user will be sent to the url specified in "assertionConsumerService": { "url": f"{base_url}/acs", ... }, which we defined in the config. This is where the SAML response is processed, which contains the XML with all the attributes.

@app.route('/acs', methods=['POST'])
def acs():
    """ Assertion Consumer Service: Process the SAML response & redirect the user back to Dash"""
    req = prepare_flask_request()
    auth = OneLogin_Saml2_Auth(req, get_saml_settings())
    auth.process_response()
    errors = auth.get_errors()

    if not errors:
        if auth.is_authenticated():
            samlUserdata = auth.get_attributes()
            samlNameId = auth.get_nameid()
            samlSessionIndex = auth.get_session_index()

            # Prepare user data for cookie
            user_data = {
                'email': samlNameId,
                'attributes': samlUserdata,
                'session_index': samlSessionIndex
            }

            # Create response with redirect
            response = make_response(redirect(APP_URL))

            # Set secure cookie with user data
            response.set_cookie(
                COOKIE_NAME,
                json.dumps(user_data),
                max_age=COOKIE_MAX_AGE,
                httponly=True,
                secure=True,
                samesite='Lax'
            )

            return response

    return f"Error: {', '.join(errors)}", 400

Once the request is validated, we send the user back to the Dash application, but with a cookie, which can only be read by our server, to allow Dash to retrieve user information.

With this, the only missing part before we can test the Authentication process is a way to visualize the user information.

@app.route('/user')
def see_user():
    # Get the cookie
    auth_cookie = request.cookies.get(COOKIE_NAME)
    
    if auth_cookie:
        try:
            # Parse the JSON data from the cookie
            user_data = json.loads(auth_cookie)
            
            # Extract user information
            email = user_data.get('email', 'N/A')
            attributes = user_data.get('attributes', {})
            session_index = user_data.get('session_index', 'N/A')
            
            # Create a response with user information
            response = f"""
            <h1>Welcome, {email}!</h1>
            <h2>Your SAML Attributes:</h2>
            <ul>
            {"".join(f"<li>{key}: {value}</li>" for key, value in attributes.items())}
            </ul>
            <p>Session Index: {session_index}</p>
            <a href="/logout">Logout</a>
            """
            
            return response
        
        except json.JSONDecodeError:
            return "Error: Invalid auth cookie", 400
    
    else:
        return redirect(url_for('login'))

Now let’s test our SAML integration, you can find the code sample here:

  1. Start the server by running python app.py in your terminal.
  2. Open your web browser and navigate to http://localhost:8050/login.
  3. You’ll be redirected to your Identity Provider’s (IdP) login page.
  4. After successfully authenticating with your IdP, you’ll be briefly redirected to http://localhost:8050/acs. This endpoint processes the SAML response.
  5. The /acs endpoint will then automatically redirect you to the home page /.

At this point, you’re successfully authenticated. To verify:

  1. Navigate to http://localhost:8050/user.
  2. This page will display your user information, confirming that the SAML authentication process worked correctly.

Note: The home page (/) may appear blank at this stage, as we haven’t yet implemented any content for it. This is normal and doesn’t affect the authentication process.

Logout

When the user has finished with our service, if they want to logout, we need to:

  1. Provide a logout button in Dash that clears the cookie and logout the user from the IdP
  2. Initiate a SAML logout request through our Flask server
  3. Process the logout response and redirect users appropriately

First, we need to add the logout service in the SAML config. For more information on the available option, consult Python3-SAML

def get_saml_settings():
    """ General config for our SP """
    return {
        ...
        "sp": {
            ...
            "singleLogoutService": {
                "url": f"{AUTH_PROXY_SERVER_URL}/sls",
                "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
            },
            ...
        },
        "idp": {
            ...
            "singleLogoutService": {
                "url": f"https://{AUTH0_ENTITY_ID}/samlp/{AUTH0_CLIENT_ID}/logout",
                "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
            },
            ...
        }
    }

And we need to implement the corresponding route

@server.route('/logout')
def logout():
    """ Single Logout Service: Process the SAML Response & logout the user """
    req = prepare_flask_request()
    auth = OneLogin_Saml2_Auth(req, get_saml_settings())
    auth_data = request.cookies.get(COOKIE_NAME)

    if auth_data:
        try:
            user_data = json.loads(auth_data)
            name_id = user_data['email']
            session_index = user_data['session_index']

            response = make_response(redirect(auth.logout(
                name_id=name_id,
                session_index=session_index,
                return_to=url_for('sls', _external=True),
            )))
            response.delete_cookie(COOKIE_NAME)
            return response
        except json.JSONDecodeError:
            pass
    return redirect(f'{APP_URL}/')


@server.route('/sls', methods=['POST'])
def sls():
    req = prepare_flask_request()
    # INFO: process_slo expect a GET, but auth0 return a POST
    req["get_data"], req["post_data"] = req["post_data"], req["get_data"]
    auth = OneLogin_Saml2_Auth(req, get_saml_settings())

    url = auth.process_slo(
        delete_session_cb=lambda: session.clear()
    )
    errors = auth.get_errors()
    if len(errors) == 0:
        if url is not None:
            return redirect(url)
        return redirect(f'{APP_URL}/')
    return "Error: " + ', '.join(errors), 400

Now we have all the necessary routes, try going to https://localhost:8050/logout and after some redirection, you will be logged out. If you go on the /user route, no cookie is present, so the server can’t return any information about you.

Dash UI

Now, let’s build our Dash application. To do so, we will simply instruct the Dash framework to use the previously created Flask server. This will expose all our routes alongside the Dash application on the / route, and since we are using cookies, the Dash component can access it to extract the user information from the cookie present in the request, and display this information, or tell the user to authenticate himself if not present.

As a bonus, let’s also use the Dash Material UI library to make the UI more interesting.

pip install dash-mui-ploomber
import json
import dash
from dash import Dash, Input, Output, html, dcc
import dash_material_ui as mui

# Import the server which we just built
from server import COOKIE_NAME, server

# Use the previous created Flask server
app = Dash(__name__, server=False)
app.init_app(server)

# Simple UI
dash._dash_renderer._set_react_version("18.2.0")
app.layout = html.Div([
    dcc.Location(id='url'),
    html.H1('Dash - SAML Auth', style={'textAlign': 'center', 'color': '#1976d2', 'marginBottom': '2rem'}),
    html.Div([
        mui.Button(id="login_button", children="Login", variant="contained"),
        mui.Button(
            id="logout_button", children="Logout", variant="outlined",
        ),
        dcc.Location(id="url_login"),
        dcc.Location(id="url_logout")
    ], style={'display': 'flex', 'gap': '1rem', 'marginBottom': '1rem', 'justifyContent': 'center'}),

    mui.Alert(
        id="user_display",
        variant="filled",
        severity="info",
    ),
    dash.page_container,
])


# Login Button
@app.callback(
    Output("url_login", "pathname"),
    Input("login_button", "n_clicks"),
    prevent_initial_call=True
)
def redirect_to_login(n_clicks):
    return "/login"


# Logout Button
@app.callback(
    Output("url_logout", "pathname"),
    Input("logout_button", "n_clicks"),
    prevent_initial_call=True
)
def redirect_to_logout(n_clicks):
    return "/logout"


# Display the current user
@app.callback(
    [Output("user_display", "children"),
     Output("user_display", "severity")],
    Input('url', 'pathname')
)
def update_user_display(pathname, request=flask.request):
    user = request.cookies.get(COOKIE_NAME)

    if user:
        user = json.loads(user)
        email = user["attributes"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]
        return f"You are connected as: {email[0]}", "success"
    else:
        return "Please login", "warning"




if __name__ == '__main__':
    app.run(debug=True)

With this, we now have a fully functional authentication system, where we can identify the user, and display the information.

Dash with SAML authentication

Security Considerations

⚠️ Important Security Notice

This implementation serves as an educational example to demonstrate SAML integration with Dash. It intentionally omits several critical security measures required for production environments. SAML, being an XML-based protocol, requires careful security configuration to prevent vulnerabilities.

Professional Alternative

Rather than implementing SAML authentication from scratch, consider using a managed service that handles authentication for your deployed applications. At Ploomber, we offer enterprise-grade authentication as part of our Teams license for Ploomber Cloud.

Our managed authentication solution supports:

  • Streamlit applications
  • Dash applications
  • Docker containers
  • And more!

With our solution, there’s no need to modify your app’s source code. We handle the complexities of SAML authentication in prior of the user reaching your application, and that with your IdP, ensuring a secure and seamless with your work place.

Contact us to learn more about