Passwordless login via allow list
We need to start by maintaining an allow list of emails. You can either store this list in your own database, or then use the metadata feature provided by SuperTokens to store this. This may seem like a strange use case of the user metadata recipe we provide, but it works.
You want to implement the following functions on your backend:
- NodeJS
- GoLang
- Python
- Other Frameworks
Important
import UserMetadata from "supertokens-node/recipe/usermetadata"
async function addEmailToAllowlist(email: string) {
let existingData = await UserMetadata.getUserMetadata("emailAllowList");
let allowList: string[] = existingData.metadata.allowList || [];
allowList = [...allowList, email];
await UserMetadata.updateUserMetadata("emailAllowList", {
allowList
});
}
async function isEmailAllowed(email: string) {
let existingData = await UserMetadata.getUserMetadata("emailAllowList");
let allowList: string[] = existingData.metadata.allowList || [];
return allowList.includes(email);
}
async function addPhoneNumberToAllowlist(phoneNumber: string) {
let existingData = await UserMetadata.getUserMetadata("phoneNumberAllowList");
let allowList: string[] = existingData.metadata.allowList || [];
allowList = [...allowList, phoneNumber];
await UserMetadata.updateUserMetadata("phoneNumberAllowList", {
allowList
});
}
async function isPhoneNumberAllowed(phoneNumber: string) {
let existingData = await UserMetadata.getUserMetadata("phoneNumberAllowList");
let allowList: string[] = existingData.metadata.allowList || [];
return allowList.includes(phoneNumber);
}
import "github.com/supertokens/supertokens-golang/recipe/usermetadata"
func addEmailToAllowlist(email string) error {
existingData, err := usermetadata.GetUserMetadata("emailAllowList")
if err != nil {
return err
}
allowList := []string{}
allowListFromMetadata, ok := existingData["allowList"].([]string)
if ok {
allowList = allowListFromMetadata
}
allowList = append(allowList, email)
_, err = usermetadata.UpdateUserMetadata("emailAllowList", map[string]interface{}{
"allowList": allowList,
})
return err
}
func isEmailAllowed(email string) (bool, error) {
existingData, err := usermetadata.GetUserMetadata("emailAllowList")
if err != nil {
return false, err
}
allowList := []string{}
allowListFromMetadata, ok := existingData["allowList"].([]string)
if ok {
allowList = allowListFromMetadata
}
for _, allowedEmail := range allowList {
if allowedEmail == email {
return true, nil
}
}
return false, nil
}
func addPhoneNumberToAllowlist(phoneNumber string) error {
existingData, err := usermetadata.GetUserMetadata("phoneNumberAllowList")
if err != nil {
return err
}
allowList := []string{}
allowListFromMetadata, ok := existingData["allowList"].([]string)
if ok {
allowList = allowListFromMetadata
}
allowList = append(allowList, phoneNumber)
_, err = usermetadata.UpdateUserMetadata("phoneNumberAllowList", map[string]interface{}{
"allowList": allowList,
})
return err
}
func isPhoneNumberAllowed(phoneNumber string) (bool, error) {
existingData, err := usermetadata.GetUserMetadata("phoneNumberAllowList")
if err != nil {
return false, err
}
allowList := []string{}
allowListFromMetadata, ok := existingData["allowList"].([]string)
if ok {
allowList = allowListFromMetadata
}
for _, allowedPhoneNumber := range allowList {
if allowedPhoneNumber == phoneNumber {
return true, nil
}
}
return false, nil
}
from supertokens_python.recipe.usermetadata.asyncio import get_user_metadata, update_user_metadata
from typing import List
async def add_email_to_allow_list(email: str):
metadataResult = await get_user_metadata("emailAllowList")
allow_list: List[str] = metadataResult.metadata["allowList"] if "allowList" in metadataResult.metadata else []
allow_list.append(email)
await update_user_metadata("emailAllowList", {
"allowList": allow_list
})
async def is_email_allowed(email: str):
metadataResult = await get_user_metadata("emailAllowList")
allow_list: List[str] = metadataResult.metadata["allowList"] if "allowList" in metadataResult.metadata else []
return email in allow_list
async def add_phone_number_to_allow_list(phone_number: str):
metadataResult = await get_user_metadata("phoneNumberAllowList")
allow_list: List[str] = metadataResult.metadata["allowList"] if "allowList" in metadataResult.metadata else []
allow_list.append(phone_number)
await update_user_metadata("phoneNumberAllowList", {
"allowList": allow_list
})
async def is_phone_number_allowed(phone_number: str):
metadataResult = await get_user_metadata("phoneNumberAllowList")
allow_list: List[str] = metadataResult.metadata["allowList"] if "allowList" in metadataResult.metadata else []
return phone_number in allow_list
important
Remember to initialise the user metadata recipe on the backend recipeList
during supertokens.init
.
Multi Tenancy
For a multi tenant setup, you can even store an allow list per tenant. This would allow you to limit sign ups for different emails / phone numbers for different tenants. If you are doing this, then you would also need to pass in the tenantID to the functions above, which you can obtain from the input to the api overrides shown below.
After that, we override the createCodePOST
API to check if the input email / phone number is allowed durign sign up. If not allowed, we send back a user friendly message to the frontend.
- NodeJS
- GoLang
- Python
- Other Frameworks
Important
import Passwordless from "supertokens-node/recipe/passwordless";
import supertokens from "supertokens-node";
Passwordless.init({
override: {
apis: (originalImplementation) => {
return {
...originalImplementation,
createCodePOST: async function (input) {
if ("email" in input) {
let existingUsers = await supertokens.listUsersByAccountInfo(input.tenantId, {
email: input.email,
});
let userWithPasswordles = existingUsers.find(u => u.loginMethods.find(lM => lM.hasSameEmailAs(input.email) && lM.recipeId === "passwordless") !== undefined);
if (userWithPasswordles === undefined) {
// this is sign up attempt
if (!(await isEmailAllowed(input.email))) {
return {
status: "GENERAL_ERROR",
message: "Sign up disabled. Please contact the admin."
}
}
}
} else {
let existingUsers = await supertokens.listUsersByAccountInfo(input.tenantId, {
phoneNumber: input.phoneNumber,
});
let userWithPasswordles = existingUsers.find(u => u.loginMethods.find(lM => lM.hasSamePhoneNumberAs(input.phoneNumber) && lM.recipeId === "passwordless") !== undefined);
if (userWithPasswordles === undefined) {
// this is sign up attempt
if (!(await isPhoneNumberAllowed(input.phoneNumber))) {
return {
status: "GENERAL_ERROR",
message: "Sign up disabled. Please contact the admin."
}
}
}
}
return await originalImplementation.createCodePOST!(input);
}
}
}
}
})
import (
"github.com/supertokens/supertokens-golang/recipe/passwordless"
"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
"github.com/supertokens/supertokens-golang/supertokens"
)
func isEmailAllowed(email string) (bool, error) {
// ... from previous code snippet
return false, nil
}
func isPhoneNumberAllowed(phoneNumber string) (bool, error) {
// ... from previous code snippet
return false, nil
}
func main() {
passwordless.Init(plessmodels.TypeInput{
Override: &plessmodels.OverrideStruct{
APIs: func(originalImplementation plessmodels.APIInterface) plessmodels.APIInterface {
originalCreateCodePOST := *originalImplementation.CreateCodePOST
(*originalImplementation.CreateCodePOST) = func(email, phoneNumber *string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.CreateCodePOSTResponse, error) {
if email != nil {
existingUser, err := passwordless.GetUserByEmail(tenantId, *email)
if err != nil {
return plessmodels.CreateCodePOSTResponse{}, err
}
if existingUser == nil {
// sign up attempt
emailAllowed, err := isEmailAllowed(*email)
if err != nil {
return plessmodels.CreateCodePOSTResponse{}, err
}
if !emailAllowed {
return plessmodels.CreateCodePOSTResponse{
GeneralError: &supertokens.GeneralErrorResponse{
Message: "Sign ups are disabled. Please contact the admin.",
},
}, nil
}
}
} else {
existingUser, err := passwordless.GetUserByPhoneNumber(tenantId, *phoneNumber)
if err != nil {
return plessmodels.CreateCodePOSTResponse{}, err
}
if existingUser == nil {
// sign up attempt
phoneNumberAllowed, err := isPhoneNumberAllowed(*phoneNumber)
if err != nil {
return plessmodels.CreateCodePOSTResponse{}, err
}
if !phoneNumberAllowed {
return plessmodels.CreateCodePOSTResponse{
GeneralError: &supertokens.GeneralErrorResponse{
Message: "Sign ups are disabled. Please contact the admin.",
},
}, nil
}
}
}
return originalCreateCodePOST(email, phoneNumber, tenantId, options, userContext)
}
return originalImplementation
},
},
})
}
from supertokens_python import init, InputAppInfo
from supertokens_python.types import GeneralErrorResponse
from supertokens_python.recipe import passwordless
from supertokens_python.asyncio import list_users_by_account_info
from supertokens_python.types import AccountInfo
from supertokens_python.recipe.passwordless.interfaces import (
APIInterface,
APIOptions,
)
from typing import Union, Dict, Any, Optional
from supertokens_python.recipe.session.interfaces import SessionContainer
async def is_email_allowed(email: str):
# from previous code snippet..
return False
async def is_phone_number_allowed(phone_number: str):
# from previous code snippet..
return False
def override_passwordless_apis(original_implementation: APIInterface):
original_create_code_post = original_implementation.create_code_post
async def create_code_post(
email: Union[str, None],
phone_number: Union[str, None],
session: Optional[SessionContainer],
should_try_linking_with_session_user: Union[bool, None],
tenant_id: str,
api_options: APIOptions,
user_context: Dict[str, Any],
):
if email is not None:
existing_user = await list_users_by_account_info(
tenant_id, AccountInfo(email=email)
)
user_with_passwordless = next(
(
user
for user in existing_user
if any(
login_method.recipe_id == "passwordless"
and login_method.has_same_email_as(email)
for login_method in user.login_methods
)
),
None,
)
if user_with_passwordless is None:
# sign up attempt
if not (await is_email_allowed(email)):
return GeneralErrorResponse(
"Sign ups disabled. Please contact admin."
)
else:
assert phone_number is not None
existing_user = await list_users_by_account_info(
tenant_id, AccountInfo(phone_number=phone_number)
)
user_with_passwordless = next(
(
user
for user in existing_user
if any(
login_method.recipe_id == "passwordless"
and login_method.has_same_phone_number_as(phone_number)
for login_method in user.login_methods
)
),
None,
)
if user_with_passwordless is None:
# sign up attempt
if not (await is_phone_number_allowed(phone_number)):
return GeneralErrorResponse(
"Sign ups disabled. Please contact admin."
)
return await original_create_code_post(
email,
phone_number,
session,
should_try_linking_with_session_user,
tenant_id,
api_options,
user_context,
)
original_implementation.create_code_post = create_code_post
return original_implementation
init(
app_info=InputAppInfo(api_domain="...", app_name="...", website_domain="..."),
framework="...",
recipe_list=[
passwordless.init(
flow_type="USER_INPUT_CODE",
override=passwordless.InputOverrideConfig(
apis=override_passwordless_apis,
),
)
],
)
createCodePOST
is called when the user enters their email or phone number to login. We override it to check:
- If there exists a user with the input email or phone number, it means they are signing in and so we allow the operation.
- Otherwise, we check if the input email / phone number is allowed by calling our
isEmailAllowed
/isPhoneNumberAllowed
function (which we implemented above). If not allowed, we return a message to the frontend.
We can add emails / phone numbers to the allow list by calling the addEmailToAllowlist
/ addPhoneNumberToAllowlist
function we implemented above.