serverless-nextjs / serverless-next.js

⚡ Deploy your Next.js apps on AWS Lambda@Edge via Serverless Components
MIT License
4.46k stars 456 forks source link

Cookie is set on localhost on every request but not in production #2286

Open albehrens opened 2 years ago

albehrens commented 2 years ago

Problem

When I run my next application locally Set-cookie is set on every request as expected (I am using nextjs-auth0 for authentication https://github.com/auth0/nextjs-auth0). But after deploying my app to AWS this is missing. Set-cookie is only set when I initially login to my application on the /api/auth/callback path.

Setup

I have my api deployed with serverless.com on AWS Api Gateway as Lambda@Edge. My next application is deployed with serverless-next.js. In my next application I have this [...path].ts file to proxy all requests to that api:

import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
import { createProxyMiddleware } from "http-proxy-middleware";
import { Request, Response } from "http-proxy-middleware/dist/types";
import { NextApiHandler } from "next";

const { AUTH0_AUDIENCE: apiURL } = process.env;
const proxy = createProxyMiddleware({
    changeOrigin: true,
    pathRewrite: {
        "^/api": "",
    },
    target: apiURL,
});

const handler: NextApiHandler = withApiAuthRequired(async (req, res) => {
    try {
        const { accessToken } = await getAccessToken(req, res);
        if (accessToken) {
            req.headers["content-type"] = "application/json";
            req.headers["authorization"] = `Bearer ${accessToken}`;
        } else {
            return res.status(403).json({
                message: "No access token",
            });
        }
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        return proxy(req as unknown as Request, res as unknown as Response, () => {});
    } catch {
        return res.status(403).json({
            message: "No access token",
        });
    }
});

export default handler;

export const config = {
    api: {
        bodyParser: false,
    },
};

Situation

Locally everything works fine and I get these response headers on every request:

access-control-allow-credentials: true
access-control-allow-methods: *
access-control-allow-origin: *
connection: close
content-encoding: gzip
content-length: 1424
content-type: application/json
date: Sat, 22 Jan 2022 05:10:45 GMT
referrer-policy: no-referrer
Set-Cookie: appSession=XXX; Path=/; Expires=Sun, 23 Jan 2022 05:10:45 GMT; HttpOnly; SameSite=Lax
strict-transport-security: max-age=15552000; includeSubDomains; preload
via: 1.1 edd6d90087c4f2b49e182778a2273adc.cloudfront.net (CloudFront)
x-amz-apigw-id: MVO4_FX3FiAFg-w=
x-amz-cf-id: LnCgjm45aQUCX4-9xpPMS_v3bvcvFevWNx4xHdMli6uzQzlMN8KO6A==
x-amz-cf-pop: AMS54-C1
x-amzn-requestid: 6a859b31-9277-473f-a5a8-e68914f5e5d0
x-amzn-trace-id: Root=1-61eb91d2-3e44d6515e9d1d12592e07f7;Sampled=0
x-cache: Miss from cloudfront
x-content-type-options: nosniff
x-dns-prefetch-control: off
x-download-options: noopen
x-permitted-cross-domain-policies: none

On production however the same requests look like this with Set-cookie missing:

access-control-allow-credentials: true
access-control-allow-methods: *
access-control-allow-origin: *
content-encoding: gzip
content-length: 1424
content-type: application/json
date: Sat, 22 Jan 2022 05:23:13 GMT
referrer-policy: no-referrer
server: CloudFront
strict-transport-security: max-age=15552000; includeSubDomains; preload
via: 1.1 4d0f1cf23ad7680cffcd37454ed8e57c.cloudfront.net (CloudFront)
x-amz-apigw-id: MVQuQHm3liAFbVw=
x-amz-cf-id: a_ZqJCDWWDLQVpK46AjVp6SF9CYKpAtkkGdDBieprl6w3IHgPGa4sQ==
x-amz-cf-pop: AMS50-C1
x-cache: LambdaGeneratedResponse from cloudfront
x-content-type-options: nosniff
x-dns-prefetch-control: off
x-download-options: noopen
x-permitted-cross-domain-policies: none

The differences between local and production are:

Can someone help me out? I am trying to figure this out for days now

chrisneal commented 2 years ago

@albehrens In your serverless.yml file, do you have it set to forward all cookies? Like below:

myNextApplication:
  component: "@sls-next/serverless-component@{version_here}"
  inputs:
    cloudfront:
      defaults:
        forward:
          cookies: "all"
albehrens commented 2 years ago

@chrisneal Thank you for your suggestion, but this did unfortunately not work

ShawnFumo commented 2 years ago

@albehrens Did you ever find a solution to this? I think we're seeing the same issue...

albehrens commented 2 years ago

@ShawnFumo I "solved" it with what I consider a hack. The problem is that the appSession cookie is not updated on requests to the api, because the Set-cookie header is missing. Therefore appSession expires after 24 hours and users have to log back in. So my solution was to increase the expiration date of appSession. If you do not want to write your own authentication handler instance of auth0 you can set the expiration date with env variables:

AUTH0_SESSION_ROLLING_DURATION=2592000 // 30 days
AUTH0_SESSION_ABSOLUTE_DURATION=2592000 // 30days

That worked for me.

ShawnFumo commented 2 years ago

@albehrens Thanks. I don't think that'll work for us unfortunately. For security reasons, we need fairly short inactivity timeouts. So we have access tokens that expire within minutes and refresh tokens that have inactivity timeouts less than a half-hour. It was only recently that we got better logging in place and could see that we had way more token refreshes than we should have. Stumbling on this thread helped put 2 and 2 together. Since the session is never getting updated, after the initial access token expires, every request to the API doing a new exchange and getting a new access token!

We're using nextjs-auth0 but the regular serverless package instead of serverless-nextjs. Will try to see if I can figure out how to fix this and will post back here in case it works with this library as well.

albehrens commented 2 years ago

@ShawnFumo Yes, I would not recommend this for production or business critical applications. I am using this for my pet projects. Please let me know if you found a solution though :)

ShawnFumo commented 2 years ago

@albehrens I was able to find a temporary solution (temp since I'm using some non-public parts of the library), though still not sure why it is happening in the first place. The cookie header isn't being produced on the response by the nextjs-auth0 library itself for some reason when running on lambda@edge. You can see what I did here to force the cookie to be output. In my case, I'm doing it only when I get a new access token, but you could do it on every request if you also used getSession and used the cookieStore.save after getAccessToken has run (getSession by itself won't ever do a token exchange, so don't get rid of getAccessToken unless you're sure you don't have to worry about the access token expiring before the session does).

Maybe the thread will give a clue to why it is happening once the nextjs-auth0 team has a chance to look at my latest posts.

ShawnFumo commented 2 years ago

@albehrens Actually, figured out the issue. What we're both doing is turning off the default body parsing, which makes body be a stream and for some reason writeHead on the response object is never called (probably some library lower down is doing an optimization). nextjs-auth0 is relying on a library called on-head which replaces the writeHead function to allow callbacks when headers are written. The callback is never called, which is what adds the cookie to the response. This fixed it for me, so hopefully it will for you too if you replace the send with that proxy call.

      // Will have an error running locally about writeHead running twice if you don't do this
      if (process.env.NODE_ENV === 'development') {
        res.status(response.status)
      } else {
        res.writeHead(response.status)
      }

      res.send(response.body);
albehrens commented 2 years ago

@ShawnFumo This is great news. Great work! Where exactly would I need to execute your sample code?

ShawnFumo commented 2 years ago

@albehrens In theory you'd grab the if/else block and stick it right before the return proxy part (leaving out the res.send). There's a chance it won't work for you since I'm not sure if the headers being locked down will freak out the proxy middleware you're using. If it doesn't work, I'm planning to submit a PR soon with a change to withApiAuthRequired that'd work without you having to change the original code. No guarantee they'll accept it, but hoping to do it similar to another change they did recently.