expressadfspassport-saml

How to fix SLO with passport-saml that works on first logout but not on subsequent logouts


I have to connect an application to my company's ADFS server. I am using passport-saml for SSO and SLO. SSO works, and SLO works on the first logout only. I am trying to make SLO work every time a user logs out.

I have been searching high and low for a solution to this problem, but it evades me. Here's the details:

  1. I clear the cookies in the browser to start with a clean slate.
  2. I login to my application which redirects to ADFS' login page
  3. Enter user credentials and then ADFS redirects back to my app's homepage
  4. I log out of my app and a request is sent to the ADFS server killing my session locally and on ADFS, I am then redirected back to my app's homepage
  5. I log in again and this works as intended
  6. I logout but this time I am sent to my ADFS server's logout page.

Further inspection shows that ADFS is not clearing its cookies so the ADFS session stays live.

I have used Firefox's SAML viewer plugin to watch what is happening and here are my findings:

On a successful logout:

HTTP:

GET https://myadfs.org/adfs/ls/?wa=wsignout1.0 HTTP/1.1 Host: myadfs.org User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: https://example.com/dashboard/data Connection: keep-alive Cookie: MSISAuth=AAEAAMVBaN7qo03wm/4jDH9e/tZ6ih6HN++2S7c7c0aXHK1RYIZ5++4Y7pf3g4v+OdRUzcJgOROfZkXx0tSEeCOfJFMluodJiSYsESiJnidVcR7Os/iHkNqIp88qGG7UZj+l8NYyvsO/7soTyQGkbMqoI0Z+0z+xXz2CZgOxsqWcjJ3FmTR32bsMR8Lra77XI2KyKycFiNYdYJ2dSKC7yBdxBRKHB7LAs4DOJKAtOt//IWspe9zPbju+x6chgP0dKToyfqX6m4EwlQnbHG4hmCImtXrEDytx1rbuLiBC7N56Y9WmGBTht5vgYvVEoA2cRqBbNYK+HoonL6+oBIJdba6+XZ2lBQsO/yJowvaHxPM8wgwLBknSt39RswaSdGjrI18CcgABAAB/eeLBPuQ9dk6ItCeTem38XttX/PQPLi52Ts+ZQGYHxs4VsO1EMe7EgMGYThPGlMCDcmS9ouXOSh6yW/LiL1jTuhc2/jhq3X0jWY+XPOSXtp81mineHeNv8SWsFjggzh5AymLtPPrPUYT6ihj9fcbJymqatsZMI5B5h0gxS2LaUUWjJyRxpMIyQXEpLSx1mxU5psQrj5/nGpOiq98uy8HE4kJp+Ey9uugSZQXhn9NwY+EqqmWxf6LDrCaeMLFDIX6mlgqu2eTLrUA9gNIJ4kSOC/5Rtw4JQVJpSeQuMom6kCHFEvZo/57BIhGkgWR8vNNCguHzZeB+as0xxfxmmb9SgAMAAMVFqaMXn0uG8+IGJIfxdIIoJ7EsLqV7so7WnFT/4OxfLzsXlO2flq0vcEbasLuLoqhGFaOuy1dkq/ft9se6Pv6rQfH7Esk/aMey/cKObBUPkcZAUFtQxXD7MSLScsiVnq3hHjrpZzEnMTToVkA9Zjv3i72Wv20tdE658+7O1olibavPPIT7Z5syoQNa1rjOAaXcPlM5hbbjXm7BiXx37ZEnvxwpY1Mf4Yocvgd9kMoApciDB2csbTf4GEic7MKeAI2G5KpwArY7g+zt4BJud+F/xnyuwVPpwPVEiNbHQnAogh5NoMDwRx+macTdkHku4AdNvruS/4L/aUHcEhPlhu3j/7r9kP1EnRso12NP1AWipsGlmpdAjoIXfK0+NBqJnDq0KwSEcvJ38OI6Z1FVkRWySi8br8pjtcytFhdh5RTkpD8FVQZ/RnGC1XE4q4IJhxMBlE1Kd8PNh3p85qpoX6r2I36a3knwK2dkm7pb0XNVwhxhC5DGpaB2iNo86CGi+BX4rICBGkNgyrOW/aWKpIhLu0bo1IDVQJw7MORdROJJk/o81E15HuC2g4r3ch+IvZOXKfAenGYM2mYrgnSRHLD0p7KsDN0vuU3IdLXAL5/D5ezr3WQFDFXPpRJyQ+qfx8kyUCe/vtvEVaNezHzOKosQsNGwSvp+lHrEGA9LLYM8RkU/Vwshgkeq2H8MoyuDRaxgOoudNGOmvwNfMp9BoOsz8OCDA5R2BB+JXzsEkSpNYebJK+VWm5wOcYnJ2j9y1OKjRU1ICRtsSPG5kLWmYUt8hHsswzrj4UAxpks+Dn2S09YzeOudC5ss5hmTM/UeVG3r3kJ9+Ad7716V9g7016u+XGhfSWty8EPxVAg0qV9wwAIk+FliWFdF1OLY1RODcsS3swqYfMrBWWdULVNl5d36ycFGucaP893o4Q/im7tx2+588lfvPbZO+DkP40MHP9Hwe++ra6kDiQx5si4M16zYIMmxa4nq6XVcr2hFlqbsLQjhIqkiFOCkt9LNRdKNZlghQkspUH44qLBq4sTHK0iD13FFmBs5rEE1CWa89oCELhea/Z9hPEtjPpC3Q52cAXBgbOJCTr6OYFYfQKbATqHdTU09/nJOafMK5ID1pf7pmBL+ZTH7Kl64lxhyO/9F84t47TctQhhFqxgsIxmv+ZVHajanNl4E0gXqJ0ULsY2h; SamlSession=aHR0cHMlM2ElMmYlMmZmcGNkcmRldi5tb2ZmaXR0Lm9yZyZGYWxzZSZDdWtyYXNTRCYmJiYmXzFkZjY4M2RhLTM4NTktNDVjNS04ODNkLTA3NmRiYTdiMjk3Yg==; MSISAuthenticated=NC8xNi8yMDE5IDExOjI2OjI4IEFN; MSISLoopDetectionCookie=MjAxOS0wNC0xNjoxMToyNjoyOFpcMQ== Upgrade-Insecure-Requests: 1

HTTP/1.1 302 Found Content-Length: 0 Content-Type: text/html; charset=utf-8 Location: https://example.com:443/login?SAMLRequest=lZLfa4MwEMf%2fFcl71KjxR7BCqS9C18I69rCXEjXpZJq4XCz982crY6yMwh7vuO9973N3OfChH9lWn%2fRkn8XnJMA6VblCR%2bpzQqWUOKWE4igMUlz7nGKexHUYJdSnKUXOqzDQabVCgesjpwKYRKXAcmXnlE8y7EeYxC%2bEsCBmYeamCX1DTjm7dIrbm%2fLd2hGY58mxaU0rzu6gpeysdbU5eb0%2bdQo5G61AXHtORjHNoQOm%2bCCA2YYd1k9bNtuzZilik4JRNJ3sRIucnbZ7tTdraYW5HykkPyNdhl4Bu23jsctotNWN7lGR33DNIn0s4gDCXHFRccWdac04Auh7XN5K8ObSc9cI8KyZwObeYlPku7ltVf7TbjN9GA6HMvcWeZEvFz8IuB6uUq24FEfSyjgNW47DlGY4og3F6RxjP4nbmid1kCV17v2h%2fE7%2beqDiCw%3d%3d&Signature=pT%2fSUpslARJlvOCah5VzZk4stZLIREyHmUFOO4siHUbkL5eJG4QsfYj9Pq%2bwxnOaPaevYkmiXq0rft3drTzJHspns9UbucyYQvEaSAZVmRTTyfPC3Z0EgVGSvtr0JL3nuDPsq2IfbToseuQQtJFsA%2b94D8KtaLjtUJxiMcQMHyg2yR00Ac3NGt9AsRg1X73X%2frt0XZDN9bSt4R8t%2bt2Yl2UsZsL4GHTGk7RbN3AUrYHsLtKeuN07umXqX3otVtHo%2f9tx2w2h1glYycYbFCk%2bWjox8Mej%2fiLLkpAhw9EXlhiTGrEJ2%2bcYvnQxGokOsz2vXEOoc3%2fhle27LuTPFMN9yw%3d%3d&SigAlg=http%3a%2f%2fwww.w3.org%2f2001%2f04%2fxmldsig-more%23rsa-sha256 Server: Microsoft-HTTPAPI/2.0 P3P: ADFS doesn't have P3P policy, please contact your site's admin for more details Set-Cookie: SamlSession=; expires=Mon, 15 Apr 2019 11:26:39 GMT; path=/adfs SamlLogout=aHR0cCUzYSUyZiUyZnJwcHNzb2Rldi5tb2ZmaXR0Lm9yZyUyZmFkZnMlMmZzZXJ2aWNlcyUyZnRydXN0Pz8/aHR0cHMlM2ElMmYlMmZmcGNkcmRldi5tb2ZmaXR0Lm9yZyZGYWxzZSZDdWtyYXNTRCYmJiYmXzFkZjY4M2RhLTM4NTktNDVjNS04ODNkLTA3NmRiYTdiMjk3Yj9fNTBhMTVmZmYtODUxNS00MzI4LWIwYTUtYTc2YjM0NzUwNTg1P3VybiUzYW9hc2lzJTNhbmFtZXMlM2F0YyUzYVNBTUwlM2EyLjAlM2FzdGF0dXMlM2FTdWNjZXNz; path=/adfs; HttpOnly; Secure MSISAuthenticated=; expires=Mon, 15 Apr 2019 11:26:39 GMT; path=/adfs MSISAuth=; expires=Mon, 15 Apr 2019 11:26:39 GMT; path=/adfs ReturnUrl=aHR0cHM6Ly9ycHBzc29kZXYubW9mZml0dC5vcmc6NDQzL2FkZnMvbHMvP3dhPXdzaWdub3V0MS4w; path=/adfs; HttpOnly; Secure MSISSignoutProtocol=U2FtbA==; expires=Tue, 16 Apr 2019 11:36:39 GMT; path=/adfs; HttpOnly; Secure Date: Tue, 16 Apr 2019 11:26:39 GMT

SAML:

<samlp:LogoutRequest ID="_50a15fff-8515-4328-b0a5-a76b34750585"
                     Version="2.0"
                     IssueInstant="2019-04-16T11:26:39.875Z"
                     Destination="https://example.com/login"
                     Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified"
                     NotOnOrAfter="2019-04-16T11:31:39.875Z"
                     xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                     > <Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">http://myadfs.org/adfs/services/trust</Issuer> <NameID xmlns="urn:oasis:names:tc:SAML:2.0:assertion">USERNAME</NameID> <samlp:SessionIndex>_1df683da-3859-45c5-883d-076dba7b297b</samlp:SessionIndex> </samlp:LogoutRequest>

On subsequent, unsuccessful logouts:

HTTP:

GET https://myadfs.org/adfs/ls/?wa=wsignout1.0 HTTP/1.1 Host: myadfs.org User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: https://example.com/dashboard/data Connection: keep-alive Cookie: MSISLoopDetectionCookie=MjAxOS0wNC0xNjoxMToyODoyNlpcMQ==; SamlLogout=aHR0cCUzYSUyZiUyZnJwcHNzb2Rldi5tb2ZmaXR0Lm9yZyUyZmFkZnMlMmZzZXJ2aWNlcyUyZnRydXN0Pz8/aHR0cHMlM2ElMmYlMmZmcGNkcmRldi5tb2ZmaXR0Lm9yZyZGYWxzZSZDdWtyYXNTRCYmJiYmXzFkZjY4M2RhLTM4NTktNDVjNS04ODNkLTA3NmRiYTdiMjk3Yj9fNTBhMTVmZmYtODUxNS00MzI4LWIwYTUtYTc2YjM0NzUwNTg1P3VybiUzYW9hc2lzJTNhbmFtZXMlM2F0YyUzYVNBTUwlM2EyLjAlM2FzdGF0dXMlM2FTdWNjZXNz; ReturnUrl=aHR0cHM6Ly9ycHBzc29kZXYubW9mZml0dC5vcmc6NDQzL2FkZnMvbHMvP3dhPXdzaWdub3V0MS4w; MSISSignoutProtocol=U2FtbA==; MSISAuth=AAEAAFOnxdlEvO8Le/Gti39Bx6BFj1cEJ39/A6ogocbLbXlBnq07uT1v+MuAzZs0NqyB1Wmqx3O8oTwPancFPCEFrQbngzsvsWI/oAXmuDih8uBG9MVPfstAu/cFPXL95V2IIUjX6r3Tv08FqipxW/1CHa7QM8XvXU5a516zFsZTaxke+ITD3B+nGPsuQY+oVG47NhtoMHmCrbShjOBd9Wn6Q5FzDqbHlxD/5czDUXixYf8gg+MTNq9W+oT5J7TF6NaBb7o1QojY7c8UoJ4fQONwlMNE17TgGVomqN4N9qVPTShGSaTlM8C+er9SOWQiALfZHvH2sv8N0AIn9qpivuCzw9WlBQsO/yJowvaHxPM8wgwLBknSt39RswaSdGjrI18CcgABAAAAz9AfrV1onudL+YY+0zL4vWeCboTECwksETafeI44/o0n0DEBx8kVGELmmPqSKD216OFB+p4k0K//HTW+YnRiuFpk1dAnN+dmwirgwzohFU1A3lWq0pQcHFyui1xs1UHnzDZokvK+7r859oZP0XZ4pGGTZsjWyc2B32FgwfvpiKYKDsWALpajW9FRDnt1VnGyDSzsN3V6vQHmKIEBZn5wb3+b3DtB9hV/ZssxiE7Xf8V8l+144wE71YH4ETNbcX0VXKNlkL9x5R+EThMlzyNl2tAcGWSk+3xM3lhfTm3+8y5GEP3rtJjLQGZSPKUljPcZM/MU3EX3YRrCkYsAyhgpgAMAAKGsYkEEca74go1dVexUCjdky1zUJMng5a/ZmKCRWTYsPT2DCjR579a0Hr69s8nl36p8EgyqnyXPm/uiFp+LPp1CuCCuXe/QYFoySixCOEcJsnRbikBEAP/Bpj5UUifnqgyO7MHH1GQiXeOlw2llsPu7rdNiEqB4X6Hqhnn6xaasl+5iqvNkZSTi8DSQc/24MRT4VsAcJcO7eqxjQBluWr2cyvdr9pn4GigQ05WaXWfogo3BwPJzLUo+NngvLHfxyn1wDmUYghc+oxS+vJwTadiiSDDzrcTVTuVxw2xj6OVi8DXbyRii5+VTKolRK0qCa/4C4BCzOOGUkooktX/GecV6eNuk8xOdLsiybY9Ah5Z2WVgraDntw/w/pP/ij4v0jDLvDQjU+BIfGOpeV1jcG9VDObir5GYGfOm59DtlRpoy/kpjiDLWI8EE75DEFlhomeae0v4xBQ6XqgVd5lEcA2DTm/3Ophg31FA2M5J65yE4t7W7inIC4XjMWFOu3GCMse7ERYyFbq59vf+iSs6eyev7wXidvAekALmq6Gk2Ths2JR1TbV27E2+kgGhmvlgiShx67E9s2wrBfPKvV7+IMS9Xe1YPKpZAlfCwnkbQNonqAMQH5LsHq1K7DWrNTcon10TiOtlMbzin8FtNphcnChHYmBbDxpqrf5xwwYXbyznQnMfeDnjN7aPo909gwhfUGNltLTOZ81m6k9c3Z0C8ugvL61bbw3Ku42OZiOnoVcEYjf50bMWZQl/hUMlRp+uHVNhK41z6U2O9Ph7S4ZI4wg7z33Z+VCp+08HpMRqrX155atJYVX73mnr3+J4rKvyJvjglb9aA333MUOC7iGMDDNImibvofyhbqK3VO+zqyPYj0R4OvhnA9RlvV10MWDhn5qnVevA5Oo1MQNPGnTLtfRZXpB8oa2bZZMh62XO4a5gZ/ioNsigiDAFKbQnx0wvBTb0uqYSZpfxoA4K2o87swOYB81FTkQNBnNZG171szH89jijOuEAI7hAWdAnM2LjagGZwWpuF2yHbJqQqsGzjvnqbQ6yMTvaEbkooSelFEBeRW2Gg5rGAjj5Pvs+T0ljhVlby6FfFKJ71NDBvn/7PGIglARSZqUZcAuthlhr8pta11WnhsfnyumvLfWvOZHZZjWslKMLBpGEBe1WgcYBUBYUrUeHmCqDRy5Zc4KJXwGrY; SamlSession=aHR0cHMlM2ElMmYlMmZmcGNkcmRldi5tb2ZmaXR0Lm9yZyZGYWxzZSZDdWtyYXNTRCYmJiYmX2NlNDAwODQxLTA2ZDItNDI3Ni05MTRlLWU5N2ExYWRlZmQzZQ==; MSISAuthenticated=NC8xNi8yMDE5IDExOjI4OjI2IEFN Upgrade-Insecure-Requests: 1

HTTP/1.1 200 OK Cache-Control: no-cache,no-store Pragma: no-cache Content-Length: 8957 Content-Type: text/html; charset=utf-8 Expires: -1 Server: Microsoft-HTTPAPI/2.0 Date: Tue, 16 Apr 2019 11:28:45 GMT

SAML:

NO SAML SENT

You will see that on a successful logout ADFS sets the cookies to clear them while an unsuccessful logout does not. Also, the unsuccessful logout does not send the SAML logout request.

Lastly, when I clear the cookies in the browser, the first login/logout session will work as intended again, and all subsequent logouts will not. I can see the cookies are retained on subsequent logouts as ADFS is not getting the SAML logout request. I just don't understand how this works on the first logout but not the following logouts. I have looked in and out of passport-saml's code but can't seem to find the issue.

Any assistance would be great.

Here is my passport.js setup:

const fs = require('fs');
const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;
require('dotenv').config();

passport.serializeUser((user, done) => {
    done(null, user);
});

passport.deserializeUser((user, done) => {
    done(null, user);
});

passport.use(new SamlStrategy({
    entryPoint: 'https://myadfs.org/adfs/ls',
    issuer: 'https://example.com',
    callbackUrl: process.env.NODESERVERURL + ':' + process.env.PORT + '/authenticate/adfs/postResponse',
    privateCert: fs.readFileSync(__dirname + '/private/keys/fpcdr.key', 'utf-8'),
    logoutUrl: 'https://myadfs.org/adfs/ls/?wa=wsignout1.0',
    signatureAlgorithm: 'sha256'
},
    function(profile, done) {
        const username = profile.nameID.toLowerCase();
        const email = profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'].toLowerCase();
        const sessionIndex = profile.sessionIndex;
        return done(null, {
            username,
            email,
            sessionIndex
        });
    })
);

module.exports = passport;

passport callbackUrl:

module.exports.adfsAuthenticate = function(req, res) {

    const email = req.user.email;
    const username = req.user.username;

    if (process.env.UAT === 'true') {
        res.status(302).redirect(LANDING_PAGE_REDIRECT_DEV);
    } else {
        res.status(302).redirect(LANDING_PAGE_REDIRECT_PROD);
    }
};

adfs logout:

module.exports.logout = function(req, res) {
    req.logout();
    req.session.destroy(function (err) {
        if (!err) {
            res.status(200).clearCookie('connect.sid', {path: '/'}).json({status: "Success"});
        } else { alert(err); }
    });
};

Solution

  • Here is my solution, shared here

    I found out why SLO doesn't work. I found a solution to make it work properly, im my case with Microsoft Azure (SAML-ADFS), using a database session storage. I log in from my app. Then I log out from microsoft azure ( not from my app), and it logs out nicely !

    I think important to mention that there is a huge difference between the IDP logout request with all others. Most of the time, passport routes handle user device's initiated requests or redirects, That means the device's cookies comes along theses requests, which allows passport-saml to successfully retreive device's user information.

    When the IDP broadcasts a logout request to relevant ISP(s), it is not always a device request (either initiated nor redirected). It is a simple HTTP GET request that contains a SAMLRequest. This SAMLRequest contains what the IDP uses to identify a saml user.

    A saml user is identified using nameID and nameIDFormat profile properties.

    Therefore, when a user logs in through an IDP, the app must store both values, nameID and nameIDFormat. You can acheive that first in you saml strategy authentication handler :

    export const samlStrategy = new SamlStrategy({
          ...samlStrategyOptionsHere,
          logoutCallbackUrl: 'https://your.isp.com/saml/logout'
       }, 
       (profile: Profile | null | undefined, done: VerifiedCallback) => {
        
        // do login process. On successful login, don't forget 
        // to pass along nameID and nameIDFormat profile values !
    
        done(null, {
            userId: user.id, // in my case, my app just need to store the user id
            nameID: profile?.nameID,
            nameIDFormat: profile?.nameIDFormat
        })
    
    });
    

    Then store nameID and nameIDFormat in passport user's session ( within serialization process ) :

    interface SessionUserData{
        userId: number,
        nameID?: string,
        nameIDFormat?: string,
    }
    passport.serializeUser<SessionUserData>((user: any, done) => {
    
        // do serialization stuff
    
        done(null, {
            userId: user.id,
            nameID: user?.nameID,
            nameIDFormat: user?.nameIDFormat
        })
    }); 
    

    SLO requests can then be handled properly by first retreiving passport sessions that holds the specified saml identification, and destroy them manually, like this :

    interface SAMLLogoutExpectedResponse{
        profile?: Profile | null | undefined;
        loggedOut?: boolean | undefined;
    }
    
    authRouter.get('/saml/logout', async (req, res)=>{
    
        // req.user may not exists here, since this is a single HTTP-GET request that contains a SAMLRequest 
        // initiated by the IDP, not a user device redirection
        // use passport-saml to validate this request
        const response:SAMLLogoutExpectedResponse = await (samlStrategy._saml as SAML).validateRedirectAsync(req.query, url.parse(req.url).query)
        
        // check the « SAML USER » profile
        if( response?.profile && 
            response.profile?.nameID && 
            response.profile?.nameIDFormat && 
            response.loggedOut===true ){
    
            // get related session(s) if exists
            const sessionsToDestroy:string[] = ((await knex.from('sessions').where(true).select('*')) ?? []).reduce((acc:any[], session:any) => {
                const userData:any = JSON.parse(session?.data ?? {})?.passport?.user
                
                if( userData &&
                    userData.nameID &&
                    userData.nameIDFormat &&
                    userData.nameID == response?.profile?.nameID &&
                    userData.nameIDFormat == response?.profile?.nameIDFormat
                    ){
    
                    acc.push(session.session_id)
                    
                }
                return acc;
            }, [])
    
            if(sessionsToDestroy.length){
                // destroy database session --> which invalidate any corresponding cookie on any device (if still exists)
                await knex.from('sessions').whereIn('session_id', sessionsToDestroy).delete();
            }
        }
        // if this is a IDP request, all ends here
        // if user initiated the logout request, IDP will redirect device here. Send the device somewhere coherent after successful logout
        res.redirect('/')
    });
    

    Voilà. I know this is a precise case, and doesn't fit to everyone. But the most important point is that a SLO callback must handle nameID and nameIDFormat values. It should not handle a device session.