Search code examples
node.jsexpresscookiesapollo-servergraphql-js

Graphql Set-Cookie being sent but not being set


After experimenting with different configurations found on questions similar to this, none of them seem to work. Set-Cookie is being sent by express, however the browser isn't setting it in Application -> Cookies

This question is for when I am running frontend on localhost:7000 and backend on localhost:4000

Frontend Technologies: vite, react, @tanstack/react-query, graphql-request (fetcher), @graphql-codegen, @graphql-codegen/typescript-react-query (Using this to generate react-query hooks for graphql)

Backend Technologies: @apollo/server, type-graphql, express, express-sessions

Repo for reproducing: https://github.com/Waqas-Abbasi/cookies-not-set-repro

Backend Server:

import 'reflect-metadata';
import 'dotenv/config';
import { expressMiddleware } from '@apollo/server/express4';
import http from 'http';
import { PrismaClient } from '@prisma/client';
import { ApolloServer } from '@apollo/server';
import express from 'express';
import bodyParser from 'body-parser';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import cors, { CorsRequest } from 'cors';
import session from 'express-session';
import buildSchemaFacade from './graphql/buildSchemaFacade';
import { redisStore } from './api/redis';

const SEVEN_DAYS = 1000 * 60 * 60 * 24 * 7;

const {
    NODE_ENV,
    PORT = 4000,
    SESSION_LIFETIME = SEVEN_DAYS,
    SESSION_SECRET
} = process.env;

async function bootstrap() {
    const prisma = new PrismaClient();

    const app = express();
    const httpServer = http.createServer(app);

    const schema = await buildSchemaFacade();

    const server = new ApolloServer({
        schema,
        plugins: [ApolloServerPluginDrainHttpServer({ httpServer })]
    });

    await server.start();

    app.use('/graphql', bodyParser.json());

    app.use(
        '/graphql',
        cors<CorsRequest>({
            origin: 'http://localhost:7000',
            credentials: true
        })
    );

    app.use(
        '/graphql',
        session({
            proxy: true,
            name: 'sessionID',
            cookie: {
                maxAge: SESSION_LIFETIME as number,
                sameSite: 'lax',
                secure: NODE_ENV === 'production',
                httpOnly: true
            },
            resave: false,
            secret: SESSION_SECRET as string,
            saveUninitialized: false,
            store: redisStore
        })
    );

    app.use(
        '/graphql',
        expressMiddleware(server, {
            context: async ({ req, res }) => ({ prisma, req, res })
        })
    );

    await new Promise<void>((resolve) =>
        httpServer.listen({ port: PORT || 4000 }, resolve)
    );

    console.log(`🚀 Server ready at http://localhost:4000/graphql`);
}

bootstrap();

Frontend:

GraphQL Client:

import { GraphQLClient } from 'graphql-request';

export const graphqlClient = new GraphQLClient(import.meta.env.VITE_GRAPHQL_ENDPOINT as string, {
    headers: {
        credentials: 'include',
        mode: 'cors',
    },
});

Login.tsx:

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { useLoginUserMutation } from '@platform/graphql/__generated__/graphql';
import { graphqlClient } from '@platform/graphql/graphqlClient';

export default function Login() {
    const [error, setError] = useState<string | null>(null);

    const { mutate } = useLoginUserMutation(graphqlClient);
    const navigate = useNavigate();

    const onSubmit = (event: any) => {
        event.preventDefault();

        const email = (event.target as HTMLFormElement).email.value;
        const password = (event.target as HTMLFormElement).password.value;

        mutate(
            { email, password },
            {
                onSuccess: (data) => {
                    // navigate('/dashboard/orders');
                    console.log(data);
                },
                onError: (error) => {
                    console.error(error);
                    setError('Something went wrong with logging in');
                },
            }
        );
    };

    return (
        <div className="flex h-screen items-center justify-center bg-slate-100">
            <div className="w-[300px] space-y-4  bg-white p-5">
                <h1 className="text-xl font-bold">Login</h1>
                <form onSubmit={onSubmit} className="flex flex-col space-y-4 text-lg">
                    <div className="flex flex-col space-y-2">
                        <label htmlFor="email">Email</label>
                        <input type="email" id="email" />
                    </div>
                    <div className="flex flex-col space-y-2">
                        <label htmlFor="password">Password</label>
                        <input type="password" id="password" />
                    </div>

                    <button type="submit" className="w-fit  bg-black p-2 px-4 text-white">
                        Login
                    </button>
                </form>
                {error && <div className="text-red-500">{error}</div>}
            </div>
        </div>
    );
}

Response when pressing Login Button:

enter image description here enter image description here enter image description here enter image description here

EDIT:

I also tried solution:

https://community.apollographql.com/t/cookie-not-shown-stored-in-the-browser/1901/8

Setting cookie’s secure field to be true and setting sameSite to none, and also passing x-forwarded-proto with value https in the graphQL client.

Still it does not work. On a side note, it is working as expected on Insomnia, just not on any browsers

EDIT 2: I've also tried replacing graphql-request with urql and apollo client, still the same issue. This leads me to think this might be a backend issue with how express session is initialised, and for some reason the browser does not like the Set-Cookie that is sent from the backend

EDIT 3:

./api/redis:

import connectRedis from 'connect-redis';
import session from 'express-session';
import RedisClient from 'ioredis';

const RedisStore = connectRedis(session);
const redisClient = new RedisClient();

export const redisStore = new RedisStore({ client: redisClient });

export default redisClient;

Edit 4:

Repo for reproducing: https://github.com/Waqas-Abbasi/cookies-not-set-repro


Solution

  • In the GraphQL client definition, replace

    headers: {
      credentials: 'include',
      mode: 'cors',
    }
    

    with

    credentials: 'include',
    mode: 'cors'
    

    because these are not headers, they are parameters of the request.

    Without the credentials: 'include' parameter in the request, the fetch method will neither send nor receive any cross-origin cookies, where a difference in the port (localhost:7000 vs. localhost:4000) already makes a request cross-origin. That's why I believe this to be the solution.

    Wesley LeMahieu's answer handles the question when a cookie counts as same-site, where the port does not matter when obtaining a site. localhost and 127.0.0.1 are not same-site.

    Cookies with SameSite: Lax are sent or received by a fetch request only if this is same-site. In the question, the fetch request is same-site, but cross-origin, and therefore requires the credentials: 'include' option.