NSSSSSSO: Not So Simple Seneca SAML SSO

This week in Telescope development we hit a major milestone.  For the first time we were able to authenticate users in our app using Seneca's Single Sign-On (SSO) SAML authentication provider.  We even got Single Logout to work (don't ask me why that's even harder than login).

One of our 1.0 goals is to allow Seneca faculty and students to add their blog feed to Telescope, and do so without keeping any username, password, or other identity information in Telescope itself.  We could just open it up, but we don't want spam filling our system.  We need to leverage an existing trust and identity system.  Luckily, Seneca has exactly that in the form of Azure Active Directory.  Even better, Seneca's ITS is willing to let us connect Telescope into it.  

Throughout the week James, Josue, and I worked with my colleague Mehrdad, from Seneca ITS, to configure, test, and debug the infrastructure.  We had planned on doing it in-person, but the shutdown of the college meant we had to do it over email.  In the end it was both way easier to implement than I'd expected, and also insanely more complicated to arrive at a working implementation then it should have been.  While it's all still fresh in my mind (and open in browser tabs), I thought I'd write a bit about what's required to get it working.

SAML: Security Assertion Markup Language

I had not worked with SAML prior to integrating it with Telescope.  My previous SSO adventures were all done as part of systems for Mozilla, which used different technologies.  However, the ideas were familiar, and since this is what Seneca uses, we needed to learn it.

SAML provides a way for you to move the authentication portion of an app to a third-party, and not bother with user credentials directly.  It's a favourite approach of enterprise web apps, and you've probably used it without knowing its name: you go to a web app, click "Login" and are redirected away to a more familiar login screen hosted by some trusted provider (e.g., Microsoft, GitHub, etc).  You enter your username and password, perhaps with a 2-factor authentication code, and then get redirected back to the original web app, where you are now logged in and authenticated.

The key ideas are these:

  1. One web app provides a service to users.  In our case, that's the Telescope web app providing blog aggregation.  In SAML we refer to this app as a Service Provider (SP).  Telescope doesn't have to (or want to) store usernames, passwords or other sensitive data because that gets handled by the second part of the system.
  2. A second web app provides login, authentication, and identity services.  In our case, that's Seneca's SSO login screen and underlying system for keeping track of users (name, email, id, password, etc.).  SAML refers to this as the Identity Provider (IdP).  The Identity Provider is responsible for securely storing user information and authenticating users on behalf of Service Providers.  In this way you can share a single login between your email, Blackboard, and also Telescope.
  3. The Service Provider and Identity Provider redirect the user, and a set of secure assertions, between them, allowing for a single Seneca account to be used across multiple web apps.  Users only need to remember one username and password, and web apps get the benefit of stronger, centralized authentication services (e.g., Telescope doesn't have to implement 2-factor authentication, we get it for free).  

If you're beginning your SAML journey, I'd recommend starting with these excellent documents:

  1. The Beer Drinker's Guide to SAML
  2. Single-Page App Authentication Using Cookies

SAML, node.js, and passport.js

The Telescope app is written in node.js and uses Express for the web server.  Because we're using node and Express, we wanted to leverage Passport.js for our authentication and authorization middleware.  Passport makes it "easy" to abstract away different authentication providers beneath Express-friendly middleware that adds user info to the session, and allows routes to be secured.  If you're new to Passport, I'd suggest spending an hour reading their docs, which are quite good.

Passport supports a strategy pattern for allowing pluggable authentication providers.  There are hundreds of them, and almost surely the one you need has been written already.  If it hasn't, you can write your own.

In our case, we decided to use passport-saml.  There are a number of other competing SAML options, some that are based on Passport, and some that aren't.  Because we only tried this one, I can't speak to how good these others are.  However, passport-saml worked for us.  The only downside to it is that the documentation can be confusing.  That was almost certainly compounded by our lack of experience with SAML in general, but be aware that you'll need to read, re-read, and re-read the docs to get things working.  I did.

During our research, I found the docs and code in this passport-stanford repo to be very helpful.  It is built on top of passport-saml and is targeted at building middleware for apps wanting to use Stanford's SAML-based authentication system.  In a lot of ways, it's a great model of what we should probably do once we ship Telescope, namely, refactor our Seneca SAML code out into its own passport-seneca project that can be used by other projects.  // TODO

Seneca SAML SSO: a recipe

Until we have a generalized solution you can grab from GitHub, I've made some notes on what the steps are to make this work.  NOTE: I won't cover doing all aspects of an express server, and instead focus on what's needed with respect to the SAML pieces.

Dependencies

First, install some dependencies:

npm install --save body-parser cookie-parser express-session passport passport-saml

Configuration

Now let's setup passport, passport-saml, and express.  Here's the overview, and I'll explain key pieces of it below:

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
passport.use(new SamlStrategy({
    // See param details at https://github.com/bergie/passport-saml#config-parameter-details
    // url where users get redirected to Seneca's SAML logout
    logoutUrl: 'https://login.microsoftonline.com/...app-uuid.../saml2',
    // url to a GET route in express app where users get redirected after logout
    logoutCallbackUrl: 'https://your-express-app.com/logout/callback',
    // url where users get redirected to Seneca's SAML login
    entryPoint: 'https://login.microsoftonline.com/...app-uuid.../saml2',
    // url to a POST route in express app where users get redirected after login
    callbackUrl: 'https://your-express-app.com/login/callback',
    // uri or url to your express app's metadata route
    issuer: 'https://your-express-app.com/sp',
    // public key cert for Seneca's IDP as a single line string
    cert: 'MJJ8C23...',
    // SAML authentication requests will be signed with RSA SHA256 hashing
    signatureAlgorithm: 'sha256',
	// let Seneca's SSO decide on the authentication context to use, see:
    // https://github.com/bergie/passport-saml/issues/226
    disableRequestedAuthnContext: true,
  },
  function(profile, done) {
    // We only need to use the displayname, emailaddress, and nameID
    done(null, {
          name: profile['http://schemas.microsoft.com/identity/claims/displayname'],
          email: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
          id: hash(profile.nameID), // hashing for storage in the db
          nameID: profile.nameID, // you need these two for logging out
          nameIDFormat: profile.nameIDFormat,
    });
  }
});
app.use(
  session({
    secret: 'super secret!',
    resave: false,
    saveUninitialized: false,
  })
);
app.use(passport.initialize());
app.use(passport.session());

The most significant aspects of this are the configuration settings for the SamlStrategy.  There are a lot of configuration options you can set, but not all of them are necessary.  The ones we needed are:

  1. issuer: in SAML this is your Service Provider's Entity ID.  It's common to use a URL that also provides your SAML Metadata (more on that below), and to use sp or serviceprovider in it somehow, to indicate its role.  The Identity Provider app will also have its own unique Entity ID as well (Seneca's lives here), but you don't need to use it in this config.  NOTE: Seneca will have to configure their server to know about your Service Provider's Entity ID in their Identity Provider's configuration--you can't just connect any Service Provider you want and have it work.  There's a trust component necessary at the configuration level.
  2. cert: Seneca expects us to sign our requests using their Public Key Certificate.  If you have the PEM file, you'll need the bit between the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- as a single line (it will be chunked into multiple lines).  If you have the key as XML, you want the bit between <X509Certificate> and </X509Certificate>.  In both cases, your goal is get a single line of text with their public key.
  3. signatureAlgorithm: by default, passport-saml uses SHA1 for signing authentication requests.  We had a crash that we couldn't trace until we figured out that it should really be using SHA256 hashing.
  4. entryPoint and callbackUrl: these two URLs are a pair.  The first, entryPoint is the URL to the Seneca SSO Identity Provider's SAML login.  You'll need to get this given to you, since it has to be configured (i.e., you can't reuse another one).  When the user needs to login, this is where they will get redirected.  The callbackUrl is the URL where Seneca's SSO will send users back to your app after they finish logging in.  This is where you'll recieve the data from Seneca about who this user is, so it needs to be a POST route.
  5. logoutUrl and logoutCallbackUrl: these are the same idea as 4., but for logging out instead of logging in.  This is often refered to as Single Logout (like Single Sign On).  The only trick here is that the Logout redirect back to your app (i.e., logoutCallbackUrl) needs to be a GET route instead of a POST.

User Profile Data

After we configure the options for the SamlStrategy, we also need to provide a callback that deals with the profile that gets returned from the Identity Provider.  When a user successfully logs in on the Seneca SSO Identity Provider, our Service Provider app will receive user information via a POST to our callbackUrl.  The data that comes back from Seneca looks like this (I've changed the actual data, but left the structure):

{
  "issuer": "https://sts.windows.net/...idp-uuid.../",
  "inResponseTo": "_851...",
  "sessionIndex": "_dfa...",
  "nameID": "username@seneca-domain.ca",
  "nameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
  "http://schemas.microsoft.com/identity/claims/tenantid": "...app-uuid...",
  "http://schemas.microsoft.com/identity/claims/objectidentifier": "...uuid...",
  "http://schemas.microsoft.com/identity/claims/displayname": "Full Name",
  "http://schemas.microsoft.com/identity/claims/identityprovider": "https://sts.windows.net/...app-uuid.../",
  "http://schemas.microsoft.com/claims/authnmethodsreferences": "http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/password",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "Firstname",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "Lastname",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "username@seneca-domain.ca",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "username@seneca-domain.ca",
  "sAMAccountName": "username"
}

You won't need or want all of what's on the profile Object, so this is your chance to create a customized user Object to use in your session.  For our Telescope app, we grab the displayname and emailaddress for use in the frontend, and also the nameID for our backend.  We hash the nameID to use in our database, and store none of the other information.  We don't want Telescope to have to worry about data breaches, leaking sensitive user information, etc.  We let Seneca's SSO deal with that, and count on it to authenticate and identify our users for us.

NOTE: if you want to include SLO for logging out, you must include the nameID and nameIDFormat in your user object on the session as well.

SAML Login and Logout Routes

With our server and SAML strategy configured, we need to turn our attention to our SAML authentication routes.  In the options above, we defined two:

  • POST /login/callback
  • GET /logout/callback

The names of these routes is up to you, but whatever they are, you'll have to configure them in both your Service Provider (i.e., in the SamlStrategy options) and in the Identity Provider: Seneca needs to know where to send your user after logging in.

Our login needs to look something like this:

// We send users here in the front end when we want to log in
app.get('/login', passport.authenticate('saml'));

// Seneca's SSO IdP will send authenticated users back to our app here
app.post('/login/callback', passport.authenticate('saml'), (req, res) => {
  // After a successful login, go to the web app's home page
  res.redirect('/');
});

When we want to have someone log into Telescope, we can point to our /login endpoint (e.g., <a href="/login">Login</a>).  This will use the passport middleware with our SAML strategy to figure out what needs to happen.  Either the user will be redirected to a login screen (if not authenticated already on Seneca), or will suddenly be logged in (if they are already logged in with Seneca in other tabs).

Similarly, our logout needs to look something like this:

app.get('/logout', (req, res) => {
  passport._strategy('saml').logout(req, (err, requestUrl) => {
    // Continue onto the Seneca logout endpoint if possible
    res.redirect(err ? '/' : requestUrl);
  });
});

app.get('/logout/callback', (req, res) => {
  // Tell passport to get rid of the user info on the session
  req.logout();
  // And go back to the web app's home page
  res.redirect('/');
});

As with login, we can provide a way to logout in our app (<a href="/logout">Logout</a>), which will trigger passport to create a SAML Single Logout Request, and give us a URL to which we can redirect the user.  Once there, the Seneca Identity Provider will log them out, and send them back to our app's /logout/callback.  Here, we can remove the user information from our session with passport's req.logout().

The login and logout routes can get more complicated, with extra features like remembering where you were coming from and going there vs. /, but I'm not going to address that here.  If you look around, there are other blogs and code in GitHub that discuss this, and the passport-stanford repo has an example app worth reading.

Entity ID and Metadata

As was mentioned above, your Service Provider needs an Entity ID, which is usually a url to your app's SAML metadata endpoint, perhaps an /sp route.  The passport-saml strategy can generate the XML for you, and your code will look something like this:

app.get('/sp', (req, res) => {
  const metadata = passport._strategy.generateServiceProviderMetadata();
  res.type('application/xml');
  res.status(200).send(metadata);
});

Protected Routes

The last thing we should mention is how to protect routes in your app, which should only work for authenticated users.  Passport makes this easy, and since we've already done all the necessary setup for Passport, we can leverage its API.

We've already seen how to protect a route that needs to redirect a user to a login screen:

app.get('/protected/route', passport.authenticate('saml'), (req, res) => {
  // User is authenticated if you get here...
  let user = req.user;
  // Do something with the user...
});

This is fine for a route that serves HTML to a user, but what about a REST API route?  In the case of an API call, we can do the following:

app.get('/protected/rest/api',
  (req, res, next) => {
    // Middleware to check if we're authenticated or not
    if(!req.isAuthenticated()) {
      res.status(403).json({
        message: 'forbidden'
      });
    } else {
      next();
    }
  },
  (req, res) => {
    res.status(200).json({
      message: 'ok'
    });
  }
);

I mention this because so much of the information you'll find when researching how to protect your routes will be based on using JWT.  This is a great option, but you don't need to do it here, since you already have a secure cookie-based authorization system via SAML.  

Testing

Our goal was to connect to Seneca's SSO, but we had to do a lot of testing locally and on our staging box before we were ready.  When you need to test your Service Provider's SAML setup against an Identity Provider, you need to run one locally.

There are a number of options for doing this, and we chose to use test-saml-idp, a dockerized PHP Identify Provider that mimics Seneca's SAML approach.  You can read a detailed post about how this works, and how to setup it up here.  I'll give you the highlights.

We need to configure the PHP app's Identity Provider to know about our Service Provider's Entity ID, Login Callback, and Logout Callback.  We can specify these on the command line as environment variables for the docker container:

docker run --name=saml-idp \
  -p 8080:8080 \
  -p 8443:8443 \
  -e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:3000/sp \
  -e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost/simplesaml/module.php/saml/sp/saml2-acs.php/test-sp \
  -e SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost/simplesaml/module.php/saml/sp/saml2-logout.php/test-sp \
  -d kristophjunge/test-saml-idp

I'm assuming that the express app you're running locally is using port 3000, so update the SIMPLESAMLPHP_SP_ENTITY_ID above to use whatever you want (NOTE: it won't hit this route, it's just a name).

By default, this PHP container will be configured with a few accounts you can use:

  1. Username: user1; Password: user1pass; Email: user1@example.com
  2. Username: user2; Password: user2pass; Email: user2@example.com

The trouble with the way this data is formatted is that it doesn't match what you'll get from Seneca.  Luckily, it's easy to provide your own set of user info that matches what we'll get from Seneca.  To do so, create a file, users.php:

<?php
/*
  Specify user info for https://hub.docker.com/r/kristophjunge/test-saml-idp/ in the same format
  as our Seneca IdP will give it.  This is modeled on the original config here:
  https://github.com/kristophjunge/docker-test-saml-idp/blob/master/config/simplesamlphp/authsources.php
*/
$config = array(

    'admin' => array(
        'core:AdminPassword',
    ),

    'example-userpass' => array(
        'exampleauth:UserPass',
        'user1:user1pass' => array(
            'uid' => array('1'),
            'eduPersonAffiliation' => array('group1'),
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'user1@example.com',
            'http://schemas.microsoft.com/identity/claims/displayname' => 'User One'
        ),
        'user2:user2pass' => array(
            'uid' => array('2'),
            'eduPersonAffiliation' => array('group2'),
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'user2@example.com',
            'http://schemas.microsoft.com/identity/claims/displayname' => 'User Two'
        )
    ),

);

This file can be mounted as a volume in the docker container with:

-v /users.php:/var/www/simplesamlphp/config/authsources.php

This will make it a lot easier to swap out the PHP and Seneca Identity Providers for testing and production, since they will give user profile data in a similar format (add any other fields you need to the Objects above).

The configuration settings for using this Identity Provider with passport-saml look like this:

passport.use(new SamlStrategy({
    logoutUrl: 'http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php',
    logoutCallbackUrl: 'https://your-express-app.com/logout/callback',
    entryPoint: 'http://localhost:8080/simplesaml/saml2/idp/SSOService.php',
    callbackUrl: 'https://your-express-app.com/login/callback',
    issuer: 'https://your-express-app.com/sp',
    // Full cert at https://github.com/kristophjunge/docker-test-saml-idp/blob/master/config/simplesamlphp/server.crt
    cert: 'MIIDXTCCAkWgAwIBAgIJALmVVu...',
    signatureAlgorithm: 'sha256',
    disableRequestedAuthnContext: true,
  },
  ...
);

Conclusion

The 'S' in SAML doesn't stand for "Simple."  I've already written over 3,000 words with this post, and that's typical of everything you'll find written about SAML.  That said, once you understand the terminology and can get the various pieces in play, the actual code you'll need is not too bad.  A lot of heavy lifting has already been done for you.

By the time we hit 1.0, our goal is that any Seneca faculty or student will be able to join Telescope using their existing Seneca login.  I'll report back when that's been achieved; but it's exciting to be this close now.

Until then, I'll see you on the other side of the SAML login redirect.

Show Comments