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:
- Supporting multiple IdPs.
- Using Flask-Login for user management.
- Using the "SSO URL" as the audience restriction (to simplify configuration on the IdP).
- Just in time provisioning of users ("SAML JIT")
- 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.
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
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)