Luny
Back

Setting up Authentication for Lubook

Registering and Logging in with user accounts, along with session tokens for authorization stored in HTTP cookies, made possible with CORS (CORS is a pain).

Published on
Updated on

8 min read

Setting up Authentication for Lubook

Check out previous chapter: Chapter 1. Setting up the server. This article is a continuation of my development process on Lubook.

Register

To even start seeing anything on the frontend, a user account and basic authentication is pretty much needed to use everything the app has to offer. But I just realized something difficult, at first, it was just a generic username-based account system, but then I would like to allow the changing of the username handles, since the primary key is the PSQL serial, not the username. I also designed a form to allow for email inputs also, with a simple email verification.

The problem is, I don’t know how to make email verification. I didn’t setup the database to do that either, yet the design files have it (wow, me to blame, I made that design file) and I feel like it might be a good idea to keep and learn this, instead of removing it. But that also comes up with a new problem, what about testing? That means another issue to mock since we can’t just create email servers to catch if the email gets sent correctly?

So what now?

After reading a bunch of articles and wikis, I think the best route would be to use nodemailer, and utilize SMTP or IMAP to send simple HTML based emails. Two problems:

The worst thing is, I can’t keep paying for more services as a solo developer on this project. I’m still a uni student with pretty limited funds living in SEA, so American-affordable prices aren’t really affordable for me. I’m already paying for a VPS instance for DB/Express, a domain and a S3 storage for images hosting. If this keeps going, it doesn’t seem viable for me at all.

I think I might have to go with PurelyMail that offers a SMTP email address with custom domains, for around $10 a year, compared to that, Google Workspace charges $6 per month, albeit with a larger storage.

Fun fact

Fun fact, for a few years already I still don’t get to use my phone number for verification on Google Accounts. The only authentication I have at hand is peer authentication, letting another device already logged in authenticate instead, which sucks really bad.

For some reason also that, Google refuses to let me try their Cloud Platform, even after deducting funds from my bank as a “verification” charge,… just to throw me an error instead, and $1 gone. This also isn’t the first time that this has happened, I did turn to Google Cloud Platform for a past project in Internet of Things with Arduino sets, but they refused service to me then due to error again.

Conclusion

Setting up the email sending is pretty simple and straight forward with Nodemailer. Here’s the result code, in case you need this later:

export const emailTransport = createTransport({
  host: "smtp.purelymail.com",
  port: 465,
  secure: true, // port 465 is SMTPS, put false if not 465
  auth: {
    user: "verify@lubook.club",
    pass: process.env.EMAIL_PASSWORD, // Of course don't expose this lol
  },
});

export async function sendVerificationEmail(
  id: number,
  name: string,
  email: string,
) {
  // SQL stuff...

  await emailTransport.sendMail({
    from: "Verify <verify@lubook.club>", // Google forces you to identify yourself as the same email as you authenticated as. If not, you can't send to gmail accounts.
    to: email,
    subject: "Lubook Verification",
    text: "text version if html can't display",
    html: "html version",
  });
}

Email Success

Log In

After registering the user should be able to verify themselves and sign in, right? We’re using HTTP cookies, with a token signed by JWT, pretty standard and “default” enough right. The problem is also, I have never done anything related to cookies. Knowing how annoying CORS is, this will be very frustrating to figure out. Since the API server sits on api.lubook.club and the web server sits on lubook.club, that’s a CORS (Cross Origin Resource Sharing) request.

Cross-Origin Resource Sharing

In case you didn’t know, CORS is a way for this website to request data from another website. Most cookies are set to be on same-site, it will only be set and sent back if and only if the domain matches exactly. A cookie from http://example.com will only be sent back to http://example.com, https://example.com is a different protocol, meaning these are of different origins. To make such a request, you need CORS, namely Cross-Origin resource sharing.

What is even worse? When you do development, the server you want to deal with is localhost:4321 for the frontend and localhost:8080 for the backend. Due to some weird issues with browsers handling cookies around localhost and URLs with ports, setting up the development environment is not straight forward. Like the one quote I saw on StackOverflow, it’s always CORS, the problem is always CORS.

Mystery Problem #841239

I have setup CORS on the express server, allowing http://localhost:4321 (which is Astro’s URL), and setup mode: 'cors' on the client’s fetch request. Doesn’t work. For some reason?

If I use curl to check, the header Access-Control-Allow-Origin: http://localhost:4321 is set correctly. Theoretically, this should allow the Astro page to connect and request for data, yet it just doesn’t work? It just crashes right away, and fetch doesn’t even go through.

Oh my gosh! It was because the allow origin allowed http://localhost:4321/ (with a slash) and the web server is on http://localhost:4321. How stingy do you have to be??

The solution

Setup CORS on the server-side:

app.use(
  cors({
    origin: process.env.CORS_ORIGIN,
    credentials: true,
    allowedHeaders: "Origin,X-Requested-By,Content-Type,Accepts",
  }),
);

app.options("*", cors());

Of course, CORS_ORIGIN is an environment variable that points to the URL of your WEB SERVER, down to every single letter. For my case, it would be https://lubook.club. I have to make sure www.lubook.club redirects back to the root domain, or CORS wouldn’t allow it; The allowed headers is to make sure that valid headers are accepted by CORS instead of it dropping it entirely.

The last line, setting up OPTIONS http verb because some browsers like late Chrome (I think?) and Safari sends an OPTIONS request before the actual fetch happens, and if that request fails, the fetch might not happen entirely.

For the client-side, I’m pretty sure CORS is the default mode, but I marked mode: "cors" in fetch requests anyway:

return await fetch(URL, {
  method: METHOD,
  mode: "cors", // Just for the peace of mind
  headers: {
    "Content-Type": "application/json",
  },
  redirect: "follow",
  body: JSON.stringify({ profile, password }),
  credentials: "include", // Make sure for cross-site cookies
});

CORS Setup

Loading the Astro page now shows an authenticated request, with proper accounts and usernames.

Frontend Testing

Fetch

Another part that I thought would be super simple turns out to have a problem again, Frontend Testing. I have done frontend testing on various applications before, the only difference is, this time, frontend has fetch requests to check for backend data, this is the problem.

The solution that I seem to gather from stackoverflow answers is to replace the fetch function right away:

const fetchMock = vi.fn(
  async (url, options) => new Response(new Blob(), { status: 200 }),
);
global.fetch = fetchMock;

The problem?

ReferenceError: global is not defined

Seems to be that it doesn’t work on Vitest since that runs on Vite, and Jest ran primarily on Webpack and that included the global variable.

The solution I found is to intercept fetch on its own, do a vi.stubGlobal("fetch", mockFn).

Browser Redirects

After registering, the app is supposed to redirect the user back to login page. But window.location.href changing would be detrimental to the browser testing mode. We need a way to intercept this call.

I have gathered a lot of information around the internet, to see what works and what doesn’t. I think the problem is that I use Vitest + Playwright + TypeScript, which a lot of solutions seem to use Jest + JSDOM + JavaScript, funnily as opposite as it can get.

What doesn’t work:

What worked: to not interfere with other tests, I put the one test that works on window.location.href on another test file. I also put the href assignment in another synchronous function in a utils file of some sort, we’re gonna mock the utils file instead, since Vitest Browser Mode is in beta and there’s not enough exposed library like Playwright.

// The utils/fetcher.ts file

export function redirect(url: string) {
  window.location.href = url;
}
// The test beforeAll() hook
vi.resetModules();
vi.mock("../../utils/fetcher.ts", () => {
  return {
    redirect: vi.fn((url) => {}),
  };
});
// The actual test

const submit = page.getByRole("button", { name: "Register" });
await submit.click();

// This part is to import the "mocked" version of the module.
// It is of type any, so be careful with TypeScript.
const module = await vi.importMock("../../utils/fetcher.ts");
expect(module.redirect).toHaveBeenCalledWith("/login");

Also needs to know that vi.unmock is also hoisted, that means even if you put it in afterAll, it will still be called right after vi.mock, and make your mocks not mocking.

What sucks? Look at this yellow stuff (I’m not happy when it’s not all green):

Bad Coverage

You clearly know what the uncovered line is in fetcher.ts.