graphql-yoga icon indicating copy to clipboard operation
graphql-yoga copied to clipboard

When defining multiple cookies, they are all sent in a single `set-cookie` header

Open j0k3r opened this issue 1 year ago • 0 comments

Describe the bug

Within my lambda, I define multiple cookies when a user logged in: Token & UserInfo. I use the cookieStore from the context to define them.

It works perfectly locally (when using createServer) both cookie are sent in a single set-cookie headers:

set-cookie: Token=JWTOKENblablabla; Domain=mydomain.me; Path=/; Expires=Fri, 17 Nov 2023 10:28:13 GMT; SameSite=Lax; HttpOnly
set-cookie: UserInfo=somedata; Domain=mydomain.me; Path=/; Expires=Fri, 17 Nov 2023 10:28:13 GMT; SameSite=Lax

But when I deploy the code to AWS (I'm using an API G REST, not HTTP) only one header set-cookie is sent with both cookies inside. So of course, the behavior isn't met on the client side:

set-cookie: Token=JWTOKENblablabla; Domain=mydomain.me; Path=/; Expires=Fri, 17 Nov 2023 09:02:41 GMT; SameSite=Lax; HttpOnly, UserInfo=somedata; Domain=mydomain.me; Path=/; Expires=Fri, 17 Nov 2023 09:02:41 GMT; SameSite=Lax

I'm using the Lambda integration as defined in that example: https://the-guild.dev/graphql/yoga-server/docs/integrations/integration-with-aws-lambda

Your Example Website or App

https://github.com/j0k3r/repro-yoga-cookies

Steps to Reproduce the Bug or Issue

It's hard to provide a sample code reproduction because the code must be deployed to AWS. But I create a small project and deployed it.

handler1 with the bug

handler1 defines 2 cookies:

import { createSchema, createYoga } from 'graphql-yoga'
import { useCookies } from '@whatwg-node/server-plugin-cookies'

const yoga = createYoga({
  logging: true,
  maskedErrors: false,
  graphqlEndpoint: '/handler1',
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        greetings: String
      }
    `,
    resolvers: {
      Query: {
        async greetings (root, args, ctx) {
          await ctx.request.cookieStore?.set('Token', 'JWTOKENblablabla')
          await ctx.request.cookieStore?.set('UserInfo', 'somedata')

          return 'This is the `greetings` field of the root `Query` type'
        }
      }
    }
  }),
  plugins: [useCookies()]
})

export async function handler(event, lambdaContext) {
  const response = await yoga.fetch(
    event.path +
      '?' +
      new URLSearchParams((event.queryStringParameters) || {}).toString(),
    {
      method: event.httpMethod,
      headers: event.headers,
      body: event.body
        ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
        : undefined
    },
    {
      event,
      lambdaContext
    }
  )

  const responseHeaders = Object.fromEntries(response.headers.entries())

  return {
    statusCode: response.status,
    headers: responseHeaders,
    body: await response.text(),
    isBase64Encoded: false
  }
}

It can be requested using:

curl -i \
  --url https://gewtxb5z00.execute-api.eu-west-1.amazonaws.com/dev/handler1 \
  --header 'Content-Type: application/json' \
  --data '{"query":"query hello { greetings }","operationName":"hello"}'

In the response, you'll have only one set-cookie with both cookies define inside:

HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 79
date: Wed, 18 Oct 2023 11:21:00 GMT
x-amzn-requestid: 4f5580bd-914c-441e-a93f-121e20b7e2ea
x-amzn-remapped-content-length: 79
set-cookie: Token=JWTOKENblablabla; Path=/; SameSite=Strict, UserInfo=somedata; Path=/; SameSite=Strict
x-amz-apigw-id: M_rgcHz2joEEJ6A=
x-amzn-trace-id: Root=1-652fbf9c-1c59373166eddd11621cacd4;Sampled=0;lineage=f3deedb7:0
x-cache: Miss from cloudfront
via: 1.1 bfd596aba0de57f83442d2ebd6b268f4.cloudfront.net (CloudFront)
x-amz-cf-pop: CDG52-P1
x-amz-cf-id: VxQaFyztVir4OwVnSpR0DAVhEB_wm7SjuiNEtlkio57n31RJuhXjlA==

{"data":{"greetings":"This is the `greetings` field of the root `Query` type"}}

handler2 with an ugly workaround

I found a workaround to solve my problem. I re-defined useCookies to set a header Set-Cookies with cookies JSON stringified inside. Then, when sending the response, I used these stringified cookies to defined the proper key multiValueHeaders used by API G to handle (at least) multiple cookies definition.

import { createSchema, createYoga } from 'graphql-yoga'
import { CookieStore, getCookieString } from '@whatwg-node/cookie-store'

const useCustomCookies = () => {
  const cookieStringsByRequest = new WeakMap()

  return {
    onRequest({ request }) {
      const cookieStrings = []
      request.cookieStore = new CookieStore(request.headers.get('cookie') ?? '')
      request.cookieStore.onchange = ({ changed, deleted }) => {
        changed.forEach((cookie) => {
          cookieStrings.push(getCookieString(cookie))
        })
        deleted.forEach((cookie) => {
          cookieStrings.push(getCookieString({ ...cookie, value: undefined }))
        })
      }
      cookieStringsByRequest.set(request, cookieStrings)
    },
    onResponse({ request, response }) {
      const cookieStrings = cookieStringsByRequest.get(request)
      cookieStrings?.forEach((cookieString) => {
        response.headers.append('Set-Cookie', cookieString)
      })

      // that the only line added
      response.headers.set('Set-Cookies', JSON.stringify(cookieStrings))
    },
  }
}


const yoga = createYoga({
  logging: true,
  maskedErrors: false,
  graphqlEndpoint: '/handler2',
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        greetings: String
      }
    `,
    resolvers: {
      Query: {
        async greetings (root, args, ctx) {
          await ctx.request.cookieStore?.set('Token', 'JWTOKENblablabla')
          await ctx.request.cookieStore?.set('UserInfo', 'somedata')

          return 'This is the `greetings` field of the root `Query` type'
        }
      }
    }
  }),
  plugins: [useCustomCookies()]
})

export async function handler(event, lambdaContext) {
  const response = await yoga.fetch(
    event.path +
      '?' +
      new URLSearchParams((event.queryStringParameters) || {}).toString(),
    {
      method: event.httpMethod,
      headers: event.headers,
      body: event.body
        ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
        : undefined
    },
    {
      event,
      lambdaContext
    }
  )

  const multiValueHeaders = {}
  const headers = Object.fromEntries(response.headers.entries())
  if (headers['set-cookies']) {
    multiValueHeaders['set-cookie'] = JSON.parse(headers['set-cookies'])

    delete headers['set-cookies']
    delete headers['set-cookie']
  }

  return {
    statusCode: response.status,
    headers,
    multiValueHeaders,
    body: await response.text(),
    isBase64Encoded: false,
  }
}

It can be requested using:

curl -i \
  --url https://gewtxb5z00.execute-api.eu-west-1.amazonaws.com/dev/handler2 \
  --header 'Content-Type: application/json' \
  --data '{"query":"query hello { greetings }","operationName":"hello"}'

In the response, you'll have two set-cookie header for each cookie:

HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 79
date: Wed, 18 Oct 2023 11:23:59 GMT
x-amzn-requestid: 35532f3c-b45b-4081-b857-2352a7b499e1
x-amzn-remapped-content-length: 79
set-cookie: Token=JWTOKENblablabla; Path=/; SameSite=Strict
set-cookie: UserInfo=somedata; Path=/; SameSite=Strict
x-amz-apigw-id: M_r8XGR5DoEECBg=
x-amzn-trace-id: Root=1-652fc04e-1ddb0e0d4661a4b85133ce7e;Sampled=0;lineage=869de0a0:0
x-cache: Miss from cloudfront
via: 1.1 a769201928d4a671d76c2aeb231718ae.cloudfront.net (CloudFront)
x-amz-cf-pop: CDG52-P1
x-amz-cf-id: ZHEFG9JsCQmFacJOlAidhY-NC9CCo5eom4o_Da064Zeo_12TbUe0aQ==

{"data":{"greetings":"This is the `greetings` field of the root `Query` type"}}

Expected behavior

As a user, I want to receive two cookies.

Screenshots or Videos

No response

Platform

  • NodeJS: 18
  • Package info:
    {
      "dependencies": {
        "@whatwg-node/cookie-store": "^0.2.2",
        "@whatwg-node/server": "^0.9.14",
        "@whatwg-node/server-plugin-cookies": "^1.0.2",
        "graphql": "^16.8.1",
        "graphql-yoga": "^5.0.0",
        "source-map-support": "^0.5.21"
      }
    }
    

Additional context

No response

j0k3r avatar Oct 18 '23 11:10 j0k3r