expressauthenticationcookiesjwtsingle-sign-on

How to store httpOnly cookie after redirect from custom SSO?


Context

There are two applications:

The auth-server has two endpoints:

An example successful response to the example URL would be: https://img.domain.com/getdata?authToken={jwt} where jwt is the generated public token. The service providers can validate this token, because it is generated with a private-key of which the service provider know its public-key counterpart.

Used libraries

Authentication flow

The flow that I'm trying to get working is the following, for this example I will assume that no cookie is set on the service provider yet:

  1. User sends a request to an authenticated path, e.g., https://img.domain.com/getdata.
  2. The service provider checks whether an Authorization cookie is already set, or if an authToken query parameter is set.
  3. Since no cookie nor query parameter is set, it redirects the user to the identity provider which shows the login page, e.g., https://auth.domain.com/?redirect=https://img.domain.com/getdata.
  4. The user logs in with their credentials, the identity service creates a private token and a public token and redirects to the original URL along with the public token in query parameter authToken, e.g., https://img.domain.com/getdata?authToken={jwt}.
  5. The service provider checks the authToken query parameter, validates it and stores it as an httpOnly cookie Authorization.
  6. The service provider then redirects to its own path without the query parameter (to clear it), e.g., https://img.domain.com/getdata.
  7. The service provider should now see the Authorization cookie and use it to validate the user.

The service provider code is the following (simplified):

server.get('/getdata', authenticate, (req, res) => { res.send("cool stuff") });

function authenticate(req, res, next) {
    if (req.query.authToken && validateToken(req.query.authToken)) {
        // If valid, then set it as cookie.
        res.cookie("Authorization", req.query.authToken, { httpOnly: true, signed: true, sameSite: "strict" });
        // Clear query parameter and redirect.
        res.redirect( req.originalUrl.split("?").shift() );
        return;
    } else if (req.signedCookies.Authorization && validateToken(query.signedCookies.Authorization) {
        next();
        return;
    }

    // No valid token found, so redirect to identity service.
    res.redirect(`auth.domain.com/?redirect=https://img.domain.com/${req.originalUrl}`)
}

function validateToken(token) {
    // Does stuff to validate token.
}

The problem

The flow from the service provider to the identity provider and back goes correctly (step 1 - 4), but storing the Authorization cookie won't work.

The authToken query parameter is correctly retrieved and the validation of it is succesful, but once the query parameters are cleared and a redirect is done again it doesn't see the Authorization cookie (I've also checked req.cookies which doesn't contain it).

I've looked up the issue and seen different things about how cookies are exactly set in HTTP, but never been able to exactly wrap my head around why this isn't working. Does anybody know why? I'm also open to feedback on my authentication flow, maybe it's just not the correct way to do it.

I must add that I've only tried this while running the img-server locally (http://localhost:3000). The auth-server is hosted on HTTPS.

Thanks in advance!

== EDIT ==
The cookie is apparently set, but it just isn't sent with the immediate redirect (to clear query parameters). When I go to localhost:3000/ afterwards, the session storage does contain the correct Authorization cookie and visiting the authenticated route works correctly. How can I fix that it does include it in its immediate redirect?


Solution

  • This is a feature or 'SameSite=strict'. The first request coming in from an external origin will not send SameSite=strict cookies.

    This behaviour is documented in the 'Lax' section on MDN:

    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#lax

    The logic is that one CSRF vector is the ability to trigger some action simply by coming from an external site.