Authentication

Introduction

TheNextStartup has a preconfigured authentication system using lucia-auth https://lucia-auth.com/. So why lucia-auth and not some other tools like Next-auth, Clerk or etc?

Here are the reasons why:

  1. Session based authentication is much simpler than token based authentication and lucia-auth promotes it. I don't want to handle token revocation, rotation and blacklisting, session-based is much more simpler because the state of your authentication is checked within your database. Token based authentication is much more suitable for scaled up systems, in our template we don't have that.

  2. Cost. Of course, it's free (well money-free) and if you're starting out, especially if you're bootstrapped, coding the authentication yourself is the way to go. If you have the cash to spend on 3rd-party authentication services like Clerk or even Firebase then you can replace this, because I'm all-in on the idea of not building out a non core business function.

  3. Documentation is clear and concise, if you want to implement magic links and 2FA the documentation is clear as the sky.

Admin & Product App Authentication Walkthrough

As I have stated earlier, I've built a simple todo "SaaS" app to showcase the benefits of using this template and now I'll guide you on how the authentication codebase works. Both the admin and product app shares the same authentication logic, so I'll explain it in one go.

Login using email and password

Drawing

  1. Bob inputs his email and password on the Login form. The email and password is validated using zod and react-hook-forms. The UI form is from shadcn/ui. The login mutation function is made using react-query.

/auth/login/page.tsx

 const { mutate: login, isPending } = useLogin();

  const onSubmit = (values: LoginForm) => {
    login(values, {
      onSuccess: () => {
        router.push('/app');
      },
      onError: (result) => {
        toast({
          variant: 'destructive',
          title: 'Nope. We cannot let you in!',
          description: result.response?.data?.message,
        });
      },
    });
  };
  
  1. The server's API logic for login just checks if the customer's account status is active and validates the password if it matches to the found customer row. If password is valid then lucia-auth creates a session row for that specific user and sets the session id into a cookie.

/web-app/.../api/auth/login/route.ts
const userExists = await db.select().from(users)
.innerJoin(customerProfiles, eq(users.id, customerProfiles.userId))
.where(and(eq(users.email, email), eq(customerProfiles.accountStatus, 'active')));

const session = await lucia.createSession(user.users.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);

For the admin app, it's just a simple checking if admin row exists

/admin-web/.../api/auth/login/route.ts
const adminExists = await db.select().from(users)
.innerJoin(adminProfiles, eq(users.id, adminProfiles.userId))
.where(and(eq(users.email, email)));

// Create session
    

The template's configuration for the session expiration is set to false, so it doesn't expire. You can set the expiration in this file. For more information check out https://lucia-auth.com/basics/sessions

/libs/ts/auth/index.ts
export const lucia = new Lucia(adapter, {
 // sessionExpiresIn: new TimeSpan(2, "w") // Add this 
  sessionCookie: {
    expires: false, // Set this to true
    attributes: {
      secure: process.env.ENV === 'PRODUCTION',
    },
  },
  getUserAttributes: (attributes) => {
    return {
      id: attributes.id,
      email: attributes.email,
    };
  },
});
  1. Server just notifies the client if login is successful or not.

Login using Google

Adding OAuth aka "social login" to apps has became the defacto standard in the app industry, one of the reasons why is that it removes the friction between the customer and your application. No more remembering of email and password combinations. I wont explain OAuth here in full details, but you can check it out here if you're interestedhttps://www.youtube.com/watch?app=desktop&v=ZV5yTm4pT8g

One of the prerequisites for using Google login is the creation of GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. Watch this video to know how to generate that variables https://www.youtube.com/watch?v=tgO_ADSvY1I. Also don't forget to set the HOST_NAME of your application.

Drawing

Kindly excuse the informal sequence diagram and let me explain how the flow works.

  1. Bob clicks the "Sign in with Google" button and by clicking that button a GET request shall happen at the server side using this API route api/auth/login/google

  2. Our API shall communicate with Google's OAuth server to generate an authorization URL

/web-app/.../api/auth/login/google/route.ts
 const url = await googleAuth.createAuthorizationURL(state, codeVerifier, {
    scopes: ['email', 'profile'],
  });
  1. After the authorization URL has been generated, the server shall redirect Bob to the generated authorization URL.

  2. A Google login page shall be displayed on Bob's screen and he can pick any Google account that he owns to be used for the application.

  3. After choosing a Google account, the GET API endpoint api/auth/login/google/callback shall be called to facilitate the account creation, login if account already exists, or converting the email and password account into a google type of customer account if Bob already used an email and password login before using his chosen Google email address.

If Google customer account already exists, we will just redirect the user to the /app page.

/web-app/.../api/auth/login/google/callback
const googleUser = await db.query.customerProfiles.findFirst({
  where: eq(customerProfiles.googleId, googleId)
});

if (googleUser) {
logger.info('Google user found', {correlationId, app: 'web-app', data: {}});

const session = await lucia.createSession(googleUser.userId as string, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return Response.redirect(redirection);
}
  1. If the Google email address is already inside our Turso database we will convert it to a Google type of account or create a new customer account if it does not exists alongside with his Stripe or Lemonsqueezy customer account.

const _user = await db.select().from(users)
.innerJoin(customerProfiles, eq(users.id, customerProfiles.userId))
.where(eq(users.email, email));

let userId = '';

if (_user.length !== 0) {
// Update it to google account type
  const [existingUser] = _user;
  const [{ updatedUserId }] = await db
  .update(customerProfiles)
  .set({ accountType: 'google', googleId })
  .where(eq(customerProfiles.userId, existingUser.customer_profiles.userId as string))
  .returning({ updatedUserId: users.id });
} else {
// Create Bob's new account for our app and payment provider of choice.
await db.transaction(async (tx) => {
  const customerId = await createPaymentCustomerAccount({email,provider: 'stripe'});

  const [newUser] = await tx.insert(users).values({ email })
  .returning({ insertedId: users.id });
  
  await tx.insert(customerProfiles).values({
    accountType: 'google',
    userId: newUser.insertedId,
    googleId,
    // stripeId: customerId,
    lemonSqueezyId: customerId,
    accountStatus: 'active',
  });

  return newUser.insertedId;
});
}
  1. We will just redirect Bob to the /app page if the Login using Google account was successful.

Sign up

For the signup flow, I broken it down into two parts. First part is the actual creation of database records and the second one is the verification of the email using a verification code. Now let's focus on the first part.

I wont make a diagram here since it's just a repeating pattern moving forward and it will be obvious later on when you make multiple forms. The pattern is

  • Create form using react-hook-forms, zod, and shadcn form to have a client side validated form.

  • Create an API endpoint to process the form

  • Make a mutation using react-query and voila

The sign up flow is just

  1. Validate email if it exists inside the customerProfiles table, if yes and it's an active account then throw an error. If yes and it's an not active account, we need to resend the verification code to the user's email address.

  2. If it does not exists at all then, we're just gonna create a payment provider account for the customer, customer row, and a verification code.

  3. After all that steps we create a session cookie and return a successful response to the customer.

Take note that even if I generated a session cookie for the customer, he will not be able to access the app entirely because his account is tagged as inactive . This is just exclusively beneficial for the second part of the sign up, which is the verification.

Verification

Honestly, I was contemplating on whether or not to include this and just let you guys implement it on your own. But for the sake of demonstration purposes, I just did it lol. This verification flow is an 1-1 implementation based on lucia-auth's documentation https://lucia-auth.com/guides/email-and-password/email-verification-codes . So just check the documentation, or if you have additional questions regarding on the implementation, don't hesitate to ping me up.

The 8-digit verification code is just valid for 5 minutes

/web-app/.../api/auth/signup/route.ts
async function generateEmailVerificationCode(
  customerProfileId: string
): Promise<string> {
  await db
    .delete(emailVerificationCodes)
    .where(eq(emailVerificationCodes.customerProfileId, customerProfileId));

  const code = generateRandomString(8, alphabet('0-9'));
  await db.insert(emailVerificationCodes).values({
    expiresAt: createDate(new TimeSpan(5, 'm')), // Feel free to modify this
    customerProfileId,
    code,
  });
  return code;
}

The code will also be send via Resend and the actual email template was made via react-email library. You can check the Resend documentation here https://resend.com/docs/send-with-nextjs to setup your account, they also provide free tier so it's a good option to jumpstart this feature. Don't forget to paste your RESEND_API_KEY on the .env file

/web-app/.../api/auth/signup/route.ts
await mailer.emails.send({
      from: '[email protected]',
      to: email,
      subject: 'Email verification',
      react: EmailVerification({ email, code }),
});

The client side shall redirect the user to the app page once the verification process was a success.

/web-app/.../auth/verify/page.tsx
 const router = useRouter();
  const { toast } = useToast();

  const { mutate: verifyCode, isPending: isVerifyPending } = useVerifyCode();

  const onVerifySubmit = (values: VerifyCode) => {
    verifyCode(values, {
      onSuccess: (_) => {
        router.push('/app');
      },
      onError: (result) => {
        toast({
          variant: 'destructive',
          title: 'Invalid code',
          description: result.response?.data?.message,
        });
      },
    });
  };

Route Handlers

If you have already checked the source code, you might notice the usage of pipe function. This function protects the API endpoints that needs authentication by piping the authentication function and the actual route handler together.

export const GET = pipe(requiresAdminAuth, getHandler);

This function is like a middleware function from ExpressJS

function logOriginalUrl (req, res, next) {
  console.log('Request URL:', req.originalUrl)
  next()
}

function logMethod (req, res, next) {
  console.log('Request Type:', req.method)
  next()
}

const logStuff = [logOriginalUrl, logMethod]
app.get('/user/:id', logStuff, (req, res, next) => {
  res.send('User Info')
})

and it's a concept from functional programming https://www.freecodecamp.org/news/pipe-and-compose-in-javascript-5b04004ac937/ . Read it if you want.

I thought of writing my own middleware function since I want to sort of emulate ExpressJS routing system inside this template, but thanks to https://github.com/KolbySisk I saved time and effort 😛. Follow him on his socials!

Page Protection

We want to block visitors on our app from accessing certain pages, especially if they are not logged in as a customer.

/web-app/src/app/[locale]/app/(root)/layout.tsx
export default async function RootLayout({ children }: Props) {
  const currentCustomer = await loadCurrentCustomer();

  if (!currentCustomer) redirect('/auth/login');

  return (
    <div className="flex min-h-screen w-full flex-col bg-muted/40">
      <SessionProvider value={currentCustomer}>
        <AppRoot>{children}</AppRoot>
      </SessionProvider>
    </div>
  );
}

This just loads the current customer by reading his cookies, validating his session inside the database, and checking if his account status is active. The result of that function is the customer row along with his email, which is then cached using the built-in cache function of React for server-side components. This will help us to offload frequent database hits for session validation. Visit this video tutorial if you want to know more about it https://www.youtube.com/watch?v=MxjCLqdk4G4

The customer object will be stored in a session context so that every pages under that shall have access to the customer object

/web-app/src/app/[locale]/app/settings/plans/page.tsx
const Plans = () => {
  const router = useRouter();
  // Can access customer object here.
  const session = useSession();

  if (!session) {
    router.push('/');
  }

  const subscriptionTier = session && session.customerProfiles.subscriptionTier;
  // Other code here.....
}

Tools like next-auth and Clerk implements this for you, but with lucia-auth you have to build it from scratch.

Last updated

Was this helpful?