2) Session customisation
The backend quick setup for the EmailPassword recipe, instructs you to add Session.init()
on the backend's reicpeList
array as well. So when the user signs up or logs in with the emailpassword recipe, a session is created at the end of it.
However, we do not want the user to be able to use the app unless they have also completed the second login challenge of verifying their phone number via an OTP. From the backend's point of view, this means that they should not be able to query any of the application's APIs route until they have finished both of the login challenges.
In order to achieve this, we will store information in the session's access token payload to indicate if they have finished both the login challenges or not. Then the API routes can only give access to the user if the session payload indicates that both the challenges have been completed. The information we will stored as a custom claim:
import { BooleanClaim } from "supertokens-node/recipe/session/claims";
export const PhoneVerifiedClaim = new BooleanClaim({ fetchValue: () => false, key: "phone-verified",});
On session creation, after the phone password login (just the first challenge) we will set this claim to false
. And then once we have verified the phone number, we will mark this as true
. The API routes will give access to the user only if this claim is true
.
To do this, we override the createNewSession
recipe function from the Session recipe:
import EmailPassword from "supertokens-node/recipe/emailpassword";import Session from "supertokens-node/recipe/session";import supertokens from "supertokens-node";import { PhoneVerifiedClaim } from "./phoneVerifiedClaim";
supertokens.init({ framework: "...", appInfo: { /*...*/ }, recipeList: [ EmailPassword.init({ /* ... */}), Session.init({ override: { functions: (originalImplementation) => { return { ...originalImplementation, createNewSession: async function (input) { // we also get the phone number of the user and save it in the // session so that the OTP can be sent to it directly let userInfo = await EmailPassword.getUserById(input.userId, input.userContext); return originalImplementation.createNewSession({ ...input, accessTokenPayload: { ...input.accessTokenPayload, ...PhoneVerifiedClaim.build(input.userId, input.userContext), phoneNumber: userInfo?.email, }, }); }, }; }, }, }) ]})
Note that we also save the phone number belonging to this user in the session. This will be used later on in the second login challenge
Next, we protect your APIs by overriding the getGlobalClaimValidators
function in the Session recipe and adding a validator for PhoneVerifiedClaim
. This will tell SuperTokens to check that this claim has the value of true
whenever verifySession
is called. If the value is not true
, then verifySession
will send a 403
status code to the frontend:
import EmailPassword from "supertokens-node/recipe/emailpassword";import Session from "supertokens-node/recipe/session";import supertokens from "supertokens-node";
supertokens.init({ framework: "...", appInfo: { /*...*/ }, recipeList: [ EmailPassword.init({ /* ... */}), Session.init({ override: { functions: (originalImplementation) => { return { ...originalImplementation, getGlobalClaimValidators: (input) => [ ...input.claimValidatorsAddedByOtherRecipes, PhoneVerifiedClaim.validators.hasValue(true), ], // overrides from other steps... }; }, }, }) ]})
You can disable this check for a specific routes by providing the overrideGlobalClaimValidators
option when calling the verifySession
function:
import { verifySession } from "supertokens-node/recipe/session/framework/express";import express from "express";import { SessionRequest } from "supertokens-node/framework/express";const app = express();
/* ... */
app.get( "/someAPI", verifySession({ overrideGlobalClaimValidators: (globalValidators) => globalValidators.filter(v => v.id !== PhoneVerifiedClaim.key) }), async (req: SessionRequest, res) => { let session = req.session!; /* TODO: Your API logic... */ });