How to stop Stripe from over-writing client's session cookie after a successful purchase
Asked Answered
A

2

6

Environment: Express, express-session, Stripe

In the following simplified example when a user requests the home page express-session assigns the user a session cookie. Refreshing the page retains the same session id as does visiting the success or fail routes. Clicking the upgrade button takes the client to a Stripe shopping cart screen that also keeps the same session id. However once the user is at the Stripe shopping cart if the user makes a successful purchase he is forwarded to the success route and the session id is over-written by Stripe. In the full version this is a problem because the user would be logged in and this causes the user to be automatically logged out after the successful purchase. I'm not sure why this is happening or how to stop it.

app.js

const bodyParser = require('body-parser');
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

app.set('view engine', 'ejs');

app.use(express.static('views'));

app.use(
    session({
        maxAge: 24 * 60 * 60 * 1000,
        name: 'randomName',
        resave: false,
        saveUninitialized: true,
        secret: 'randomSecret',
        cookie: {
            sameSite: true,
            secure: false
        } 
    })
);

app.get('/', function(req, res) {

    req.session.userValues = true;
    console.log(req.session);

    res.render('index', { stripePublicKey: process.env.STRIPE_PUBLIC_KEY });
});

app.get('/success', function(req, res) {

    console.log(req.session);

    res.render('success');
});    

app.get('/fail', function(req, res) {

    console.log(req.session);

    res.render('fail');
});

app.post('/create-checkout-session', bodyParser.raw({ type: 'application/json' }), async function(req, res) {

    console.log(req.session);

    const session = await stripe.checkout.sessions.create({
        submit_type: 'auto',
        payment_method_types: ['card'],
        line_items: [
            {
                price_data: {
                    currency: 'usd',
                    product_data: {
                        name: 'name of product',
                        description: 'description of product'
                    },
                    unit_amount: 100
                },
                quantity: 1,
            }
        ],
        locale: 'en',
        mode: 'payment',
        success_url: 'http://localhost:8080/success',
        cancel_url: 'http://localhost:8080/fail'
    });

    res.json({ id: session.id });
});    

app.listen(8080, function() {
    console.log('listening on port 8080');
});

index.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upgrade</title>
    <script>var stripePublicKey = '<%- stripePublicKey %>';</script>
    <script defer src="https://js.stripe.com/v3/"></script>
    <script defer src="checkout.js"></script>
</head>
<body>
    
    <button id="checkout-button">upgrade premium</button>

</body>
</html>

checkout.js

var stripe = Stripe(stripePublicKey);

var checkoutButton = document.getElementById('checkout-button');

checkoutButton.addEventListener('click', checkoutSequence);

function checkoutSequence() {

    fetch('/create-checkout-session', {
        method: 'POST',
    })
    .then(function(response) {
        return response.json();
    })
    .then(function(session) {
        console.log(session);
        return stripe.redirectToCheckout({ sessionId: session.id });
    })
    .then(function(result) {
        if (result.error) {
            alert(result.error.message);
        }
    })
    .catch(function(error) {
        console.error('Error:', error);
    });
}
Aldebaran answered 8/9, 2020 at 19:40 Comment(0)
A
4

After 6 hours of testing I found the problem. cookie.sameSite must be set to lax instead of true. Evidently when Stripe hits the success route express-session determines that this is coming from an outside site and resets the cookie.

app.use(
    session({
        maxAge: 24 * 60 * 60 * 1000,
        name: 'randomName',
        resave: false,
        saveUninitialized: true,
        secret: 'randomSecret',
        cookie: {
            sameSite: 'lax',
            secure: false
        } 
    })
);
Aldebaran answered 8/9, 2020 at 20:12 Comment(6)
Curious. I tested your code out locally on my end and saw no issues with the cookie. The initial randomName cookie that was set when the app first loaded was still there when checkout redirected to both the /success and /fail urls. Even with the sameSite option set to true (i.e., SameSite=Strict). How are you checking if the cookie is there or not? One thing to be mindful of is that cookie is an http-only cookie which has certain levels of protection that prevent it from being read from client-side JS.Eyewash
When I run it with sameSite: true I watch the console and it always changes the session ID after a successful purchase. In my real version it also logs the user out. I wonder if the browser has an impact on how it behaves. I'm using Chrome on Windows 10. Thanks for checking it!Aldebaran
Gotcha, I missed the fact that express-session was replacing the cookie with a brand new one. Which is unexpected for me. I interpreted your question initially to mean that no session cookie was visible at all in the fail/success redirects. But there is one; it's just a brand new one each time. At this point I think it's safe to say that this is more a quirk with express-session and how its configured. We can rule out Stripe Checkout I think, since you get the same behavior even if you navigate manually from the site root to /fail or /success.Eyewash
It seems like you have a workaround for now, but it might be worthwhile opening up an issue on the express-session repo to see why this is happening; I'm curious myself. github.com/expressjs/session#readmeEyewash
I don't think there's anything Express Session can do about it. When Stripe redirects back to the /success page, the session cookie is simply not sent by the browser (I've tested Chrome and Firefox on Mac) because it's not coming from that site, so Express just sees it as a new anonymous visitor and sets a new session cookie.Carpal
I wondered if it was possible to create a non-sessioned landing page on your site which then redirects to the real /success, preserving the url parameters. I found you can do this with a client-side redirect using a meta tag, but not a server-side redirect which still doesn't send the cookie.Carpal
C
1

I agree that turning off the SameSite strict solves the problem easily. But I was intrigued to see if there was a way to keep the security setting.

I found you can work around the fact that SameSite cookies are not sent when loading the /success page directly from Stripe, if you bounce it off an "un-sessioned tween" page on your site, i.e. a page that has no session handling and therefore doesn't detect the missing session cookie and so doesn't replace it. However I found you have to use a client-side redirect for it to work.

This seems to make sense, as the meta refresh is performing a client-side navigation from your site to your site, so it's like a user clicking a link. However in the server-redirect the browser seems to "know" the chain of events hasn't been broken from the Stripe site to yours. If you set a breakpoint in the server-side redirect, you can see the browser is still sitting in Stripe, so I guess it still thinks it's in that context when responding to the 301 redirect response, and doesn't send the cookie. Interesting boundary case.

The flow:

  • Your site page: configure return url as /success-redirect
  • Stripe payment...
  • /success-redirect (from Stripe with no session cookie)
  • < meta refresh > in browser
  • /success (has cookie!)

App.js / router

    const url = require('url');

    // Add this route before adding your session to the app/router
    // so this route will not set a new session cookie, and won't overwrite the session. 
    // The previous session cookie stays in the browser...
    app.get('/success-redirect', (req, res, next)=>{
        // Server side redirect doesn't work, this still wont send the cookie, 
        // even though it's the browser responding to the 302/301
        // res.redirect(url.format({
        //  pathname:'/success',
        //  query:req.query,
        // }));

        // If you load an actual page on the site domain, then it does a meta-redirect,
       // the original session cookie is sent and this success page is logged in normally
        res.render('success-redirect.hbs', {
            url: url.format({pathname:'/success',query:req.query,}),
        });

    });

    // then add your session middleware as usual
    app.use(session(...));

success-redirect.hbs

<meta http-equiv="refresh" content="1;URL='{{url}}'" />

This kind of thing probably explains why some complex/aggregate sites have so many transient redirect pages.

Carpal answered 10/9, 2021 at 2:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.