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