NodeJS/ExpressJS/passport-saml ADFS SingleLogout implementation

1.4k Views Asked by At

I did not know where to go next so I'm going to post my issue here, as I've already seen some related issues on this matter. Unfortunately the solutions provided did not work in my case, and I do not know what to try more.

So some background: I have a NodeJS/ExpressJS/passport-saml application that authenticates against an ADFS system. The SSO part of the matter works perfectly, but I can't seem to get the SLO part working.

What happens is that when I initiate either a SP-initiated or IdP-initiated logout it hangs on the first SP. This first SP is being logged out correctly, but it is then redirected to the login page of the first SP and keeps waiting for the credentials to be entered, effectively halting the redirect chain that has to happen.

What I've tried so far is a lot, including using both POST and HTTP-Redirect bindings on my SLO ADFS endpoint/NodeJS server, modifying the routes etc.

Current implementation is as follows: SLO endpoint configuration (equal for each SP, the blacked out part contains ): endpoint

The passport-saml configuration is as follows on the SP server:

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ IMPORTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//

// NodeJS native
const path = require('path');
const fs = require('fs');

// NodeJS packages
const SamlStrategy = require('passport-saml').Strategy;
const { Database } = require('../../Database');

// Custom imports
const { ApplicationConfiguration } = require('../../ApplicationConfiguration');

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ CONSTANTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//

let strategy = {};

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ INIT ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//

/**
 * Initialise the passport saml strategy with the necessary configuration parameters.
 */
const initStrategy = () => {
  // Get additional required configuration
  const config = ApplicationConfiguration.getProperties([
    ['CGS_HOST'],
    ['AUTH_PORT'],
    ['SSO', 'host'],
    ['SSO', 'identifier'],
    ['SSO', 'cert'],
    ['SSO', 'algorithm'],
    ['HTTPS_CERT_PRIVATE_PATH'],
  ]);
  // Define the SAML strategy based on configuration
  strategy = new SamlStrategy(
    {
      // URL that should be configured inside the AD FS as return URL for authentication requests
      callbackUrl: `https://${<sp_host_name>}:${<sp_port_value>}/sso/callback`,
      // URL on which the AD FS should be reached
      entryPoint: <idp_host_name>,
      // Identifier for the CIR-COO application in the AD FS
      issuer: <sp_identifier_in_idp>,
      identifierFormat: null,
      // CIR-COO private certificate
      privateCert: fs.readFileSync(<sp_server_private_cert_path>, 'utf8'),
      // Identity Provider's public key
      cert: fs.readFileSync(<idp_server_public_cert_path>, 'utf8'),
      authnContext: ['urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'],
      // AD FS signature hash algorithm with which the response is encrypted
      signatureAlgorithm: <idp_signature_algorithm>,
      // Single Log Out URL AD FS
      logoutUrl: <idp_host_name>,
      // Single Log Out callback URL
      logoutCallbackUrl: `https://${<sp_host_name>}:${<sp_port_value>}/slo/callback`,
      // skew that is acceptable between client and server when checking validity timestamps
      acceptedClockSkewMs: -1,
    },
    async (profile, done) => {
      // Map ADFS groups to Group without ADFS\\ characters
      const roles = profile.Roles.map(role => role.replace('ADFS\\', ''));
      // Get id's from the roles
      const queryResult = await Database.executeQuery('auth-groups', 'select_group_ids_by_name', [roles]);
      // Map Query result to Array for example: [1,2]
      const groupIds = queryResult.map(group => group.id);
      done(null,
        {
          sessionIndex: profile.sessionIndex,
          nameID: profile.nameID,
          nameIDFormat: profile.nameIDFormat,
          id: profile.DistinguishedName,
          username: profile.DistinguishedName,
          displayName: profile.DisplayName,
          groups: profile.Roles,
          mail: profile.Emailaddress,
          groupIds,
        });
    },
  );
  // Return the passport strategy
  return strategy;
};


// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ PASSPORT CONFIG ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//

/**
 * Initialise the passport instance and add the saml passport strategy to it for authentication
 * @param {Object} passport - Passport object
 */
const initPassport = (passport) => {
  // (De)serialising
  passport.serializeUser((user, done) => {
    done(null, user);
  });
  passport.deserializeUser((user, done) => {
    done(null, user);
  });
  // Initialise the strategy
  const passportStrategy = initStrategy();
  // Addition strategy to passport
  passport.use('saml', passportStrategy);
};

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ HELPERS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//

/**
 * Get the metadata from the Service Provider (this server).
 * @param {String} publicPath - Path to public certificate
 * @return {Promise<any>} - Metadata object for this application
 */
const getMetaData = publicPath => new Promise((resolve) => {
  const metaData = strategy.generateServiceProviderMetadata({}, fs.readFileSync(path.join(publicPath), 'utf8'));
  resolve(metaData);
});

/**
 * Construct a Single Logout Request and send it to the IdP.
 * @param {Object} req - Default request object
 * @param {Object} res - Default response object
 */
const logout = (req, res) => {
  // Construct SLO request for IdP
  strategy.logout(req, (err, url) => {
    req.logOut();
    // Redirect to SLO callback URL and send logout request.
    return res.redirect(url);
  });
};

const getStrategy = () => strategy;

module.exports = {
  initPassport,
  getStrategy,
  getMetaData,
  logout,
};

And the relevant routes and functions are as follows:

const logOutLocalSession = sid => new Promise(((resolve, reject) => {
    log.info(`Received request to destroy session with sid ${sid}.`);
    // Destroy local session
    store.destroy(sid, (err) => {
      if (err) {
        log.error(`Error occurred while logging out local session with SID ${sid}: ${err}`);
        reject('Onbekende fout opgetreden bij uitloggen lokaal.');
      }
      log.info(`Successfully logged out user locally with SID ${sid}.`);
      resolve();
    });
}));

const logOutAllSessions = async (req, res) => {
    // Extract username to get all sessions
    const { username } = req.session.passport.user;
    log.info(`Received request to log user ${username} out of all sessions.`);
    const sessionIdsRes = await Database.executeQuery('sessions', 'select_sids_by_user_id', [username]);
    // Loop over all sessions and destroy them
    const destroyPromises = [];
    sessionIdsRes.forEach((sessionIdRes) => {
      destroyPromises.push(logOutLocalSession(sessionIdRes.sid));
    });
    await Promise.all(destroyPromises);
    // Remove local session from request
    req.session = null;
    log.info(`User ${username} logged out successfully from all known sessions.`);
};

const logOutIdp = (req, res) => {
    const { username } = req.session.passport.user;
    log.info(`Received request to log out user ${username} on Identity Provider.`);
    const strategy = passportImpl.getStrategy();
    // Create logout request for IdP
    strategy.logout(req, async (err, url) => {
      // Destroy local sessions
      logOutAllSessions(req, res);
      // Redirect to SLO callback URL and send logout request.
      return res.redirect(url);
    });
};

// SP initiated logout sequence  
app.get('/auth/logout', (req, res) => {
    const { username } = req.session.passport.user;
    // If user not logged in, redirect to login
    if (!req.user) {
      return res.redirect('/saml/login');
    }

    if (username === 'Administrator' || username === 'Support user') {
      logOutLocalSession(req.session.id);
    } else {
      logOutIdp(req, res);
    }
});

// IdP initiated logout sequence or from other SP
app.post('/slo/callback', logOutAllSessions);

If there is some information missing I will be able to provide. I do hope I can get some leads on what to try next! Thanks in advance !

2

There are 2 best solutions below

0
On

In terms of the ADFS configuration:

"Trusted URL" should be the ADFS logout endpoint - you can see that in the metadata - so that ADFS can clear cookies.

"Response URL" should be the endpoint in your app. that expects the SLO response so that it can clear client cookies.

0
On

This same question was asked at passport-saml's issues.

Here is link to answer: https://github.com/node-saml/passport-saml/issues/430#issuecomment-1049217109

Short answer: it seems that your code does not implement LogoutRequest handling correctly. It must answer with LogoutResponse (instead of redirecting to frontpage) when IdP is sending LogoutRequest via front channel. Redirecting to SP's front page breaks SLO signalling / propagation from IdP to all SPs that are participating to SSO session which is being signed out.

Furthermore due to how modern browsers work with 3rd party cookies you might end up to situation where LogoutRequest issued by IdP towards your SP is received by your SP without any session cookies. Passport-saml's default implementation of IdP initiated LogoutRequest / IdP initiated SLO doesn't work correctly in such situations (for more infornation see https://github.com/node-saml/passport-saml/issues/419 ) so you have to implement IdP initiated SLO handling by yourself if you want to have fully functionally IdP initiated SLO with passport-saml.