Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ADFS SingleLogOut hangs on first application #430

Closed
TomasSchlepers opened this issue Apr 5, 2020 · 2 comments
Closed

ADFS SingleLogOut hangs on first application #430

TomasSchlepers opened this issue Apr 5, 2020 · 2 comments

Comments

@TomasSchlepers
Copy link

TomasSchlepers commented Apr 5, 2020

Hi there,

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 <sp_host_name>):
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 !

@abcd-ca
Copy link

abcd-ca commented Feb 21, 2022

Your SLO request routine looks the same as mine and I'm also having trouble. Here's a SO ticket I created.

@srd90
Copy link

srd90 commented Feb 23, 2022

@TomasSchlepers you wrote:

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.

I would have expected to see that your /slo/callback implementation would response with LogoutResponse to incoming LogoutRequest as specified in SAML SLO specification.

NOTE: IdP shall report status of SLO with LogoutResponse to that same callback if SP instance was the one which was used to trigger SLO so prepare to handle also that message properly (as of now your implemenation would trigger yet another logoutAllSessions()). See also #221 (comment)

Furthermore if your IdP is at different TLD as your SPs you might have similar problem thatpassport-saml's own SLO handling implementation has ( see #419 )

Side notes:

  • You have configured acceptedClockSkewMs: -1 which disables NotOnOrAfter validation (as described in passport-saml's README.md (link to version 3.2.1). It is possible to reuse/replay saved SAML login response over and over again without ever needing to visit IdP (i.e. user whose access is terminated at AD could still login to your SP by posting saved SAML login response).
  • you have not enabled audience verification. SAML login response targeted to SP1 could be replayed to SP2 and it would accept it as valid login.

@node-saml node-saml locked and limited conversation to collaborators Feb 24, 2022
@markstos markstos converted this issue into discussion #676 Feb 24, 2022

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants