async-labs / saas

Build your own SaaS business with SaaS boilerplate. Productive stack: React, Material-UI, Next, MobX, WebSockets, Express, Node, Mongoose, MongoDB. Written with TypeScript.
https://saas-app.async-await.com
MIT License
4.05k stars 674 forks source link

Split server passwordless login issues #124

Closed stavroslee closed 3 years ago

stavroslee commented 3 years ago

I was attempting to deploy the servers on 2 different services (vercel for nextjs and heroku for the express server) when I ran into a problem. When clicking the login email I was correctly getting my session cookie set but when the browser is redirected to the nextjs server it does not have the cookie and therefore can never log in.

I tried many variations of setting the COOKIE_DOMAIN to be the full URL of the nextjs app but chrome kept the cookie tied to my heroku domain (the express server).

Has anyone else had this issue or had it work correctly?

At this point I'm not sure how it could work if the servers are not on the same domain (unless the cookies are not httpOnly and its explicitly passed to nextjs)

Thanks in advance.


Click to see Hill for issue #124
  <div><img src="https://async-github-hills.s3.amazonaws.com/async-labs-saas/124.png" alt="Single issue hill" class="s3-image" width="100%" /></div></details>
  <details><summary>Click to see <b>Hill for all issues </b></summary><div>
  <img src="https://async-github-hills.s3.amazonaws.com/async-labs-saas/all-issue-hill.png" alt="All issue hill" class="s3-image" width="100%" /></div></details>

created by Async

tima101 commented 3 years ago

@stavroslee Does passwordless login work locally? What is value for COOKIE_DOMAIN? It should be of format .yourdomain.com and I think all subdomains will be included. I haven't tried deploying APP and API to different domains.

stavroslee commented 3 years ago

Sorry for not starting with that info.

Yes it works fine when both are on localhost. for COOKIE_DOMAIN I have tried a full url (https://xxxx.vercel.app) partial (vercel.app and .vercel.app)

So when the user clicks the email to login it is a link to the express server (heroku in my case). This sets a cookie on the browser and that cookie is not available when it calls the nextjs server (since they are on different domains).

additionally I found out that putting both on heroku and setting the cookie domain to .herokuapp.com wont share the cookie because of https://devcenter.heroku.com/articles/cookies-and-herokuapp-com

tima101 commented 3 years ago

@stavroslee Thanks for more info. Still I am not sure what code throws error. Can you provide error's message on APP and on API. I think it should work even on different domains but haven't tried it yet.

passwordless, when successful, will populate req.user with proper object on API server. When you load APP in your browser, you will be logged-in because APP will send request to API and check for req.user.

Do you have a CORS error by any chance? What are your value for PRODUCTION_URL_APP and PRODUCTION_URL_API? Does redirect to APP works after you click on link that leads to API?:

      res.redirect(
        `${dev ? process.env.URL_APP : process.env.PRODUCTION_URL_APP}${redirectUrlAfterLogin}`,
      );

You can add console.log statements into passwordless setup code:

import * as passwordless from 'passwordless';

import sendEmail from './aws-ses';
import getEmailTemplate from './models/EmailTemplate';
import User from './models/User';
import PasswordlessMongoStore from './passwordless-token-mongostore';
import Invitation from './models/Invitation';

function setupPasswordless({ server }) {
  const mongoStore = new PasswordlessMongoStore();

  const dev = process.env.NODE_ENV !== 'production';

  passwordless.addDelivery(async (tokenToSend, uidToSend, recipient, callback) => {
    try {
      const template = await getEmailTemplate('login', {
        loginURL: `${
          dev ? process.env.URL_API : process.env.PRODUCTION_URL_API
        }/auth/logged_in?token=${tokenToSend}&uid=${encodeURIComponent(uidToSend)}`,
      });

      await sendEmail({
        from: `Kelly from saas-app.builderbook.org <${process.env.EMAIL_SUPPORT_FROM_ADDRESS}>`,
        to: [recipient],
        subject: template.subject,
        body: template.message,
      });

      callback();
    } catch (err) {
      console.error('Email sending error:', err);
      callback(err);
    }
  });

  passwordless.init(mongoStore);
  server.use(passwordless.sessionSupport());

  server.use((req, __, next) => {
    if (req.user && typeof req.user === 'string') {
      User.findById(req.user, User.publicFields(), (err, user) => {
        req.user = user;
        console.log('passwordless middleware');
        next(err);
      });
    } else {
      next();
    }
  });

  server.post(
    '/auth/email-login-link',
    passwordless.requestToken(async (email, __, callback) => {
      try {
        const user = await User.findOne({ email })
          .select('_id')
          .setOptions({ lean: true });

        if (user) {
          callback(null, user._id);
        } else {
          const id = await mongoStore.storeOrUpdateByEmail(email);
          callback(null, id);
        }
      } catch (error) {
        callback(error, null);
      }
    }),
    (req, res) => {
      if (req.query && req.query.invitationToken) {
        req.session.invitationToken = req.query.invitationToken;
      } else {
        req.session.invitationToken = null;
      }

      res.json({ done: 1 });
    },
  );

  server.get(
    '/auth/logged_in',
    passwordless.acceptToken(),
    (req, __, next) => {
      if (req.user && typeof req.user === 'string') {
        User.findById(req.user, User.publicFields(), (err, user) => {
          req.user = user;
          next(err);
        });
      } else {
        next();
      }
    },
    (req, res) => {
      if (req.user && req.session.invitationToken) {
        Invitation.addUserToTeam({
          token: req.session.invitationToken,
          user: req.user,
        }).catch((err) => console.error(err));

        req.session.invitationToken = null;
      }

      let redirectUrlAfterLogin;

      if (req.user && !req.user.defaultTeamSlug) {
        redirectUrlAfterLogin = '/create-team';
      } else {
        redirectUrlAfterLogin = `/team/${req.user.defaultTeamSlug}/discussions`;
      }

      res.redirect(
        `${dev ? process.env.URL_APP : process.env.PRODUCTION_URL_APP}${redirectUrlAfterLogin}`,
      );
    },
  );

  server.get('/logout', passwordless.logout(), (req, res) => {
    req.logout();
    res.redirect(`${dev ? process.env.URL_APP : process.env.PRODUCTION_URL_APP}/login`);
  });
}

export { setupPasswordless };
stavroslee commented 3 years ago

@tima101 thanks for the quick follow up. I was experimenting with this to confirm my issue.

When I click the link from the email, passwordless works. It finds the email token and sets the cookie for the session. This cookie however is tied to the domain of API. The client is then redirected to the proper PRODUCTION_URL_APP and thats where I am then redirected to /login (since nextjs is not authenticated).

when APP calls API (when the nextjs server makes a call to the express server) there is no user on the request (req.user) I tracked this down to the fact that the browser does not send the session cookie to APP. I confirmed the cookie headers are copied from the browser to nextjs when a request is recieved. I believe thats because the browser does not share the cookie from API with APP since they're on different domains.

I confirmed the proper login cookie exists on the browser for API (in my case with chrome seen by right clicking > Inspect > clicking the application tab > expanding cookies under the Storage header). As I read up on this browsers wont expose cookies to different domains.

To answer the rest of your questions: I am using CORS and as far as I can tell its working correctly (the socket IO connections are working)

Again thank you for being responsive.

tima101 commented 3 years ago

@stavroslee I can look at your codebase if you like. Did you make any changes other than env variables?

Does Google OAuth work for you?

I can try deploying APP to aaa.builderbook.org and API to bbb.async-await.com but this is not my priority.

stavroslee commented 3 years ago

I have made a few other changes - like instead of having different env vars like URL_APP and PRODUCTION_URL_APP I just have URL_APP and its up to the configuration of the server.

edit to answer your question: I have not tried google oauth and had removed it for now.

I don't think you should prioritize the different domains if its not a priority for you, I was hoping others had already and I just had configuration issues. Can you drop a note when you do try it out?

tima101 commented 3 years ago

@stavroslee I am curious to know if you tried removing cookie.domain property from:

  cookie: {
    httpOnly: true,
    maxAge: 14 * 24 * 60 * 60 * 1000, // expires in 14 days
    domain: process.env.COOKIE_DOMAIN,
  } as any,
stavroslee commented 3 years ago

Hi @tima101 . I did. To keep moving forward, I've had to use passwordless JWT so I could pass the token around more easily.

Let me know if I should close this issue.

tima101 commented 3 years ago

@stavroslee It can stay open, someone may reproduce and suggest solution.

When you create your new DNS records for APP and API apps, do you have the same root domain for them or different? For example, app.domain1.com/api.domain1.com or app.domain1.com/api.domain2.com?

stavroslee commented 3 years ago

I had different root domains .herokuapp.com and .vercel.app

tima101 commented 3 years ago

@stavroslee So it will never have more user friendly domain name? If it will, will APP and API have the same domain?

stavroslee commented 3 years ago

I understand you question now.
It will have a nicer domain name (for APP). I wasn't planning on a nicer domain name for API. If you tell me that would solve my problem, I would look into it.

tima101 commented 3 years ago

@stavroslee Yes, Passwordless API should work for app.domain1.com and api.domain1.com combo.