14
votes

I am looking to implement a SAML 2.0 based service provider in Python.

My web apps are currently all Flask applications. I plan to make a Flask blueprint/decorator that allows me to drop single sign-on capabilities into preexisting applications.

I have looked into python-saml extensively and unfortunately there are dependency issues that are not worth resolving, as I have too many preexisting servers/apps whos environments won't be compatible.

PySAML2 looks like it could work, however there is little documentation, and what documentation is available I have trouble comprehending. There are no examples of PySAML2 used in a Flask app.

The Identity Provider I have is Okta. I have Okta set up so that after I login at Okta, I am redirected to my app.

Can anyone offer any advice on using PySAML2, or perhaps advice on how to best authenticate a user using SAML 2.0 who is visiting my application?

1

1 Answers

17
votes

Update: A detailed explanation on using PySAML2 with Okta is now on developer.okta.com.

Below is some sample code for implementing a SAML SP in Python/Flask. This sample code demonstrates several things:

  1. Supporting multiple IdPs.
  2. Using Flask-Login for user management.
  3. Using the "SSO URL" as the audience restriction (to simplify configuration on the IdP).
  4. Just in time provisioning of users ("SAML JIT")
  5. Passing additional user information in Attribute Statements.

What is not demonstrated is doing SP initiated authentication requests - I'll followup with that later.

At some point, I hope to create a wrapper around pysaml2 that has opinionated defaults.

Lastly, like python-saml, the pysaml2 library makes use of the xmlsec1 binary. This might also cause dependency issues in your server environments. If that's the case, you'll want to look into replacing xmlsec1 with the signxml library.

Everything in the sample below should work with the following setup:

$ virtualenv venv
$ source venv/bin/activate
$ pip install flask flask-login pysaml2

Finally, you'll need to do to things on the Okta side for this to work.

First: In the General tab of your Okta application configuration, configure the application to send the "FirstName" and "LastName" Attribute Statements. Adding Attribute Statements to an Okta application

Second: In the Single Sign On tab of your Okta application configuration, take of the url and put them in a file named example.okta.com.metadata. You can do this with a command like the one below.

$ curl [the metadata url for your Okta application] > example.okta.com.metadata

Where to find the metadata url for an Okta application

Here is what you'll need for your Python/Flask application to handle IdP initiated SAML requests:

# -*- coding: utf-8 -*-
import base64
import logging
import os
import urllib
import uuid
import zlib

from flask import Flask
from flask import redirect
from flask import request
from flask import url_for
from flask.ext.login import LoginManager
from flask.ext.login import UserMixin
from flask.ext.login import current_user
from flask.ext.login import login_required
from flask.ext.login import login_user
from saml2 import BINDING_HTTP_POST
from saml2 import BINDING_HTTP_REDIRECT
from saml2 import entity
from saml2.client import Saml2Client
from saml2.config import Config as Saml2Config

# PER APPLICATION configuration settings.
# Each SAML service that you support will have different values here.
idp_settings = {
    u'example.okta.com': {
        u"metadata": {
            "local": [u'./example.okta.com.metadata']
        }
    },
}
app = Flask(__name__)
app.secret_key = str(uuid.uuid4())  # Replace with your secret key
login_manager = LoginManager()
login_manager.setup_app(app)
logging.basicConfig(level=logging.DEBUG)
# Replace this with your own user store
user_store = {}


class User(UserMixin):
    def __init__(self, user_id):
        user = {}
        self.id = None
        self.first_name = None
        self.last_name = None
        try:
            user = user_store[user_id]
            self.id = unicode(user_id)
            self.first_name = user['first_name']
            self.last_name = user['last_name']
        except:
            pass


@login_manager.user_loader
def load_user(user_id):
    return User(user_id)


@app.route("/")
def main_page():
    return "Hello"


@app.route("/saml/sso/<idp_name>", methods=['POST'])
def idp_initiated(idp_name):
    settings = idp_settings[idp_name]
    settings['service'] = {
        'sp': {
            'endpoints': {
                'assertion_consumer_service': [
                    (request.url, BINDING_HTTP_REDIRECT),
                    (request.url, BINDING_HTTP_POST)
                ],
            },
            # Don't verify that the incoming requests originate from us via
            # the built-in cache for authn request ids in pysaml2
            'allow_unsolicited': True,
            'authn_requests_signed': False,
            'logout_requests_signed': True,
            'want_assertions_signed': True,
            'want_response_signed': False,
        },
    }

    spConfig = Saml2Config()
    spConfig.load(settings)
    spConfig.allow_unknown_attributes = True

    cli = Saml2Client(config=spConfig)
    try:
        authn_response = cli.parse_authn_request_response(
            request.form['SAMLResponse'],
            entity.BINDING_HTTP_POST)
        authn_response.get_identity()
        user_info = authn_response.get_subject()
        username = user_info.text
        valid = True
    except Exception as e:
        logging.error(e)
        valid = False
        return str(e), 401

    # "JIT provisioning"
    if username not in user_store:
        user_store[username] = {
            'first_name': authn_response.ava['FirstName'][0],
            'last_name': authn_response.ava['LastName'][0],
            }
    user = User(username)
    login_user(user)
    # TODO: If it exists, redirect to request.form['RelayState']
    return redirect(url_for('user'))


@app.route("/user")
@login_required
def user():
    msg = u"Hello {user.first_name} {user.last_name}".format(user=current_user)
    return msg


if __name__ == "__main__":
    port = int(os.environ.get('PORT', 5000))
    if port == 5000:
        app.debug = True
    app.run(host='0.0.0.0', port=port)