https://supertokens.com/ logo
Getting MFA to work with NestJS backend and NestJS Frontend
e

edgahan

12/26/2022, 1:28 PM
Getting MFA to work with NestJS backend and NestJS Frontend
Just spent most of the Christmas day getting MFA working with NextJS 13 with a NestJS backend. Thanks for the great library! Few things that I'm just writing in case some future person like me is using Discord's search feature to unblock themselves 🙂 Not really a support question, so feel free to delete
Background Context: Using
Caddy Server
to setup Next and Nest with proper SSL/Domains. You kinda make your life easier with this because of the cookies/ssl stuff. Could be useful to know, just make a
Caddyfile
like this and run
caddy start
api.example.localhost {
    reverse_proxy localhost:3001
}

web.example.localhost {
    reverse_proxy localhost:3000
}
NextJS only as a 'frontend' (with SSR). So I'm not using the serverless functions or API functionalities. NestJS is the API backend. As per the Caddyfile above, both on the same main domain as
api
and
web
.
The 2FA flow is: Email form which sends a magic link, when user clicks on the magic link then they get to a page where they have to enter a phone number, then enter the OTP sent via SMS. The second time going through the flow the user's phone number will be saved so after the magic link being clicked they will just be presented the 'enter SMS OTP' form. On NextJS there are two main pages: -
/app/signin/page.tsx
- This page renders the correct LoginForm (Enter email, Phone Number, SMS OTP, etc). Logic below. -
/app/signin/verify/page.tsx
- This page verifies the magic link clicked via email. It can also send out SMS codes automatically. Info below. The flow is: - User enters an email on
/signin
route. At this point
/app/signin/page.tsx
is rendering the 'Enter email' form. - Magic link is sent via email - Magic link is clicked and user is lands on
/signin/verify
page w/ query params. - On this page, inside a
useEffect
we: -
await consumePasswordlessCode()
- If consume password code is successful we:
await Passwordless.clearLoginAttemptInfo()
- If there is a phone number in
await Session.getAccessTokenPayloadSecurely()
then send out a code via
await createCode
. - Either way, redirect back to
/signin
Some other useful tips: - On the NestJS API backend most things work as per the MFA docs. - But I originally added
Passwordless.init
twice to the receipe list but that gave an error that I couldn't use it twice. Sure there's an easy way around this, but instead I just did:
ThirdPartyPasswordless.init({
    flowType: "MAGIC_LINK",
    contactMethod: "EMAIL"
}),
Passwordless.init({
    flowType: "USER_INPUT_CODE",
    contactMethod: "PHONE",
    override: {...}, // from the MFA docs
    ...
})
- Make sure you have
UserMetadata.init()
in the recipe list. The docs say it twice. - With the Next 13
/app
directory it's SSR. That means to get
SuperTokens.init
working I had to put it in a component with
use client
at the top. I just made the component return null here, I guess. App Layout:
<body>
    <SuperTokensInit />
    {children}
</body>
And then the SuperTokensInit Component itself looks like:
"use client";

import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import ThirdPartyPasswordless from 'supertokens-web-js/recipe/thirdpartypasswordless'

SuperTokens.init({
  appInfo: {
    appName: "AppName",
    apiDomain: "https://api.example.localhost",
    apiBasePath: "/auth",
  },
  recipeList: [
    ThirdPartyPasswordless.init(),
    Session.init()
  ]
});

export default function SuperTokensInit() {
  return (
    null
  );
}
- I had to switch between
https://supertokens.com/docs/mfa/frontend-custom
(using a custom UI) and
https://supertokens.com/docs/mfa/pre-built-ui/showing-login-ui
(Using the prebuilt UI) because the prebuilt UI pages have more info, e.g. how to collect OTP. - On the user management page you will see two users - one with the phone number and one with the email. They will be linked. (It would be slightly nicer to only see one user here) - Logic for which LoginForm to display (obviously can have better variable names) - This will basically render one of: - Email Form - Phone Number Form - SMS OTP Form
- Note this could break some edge flows - my UI is quite simple at the moment.
useEffect(() => {
    const getLoginComponent = async () => {
      if (!(await Session.doesSessionExist())) {
        // Show Email Form
        // If session doesn't exist, show first factor form
        setForm('firstFactorEmailLoginForm')
      } else if ((await getLoginAttemptInfo() !== undefined)) {
        // Show SMS OPT Form
        // The session DOES exist. The session will only exist is /verify flow is finished.
        // And we have Login Attempt Info from 2nd factor (phone screen)
        setForm('secondFactorSmsOtpForm')
      } else if (!(await Session.getClaimValue({ claim: SecondFactorClaim }))) {
        // Show SMS OPT Form or Show Phone Number Form
        // Session exists and we have login attempt info
        // We do not have claim value from the second factor.
        // If we have a phone number from prev. flow, show OTP screen.
        // For this to work we had to create a code on the `/signin/verify` page if `consumePasswordlessCode` worked.
        /*
            await createCode({
              phoneNumber: accessTokenPayload.phoneNumber
            });
        */
        const accessTokenPayload = await Session.getAccessTokenPayloadSecurely();
        if (accessTokenPayload.phoneNumber === undefined) {
          setForm('secondFactorPhoneNumberForm')
        } else {
          setForm('secondFactorSmsOtpForm')
        }
      } else {
        // 2FA is finished
        // We finished first and second factor, everything should be good to go.
        // You probably would have had to overwrite `consumeCodePOST` in the `supertokens.service.ts` file so that you create a record in your own DB.
        // After that you can query your own API `/users/me` or something and redirect based on onboarding state.
        redirectBasedOnUser()
      }
    }
    getLoginComponent()
  }, [])
r

rp

12/26/2022, 1:42 PM
thanks @edgahan
@edgahan thanks a lot for this info once again. I read it again today. This helps us plan our actual 2fa implementation coming shortly :))