From b8b3e3c0c793b7ffe5ebb609e5bbf1fc4f6b3684 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Mon, 30 Oct 2023 14:37:23 +0100 Subject: [PATCH 1/7] test: initial test for MFA --- examples/for-tests/src/App.js | 12 +- examples/for-tests/src/testContext.js | 11 +- test/end-to-end/mfa.mock.signin.test.js | 156 ++++++++++++++++++++++++ test/server/index.js | 98 +++++++++++++-- 4 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 test/end-to-end/mfa.mock.signin.test.js diff --git a/examples/for-tests/src/App.js b/examples/for-tests/src/App.js index cc4530c37..e9a46b4a7 100644 --- a/examples/for-tests/src/App.js +++ b/examples/for-tests/src/App.js @@ -174,7 +174,8 @@ const testContext = getTestContext(); let totpDevices = []; let tryCount = 0; -setInterval(() => (tryCount = tryCount > 0 ? tryCount - 1 : 0), 30); + +setInterval(() => (tryCount = tryCount > 0 ? tryCount - 1 : 0), 30000); window.resetTOTP = () => { totpDevices = []; tryCount = 0; @@ -207,6 +208,11 @@ let recipeList = [ verifyCode: async ({ totp }) => { const dev = totpDevices.find((d) => d.deviceName.endsWith(totp) && d.verified); if (dev) { + await fetch("http://localhost:8082/completeFactor", { + method: "POST", + body: JSON.stringify({ id: "totp" }), + headers: new Headers([["Content-Type", "application/json"]]), + }); return { status: "OK" }; } @@ -223,9 +229,9 @@ let recipeList = [ if (deviceName.endsWith(totp)) { const wasAlreadyVerified = dev.verified; dev.verified = true; - await fetch("http://localhost:8082/mergeIntoAccessTokenPayload", { + await fetch("http://localhost:8082/completeFactor", { method: "POST", - body: JSON.stringify({ hasTOTP: true }), + body: JSON.stringify({ id: "totp" }), headers: new Headers([["Content-Type", "application/json"]]), }); return { status: "OK", wasAlreadyVerified }; diff --git a/examples/for-tests/src/testContext.js b/examples/for-tests/src/testContext.js index ab1e6e3c5..5bd49bb31 100644 --- a/examples/for-tests/src/testContext.js +++ b/examples/for-tests/src/testContext.js @@ -9,6 +9,7 @@ export function getTestContext() { thirdPartyRedirectURL: localStorage.getItem("thirdPartyRedirectURL"), authRecipe: window.localStorage.getItem("authRecipe") || "emailpassword", usesDynamicLoginMethods: localStorage.getItem("usesDynamicLoginMethods") === "true", + enableAllRecipes: localStorage.getItem("enableAllRecipes") === "true", clientRecipeListForDynamicLogin: localStorage.getItem("clientRecipeListForDynamicLogin"), mockLoginMethodsForDynamicLogin: localStorage.getItem("mockLoginMethodsForDynamicLogin"), staticProviderList: localStorage.getItem("staticProviderList"), @@ -23,7 +24,15 @@ export function getEnabledRecipes() { let enabledRecipes = []; - if (true) { + if (testContext.enableAllRecipes) { + enabledRecipes = [ + "emailpassword", + "thirdparty", + "thirdpartyemailpassword", + "passwordless", + "thirdpartypasswordless", + ]; + } else if (testContext.usesDynamicLoginMethods) { if (testContext.clientRecipeListForDynamicLogin) { enabledRecipes = JSON.parse(testContext.clientRecipeListForDynamicLogin); } else { diff --git a/test/end-to-end/mfa.mock.signin.test.js b/test/end-to-end/mfa.mock.signin.test.js new file mode 100644 index 000000000..c860c6006 --- /dev/null +++ b/test/end-to-end/mfa.mock.signin.test.js @@ -0,0 +1,156 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * Imports + */ + +import assert from "assert"; +import puppeteer from "puppeteer"; +import { + clearBrowserCookiesWithoutAffectingConsole, + clickForgotPasswordLink, + getFieldErrors, + getGeneralError, + getInputNames, + getInputTypes, + getLogoutButton, + getLabelsText, + getPlaceholders, + getUserIdWithAxios, + getSessionHandleWithAxios, + getUserIdWithFetch, + getSessionHandleWithFetch, + getShowPasswordIcon, + getSubmitFormButtonLabel, + getInputAdornmentsSuccess, + hasMethodBeenCalled, + setInputValues, + submitForm, + submitFormReturnRequestAndResponse, + toggleShowPasswordIcon, + toggleSignInSignUp, + getInputAdornmentsError, + defaultSignUp, + getUserIdFromSessionContext, + getTextInDashboardNoAuth, + waitForSTElement, + screenshotOnFailure, + isGeneralErrorSupported, + setGeneralErrorToLocalStorage, + getInvalidClaimsJSON as getInvalidClaims, + waitForText, + backendBeforeEach, + getTestEmail, + getPasswordlessDevice, +} from "../helpers"; +import fetch from "isomorphic-fetch"; +import { SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; + +import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL, SIGN_OUT_API } from "../constants"; + +/* + * Tests. + */ +describe("SuperTokens SignIn", function () { + let browser; + let page; + let consoleLogs = []; + + before(async function () { + await backendBeforeEach(); + + await fetch(`${TEST_SERVER_BASE_URL}/startst`, { + method: "POST", + }).catch(console.error); + + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: false, + }); + }); + + after(async function () { + await browser.close(); + + await fetch(`${TEST_SERVER_BASE_URL}/after`, { + method: "POST", + }).catch(console.error); + + await fetch(`${TEST_SERVER_BASE_URL}/stopst`, { + method: "POST", + }).catch(console.error); + }); + + afterEach(function () { + return screenshotOnFailure(this, browser); + }); + + beforeEach(async function () { + page = await browser.newPage(); + page.on("console", (consoleObj) => { + const log = consoleObj.text(); + if (log.startsWith("ST_LOGS")) { + consoleLogs.push(log); + } + }); + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + }); + + describe("SignIn test ", function () { + it("Successful Sign In", async function () { + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, consoleLogs); + const email = await getTestEmail(); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMFAInfo`, { + method: "POST", + headers: new Headers([["content-type", "application/json"]]), + body: JSON.stringify({ + requirements: ["otp-email"], + }), + }); + assert.strictEqual(resp.status, 200); + console.log(await resp.text()); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await toggleSignInSignUp(page); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: "Asdf12.." }, + { name: "name", value: "asdf" }, + { name: "age", value: "20" }, + ]); + + await submitForm(page); + await new Promise((res) => setTimeout(res, 250)); + + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); + + await page.waitForSelector(".sessionInfo-user-id"); + }); + }); +}); diff --git a/test/server/index.js b/test/server/index.js index cd029d4a1..4de0ec229 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -216,6 +216,7 @@ let passwordlessConfig = {}; let accountLinkingConfig = {}; let enabledProviders = undefined; let enabledRecipes = undefined; +let mfaInfo = {}; initST(); @@ -382,16 +383,32 @@ app.post( } ); -let mfaInfo = {}; -app.post("/setMFAInfo", verifySession(), async (req, res) => { - let session = req.session; - - mfaInfo = req.body.mfaInfo; - await session.mergeIntoAccessTokenPayload(req.body.payload); +app.post("/setMFAInfo", async (req, res) => { + mfaInfo = { mfaInfo, ...req.body }; res.send({ status: "OK" }); }); +app.post("/completeFactor", verifySession(), async (req, res) => { + let session = req.session; + const mfaClaim = payload["st-mfa"]; + const c = { + ...mfaClaim.c, + [req.payload.id]: Date.now(), + }; + if (req.payload.id === "totp") { + mfaInfo.hasTOTP = true; + } + + await session.mergeIntoAccessTokenPayload({ + "st-mfa": { + ...mfaClaim, + c, + n: getNextArray(c), + }, + }); +}); + app.post("/mergeIntoAccessTokenPayload", verifySession(), async (req, res) => { let session = req.session; @@ -413,15 +430,47 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { if (user.emails.length > 0) { isAlreadySetup.push("otp-email"); } - if (payload.hasTOTP) { - isAlreadySetup.push("totp"); + + if (mfaInfo.hasTOTP) { + isAllowedToSetup.push("totp"); } + const mfaClaim = payload["st-mfa"]; - if (mfaClaim === undefined) { + console.log(mfaInfo, mfaClaim); + if (mfaInfo?.claimValue) { + await session.mergeIntoAccessTokenPayload({ + "st-mfa": mfaInfo.claimValue, + }); + } else if (mfaInfo?.requirements) { + let c; + if (mfaClaim) { + c = mfaClaim.c; + } else { + const recipeUser = user.loginMethods.find( + (u) => u.recipeUserId.toString() === session.getRecipeUserId().toString() + ); + if (recipeUser.recipeId !== "passwordless") { + c = { [recipeUser.recipeUserId]: Date.now() }; + } else if (recipeUser.email) { + // This isn't correct, but will do for testing + c = { "otp-email": Date.now() }; + } else { + c = { "otp-phone": Date.now() }; + } + } + + let n = getNextArray(c); + await session.mergeIntoAccessTokenPayload({ + "st-mfa": { + c: c, + n: n, + }, + }); + } else if (mfaClaim === undefined) { await session.mergeIntoAccessTokenPayload({ "st-mfa": { c: {}, // Technically the first factor should be in there... but it isn't necessary - n: ["otp-phone", "otp-email"], + n: [], }, }); } @@ -440,7 +489,7 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { isAllowedToSetup, isAlreadySetup, }, - ...mfaInfo, + ...mfaInfo.resp, }); }); @@ -600,8 +649,35 @@ server.listen(process.env.NODE_PORT === undefined ? 8080 : process.env.NODE_PORT } })(process.env.START === "true"); +function getNextArray(c) { + let n = []; + for (const step of mfaInfo.requirements) { + if (typeof step === "string") { + if (c[step] === undefined) { + n = [step]; + break; + } + } else if (step.oneOf !== undefined) { + if (step.oneOf.every((id) => c[id] === undefined)) { + n = step.oneOf; + break; + } + } else if (step.allOf !== undefined) { + const missing = step.allOf.filter((id) => c[id] === undefined); + if (missing.length > 0) { + n = missing; + break; + } + } else { + throw new Error("Bad requirement" + JSON.stringify(step)); + } + } + return n; +} + function initST() { if (process.env.TEST_MODE) { + mfaInfo = {}; if (userRolesSupported) { UserRolesRaw.reset(); } From c11f18504ed943103a269e241db22481153b4e38 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Wed, 1 Nov 2023 00:52:36 +0100 Subject: [PATCH 2/7] test: add firstFactors tests --- test/end-to-end/mfa.mock.firstFactors.test.js | 333 ++++++++++++++++++ test/server/index.js | 1 - 2 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 test/end-to-end/mfa.mock.firstFactors.test.js diff --git a/test/end-to-end/mfa.mock.firstFactors.test.js b/test/end-to-end/mfa.mock.firstFactors.test.js new file mode 100644 index 000000000..5f3ff3a69 --- /dev/null +++ b/test/end-to-end/mfa.mock.firstFactors.test.js @@ -0,0 +1,333 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * Imports + */ + +import assert from "assert"; +import puppeteer from "puppeteer"; +import { + clearBrowserCookiesWithoutAffectingConsole, + clickForgotPasswordLink, + getFieldErrors, + getGeneralError, + getInputNames, + getInputTypes, + getLogoutButton, + getLabelsText, + getPlaceholders, + getUserIdWithAxios, + getSessionHandleWithAxios, + getUserIdWithFetch, + getSessionHandleWithFetch, + getShowPasswordIcon, + getSubmitFormButtonLabel, + getInputAdornmentsSuccess, + hasMethodBeenCalled, + setInputValues, + submitForm, + submitFormReturnRequestAndResponse, + toggleShowPasswordIcon, + toggleSignInSignUp, + getInputAdornmentsError, + defaultSignUp, + getUserIdFromSessionContext, + getTextInDashboardNoAuth, + waitForSTElement, + screenshotOnFailure, + isGeneralErrorSupported, + setGeneralErrorToLocalStorage, + getInvalidClaimsJSON as getInvalidClaims, + waitForText, + backendBeforeEach, + getTestEmail, + getPasswordlessDevice, + waitFor, +} from "../helpers"; +import fetch from "isomorphic-fetch"; +import { SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; + +import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL, SIGN_OUT_API } from "../constants"; + +/* + * Tests. + */ +describe("SuperTokens SignIn", function () { + let browser; + let page; + let consoleLogs = []; + + before(async function () { + await backendBeforeEach(); + + await fetch(`${TEST_SERVER_BASE_URL}/startst`, { + method: "POST", + }).catch(console.error); + + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: true, + }); + }); + + after(async function () { + await browser.close(); + + await fetch(`${TEST_SERVER_BASE_URL}/after`, { + method: "POST", + }).catch(console.error); + + await fetch(`${TEST_SERVER_BASE_URL}/stopst`, { + method: "POST", + }).catch(console.error); + }); + + afterEach(async function () { + await screenshotOnFailure(this, browser); + if (page) { + page.evaluate(() => window.localStorage.removeItem("firstFactors")); + await page.close(); + } + }); + + beforeEach(async function () { + page = await browser.newPage(); + page.on("console", (consoleObj) => { + const log = consoleObj.text(); + if (log.startsWith("ST_LOGS")) { + consoleLogs.push(log); + } + }); + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "true"); + }); + }); + + describe("with firstFactors set on the client", () => { + it("should display pwless w/ phone for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-phone"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-email"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-email, otp-phone"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL_OR_PHONE"); + }); + + it("should display tp-pwless w/ email for [thirdparty, otp-email]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "thirdparty, otp-email"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + + it("should display tp-ep for [thirdparty, emailpassword]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "thirdparty, emailpassword"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkEmailPasswordLoginUI(page); + }); + + it("should throw for [unknown]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "unknown"); + }); + + let hitErrorBoundary = false; + page.on("console", (ev) => { + if (ev.text() === "ST_THROWN_ERROR") { + hitErrorBoundary = true; + } + }); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitFor(500); + assert(hitErrorBoundary); + }); + }); + + describe("with firstFactors set on the tenant", () => { + beforeEach(async () => { + await page.evaluate(() => { + window.localStorage.setItem("usesDynamicLoginMethods", "true"); + }); + }); + it("should display pwless w/ phone for [otp-phone] even if the client side firstFactor array is different", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + window.localStorage.setItem("firstFactors", "unknown"); + }, getDynLoginMethods(["otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ phone for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-email"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-email", "otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL_OR_PHONE"); + }); + + it("should display tp-pwless w/ email for [thirdparty, otp-email]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["thirdparty", "otp-email"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + + it("should display tp-ep for [thirdparty, emailpassword]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["thirdparty", "emailpassword"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkEmailPasswordLoginUI(page); + }); + + it("should throw for [unknown]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["unknown"])); + + let hitErrorBoundary = false; + page.on("console", (ev) => { + if (ev.text() === "ST_THROWN_ERROR") { + hitErrorBoundary = true; + } + }); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitFor(500); + assert(hitErrorBoundary); + }); + }); +}); + +function getDynLoginMethods(firstFactors) { + return JSON.stringify({ + emailPassword: { enabled: true }, + passwordless: { enabled: true }, + thirdParty: { enabled: true, providers: [{ id: "google", name: "Google" }] }, + firstFactors, + }); +} + +async function checkPasswordlessLoginUI(page, contactMethod) { + switch (contactMethod) { + case "EMAIL_OR_PHONE": + await waitForSTElement(page, "[data-supertokens~=input][name=emailOrPhone]"); + break; + case "EMAIL": + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + break; + case "PHONE": + await waitForSTElement(page, "[data-supertokens~=input][name=phoneNumber_text]"); + break; + default: + throw new Error("Unknown contact method " + contactMethod); + } +} + +async function checkThirdPartyLoginUI(page) { + // This basically checks that there is a provider shown + await waitForSTElement(page, "[data-supertokens~=providerContainer]"); +} + +async function checkEmailPasswordLoginUI(page) { + // This basically checks that there is a provider shown + await waitForSTElement(page, "[data-supertokens~=input][name=password]"); +} diff --git a/test/server/index.js b/test/server/index.js index 4de0ec229..d47d8e196 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -436,7 +436,6 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { } const mfaClaim = payload["st-mfa"]; - console.log(mfaInfo, mfaClaim); if (mfaInfo?.claimValue) { await session.mergeIntoAccessTokenPayload({ "st-mfa": mfaInfo.claimValue, From acfecc8f2a6ee4ff7928b7e6cbf1eaf4ff052807 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Mon, 6 Nov 2023 13:08:05 +0100 Subject: [PATCH 3/7] tests: complete initial test-set of MFA --- examples/for-tests/src/App.js | 50 +- examples/for-tests/src/testContext.js | 25 +- test/constants.js | 2 + test/end-to-end/mfa.mock.firstFactors.test.js | 21 +- test/end-to-end/mfa.mock.signin.test.js | 1279 ++++++++++++++++- test/helpers.js | 22 +- test/server/index.js | 82 +- 7 files changed, 1378 insertions(+), 103 deletions(-) diff --git a/examples/for-tests/src/App.js b/examples/for-tests/src/App.js index 9d6da0860..75ef6a7f1 100644 --- a/examples/for-tests/src/App.js +++ b/examples/for-tests/src/App.js @@ -172,12 +172,38 @@ const formFields = [ const testContext = getTestContext(); -let totpDevices = []; +let storedTOTPDevices = window.localStorage.getItem("totpDevices"); +let totpDevices = storedTOTPDevices ? JSON.parse(storedTOTPDevices) : []; + +function removeTOTPDevice(deviceName) { + const origLength = totpDevices.length; + totpDevices = totpDevices.filter((d) => d.deviceName !== deviceName); + window.localStorage.setItem("totpDevices", JSON.stringify(totpDevices)); + return totpDevices.length !== origLength; +} + +function addTOTPDevice(deviceName) { + totpDevices.push({ + deviceName, + verified: false, + }); + window.localStorage.setItem("totpDevices", JSON.stringify(totpDevices)); +} + +function verifyTOTPDevice(deviceName) { + totpDevices = totpDevices.filter((d) => d.deviceName !== deviceName); + totpDevices.push({ + deviceName, + verified: true, + }); + window.localStorage.setItem("totpDevices", JSON.stringify(totpDevices)); +} let tryCount = 0; setInterval(() => (tryCount = tryCount > 0 ? tryCount - 1 : 0), 30000); window.resetTOTP = () => { totpDevices = []; + window.localStorage.setItem("totpDevices", JSON.stringify(totpDevices)); tryCount = 0; }; let recipeList = [ @@ -187,16 +213,11 @@ let recipeList = [ ...oI, listDevices: async () => ({ devices: totpDevices, status: "OK" }), removeDevice: async ({ deviceName }) => { - const origLength = totpDevices.length; - totpDevices = totpDevices.filter((d) => d.deviceName !== deviceName); - return { status: "OK", didDeviceExist: origLength !== totpDevices.length }; + return { status: "OK", didDeviceExist: removeTOTPDevice(deviceName) }; }, createDevice: async ({ deviceName }) => { deviceName = deviceName ?? `totp-${Date.now()}`; - totpDevices.push({ - deviceName, - verified: false, - }); + addTOTPDevice(deviceName); return { status: "OK", deviceName: deviceName, @@ -228,7 +249,7 @@ let recipeList = [ } if (deviceName.endsWith(totp)) { const wasAlreadyVerified = dev.verified; - dev.verified = true; + verifyTOTPDevice(deviceName); await fetch("http://localhost:8082/completeFactor", { method: "POST", body: JSON.stringify({ id: "totp" }), @@ -557,10 +578,13 @@ export function DashboardHelper({ redirectOnLogout, ...props } = {}) {
session context userID: {sessionContext.userId}
{JSON.stringify(sessionContext.invalidClaims, undefined, 2)}
- MultiFactorAuth.redirectToFactorChooser(true, props.history)}>MFA chooser - MultiFactorAuth.redirectToFactor("totp", true, props.history)}>TOTP - MultiFactorAuth.redirectToFactor("otp-email", true, props.history)}>OTP-Email - MultiFactorAuth.redirectToFactor("otp-phone", true, props.history)}>OTP-Phone + { + return MultiFactorAuth.redirectToFactorChooser(true, props.history); + }}> + MFA chooser + ); } diff --git a/examples/for-tests/src/testContext.js b/examples/for-tests/src/testContext.js index bd2f0a623..5b6b38b7b 100644 --- a/examples/for-tests/src/testContext.js +++ b/examples/for-tests/src/testContext.js @@ -15,7 +15,10 @@ export function getTestContext() { staticProviderList: localStorage.getItem("staticProviderList"), mockTenantId: localStorage.getItem("mockTenantId"), clientType: localStorage.getItem("clientType") || undefined, - firstFactors: localStorage.getItem("firstFactors")?.split(", "), + firstFactors: + localStorage.getItem("firstFactors") !== null + ? localStorage.getItem("firstFactors").split(", ") + : undefined, }; return ret; } @@ -33,18 +36,16 @@ export function getEnabledRecipes() { "passwordless", "thirdpartypasswordless", ]; + } else if (testContext.clientRecipeListForDynamicLogin) { + enabledRecipes = JSON.parse(testContext.clientRecipeListForDynamicLogin); } else if (testContext.usesDynamicLoginMethods) { - if (testContext.clientRecipeListForDynamicLogin) { - enabledRecipes = JSON.parse(testContext.clientRecipeListForDynamicLogin); - } else { - enabledRecipes = [ - "emailpassword", - "thirdparty", - "thirdpartyemailpassword", - "passwordless", - "thirdpartypasswordless", - ]; - } + enabledRecipes = [ + "emailpassword", + "thirdparty", + "thirdpartyemailpassword", + "passwordless", + "thirdpartypasswordless", + ]; } else { if (testContext.authRecipe === "both") { enabledRecipes.push("emailpassword", "thirdparty"); diff --git a/test/constants.js b/test/constants.js index f3e58135a..cde05fd8c 100644 --- a/test/constants.js +++ b/test/constants.js @@ -32,6 +32,8 @@ export const RESET_PASSWORD_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/user export const SEND_VERIFY_EMAIL_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/user/email/verify/token`; export const VERIFY_EMAIL_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/user/email/verify`; export const SIGN_IN_UP_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup`; +export const CREATE_CODE_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup/code`; +export const CREATE_DEVICE_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup/code`; export const GET_AUTH_URL_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/authorisationurl`; export const ST_ROOT_SELECTOR = `#${ST_ROOT_ID}`; diff --git a/test/end-to-end/mfa.mock.firstFactors.test.js b/test/end-to-end/mfa.mock.firstFactors.test.js index 5f3ff3a69..673e24c45 100644 --- a/test/end-to-end/mfa.mock.firstFactors.test.js +++ b/test/end-to-end/mfa.mock.firstFactors.test.js @@ -65,7 +65,7 @@ import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_U /* * Tests. */ -describe("SuperTokens SignIn", function () { +describe("SuperTokens MFA firstFactors support", function () { let browser; let page; let consoleLogs = []; @@ -183,18 +183,20 @@ describe("SuperTokens SignIn", function () { window.localStorage.setItem("firstFactors", "unknown"); }); - let hitErrorBoundary = false; + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); page.on("console", (ev) => { if (ev.text() === "ST_THROWN_ERROR") { - hitErrorBoundary = true; + onErrorBoundaryHit(true); } }); await Promise.all([ page.goto(`${TEST_CLIENT_BASE_URL}/auth`), page.waitForNavigation({ waitUntil: "networkidle0" }), ]); - await waitFor(500); - assert(hitErrorBoundary); + assert(await hitErrorBoundary); }); }); @@ -281,10 +283,13 @@ describe("SuperTokens SignIn", function () { window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); }, getDynLoginMethods(["unknown"])); - let hitErrorBoundary = false; + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); page.on("console", (ev) => { if (ev.text() === "ST_THROWN_ERROR") { - hitErrorBoundary = true; + onErrorBoundaryHit(true); } }); await Promise.all([ @@ -292,7 +297,7 @@ describe("SuperTokens SignIn", function () { page.waitForNavigation({ waitUntil: "networkidle0" }), ]); await waitFor(500); - assert(hitErrorBoundary); + assert(await hitErrorBoundary); }); }); }); diff --git a/test/end-to-end/mfa.mock.signin.test.js b/test/end-to-end/mfa.mock.signin.test.js index c860c6006..fdb9f3725 100644 --- a/test/end-to-end/mfa.mock.signin.test.js +++ b/test/end-to-end/mfa.mock.signin.test.js @@ -55,16 +55,19 @@ import { backendBeforeEach, getTestEmail, getPasswordlessDevice, + waitFor, + getFactorChooserOptions, } from "../helpers"; import fetch from "isomorphic-fetch"; -import { SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; +import { CREATE_CODE_API, SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL, SIGN_OUT_API } from "../constants"; +import { getTestPhoneNumber } from "../exampleTestHelpers"; /* * Tests. */ -describe("SuperTokens SignIn", function () { +describe("SuperTokens SignIn w/ MFA", function () { let browser; let page; let consoleLogs = []; @@ -78,7 +81,7 @@ describe("SuperTokens SignIn", function () { browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"], - headless: false, + headless: true, }); }); @@ -94,63 +97,1273 @@ describe("SuperTokens SignIn", function () { }).catch(console.error); }); - afterEach(function () { - return screenshotOnFailure(this, browser); + afterEach(async function () { + await screenshotOnFailure(this, browser); + if (page) { + await page.close(); + } }); beforeEach(async function () { page = await browser.newPage(); page.on("console", (consoleObj) => { const log = consoleObj.text(); + // console.log(log); if (log.startsWith("ST_LOGS")) { consoleLogs.push(log); } }); consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + }); + + it("sign in with email-otp (auto-setup)", async function () { + const email = await getTestEmail(); + + await setMFAInfo({ + requirements: ["otp-email"], + }); + + await tryEmailPasswordSignUp(page, email); + + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); + + await waitForDashboard(page); }); - describe("SignIn test ", function () { - it("Successful Sign In", async function () { - consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, consoleLogs); + describe("sign in + setup + sign in with chooser flow", () => { + it("set up otp-phone and sign-in", async function () { const email = await getTestEmail(); - await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + const phoneNumber = getTestPhoneNumber(); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + }); + + await tryEmailPasswordSignUp(page, email); + + await completeOTP(page); + + await waitForDashboard(page); + await setupOTP(page, "PHONE", phoneNumber); + + await logout(page); + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("set up otp-email and sign-in", async function () { + await setMFAInfo({ + requirements: [], + }); + const email = await getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + + await tryPasswordlessSignInUp(page, phoneNumber); + + await waitForDashboard(page); + await setupOTP(page, "EMAIL", email); + + await logout(page); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email"] }], + }); + + await tryPasswordlessSignInUp(page, phoneNumber); + + await waitFor(500); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("set up totp and sign-in", async function () { + await setMFAInfo({ + requirements: [], + }); + const email = await getTestEmail(); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "totp"] }], + }); + + await tryEmailPasswordSignUp(page, email); + await completeOTP(page); + + await waitForDashboard(page); + + const totp = await setupTOTP(page); + + await logout(page); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + }); + + describe("chooser screen", () => { + let email, phoneNumber; + let totp; + before(async () => { + page = await browser.newPage(); + ({ email, phoneNumber, totp } = await setupUserWithAllFactors(page)); + await page.close(); + }); + + it("should redirect to the factor screen during sign in if only one factor is available (limited by FE recipe inits)", async () => { + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "false"); + window.localStorage.setItem("clientRecipeListForDynamicLogin", JSON.stringify(["emailpassword"])); + }); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone", "totp"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + + it("should redirect to the factor screen during sign in if only one factor is available (limited by isAlreadySetup/isAllowedToSetup)", async () => { + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "false"); + window.localStorage.setItem("clientRecipeListForDynamicLogin", JSON.stringify(["emailpassword"])); + }); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone", "totp"] }], + hasTOTP: true, + isAlreadySetup: ["totp"], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should redirect to the factor screen during sign in if only one factor is available (limited by next array)", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["totp"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + + it("should show all factors the user can complete or set up in the next array", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + const options = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(options), new Set(["otp-email", "totp"])); + }); + + it("should show all factors the user can complete or set up if the next array is empty", async () => { + await setMFAInfo({ + requirements: [], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await goToFactorChooser(page); + + const optionsBefore2FA = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(optionsBefore2FA), new Set(["otp-phone", "otp-email"])); + + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await goToFactorChooser(page); + + const optionsAfter2FA = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(optionsAfter2FA), new Set(["otp-phone", "otp-email", "totp"])); + }); + + it("should show access denied if there are no available options during sign in", async () => { + await setMFAInfo({ + requirements: ["otp-phone"], + isAlreadySetup: ["otp-email"], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + }); + + it("should show access denied if there are no available options after sign in", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + await goToFactorChooser(page, false); + + await waitForAccessDenied(page); + }); + + // TODO: for some reason this doesn't hit the error boundary + it.skip("should show throw if the only next option is an unknown factor id", async () => { + await setMFAInfo({ + requirements: ["unknown"], + }); + + await expectErrorThrown(page, () => tryEmailPasswordSignIn(page, email)); + }); + + it("should show a back link only if visited after sign in", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + + await waitForSTElement(page, "[data-supertokens~=backButton]", true); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await goToFactorChooser(page); + + await waitForSTElement(page, "[data-supertokens~=backButton]"); + }); + + it("should show a logout link", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + + await waitForSTElement(page, "[data-supertokens~=secondaryLinkWithLeftArrow]"); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await goToFactorChooser(page); + + await waitForSTElement(page, "[data-supertokens~=secondaryLinkWithLeftArrow]"); + }); + }); + ``; + + describe("factor screens", () => { + describe("otp", () => { + describe("otp-phone", () => { + getOTPTests("PHONE", "otp-phone"); + }); + + describe("otp-email", () => { + getOTPTests("EMAIL", "otp-email"); + }); + + function getOTPTests(contactMethod, factorId) { + let email, phoneNumber; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(factorId); + phoneNumber = getTestPhoneNumber(); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => + window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo") + ); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + + await page.close(); + }); + + it("should show access denied if the app navigates to the setup page but the user it is not allowed to set up the factor", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}?setup=true`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await waitForAccessDenied(page); + }); + + it("should show access denied if setup is not allowed but the factor is not set up", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitForAccessDenied(page); + }); + + it("should handle createCode failures gracefully", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + }); + + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url() === CREATE_CODE_API && request.method() === "POST") { + return request.respond({ + status: 400, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "BAD_INPUT", + }), + }); + } + + return request.continue(); + }; + page.on("request", requestHandler); + try { + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + }); + + it("should enable you to change the contact info during setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + }); + + await tryEmailPasswordSignIn(page, email); + + await setInputValues(page, [ + contactMethod === "PHONE" + ? { name: "phoneNumber_text", value: getTestPhoneNumber() } + : { name: "email", value: await getTestEmail() }, + ]); + await submitForm(page); + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + const changeContact = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await changeContact.click(); + + await setInputValues(page, [ + contactMethod === "PHONE" + ? { name: "phoneNumber_text", value: phoneNumber } + : { name: "email", value: email }, + ]); + await submitForm(page); + await completeOTP(page); + }); + + it("should show a link redirecting back if visited after sign in - setup", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + it("should show a link redirecting back if visited after sign in - verification", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + it("should show a link redirecting to the chooser screen if other options are available during sign in - setup", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "totp"] }], + isAlreadySetup: ["totp"], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + it("should show a link redirecting to the chooser screen if other options are available during sign in - verification", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "totp"] }], + isAlreadySetup: [factorId, "totp"], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + + it("should show a logout link - setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + + it("should show a logout link - setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + } + }); + + describe("totp", () => { + const factorId = "totp"; + + let email, phoneNumber; + before(async () => { + await setMFAInfo({ + isAllowedToSetup: ["totp"], + }); + page = await browser.newPage(); + + email = await getTestEmail(factorId); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await setupTOTP(page); + await waitForDashboard(page); + + await page.close(); + }); + + it("should show access denied if the app navigates to the setup page but the user it is not allowed to set up the factor", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}?setup=true`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await waitForAccessDenied(page); + }); + + it("should show access denied if setup is not allowed but the factor is not set up", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitForAccessDenied(page); + }); + + // TODO: re-enable this + it.skip("should handle createDevice failures gracefully", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + }); + + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url() === CREATE_DEVICE_API && request.method() === "POST") { + return request.respond({ + status: 400, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "BAD_INPUT", + }), + }); + } + + return request.continue(); + }; + page.on("request", requestHandler); + try { + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + }); + + it("should show a link redirecting back if visited after sign in - setup", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + + it("should show a link redirecting back if visited after sign in - verification", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + + it("should show a link redirecting to the chooser screen if other options are available during sign in - setup", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "otp-email"] }], + isAlreadySetup: ["otp-email"], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); - let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMFAInfo`, { - method: "POST", - headers: new Headers([["content-type", "application/json"]]), - body: JSON.stringify({ - requirements: ["otp-email"], - }), + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); }); - assert.strictEqual(resp.status, 200); - console.log(await resp.text()); + + it("should show a link redirecting to the chooser screen if other options are available during sign in - verification", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "otp-email"] }], + isAlreadySetup: [factorId, "otp-email"], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + + it("should show a logout link - setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + + it("should show a logout link - verify", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + }); + }); + + describe("default requirements", () => { + let email, phoneNumber; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(); + phoneNumber = getTestPhoneNumber(); + await Promise.all([ page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), page.waitForNavigation({ waitUntil: "networkidle0" }), ]); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + + await page.close(); + }); + + beforeEach(async () => { + await setMFAInfo({}); + }); + + it("should not require any factors after sign up", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + }); + + it("should not allow you to set up a secondary factor before completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(list, ["otp-email"]); + }); + + it("should not allow you to set up all other factors after completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + // TODO: validate + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "otp-phone", "totp"])); + }); + + it("should require 2fa to sign in after setting up another factor", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + const totp = await setupTOTP(page); + await logout(page); + + await tryEmailPasswordSignIn(page, email); + const list = await getFactorChooserOptions(page); + // TODO: validate this, maybe it should only be totp? + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should not require any factors after sign up", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + }); + + it("should not allow you to set up a secondary factor before completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(list, ["otp-email"]); + }); + + it("should not allow you to set up all other factors after completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + // TODO: validate + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); - await toggleSignInSignUp(page); + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "otp-phone", "totp"])); + }); + + it("should require 2fa to sign in after setting up another factor", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + const totp = await setupTOTP(page); + await logout(page); + + await tryEmailPasswordSignIn(page, email); + const list = await getFactorChooserOptions(page); + // TODO: validate this, maybe it should only be totp? + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + }); + + describe("requirement handling", () => { + let email, phoneNumber; + let totp; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(); + phoneNumber = getTestPhoneNumber(); - await setInputValues(page, [ - { name: "email", value: email }, - { name: "password", value: "Asdf12.." }, - { name: "name", value: "asdf" }, - { name: "age", value: "20" }, + await setMFAInfo({}); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), ]); - await submitForm(page); - await new Promise((res) => setTimeout(res, 250)); + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); - await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); - const loginAttemptInfo = JSON.parse( - await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) - ); - const device = await getPasswordlessDevice(loginAttemptInfo); - await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); - await submitForm(page); + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + await setupOTP(page, "PHONE", phoneNumber); + totp = await setupTOTP(page); - await page.waitForSelector(".sessionInfo-user-id"); + await page.close(); + }); + + describe("multistep requirement list", () => { + it("multistep requirements should happen in order (allOf -> oneOf)", async () => { + await setMFAInfo({ + requirements: [{ allOf: ["otp-phone", "totp"] }, { oneOf: ["otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await completeTOTP(page, totp); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("multistep requirements should happen in order (oneOf -> allOf)", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-phone", "totp"] }, { allOf: ["totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + const factors2 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors2), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await completeOTP(page); + await waitForDashboard(page); + }); + it("string requirements strictly set the order of the factor screens", async () => { + await setMFAInfo({ + requirements: ["otp-phone", "totp", "otp-email"], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + console.log("a1"); + await completeOTP(page, "PHONE"); + console.log("a2"); + await completeTOTP(page, totp); + console.log("a3"); + await completeOTP(page, "EMAIL"); + console.log("a4"); + await waitForDashboard(page); + }); + }); + + describe("allOf", () => { + it("should pass if all requirements are complete", async () => { + await setMFAInfo({ + requirements: [{ allOf: ["otp-phone", "totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp", "otp-email"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + const factors2 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors2), new Set(["totp", "otp-email"])); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should pass if the array is empty", async () => { + await setMFAInfo({ + requirements: [{ allOf: [] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForDashboard(page); + }); + }); + describe("oneOf", () => { + it("should pass if one of the requirements are complete", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-phone", "totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp", "otp-email"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await waitForDashboard(page); + }); + it("should pass if the array is empty", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForDashboard(page); + }); }); }); }); + +async function setupUserWithAllFactors(page) { + // TODO: it'd be cleaner if this part was not done through the app + const email = await getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + await clearBrowserCookiesWithoutAffectingConsole(page, []); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + }); + + await tryEmailPasswordSignUp(page, email); + + await completeOTP(page); + + await waitForDashboard(page); + await setupOTP(page, "PHONE", phoneNumber); + + await waitForDashboard(page); + const totp = await setupTOTP(page); + return { email, phoneNumber, totp }; +} + +async function setMFAInfo(mfaInfo) { + let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMFAInfo`, { + method: "POST", + headers: new Headers([["content-type", "application/json"]]), + body: JSON.stringify(mfaInfo), + }); + assert.strictEqual(resp.status, 200); +} + +async function completeOTP(page, contactMethod) { + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + if (contactMethod) { + assert.strictEqual(loginAttemptInfo.contactMethod, contactMethod); + } + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); +} + +async function logout(page) { + await waitForDashboard(page); + const logoutButton = await getLogoutButton(page); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page); +} + +async function waitForDashboard(page) { + await Promise.all([page.waitForSelector(".sessionInfo-user-id"), page.waitForNetworkIdle()]); +} + +async function waitForAccessDenied(page) { + const error = await waitForSTElement(page, "[data-supertokens~=accessDeniedError]"); + return error.evaluate((e) => e.textContent); +} + +async function setupOTP(page, contactMethod, phoneNumber) { + await goToFactorChooser(page); + await chooseFactor(page, contactMethod === "PHONE" ? "otp-phone" : "otp-email"); + + await setInputValues(page, [ + { name: contactMethod === "PHONE" ? "phoneNumber_text" : "email", value: phoneNumber }, + ]); + await submitForm(page); + + await completeOTP(page); +} + +async function setupTOTP(page) { + await goToFactorChooser(page); + await chooseFactor(page, "totp"); + const showSecret = await waitForSTElement(page, "[data-supertokens~=showTOTPSecretBtn]"); + await showSecret.click(); + + const secretDiv = await waitForSTElement(page, "[data-supertokens~=totpSecret]"); + const secret = await secretDiv.evaluate((e) => e.textContent); + + const totp = secret.substring(secret.length - 4); + + await completeTOTP(page, totp); + return totp; +} + +async function completeTOTP(page, totp) { + await setInputValues(page, [{ name: "totp", value: totp }]); + await submitForm(page); +} + +async function tryEmailPasswordSignUp(page, email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await toggleSignInSignUp(page); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: "Asdf12.." }, + { name: "name", value: "asdf" }, + { name: "age", value: "20" }, + ]); + + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryEmailPasswordSignIn(page, email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: "Asdf12.." }, + ]); + + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryPasswordlessSignInUp(page, contactInfo) { + await page.evaluate(() => localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=passwordless`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await setInputValues(page, [{ name: "emailOrPhone", value: contactInfo }]); + await submitForm(page); + + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryThirdPartySignInUp(page, email, isVerified = true, userId = email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=thirdparty`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await assertProviders(page); + + await clickOnProviderButton(page, "Mock Provider"); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, `/mockProvider/auth`); + assert.ok(url.searchParams.get("state")); + + await Promise.all([ + page.goto( + `${TEST_CLIENT_BASE_URL}/auth/callback/mock-provider?code=asdf&email=${encodeURIComponent( + email + )}&userId=${encodeURIComponent(userId)}&isVerified=${isVerified}&state=${url.searchParams.get("state")}` + ), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function expectErrorThrown(page, cb) { + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); + page.on("console", (ev) => { + // console.log(ev.text()); + if (ev.text() === "ST_THROWN_ERROR") { + onErrorBoundaryHit(true); + } + }); + await Promise.all([hitErrorBoundary, cb()]); + assert(hitErrorBoundary); +} +async function goToFactorChooser(page, waitForList = true) { + const ele = await page.waitForSelector(".goToFactorChooser"); + await waitFor(100); + await Promise.all([page.waitForNavigation({ waitUntil: "networkidle0" }), ele.click()]); + if (waitForList) { + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + } +} + +async function chooseFactor(page, id) { + const ele = await waitForSTElement(page, `[data-supertokens~=factorChooserOption][data-supertokens~=${id}]`); + await waitFor(100); + await Promise.all([page.waitForNavigation({ waitUntil: "networkidle0" }), ele.click()]); + await waitForSTElement(page); +} diff --git a/test/helpers.js b/test/helpers.js index c1d6c9f0b..7deaee58e 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -261,6 +261,24 @@ export async function getInputAdornmentsError(page) { ); } +export async function getFactorChooserOptions(page) { + await waitForSTElement(page, "[data-supertokens~='factorChooserList']"); + return await page.evaluate( + ({ ST_ROOT_SELECTOR }) => + Array.from( + document + .querySelector(ST_ROOT_SELECTOR) + .shadowRoot.querySelectorAll("[data-supertokens~='factorChooserOption']"), + (i) => + i.dataset["supertokens"] + ?.split(" ") + .filter((x) => x !== "factorChooserOption") + .join(" ") + ), + { ST_ROOT_SELECTOR } + ); +} + export async function getInputTypes(page) { await waitForSTElement(page); return await page.evaluate( @@ -873,8 +891,8 @@ export async function setGeneralErrorToLocalStorage(recipeName, action, page) { }); } -export async function getTestEmail() { - return `john.doe+${Date.now()}@supertokens.io`; +export async function getTestEmail(post) { + return `john.doe+${Date.now()}-${post ?? "0"}@supertokens.io`; } export async function setupTenant(tenantId, loginMethods) { diff --git a/test/server/index.js b/test/server/index.js index d47d8e196..ed676f85f 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -162,7 +162,7 @@ morgan.token("body", function (req, res) { }); morgan.token("res-body", function (req, res) { - return typeof res.__custombody__ ? res.__custombody__ : JSON.stringify(res.__custombody__); + return typeof res.__custombody__ === "string" ? res.__custombody__ : JSON.stringify(res.__custombody__); }); app.use(urlencodedParser); @@ -262,6 +262,7 @@ app.post("/startst", async (req, res) => { app.post("/beforeeach", async (req, res) => { deviceStore = new Map(); + mfaInfo = {}; accountLinkingConfig = {}; passwordlessConfig = {}; enabledProviders = undefined; @@ -384,19 +385,20 @@ app.post( ); app.post("/setMFAInfo", async (req, res) => { - mfaInfo = { mfaInfo, ...req.body }; + mfaInfo = req.body; res.send({ status: "OK" }); }); app.post("/completeFactor", verifySession(), async (req, res) => { let session = req.session; + const payload = session.getAccessTokenPayload(); const mfaClaim = payload["st-mfa"]; const c = { ...mfaClaim.c, - [req.payload.id]: Date.now(), + [req.body.id]: Date.now(), }; - if (req.payload.id === "totp") { + if (req.body.id === "totp") { mfaInfo.hasTOTP = true; } @@ -407,6 +409,8 @@ app.post("/completeFactor", verifySession(), async (req, res) => { n: getNextArray(c), }, }); + + res.send({ status: "OK" }); }); app.post("/mergeIntoAccessTokenPayload", verifySession(), async (req, res) => { @@ -432,30 +436,30 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { } if (mfaInfo.hasTOTP) { - isAllowedToSetup.push("totp"); + isAlreadySetup.push("totp"); } + let c; + + const recipeUser = user.loginMethods.find( + (u) => u.recipeUserId.toString() === session.getRecipeUserId().toString() + ); + if (recipeUser.recipeId !== "passwordless") { + c = { [recipeUser.recipeId]: Date.now() }; + } else if (recipeUser.email) { + // This isn't correct, but will do for testing + c = { "otp-email": Date.now() }; + } else { + c = { "otp-phone": Date.now() }; + } const mfaClaim = payload["st-mfa"]; if (mfaInfo?.claimValue) { await session.mergeIntoAccessTokenPayload({ "st-mfa": mfaInfo.claimValue, }); } else if (mfaInfo?.requirements) { - let c; if (mfaClaim) { c = mfaClaim.c; - } else { - const recipeUser = user.loginMethods.find( - (u) => u.recipeUserId.toString() === session.getRecipeUserId().toString() - ); - if (recipeUser.recipeId !== "passwordless") { - c = { [recipeUser.recipeUserId]: Date.now() }; - } else if (recipeUser.email) { - // This isn't correct, but will do for testing - c = { "otp-email": Date.now() }; - } else { - c = { "otp-phone": Date.now() }; - } } let n = getNextArray(c); @@ -468,8 +472,8 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { } else if (mfaClaim === undefined) { await session.mergeIntoAccessTokenPayload({ "st-mfa": { - c: {}, // Technically the first factor should be in there... but it isn't necessary - n: [], + c: c, + n: !mfaInfo.hasTOTP ? [] : getNextArray(c, [{ oneOf: ["totp", "otp-phone", "otp-email"] }]), }, }); } @@ -485,8 +489,8 @@ app.get("/auth/mfa/info", verifySession(), async (req, res) => { email: user.emails[0], phoneNumber: user.phoneNumbers[0], factors: { - isAllowedToSetup, - isAlreadySetup, + isAllowedToSetup: mfaInfo.isAllowedToSetup ?? isAllowedToSetup, + isAlreadySetup: mfaInfo.isAlreadySetup ?? isAlreadySetup, }, ...mfaInfo.resp, }); @@ -648,9 +652,9 @@ server.listen(process.env.NODE_PORT === undefined ? 8080 : process.env.NODE_PORT } })(process.env.START === "true"); -function getNextArray(c) { +function getNextArray(c, requirements) { let n = []; - for (const step of mfaInfo.requirements) { + for (const step of requirements ?? mfaInfo.requirements ?? []) { if (typeof step === "string") { if (c[step] === undefined) { n = [step]; @@ -671,6 +675,7 @@ function getNextArray(c) { throw new Error("Bad requirement" + JSON.stringify(step)); } } + return n; } @@ -1057,11 +1062,20 @@ function initST() { message: "general error from API consume code", }; } + + const deviceInfo = await input.options.recipeImplementation.listCodesByPreAuthSessionId( + { + tenantId: input.tenantId, + preAuthSessionId: input.preAuthSessionId, + userContext: input.userContext, + } + ); const resp = await originalImplementation.consumeCodePOST(input); if (resp.status === "OK") { let session = await Session.getSession(input.options.req, input.options.res, { overrideGlobalClaimValidators: () => [], + sessionRequired: false, }); if (session) { @@ -1070,26 +1084,24 @@ function initST() { resp.session.getRecipeUserId(), session.getUserId() ); + const mfaClaim = session.getAccessTokenPayload()["st-mfa"]; let factorId; - const loginMethod = resp.user.loginMethods.find( - (lm) => - lm.recipeUserId.getAsString() === - resp.session.getRecipeUserId().getAsString() - ); - if (loginMethod.email !== undefined) { + if (deviceInfo.email !== undefined) { factorId = "otp-email"; } else { factorId = "otp-phone"; } + + const c = { + ...mfaClaim?.c, + [factorId]: new Date() / 1000, + }; await session.mergeIntoAccessTokenPayload({ "st-mfa": { - c: { - ...mfaClaim?.c, - [factorId]: new Date() / 1000, - }, - n: [], + c, + n: getNextArray(c), }, }); } From 7ead9c6eb7b5710e1e3ac25cdb300ce300303adc Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Tue, 7 Nov 2023 14:40:15 +0100 Subject: [PATCH 4/7] test: update first factor tests to match new behaviour w/ extra checks From 60e0cbb570ecddb3c0bc14a7c05ec2fd469a10dd Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 9 Nov 2023 22:26:49 +0100 Subject: [PATCH 5/7] test: clean up --- test/end-to-end/mfa.mock.firstFactors.test.js | 34 +------------------ test/end-to-end/mfa.mock.signin.test.js | 33 ++---------------- 2 files changed, 3 insertions(+), 64 deletions(-) diff --git a/test/end-to-end/mfa.mock.firstFactors.test.js b/test/end-to-end/mfa.mock.firstFactors.test.js index 673e24c45..5207c105e 100644 --- a/test/end-to-end/mfa.mock.firstFactors.test.js +++ b/test/end-to-end/mfa.mock.firstFactors.test.js @@ -21,46 +21,14 @@ import assert from "assert"; import puppeteer from "puppeteer"; import { clearBrowserCookiesWithoutAffectingConsole, - clickForgotPasswordLink, - getFieldErrors, - getGeneralError, - getInputNames, - getInputTypes, - getLogoutButton, - getLabelsText, - getPlaceholders, - getUserIdWithAxios, - getSessionHandleWithAxios, - getUserIdWithFetch, - getSessionHandleWithFetch, - getShowPasswordIcon, - getSubmitFormButtonLabel, - getInputAdornmentsSuccess, - hasMethodBeenCalled, - setInputValues, - submitForm, - submitFormReturnRequestAndResponse, - toggleShowPasswordIcon, - toggleSignInSignUp, - getInputAdornmentsError, - defaultSignUp, - getUserIdFromSessionContext, - getTextInDashboardNoAuth, waitForSTElement, screenshotOnFailure, - isGeneralErrorSupported, - setGeneralErrorToLocalStorage, - getInvalidClaimsJSON as getInvalidClaims, - waitForText, backendBeforeEach, - getTestEmail, - getPasswordlessDevice, waitFor, } from "../helpers"; import fetch from "isomorphic-fetch"; -import { SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; -import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL, SIGN_OUT_API } from "../constants"; +import { TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL } from "../constants"; /* * Tests. diff --git a/test/end-to-end/mfa.mock.signin.test.js b/test/end-to-end/mfa.mock.signin.test.js index fdb9f3725..6efd9d6b3 100644 --- a/test/end-to-end/mfa.mock.signin.test.js +++ b/test/end-to-end/mfa.mock.signin.test.js @@ -21,37 +21,12 @@ import assert from "assert"; import puppeteer from "puppeteer"; import { clearBrowserCookiesWithoutAffectingConsole, - clickForgotPasswordLink, - getFieldErrors, - getGeneralError, - getInputNames, - getInputTypes, getLogoutButton, - getLabelsText, - getPlaceholders, - getUserIdWithAxios, - getSessionHandleWithAxios, - getUserIdWithFetch, - getSessionHandleWithFetch, - getShowPasswordIcon, - getSubmitFormButtonLabel, - getInputAdornmentsSuccess, - hasMethodBeenCalled, setInputValues, submitForm, - submitFormReturnRequestAndResponse, - toggleShowPasswordIcon, toggleSignInSignUp, - getInputAdornmentsError, - defaultSignUp, - getUserIdFromSessionContext, - getTextInDashboardNoAuth, waitForSTElement, screenshotOnFailure, - isGeneralErrorSupported, - setGeneralErrorToLocalStorage, - getInvalidClaimsJSON as getInvalidClaims, - waitForText, backendBeforeEach, getTestEmail, getPasswordlessDevice, @@ -59,9 +34,9 @@ import { getFactorChooserOptions, } from "../helpers"; import fetch from "isomorphic-fetch"; -import { CREATE_CODE_API, SOMETHING_WENT_WRONG_ERROR, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; +import { CREATE_CODE_API, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; -import { EMAIL_EXISTS_API, SIGN_IN_API, TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL, SIGN_OUT_API } from "../constants"; +import { TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL } from "../constants"; import { getTestPhoneNumber } from "../exampleTestHelpers"; /* @@ -1095,13 +1070,9 @@ describe("SuperTokens SignIn w/ MFA", function () { }); await tryEmailPasswordSignIn(page, email); - console.log("a1"); await completeOTP(page, "PHONE"); - console.log("a2"); await completeTOTP(page, totp); - console.log("a3"); await completeOTP(page, "EMAIL"); - console.log("a4"); await waitForDashboard(page); }); }); From 78c2a96e52675c97383da0abd94bce8e180111e5 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Tue, 21 Nov 2023 02:00:06 +0100 Subject: [PATCH 6/7] test: update tests for MFA --- test/constants.js | 1 + test/end-to-end/mfa.firstFactors.test.js | 306 ++++ test/end-to-end/mfa.mock.signin.test.js | 30 +- test/end-to-end/mfa.signin.test.js | 1342 +++++++++++++++++ ...multitenancy.dynamic_login_methods.test.js | 25 +- test/server/index.js | 331 ++-- test/server/package-lock.json | 11 +- test/server/package.json | 2 +- test/unit/componentOverrides.test.tsx | 38 +- test/unit/recipe/session/sessionAuth.test.tsx | 94 +- 10 files changed, 1967 insertions(+), 213 deletions(-) create mode 100644 test/end-to-end/mfa.firstFactors.test.js create mode 100644 test/end-to-end/mfa.signin.test.js diff --git a/test/constants.js b/test/constants.js index cde05fd8c..50bc151bf 100644 --- a/test/constants.js +++ b/test/constants.js @@ -35,6 +35,7 @@ export const SIGN_IN_UP_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup export const CREATE_CODE_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup/code`; export const CREATE_DEVICE_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/signinup/code`; export const GET_AUTH_URL_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/authorisationurl`; +export const LOGIN_METHODS_API = `${TEST_APPLICATION_SERVER_BASE_URL}/auth/loginmethods`; export const ST_ROOT_SELECTOR = `#${ST_ROOT_ID}`; export const SOMETHING_WENT_WRONG_ERROR = "Something went wrong. Please try again."; diff --git a/test/end-to-end/mfa.firstFactors.test.js b/test/end-to-end/mfa.firstFactors.test.js new file mode 100644 index 000000000..5207c105e --- /dev/null +++ b/test/end-to-end/mfa.firstFactors.test.js @@ -0,0 +1,306 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * Imports + */ + +import assert from "assert"; +import puppeteer from "puppeteer"; +import { + clearBrowserCookiesWithoutAffectingConsole, + waitForSTElement, + screenshotOnFailure, + backendBeforeEach, + waitFor, +} from "../helpers"; +import fetch from "isomorphic-fetch"; + +import { TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL } from "../constants"; + +/* + * Tests. + */ +describe("SuperTokens MFA firstFactors support", function () { + let browser; + let page; + let consoleLogs = []; + + before(async function () { + await backendBeforeEach(); + + await fetch(`${TEST_SERVER_BASE_URL}/startst`, { + method: "POST", + }).catch(console.error); + + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: true, + }); + }); + + after(async function () { + await browser.close(); + + await fetch(`${TEST_SERVER_BASE_URL}/after`, { + method: "POST", + }).catch(console.error); + + await fetch(`${TEST_SERVER_BASE_URL}/stopst`, { + method: "POST", + }).catch(console.error); + }); + + afterEach(async function () { + await screenshotOnFailure(this, browser); + if (page) { + page.evaluate(() => window.localStorage.removeItem("firstFactors")); + await page.close(); + } + }); + + beforeEach(async function () { + page = await browser.newPage(); + page.on("console", (consoleObj) => { + const log = consoleObj.text(); + if (log.startsWith("ST_LOGS")) { + consoleLogs.push(log); + } + }); + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "true"); + }); + }); + + describe("with firstFactors set on the client", () => { + it("should display pwless w/ phone for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-phone"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-email"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "otp-email, otp-phone"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL_OR_PHONE"); + }); + + it("should display tp-pwless w/ email for [thirdparty, otp-email]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "thirdparty, otp-email"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + + it("should display tp-ep for [thirdparty, emailpassword]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "thirdparty, emailpassword"); + }); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkEmailPasswordLoginUI(page); + }); + + it("should throw for [unknown]", async () => { + await page.evaluate(() => { + window.localStorage.setItem("firstFactors", "unknown"); + }); + + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); + page.on("console", (ev) => { + if (ev.text() === "ST_THROWN_ERROR") { + onErrorBoundaryHit(true); + } + }); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + assert(await hitErrorBoundary); + }); + }); + + describe("with firstFactors set on the tenant", () => { + beforeEach(async () => { + await page.evaluate(() => { + window.localStorage.setItem("usesDynamicLoginMethods", "true"); + }); + }); + it("should display pwless w/ phone for [otp-phone] even if the client side firstFactor array is different", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + window.localStorage.setItem("firstFactors", "unknown"); + }, getDynLoginMethods(["otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ phone for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "PHONE"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-email"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + it("should display pwless w/ email for [otp-phone]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["otp-email", "otp-phone"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkPasswordlessLoginUI(page, "EMAIL_OR_PHONE"); + }); + + it("should display tp-pwless w/ email for [thirdparty, otp-email]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["thirdparty", "otp-email"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkPasswordlessLoginUI(page, "EMAIL"); + }); + + it("should display tp-ep for [thirdparty, emailpassword]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["thirdparty", "emailpassword"])); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await checkThirdPartyLoginUI(page); + await checkEmailPasswordLoginUI(page); + }); + + it("should throw for [unknown]", async () => { + await page.evaluate((dynLoginMethods) => { + window.localStorage.setItem("mockLoginMethodsForDynamicLogin", dynLoginMethods); + }, getDynLoginMethods(["unknown"])); + + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); + page.on("console", (ev) => { + if (ev.text() === "ST_THROWN_ERROR") { + onErrorBoundaryHit(true); + } + }); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitFor(500); + assert(await hitErrorBoundary); + }); + }); +}); + +function getDynLoginMethods(firstFactors) { + return JSON.stringify({ + emailPassword: { enabled: true }, + passwordless: { enabled: true }, + thirdParty: { enabled: true, providers: [{ id: "google", name: "Google" }] }, + firstFactors, + }); +} + +async function checkPasswordlessLoginUI(page, contactMethod) { + switch (contactMethod) { + case "EMAIL_OR_PHONE": + await waitForSTElement(page, "[data-supertokens~=input][name=emailOrPhone]"); + break; + case "EMAIL": + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + break; + case "PHONE": + await waitForSTElement(page, "[data-supertokens~=input][name=phoneNumber_text]"); + break; + default: + throw new Error("Unknown contact method " + contactMethod); + } +} + +async function checkThirdPartyLoginUI(page) { + // This basically checks that there is a provider shown + await waitForSTElement(page, "[data-supertokens~=providerContainer]"); +} + +async function checkEmailPasswordLoginUI(page) { + // This basically checks that there is a provider shown + await waitForSTElement(page, "[data-supertokens~=input][name=password]"); +} diff --git a/test/end-to-end/mfa.mock.signin.test.js b/test/end-to-end/mfa.mock.signin.test.js index 6efd9d6b3..1090fc1c2 100644 --- a/test/end-to-end/mfa.mock.signin.test.js +++ b/test/end-to-end/mfa.mock.signin.test.js @@ -42,7 +42,7 @@ import { getTestPhoneNumber } from "../exampleTestHelpers"; /* * Tests. */ -describe("SuperTokens SignIn w/ MFA", function () { +describe.skip("SuperTokens SignIn w/ MFA", function () { let browser; let page; let consoleLogs = []; @@ -302,8 +302,7 @@ describe("SuperTokens SignIn w/ MFA", function () { await waitForAccessDenied(page); }); - // TODO: for some reason this doesn't hit the error boundary - it.skip("should show throw if the only next option is an unknown factor id", async () => { + it("should show throw if the only next option is an unknown factor id", async () => { await setMFAInfo({ requirements: ["unknown"], }); @@ -476,7 +475,7 @@ describe("SuperTokens SignIn w/ MFA", function () { await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); const changeContact = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await changeContact.click(); @@ -547,7 +546,7 @@ describe("SuperTokens SignIn w/ MFA", function () { const chooseAnotherFactor = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=pwlessMFAFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await chooseAnotherFactor.click(); @@ -569,7 +568,7 @@ describe("SuperTokens SignIn w/ MFA", function () { const chooseAnotherFactor = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await chooseAnotherFactor.click(); await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); @@ -590,14 +589,14 @@ describe("SuperTokens SignIn w/ MFA", function () { const logoutButton = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=pwlessMFAFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); await waitForSTElement(page, "[data-supertokens~=input][name=email]"); assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); }); - it("should show a logout link - setup", async () => { + it("should show a logout link - verification", async () => { await setMFAInfo({ requirements: [factorId], isAlreadySetup: [factorId], @@ -612,7 +611,7 @@ describe("SuperTokens SignIn w/ MFA", function () { const logoutButton = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); await waitForSTElement(page, "[data-supertokens~=input][name=email]"); @@ -783,7 +782,8 @@ describe("SuperTokens SignIn w/ MFA", function () { const chooseAnotherFactor = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + + "[data-supertokens~=totpMFASetupFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await chooseAnotherFactor.click(); @@ -806,7 +806,7 @@ describe("SuperTokens SignIn w/ MFA", function () { const chooseAnotherFactor = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + "[data-supertokens~=totpMFAVerificationFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await chooseAnotherFactor.click(); await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); @@ -827,7 +827,8 @@ describe("SuperTokens SignIn w/ MFA", function () { const logoutButton = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + + "[data-supertokens~=totpMFASetupFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); await waitForSTElement(page, "[data-supertokens~=input][name=email]"); @@ -849,7 +850,8 @@ describe("SuperTokens SignIn w/ MFA", function () { const logoutButton = await waitForSTElement( page, - "[data-supertokens~=secondaryLinkWithLeftArrow]:nth-child(1)" + + "[data-supertokens~=totpMFAVerificationFooter] [data-supertokens~=secondaryText]:nth-child(1)" ); await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); await waitForSTElement(page, "[data-supertokens~=input][name=email]"); @@ -1160,7 +1162,7 @@ async function setupUserWithAllFactors(page) { } async function setMFAInfo(mfaInfo) { - let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMFAInfo`, { + let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMockMFAInfo`, { method: "POST", headers: new Headers([["content-type", "application/json"]]), body: JSON.stringify(mfaInfo), diff --git a/test/end-to-end/mfa.signin.test.js b/test/end-to-end/mfa.signin.test.js new file mode 100644 index 000000000..659b7ba88 --- /dev/null +++ b/test/end-to-end/mfa.signin.test.js @@ -0,0 +1,1342 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * Imports + */ + +import assert from "assert"; +import puppeteer from "puppeteer"; +import { + clearBrowserCookiesWithoutAffectingConsole, + getLogoutButton, + setInputValues, + submitForm, + toggleSignInSignUp, + waitForSTElement, + screenshotOnFailure, + backendBeforeEach, + getTestEmail, + getPasswordlessDevice, + waitFor, + getFactorChooserOptions, +} from "../helpers"; +import fetch from "isomorphic-fetch"; +import { CREATE_CODE_API, TEST_APPLICATION_SERVER_BASE_URL } from "../constants"; + +import { TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL } from "../constants"; +import { getTestPhoneNumber } from "../exampleTestHelpers"; + +/* + * Tests. + */ +describe("SuperTokens SignIn w/ MFA", function () { + let browser; + let page; + let consoleLogs = []; + + before(async function () { + await backendBeforeEach(); + + await fetch(`${TEST_SERVER_BASE_URL}/startst`, { + method: "POST", + }).catch(console.error); + + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: false, + }); + }); + + after(async function () { + await browser.close(); + + await fetch(`${TEST_SERVER_BASE_URL}/after`, { + method: "POST", + }).catch(console.error); + + await fetch(`${TEST_SERVER_BASE_URL}/stopst`, { + method: "POST", + }).catch(console.error); + }); + + afterEach(async function () { + await screenshotOnFailure(this, browser); + if (page) { + await page.close(); + } + }); + + beforeEach(async function () { + page = await browser.newPage(); + page.on("console", (consoleObj) => { + const log = consoleObj.text(); + // console.log(log); + if (log.startsWith("ST_LOGS")) { + consoleLogs.push(log); + } + }); + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + }); + + it("sign in with email-otp (auto-setup)", async function () { + const email = await getTestEmail(); + + await setMFAInfo({ + requirements: ["otp-email"], + }); + + await tryEmailPasswordSignUp(page, email); + + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); + + await waitForDashboard(page); + }); + + describe("sign in + setup + sign in with chooser flow", () => { + it("set up otp-phone and sign-in", async function () { + const email = await getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + }); + + await tryEmailPasswordSignUp(page, email); + + await completeOTP(page); + + await waitForDashboard(page); + await setupOTP(page, "PHONE", phoneNumber); + + await logout(page); + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("set up otp-email and sign-in", async function () { + await setMFAInfo({ + requirements: [], + }); + const email = await getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + + await tryPasswordlessSignInUp(page, phoneNumber); + + await waitForDashboard(page); + await setupOTP(page, "EMAIL", email); + + await logout(page); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email"] }], + }); + + await tryPasswordlessSignInUp(page, phoneNumber); + + await waitFor(500); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("set up totp and sign-in", async function () { + await setMFAInfo({ + requirements: [], + }); + const email = await getTestEmail(); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "totp"] }], + }); + + await tryEmailPasswordSignUp(page, email); + await completeOTP(page); + + await waitForDashboard(page); + + const totp = await setupTOTP(page); + + await logout(page); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + }); + + describe("chooser screen", () => { + let email, phoneNumber; + let totp; + before(async () => { + page = await browser.newPage(); + ({ email, phoneNumber, totp } = await setupUserWithAllFactors(page)); + await page.close(); + }); + + it("should redirect to the factor screen during sign in if only one factor is available (limited by FE recipe inits)", async () => { + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "false"); + window.localStorage.setItem("clientRecipeListForDynamicLogin", JSON.stringify(["emailpassword"])); + }); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone", "totp"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + + it("should redirect to the factor screen during sign in if only one factor is available (limited by isAlreadySetup/isAllowedToSetup)", async () => { + await page.evaluate(() => { + window.localStorage.setItem("enableAllRecipes", "false"); + window.localStorage.setItem("clientRecipeListForDynamicLogin", JSON.stringify(["emailpassword"])); + }); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone", "totp"] }], + hasTOTP: true, + isAlreadySetup: ["totp"], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should redirect to the factor screen during sign in if only one factor is available (limited by next array)", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["totp"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + + it("should show all factors the user can complete or set up in the next array", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + + const options = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(options), new Set(["otp-email", "totp"])); + }); + + it("should show all factors the user can complete or set up if the next array is empty", async () => { + await setMFAInfo({ + requirements: [], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await goToFactorChooser(page); + + const optionsBefore2FA = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(optionsBefore2FA), new Set(["otp-phone", "otp-email"])); + + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await goToFactorChooser(page); + + const optionsAfter2FA = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(optionsAfter2FA), new Set(["otp-phone", "otp-email", "totp"])); + }); + + it("should show access denied if there are no available options during sign in", async () => { + await setMFAInfo({ + requirements: ["otp-phone"], + isAlreadySetup: ["otp-email"], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + }); + + it("should show access denied if there are no available options after sign in", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + await goToFactorChooser(page, false); + + await waitForAccessDenied(page); + }); + + it("should show throw if the only next option is an unknown factor id", async () => { + await setMFAInfo({ + requirements: ["unknown"], + }); + + await expectErrorThrown(page, () => tryEmailPasswordSignIn(page, email)); + }); + + it("should show a back link only if visited after sign in", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + + await waitForSTElement(page, "[data-supertokens~=backButton]", true); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await goToFactorChooser(page); + + await waitForSTElement(page, "[data-supertokens~=backButton]"); + }); + + it("should show a logout link", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + hasTOTP: false, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + + await waitForSTElement(page, "[data-supertokens~=secondaryLinkWithLeftArrow]"); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await goToFactorChooser(page); + + await waitForSTElement(page, "[data-supertokens~=secondaryLinkWithLeftArrow]"); + }); + }); + ``; + + describe("factor screens", () => { + describe("otp", () => { + describe("otp-phone", () => { + getOTPTests("PHONE", "otp-phone"); + }); + + describe("otp-email", () => { + getOTPTests("EMAIL", "otp-email"); + }); + + function getOTPTests(contactMethod, factorId) { + let email, phoneNumber; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(factorId); + phoneNumber = getTestPhoneNumber(); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => + window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo") + ); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + + await page.close(); + }); + + it("should show access denied if the app navigates to the setup page but the user it is not allowed to set up the factor", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}?setup=true`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await waitForAccessDenied(page); + }); + + it("should show access denied if setup is not allowed but the factor is not set up", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitForAccessDenied(page); + }); + + it("should handle createCode failures gracefully", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + }); + + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url() === CREATE_CODE_API && request.method() === "POST") { + return request.respond({ + status: 400, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "BAD_INPUT", + }), + }); + } + + return request.continue(); + }; + page.on("request", requestHandler); + try { + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + }); + + it("should enable you to change the contact info during setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + }); + + await tryEmailPasswordSignIn(page, email); + + await setInputValues(page, [ + contactMethod === "PHONE" + ? { name: "phoneNumber_text", value: getTestPhoneNumber() } + : { name: "email", value: await getTestEmail() }, + ]); + await submitForm(page); + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + const changeContact = await waitForSTElement( + page, + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await changeContact.click(); + + await setInputValues(page, [ + contactMethod === "PHONE" + ? { name: "phoneNumber_text", value: phoneNumber } + : { name: "email", value: email }, + ]); + await submitForm(page); + await completeOTP(page); + }); + + it("should show a link redirecting back if visited after sign in - setup", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + it("should show a link redirecting back if visited after sign in - verification", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + it("should show a link redirecting to the chooser screen if other options are available during sign in - setup", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "totp"] }], + isAlreadySetup: ["totp"], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=pwlessMFAFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + it("should show a link redirecting to the chooser screen if other options are available during sign in - verification", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "totp"] }], + isAlreadySetup: [factorId, "totp"], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + + it("should show a logout link - setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=pwlessMFAFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + + it("should show a logout link - verification", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + "[data-supertokens~=pwlessMFAOTPFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + } + }); + + describe("totp", () => { + const factorId = "totp"; + + let email, phoneNumber; + before(async () => { + await setMFAInfo({ + isAllowedToSetup: ["totp"], + }); + page = await browser.newPage(); + + email = await getTestEmail(factorId); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await setupTOTP(page); + await waitForDashboard(page); + + await page.close(); + }); + + it("should show access denied if the app navigates to the setup page but the user it is not allowed to set up the factor", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}?setup=true`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await waitForAccessDenied(page); + }); + + it("should show access denied if setup is not allowed but the factor is not set up", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [], + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await waitForAccessDenied(page); + }); + + // TODO: re-enable this + it.skip("should handle createDevice failures gracefully", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + }); + + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url() === CREATE_DEVICE_API && request.method() === "POST") { + return request.respond({ + status: 400, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "BAD_INPUT", + }), + }); + } + + return request.continue(); + }; + page.on("request", requestHandler); + try { + await tryEmailPasswordSignIn(page, email); + await waitForAccessDenied(page); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + }); + + it("should show a link redirecting back if visited after sign in - setup", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + + it("should show a link redirecting back if visited after sign in - verification", async () => { + await setMFAInfo({ + requirements: [], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/mfa/${factorId}`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + const backBtn = await waitForSTElement(page, "[data-supertokens~=backButton]"); + await backBtn.click(); + await waitForDashboard(page); + }); + + it("should show a link redirecting to the chooser screen if other options are available during sign in - setup", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "otp-email"] }], + isAlreadySetup: ["otp-email"], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + + "[data-supertokens~=totpMFASetupFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + + it("should show a link redirecting to the chooser screen if other options are available during sign in - verification", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [factorId, "otp-email"] }], + isAlreadySetup: [factorId, "otp-email"], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + await chooseFactor(page, factorId); + + const chooseAnotherFactor = await waitForSTElement( + page, + "[data-supertokens~=totpMFAVerificationFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await chooseAnotherFactor.click(); + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + }); + + it("should show a logout link - setup", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [], + isAllowedToSetup: [factorId], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + + "[data-supertokens~=totpMFASetupFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + + it("should show a logout link - verify", async () => { + await setMFAInfo({ + requirements: [factorId], + isAlreadySetup: [factorId], + isAllowedToSetup: [], + resp: { + email, + phoneNumber, + }, + }); + + await tryEmailPasswordSignIn(page, email); + + const logoutButton = await waitForSTElement( + page, + + "[data-supertokens~=totpMFAVerificationFooter] [data-supertokens~=secondaryText]:nth-child(1)" + ); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~=input][name=email]"); + assert.strictEqual(await page.url(), `${TEST_CLIENT_BASE_URL}/auth/`); + }); + }); + }); + + describe("default requirements", () => { + let email, phoneNumber; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(); + phoneNumber = getTestPhoneNumber(); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + + await page.close(); + }); + + beforeEach(async () => { + await setMFAInfo({}); + }); + + it("should not require any factors after sign up", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + }); + + it("should not allow you to set up a secondary factor before completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(list, ["otp-email"]); + }); + + it("should not allow you to set up all other factors after completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + // TODO: validate + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "otp-phone", "totp"])); + }); + + it("should require 2fa to sign in after setting up another factor", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + const totp = await setupTOTP(page); + await logout(page); + + await tryEmailPasswordSignIn(page, email); + const list = await getFactorChooserOptions(page); + // TODO: validate this, maybe it should only be totp? + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should not require any factors after sign up", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + }); + + it("should not allow you to set up a secondary factor before completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(list, ["otp-email"]); + }); + + it("should not allow you to set up all other factors after completing 2FA", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + // TODO: validate + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await goToFactorChooser(page); + + const list = await getFactorChooserOptions(page); + + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "otp-phone", "totp"])); + }); + + it("should require 2fa to sign in after setting up another factor", async () => { + await tryEmailPasswordSignIn(page, email); + + await waitForDashboard(page); + + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + const totp = await setupTOTP(page); + await logout(page); + + await tryEmailPasswordSignIn(page, email); + const list = await getFactorChooserOptions(page); + // TODO: validate this, maybe it should only be totp? + assert.deepStrictEqual(new Set(list), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + }); + + describe("requirement handling", () => { + let email, phoneNumber; + let totp; + before(async () => { + await setMFAInfo({}); + page = await browser.newPage(); + + email = await getTestEmail(); + phoneNumber = getTestPhoneNumber(); + + await setMFAInfo({}); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, []); + + await page.evaluate(() => window.localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await page.evaluate(() => window.localStorage.removeItem("clientRecipeListForDynamicLogin")); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await tryEmailPasswordSignUp(page, email); + await waitForDashboard(page); + await goToFactorChooser(page); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + await setupOTP(page, "PHONE", phoneNumber); + totp = await setupTOTP(page); + + await page.close(); + }); + + describe("multistep requirement list", () => { + it("multistep requirements should happen in order (allOf -> oneOf)", async () => { + await setMFAInfo({ + requirements: [{ allOf: ["otp-phone", "totp"] }, { oneOf: ["otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await completeTOTP(page, totp); + await completeOTP(page); + await waitForDashboard(page); + }); + + it("multistep requirements should happen in order (oneOf -> allOf)", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-phone", "totp"] }, { allOf: ["totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + const factors2 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors2), new Set(["otp-email", "totp"])); + await chooseFactor(page, "totp"); + await completeTOTP(page, totp); + await completeOTP(page); + await waitForDashboard(page); + }); + it("string requirements strictly set the order of the factor screens", async () => { + await setMFAInfo({ + requirements: ["otp-phone", "totp", "otp-email"], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + await completeOTP(page, "PHONE"); + await completeTOTP(page, totp); + await completeOTP(page, "EMAIL"); + await waitForDashboard(page); + }); + }); + + describe("allOf", () => { + it("should pass if all requirements are complete", async () => { + await setMFAInfo({ + requirements: [{ allOf: ["otp-phone", "totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp", "otp-email"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + const factors2 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors2), new Set(["totp", "otp-email"])); + await chooseFactor(page, "otp-email"); + await completeOTP(page); + + await completeTOTP(page, totp); + await waitForDashboard(page); + }); + it("should pass if the array is empty", async () => { + await setMFAInfo({ + requirements: [{ allOf: [] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForDashboard(page); + }); + }); + describe("oneOf", () => { + it("should pass if one of the requirements are complete", async () => { + await setMFAInfo({ + requirements: [{ oneOf: ["otp-phone", "totp", "otp-email"] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + const factors1 = await getFactorChooserOptions(page); + assert.deepStrictEqual(new Set(factors1), new Set(["otp-phone", "totp", "otp-email"])); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + + await waitForDashboard(page); + }); + it("should pass if the array is empty", async () => { + await setMFAInfo({ + requirements: [{ oneOf: [] }], + hasTOTP: true, + }); + + await tryEmailPasswordSignIn(page, email); + await waitForDashboard(page); + }); + }); + }); +}); + +async function setupUserWithAllFactors(page) { + // TODO: it'd be cleaner if this part was not done through the app + const email = await getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + await clearBrowserCookiesWithoutAffectingConsole(page, []); + await page.evaluate(() => window.localStorage.setItem("enableAllRecipes", "true")); + + await setMFAInfo({ + requirements: [{ oneOf: ["otp-email", "otp-phone"] }], + }); + + await tryEmailPasswordSignUp(page, email); + + await completeOTP(page); + + await waitForDashboard(page); + await setupOTP(page, "PHONE", phoneNumber); + + await waitForDashboard(page); + const totp = await setupTOTP(page); + return { email, phoneNumber, totp }; +} + +async function setMFAInfo(mfaInfo) { + let resp = await fetch(`${TEST_APPLICATION_SERVER_BASE_URL}/setMFAInfo`, { + method: "POST", + headers: new Headers([["content-type", "application/json"]]), + body: JSON.stringify(mfaInfo), + }); + assert.strictEqual(resp.status, 200); +} + +async function completeOTP(page, contactMethod) { + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + if (contactMethod) { + assert.strictEqual(loginAttemptInfo.contactMethod, contactMethod); + } + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); +} + +async function logout(page) { + await waitForDashboard(page); + const logoutButton = await getLogoutButton(page); + await Promise.all([logoutButton.click(), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page); +} + +async function waitForDashboard(page) { + await Promise.all([page.waitForSelector(".sessionInfo-user-id"), page.waitForNetworkIdle()]); +} + +async function waitForAccessDenied(page) { + const error = await waitForSTElement(page, "[data-supertokens~=accessDeniedError]"); + return error.evaluate((e) => e.textContent); +} + +async function setupOTP(page, contactMethod, phoneNumber) { + await goToFactorChooser(page); + await chooseFactor(page, contactMethod === "PHONE" ? "otp-phone" : "otp-email"); + + await setInputValues(page, [ + { name: contactMethod === "PHONE" ? "phoneNumber_text" : "email", value: phoneNumber }, + ]); + await submitForm(page); + + await completeOTP(page); +} + +async function setupTOTP(page) { + await goToFactorChooser(page); + await chooseFactor(page, "totp"); + const showSecret = await waitForSTElement(page, "[data-supertokens~=showTOTPSecretBtn]"); + await showSecret.click(); + + const secretDiv = await waitForSTElement(page, "[data-supertokens~=totpSecret]"); + const secret = await secretDiv.evaluate((e) => e.textContent); + + const totp = secret.substring(secret.length - 4); + + await completeTOTP(page, totp); + return totp; +} + +async function completeTOTP(page, totp) { + await setInputValues(page, [{ name: "totp", value: totp }]); + await submitForm(page); +} + +async function tryEmailPasswordSignUp(page, email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await toggleSignInSignUp(page); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: "Asdf12.." }, + { name: "name", value: "asdf" }, + { name: "age", value: "20" }, + ]); + + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryEmailPasswordSignIn(page, email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=emailpassword`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: "Asdf12.." }, + ]); + + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryPasswordlessSignInUp(page, contactInfo) { + await page.evaluate(() => localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=passwordless`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await setInputValues(page, [{ name: "emailOrPhone", value: contactInfo }]); + await submitForm(page); + + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + const device = await getPasswordlessDevice(loginAttemptInfo); + await setInputValues(page, [{ name: "userInputCode", value: device.codes[0].userInputCode }]); + await submitForm(page); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function tryThirdPartySignInUp(page, email, isVerified = true, userId = email) { + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth/?rid=thirdparty`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + await assertProviders(page); + + await clickOnProviderButton(page, "Mock Provider"); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, `/mockProvider/auth`); + assert.ok(url.searchParams.get("state")); + + await Promise.all([ + page.goto( + `${TEST_CLIENT_BASE_URL}/auth/callback/mock-provider?code=asdf&email=${encodeURIComponent( + email + )}&userId=${encodeURIComponent(userId)}&isVerified=${isVerified}&state=${url.searchParams.get("state")}` + ), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + await new Promise((res) => setTimeout(res, 1000)); +} + +async function expectErrorThrown(page, cb) { + let onErrorBoundaryHit; + let hitErrorBoundary = new Promise((res) => { + onErrorBoundaryHit = res; + }); + page.on("console", (ev) => { + // console.log(ev.text()); + if (ev.text() === "ST_THROWN_ERROR") { + onErrorBoundaryHit(true); + } + }); + await Promise.all([hitErrorBoundary, cb()]); + assert(hitErrorBoundary); +} +async function goToFactorChooser(page, waitForList = true) { + const ele = await page.waitForSelector(".goToFactorChooser"); + await waitFor(100); + await Promise.all([page.waitForNavigation({ waitUntil: "networkidle0" }), ele.click()]); + if (waitForList) { + await waitForSTElement(page, "[data-supertokens~=factorChooserList]"); + } +} + +async function chooseFactor(page, id) { + const ele = await waitForSTElement(page, `[data-supertokens~=factorChooserOption][data-supertokens~=${id}]`); + await waitFor(100); + await Promise.all([page.waitForNavigation({ waitUntil: "networkidle0" }), ele.click()]); + await waitForSTElement(page); +} diff --git a/test/end-to-end/multitenancy.dynamic_login_methods.test.js b/test/end-to-end/multitenancy.dynamic_login_methods.test.js index fc019b57c..39b22faf8 100644 --- a/test/end-to-end/multitenancy.dynamic_login_methods.test.js +++ b/test/end-to-end/multitenancy.dynamic_login_methods.test.js @@ -43,10 +43,10 @@ import { import { TEST_CLIENT_BASE_URL, DEFAULT_WEBSITE_BASE_PATH, - ST_ROOT_SELECTOR, TEST_SERVER_BASE_URL, SIGN_IN_UP_API, SOMETHING_WENT_WRONG_ERROR, + LOGIN_METHODS_API, } from "../constants"; let connectionURI; @@ -199,6 +199,16 @@ describe("SuperTokens Multitenancy dynamic login methods", function () { }); it("should postpone render with react-router-dom", async function () { + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url().startsWith(LOGIN_METHODS_API)) { + setTimeout(() => request.continue(), 500); + } else { + request.continue(); + } + }; + page.on("request", requestHandler); + await enableDynamicLoginMethods(page, { emailPassword: { enabled: false }, passwordless: { enabled: false }, @@ -207,7 +217,7 @@ describe("SuperTokens Multitenancy dynamic login methods", function () { providers: [{ id: "apple", name: "Apple" }], }, }); - await Promise.all([page.goto(`${TEST_CLIENT_BASE_URL}${DEFAULT_WEBSITE_BASE_PATH}`)]); + await page.goto(`${TEST_CLIENT_BASE_URL}${DEFAULT_WEBSITE_BASE_PATH}`); const spinner = await waitForSTElement(page, "[data-supertokens~=delayedRender]"); assert.ok(spinner); @@ -217,6 +227,15 @@ describe("SuperTokens Multitenancy dynamic login methods", function () { }); it("should postpone render with no react-router-dom", async function () { + await page.setRequestInterception(true); + const requestHandler = (request) => { + if (request.url().startsWith(LOGIN_METHODS_API)) { + setTimeout(() => request.continue(), 500); + } else { + request.continue(); + } + }; + page.on("request", requestHandler); await enableDynamicLoginMethods(page, { emailPassword: { enabled: false }, passwordless: { enabled: false }, @@ -225,7 +244,7 @@ describe("SuperTokens Multitenancy dynamic login methods", function () { providers: [{ id: "apple", name: "Apple" }], }, }); - await Promise.all([page.goto(`${TEST_CLIENT_BASE_URL}${DEFAULT_WEBSITE_BASE_PATH}?router=no-router`)]); + await page.goto(`${TEST_CLIENT_BASE_URL}${DEFAULT_WEBSITE_BASE_PATH}?router=no-router`); const spinner = await waitForSTElement(page, "[data-supertokens~=delayedRender]"); assert.ok(spinner); diff --git a/test/server/index.js b/test/server/index.js index ed676f85f..d9e01607e 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -99,6 +99,28 @@ try { accountLinkingSupported = false; } +/** @type {import("supertokens-node/recipe/usermetadata").default | undefined} */ +let UserMetadata; +let UserMetadataRaw, userMetadataSupported; +try { + UserMetadataRaw = require("supertokens-node/lib/build/recipe/usermetadata/recipe").default; + UserMetadata = require("supertokens-node/recipe/usermetadata"); + userMetadataSupported = true; +} catch (ex) { + userMetadataSupported = false; +} + +/** @type {import("supertokens-node/recipe/multifactorauth").default | undefined} */ +let MultiFactorAuth; +let MultiFactorAuthRaw, multiFactorAuthSupported; +try { + MultiFactorAuthRaw = require("supertokens-node/lib/build/recipe/multifactorauth/recipe").default; + MultiFactorAuth = require("supertokens-node/recipe/multifactorauth"); + multiFactorAuthSupported = true; +} catch (ex) { + multiFactorAuthSupported = false; +} + let generalErrorSupported; if (maxVersion(nodeSDKVersion, "9.9.9") === "9.9.9") { @@ -392,23 +414,24 @@ app.post("/setMFAInfo", async (req, res) => { app.post("/completeFactor", verifySession(), async (req, res) => { let session = req.session; - const payload = session.getAccessTokenPayload(); - const mfaClaim = payload["st-mfa"]; - const c = { - ...mfaClaim.c, - [req.body.id]: Date.now(), - }; - if (req.body.id === "totp") { - mfaInfo.hasTOTP = true; - } - - await session.mergeIntoAccessTokenPayload({ - "st-mfa": { - ...mfaClaim, - c, - n: getNextArray(c), - }, - }); + // const payload = session.getAccessTokenPayload(); + // const mfaClaim = payload["st-mfa"]; + // const c = { + // ...mfaClaim.c, + // [req.body.id]: Date.now(), + // }; + // if (req.body.id === "totp") { + // mfaInfo.hasTOTP = true; + // } + + // await session.mergeIntoAccessTokenPayload({ + // "st-mfa": { + // ...mfaClaim, + // c, + // n: getNextArray(c), + // }, + // }); + await MultiFactorAuth.markFactorAsCompleteInSession(session, req.body.id); res.send({ status: "OK" }); }); @@ -422,79 +445,79 @@ app.post("/mergeIntoAccessTokenPayload", verifySession(), async (req, res) => { }); // TODO: remove this after we get backend SDK support -app.get("/auth/mfa/info", verifySession(), async (req, res) => { - let session = req.session; - const user = await SuperTokens.getUser(session.getUserId()); - const payload = session.getAccessTokenPayload(); - let isAllowedToSetup = []; - let isAlreadySetup = []; - if (user.phoneNumbers.length > 0) { - isAlreadySetup.push("otp-phone"); - } - if (user.emails.length > 0) { - isAlreadySetup.push("otp-email"); - } - - if (mfaInfo.hasTOTP) { - isAlreadySetup.push("totp"); - } - - let c; - - const recipeUser = user.loginMethods.find( - (u) => u.recipeUserId.toString() === session.getRecipeUserId().toString() - ); - if (recipeUser.recipeId !== "passwordless") { - c = { [recipeUser.recipeId]: Date.now() }; - } else if (recipeUser.email) { - // This isn't correct, but will do for testing - c = { "otp-email": Date.now() }; - } else { - c = { "otp-phone": Date.now() }; - } - const mfaClaim = payload["st-mfa"]; - if (mfaInfo?.claimValue) { - await session.mergeIntoAccessTokenPayload({ - "st-mfa": mfaInfo.claimValue, - }); - } else if (mfaInfo?.requirements) { - if (mfaClaim) { - c = mfaClaim.c; - } - - let n = getNextArray(c); - await session.mergeIntoAccessTokenPayload({ - "st-mfa": { - c: c, - n: n, - }, - }); - } else if (mfaClaim === undefined) { - await session.mergeIntoAccessTokenPayload({ - "st-mfa": { - c: c, - n: !mfaInfo.hasTOTP ? [] : getNextArray(c, [{ oneOf: ["totp", "otp-phone", "otp-email"] }]), - }, - }); - } - if ( - isAlreadySetup.length === 0 || - (mfaClaim !== undefined && isAlreadySetup.some((id) => mfaClaim.c[id] !== undefined)) - ) { - isAllowedToSetup = ["otp-phone", "otp-email", "totp"].filter((id) => !isAlreadySetup.includes(id)); - } - - res.send({ - status: "OK", - email: user.emails[0], - phoneNumber: user.phoneNumbers[0], - factors: { - isAllowedToSetup: mfaInfo.isAllowedToSetup ?? isAllowedToSetup, - isAlreadySetup: mfaInfo.isAlreadySetup ?? isAlreadySetup, - }, - ...mfaInfo.resp, - }); -}); +// app.get("/auth/mfa/info", verifySession(), async (req, res) => { +// let session = req.session; +// const user = await SuperTokens.getUser(session.getUserId()); +// const payload = session.getAccessTokenPayload(); +// let isAllowedToSetup = []; +// let isAlreadySetup = []; +// if (user.phoneNumbers.length > 0) { +// isAlreadySetup.push("otp-phone"); +// } +// if (user.emails.length > 0) { +// isAlreadySetup.push("otp-email"); +// } + +// if (mfaInfo.hasTOTP) { +// isAlreadySetup.push("totp"); +// } + +// let c; + +// const recipeUser = user.loginMethods.find( +// (u) => u.recipeUserId.toString() === session.getRecipeUserId().toString() +// ); +// if (recipeUser.recipeId !== "passwordless") { +// c = { [recipeUser.recipeId]: Date.now() }; +// } else if (recipeUser.email) { +// // This isn't correct, but will do for testing +// c = { "otp-email": Date.now() }; +// } else { +// c = { "otp-phone": Date.now() }; +// } +// const mfaClaim = payload["st-mfa"]; +// if (mfaInfo?.claimValue) { +// await session.mergeIntoAccessTokenPayload({ +// "st-mfa": mfaInfo.claimValue, +// }); +// } else if (mfaInfo?.requirements) { +// if (mfaClaim) { +// c = mfaClaim.c; +// } + +// let n = getNextArray(c); +// await session.mergeIntoAccessTokenPayload({ +// "st-mfa": { +// c: c, +// n: n, +// }, +// }); +// } else if (mfaClaim === undefined) { +// await session.mergeIntoAccessTokenPayload({ +// "st-mfa": { +// c: c, +// n: !mfaInfo.hasTOTP ? [] : getNextArray(c, [{ oneOf: ["totp", "otp-phone", "otp-email"] }]), +// }, +// }); +// } +// if ( +// isAlreadySetup.length === 0 || +// (mfaClaim !== undefined && isAlreadySetup.some((id) => mfaClaim.c[id] !== undefined)) +// ) { +// isAllowedToSetup = ["otp-phone", "otp-email", "totp"].filter((id) => !isAlreadySetup.includes(id)); +// } + +// res.send({ +// status: "OK", +// email: user.emails[0], +// phoneNumber: user.phoneNumbers[0], +// factors: { +// isAllowedToSetup: mfaInfo.isAllowedToSetup ?? isAllowedToSetup, +// isAlreadySetup: mfaInfo.isAlreadySetup ?? isAlreadySetup, +// }, +// ...mfaInfo.resp, +// }); +// }); app.get("/token", async (_, res) => { res.send({ @@ -702,6 +725,14 @@ function initST() { AccountLinkingRaw.reset(); } + if (userMetadataSupported) { + UserMetadataRaw.reset(); + } + + if (multiFactorAuthSupported) { + MultiFactorAuthRaw.reset(); + } + EmailVerificationRaw.reset(); EmailPasswordRaw.reset(); ThirdPartyRaw.reset(); @@ -1063,49 +1094,49 @@ function initST() { }; } - const deviceInfo = await input.options.recipeImplementation.listCodesByPreAuthSessionId( - { - tenantId: input.tenantId, - preAuthSessionId: input.preAuthSessionId, - userContext: input.userContext, - } - ); + // const deviceInfo = await input.options.recipeImplementation.listCodesByPreAuthSessionId( + // { + // tenantId: input.tenantId, + // preAuthSessionId: input.preAuthSessionId, + // userContext: input.userContext, + // } + // ); const resp = await originalImplementation.consumeCodePOST(input); - if (resp.status === "OK") { - let session = await Session.getSession(input.options.req, input.options.res, { - overrideGlobalClaimValidators: () => [], - sessionRequired: false, - }); - - if (session) { - await AccountLinking.createPrimaryUser(session.getRecipeUserId()); - await AccountLinking.linkAccounts( - resp.session.getRecipeUserId(), - session.getUserId() - ); - - const mfaClaim = session.getAccessTokenPayload()["st-mfa"]; - - let factorId; - if (deviceInfo.email !== undefined) { - factorId = "otp-email"; - } else { - factorId = "otp-phone"; - } - - const c = { - ...mfaClaim?.c, - [factorId]: new Date() / 1000, - }; - await session.mergeIntoAccessTokenPayload({ - "st-mfa": { - c, - n: getNextArray(c), - }, - }); - } - } + // if (resp.status === "OK") { + // let session = await Session.getSession(input.options.req, input.options.res, { + // overrideGlobalClaimValidators: () => [], + // sessionRequired: false, + // }); + + // // if (session) { + // // await AccountLinking.createPrimaryUser(session.getRecipeUserId()); + // // await AccountLinking.linkAccounts( + // // resp.session.getRecipeUserId(), + // // session.getUserId() + // // ); + + // // const mfaClaim = session.getAccessTokenPayload()["st-mfa"]; + + // // let factorId; + // // if (deviceInfo.email !== undefined) { + // // factorId = "otp-email"; + // // } else { + // // factorId = "otp-phone"; + // // } + + // // const c = { + // // ...mfaClaim?.c, + // // [factorId]: new Date() / 1000, + // // }; + // // await session.mergeIntoAccessTokenPayload({ + // // "st-mfa": { + // // c, + // // n: getNextArray(c), + // // }, + // // }); + // // } + // } return resp; }, }; @@ -1224,6 +1255,44 @@ function initST() { ]); } } + if (multiFactorAuthSupported) { + recipeList.push([ + "multifactorauth", + MultiFactorAuth.init({ + firstFactors: mfaInfo.firstFactors, + override: { + functions: (oI) => ({ + ...oI, + getFactorsSetupForUser: async (input) => { + const res = await oI.getFactorsSetupForUser(input); + return mfaInfo?.isAllowedToSetup ?? res; + }, + getAllAvailableFactorIds: async (input) => { + const res = await oI.getAllAvailableFactorIds(input); + if (mfaInfo?.isAllowedToSetup || mfaInfo?.isAlreadySetup) { + return [...mfaInfo.isAllowedToSetup, ...mfaInfo.isAlreadySetup]; + } + return res; + }, + isAllowedToSetupFactor: async (input) => { + const res = await oI.isAllowedToSetupFactor(input); + if (mfaInfo?.isAllowedToSetup) { + return mfaInfo.isAllowedToSetup.includes(input.factorId); + } + return res; + }, + getMFARequirementsForAuth: async (input) => { + const res = await oI.getMFARequirementsForAuth(input); + if (mfaInfo?.requirements) { + return mfaInfo.requirements; + } + return res; + }, + }), + }, + }), + ]); + } SuperTokens.init({ appInfo: { diff --git a/test/server/package-lock.json b/test/server/package-lock.json index 70cc7528f..3c7b82af3 100644 --- a/test/server/package-lock.json +++ b/test/server/package-lock.json @@ -15,7 +15,7 @@ "dotenv": "^8.2.0", "express": "4.17.1", "morgan": "^1.10.0", - "supertokens-node": "^16.3.4" + "supertokens-node": "github:supertokens/supertokens-node#mfa-impl" } }, "node_modules/accepts": { @@ -884,8 +884,8 @@ }, "node_modules/supertokens-node": { "version": "16.3.4", - "resolved": "https://registry.npmjs.org/supertokens-node/-/supertokens-node-16.3.4.tgz", - "integrity": "sha512-mEoP+MFiFCxfM1Lul11M+FTK3/yucmNZjjD8C981VyOzoXEYnwvoPTnYPrDjTbkfGJt5wDioAdZ/b3ygknUYJg==", + "resolved": "git+ssh://git@github.com/supertokens/supertokens-node.git#0629531763edf6b083874effc0e0506b638373ee", + "license": "Apache-2.0", "dependencies": { "content-type": "^1.0.5", "cookie": "0.4.0", @@ -1729,9 +1729,8 @@ "integrity": "sha512-r0JFBjkMIdep3Lbk3JA+MpnpuOtw4RSyrlRAbrzMcxwiYco3GFWl/daimQZ5b1forOiUODpOlXbSOljP/oyurg==" }, "supertokens-node": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/supertokens-node/-/supertokens-node-16.3.4.tgz", - "integrity": "sha512-mEoP+MFiFCxfM1Lul11M+FTK3/yucmNZjjD8C981VyOzoXEYnwvoPTnYPrDjTbkfGJt5wDioAdZ/b3ygknUYJg==", + "version": "git+ssh://git@github.com/supertokens/supertokens-node.git#0629531763edf6b083874effc0e0506b638373ee", + "from": "supertokens-node@github:supertokens/supertokens-node#mfa-impl", "requires": { "content-type": "^1.0.5", "cookie": "0.4.0", diff --git a/test/server/package.json b/test/server/package.json index 4dde305c0..d331b14f7 100644 --- a/test/server/package.json +++ b/test/server/package.json @@ -15,6 +15,6 @@ "dotenv": "^8.2.0", "express": "4.17.1", "morgan": "^1.10.0", - "supertokens-node": "^16.3.4" + "supertokens-node": "github:supertokens/supertokens-node#mfa-impl" } } diff --git a/test/unit/componentOverrides.test.tsx b/test/unit/componentOverrides.test.tsx index bd1e3918e..2a985719f 100644 --- a/test/unit/componentOverrides.test.tsx +++ b/test/unit/componentOverrides.test.tsx @@ -9,6 +9,8 @@ import { ComponentOverrideMap as EmailVerificationOverrideMap } from "../../lib/ import { ComponentOverrideMap as ThirdPartyEmailPasswordOverrideMap } from "../../lib/ts/recipe/thirdpartyemailpassword/types"; import { ComponentOverrideMap as PasswordlessOverrideMap } from "../../lib/ts/recipe/passwordless/types"; import { ComponentOverrideMap as ThirdPartyPasswordlessOverrideMap } from "../../lib/ts/recipe/thirdpartypasswordless/types"; +import { ComponentOverrideMap as TOTPOverrideMap } from "../../lib/ts/recipe/totp/types"; +import { ComponentOverrideMap as MFAOverrideMap } from "../../lib/ts/recipe/multifactorauth/types"; import "@testing-library/jest-dom"; import EmailPassword from "../../lib/ts/recipe/emailpassword/recipe"; @@ -57,13 +59,31 @@ import { EmailOrPhoneForm } from "../../lib/ts/recipe/passwordless/components/th import ThirdParty from "../../lib/ts/recipe/thirdparty/recipe"; import { Google, ThirdpartyComponentsOverrideProvider } from "../../lib/ts/recipe/thirdparty"; import { SignInAndUpCallback } from "../../lib/ts/recipe/thirdparty/prebuiltui"; +import { MFAFooter } from "../../lib/ts/recipe/passwordless/components/themes/mfa/mfaFooter"; +import { MFAHeader } from "../../lib/ts/recipe/passwordless/components/themes/mfa/mfaHeader"; +import { MFAOTPFooter } from "../../lib/ts/recipe/passwordless/components/themes/mfa/mfaOTPFooter"; +import { MFAOTPHeader } from "../../lib/ts/recipe/passwordless/components/themes/mfa/mfaOTPHeader"; +import { BlockedScreen } from "../../lib/ts/recipe/totp/components/themes/mfa/blockedScreen"; +import { CodeForm } from "../../lib/ts/recipe/totp/components/themes/mfa/totpCodeForm"; +import { CodeVerificationFooter } from "../../lib/ts/recipe/totp/components/themes/mfa/totpCodeVerificationFooter"; +import { CodeVerificationHeader } from "../../lib/ts/recipe/totp/components/themes/mfa/totpCodeVerificationHeader"; +import { DeviceInfoSection } from "../../lib/ts/recipe/totp/components/themes/mfa/totpDeviceInfoSection"; +import { DeviceSetupFooter } from "../../lib/ts/recipe/totp/components/themes/mfa/totpDeviceSetupFooter"; +import { DeviceSetupHeader } from "../../lib/ts/recipe/totp/components/themes/mfa/totpDeviceSetupHeader"; +import { LoadingScreen } from "../../lib/ts/recipe/totp/components/themes/mfa/loadingScreen"; +import { FactorChooserFooter } from "../../lib/ts/recipe/multifactorauth/components/themes/factorChooser/factorChooserFooter"; +import { FactorChooserHeader } from "../../lib/ts/recipe/multifactorauth/components/themes/factorChooser/factorChooserHeader"; +import { FactorList } from "../../lib/ts/recipe/multifactorauth/components/themes/factorChooser/factorList"; +import { FactorOption } from "../../lib/ts/recipe/multifactorauth/components/themes/factorChooser/factorOption"; type AllComponentsOverrideMap = EmailPasswordOverrideMap & ThirdPartyOverrideMap & EmailVerificationOverrideMap & ThirdPartyEmailPasswordOverrideMap & PasswordlessOverrideMap & - ThirdPartyPasswordlessOverrideMap; + ThirdPartyPasswordlessOverrideMap & + TOTPOverrideMap & + MFAOverrideMap; const makeOverride = () => () =>

Override

; const WithProvider: React.FC = ({ overrideMap, children }) => { @@ -103,7 +123,23 @@ describe("Theme component overrides", () => { PasswordlessLinkSent_Override: LinkSent, PasswordlessCloseTabScreen_Override: CloseTabScreen, PasswordlessLinkClickedScreen_Override: LinkClickedScreen, + PasswordlessMFAFooter_Override: MFAFooter, + PasswordlessMFAHeader_Override: MFAHeader, + PasswordlessMFAOTPFooter_Override: MFAOTPFooter, + PasswordlessMFAOTPHeader_Override: MFAOTPHeader, ThirdPartyPasswordlessHeader_Override: ThirdPartyPasswordlessHeader, + TOTPBlockedScreen_Override: BlockedScreen, + TOTPCodeForm_Override: CodeForm, + TOTPCodeVerificationFooter_Override: CodeVerificationFooter, + TOTPCodeVerificationHeader_Override: CodeVerificationHeader, + TOTPDeviceInfoSection_Override: DeviceInfoSection, + TOTPDeviceSetupFooter_Override: DeviceSetupFooter, + TOTPDeviceSetupHeader_Override: DeviceSetupHeader, + TOTPLoadingScreen_Override: LoadingScreen, + MFAFactorChooserFooter_Override: FactorChooserFooter, + MFAFactorChooserHeader_Override: FactorChooserHeader, + MFAFactorList_Override: [FactorList, { availableFactors: [] }], + MFAFactorOption_Override: [FactorOption, { logo: () =>

!

}], }; Object.entries(overrides).forEach(([key, comp]) => { diff --git a/test/unit/recipe/session/sessionAuth.test.tsx b/test/unit/recipe/session/sessionAuth.test.tsx index ba23082a5..af8e5652d 100644 --- a/test/unit/recipe/session/sessionAuth.test.tsx +++ b/test/unit/recipe/session/sessionAuth.test.tsx @@ -286,10 +286,7 @@ describe("SessionAuth", () => { test("call onSessionExpired on UNAUTHORISED", async () => { // given const mockOnSessionExpired = jest.fn(); - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementation((fn) => { - listenerFn = fn; - }); + const listenerAdded = useMockEventListener(); // when const result = render( @@ -301,8 +298,9 @@ describe("SessionAuth", () => { // Wait for full rendering expect(await result.findByText(/^userId:/)).toBeInTheDocument(); + const listenerFn = await listenerAdded; await act(() => - listenerFn({ + listenerFn!({ action: "UNAUTHORISED", sessionContext: { doesSessionExist: false, @@ -320,10 +318,7 @@ describe("SessionAuth", () => { test("update context on SIGN_OUT", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementation((fn) => { - listenerFn = fn; - }); + const listenerAdded = useMockEventListener(); // when const result = render( @@ -335,8 +330,9 @@ describe("SessionAuth", () => { // Wait for full rendering expect(await result.findByText(/^userId:/)).toBeInTheDocument(); + const listenerFn = await listenerAdded; await act(() => - listenerFn({ + listenerFn!({ action: "SIGN_OUT", sessionContext: { doesSessionExist: false, @@ -351,10 +347,7 @@ describe("SessionAuth", () => { test("update context on SESSION_CREATED", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementation((fn) => { - listenerFn = fn; - }); + const listenerAdded = useMockEventListener(); // when const result = render( @@ -365,8 +358,9 @@ describe("SessionAuth", () => { expect(await result.findByText(/^userId:/)).toHaveTextContent(`userId: mock-user-id`); + const listenerFn = await listenerAdded; await act(() => - listenerFn({ + listenerFn!({ action: "SESSION_CREATED", sessionContext: { doesSessionExist: true, @@ -387,12 +381,7 @@ describe("SessionAuth", () => { test("update context on SESSION_REFRESH", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -408,9 +397,10 @@ describe("SessionAuth", () => { afterRefreshKey: "afterRefreshValue", }; + const listenerFn = await listenerAdded; // when await act(() => - listenerFn({ + listenerFn!({ action: "REFRESH_SESSION", sessionContext: { doesSessionExist: true, @@ -428,12 +418,7 @@ describe("SessionAuth", () => { test("update context on ACCESS_TOKEN_PAYLOAD_UPDATED", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -455,9 +440,10 @@ describe("SessionAuth", () => { }, }; + const listenerFn = await listenerAdded; // when await act(() => - listenerFn({ + listenerFn!({ action: "ACCESS_TOKEN_PAYLOAD_UPDATED", sessionContext: { doesSessionExist: true, @@ -476,12 +462,7 @@ describe("SessionAuth", () => { describe("redirections", () => { test("redirect on ACCESS_TOKEN_PAYLOAD_UPDATED for invalid claim", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -510,8 +491,9 @@ describe("SessionAuth", () => { loading: false, }); + const listenerFn = await listenerAdded; await act(() => - listenerFn({ + listenerFn!({ action: "ACCESS_TOKEN_PAYLOAD_UPDATED", sessionContext: { doesSessionExist: true, @@ -531,12 +513,7 @@ describe("SessionAuth", () => { test("not redirect on ACCESS_TOKEN_PAYLOAD_UPDATED for invalid claim if doRedirection=false", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -557,8 +534,9 @@ describe("SessionAuth", () => { }, }; + const listenerFn = await listenerAdded; await act(() => - listenerFn({ + listenerFn!({ action: "ACCESS_TOKEN_PAYLOAD_UPDATED", sessionContext: { doesSessionExist: true, @@ -578,12 +556,7 @@ describe("SessionAuth", () => { test("redirect on UNAUTHORISED", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -596,9 +569,10 @@ describe("SessionAuth", () => { expect(await result.findByText(/^testClaimValue:/)).toHaveTextContent(`testClaimValue: undefined`); + const listenerFn = await listenerAdded; await Promise.all([ act(() => - listenerFn({ + listenerFn!({ action: "UNAUTHORISED", sessionContext: { doesSessionExist: false, @@ -616,12 +590,7 @@ describe("SessionAuth", () => { test("not redirect on UNAUTHORISED if doRedirection=false", async () => { // given - let listenerFn: (event: any) => void; - MockSession.addEventListener.mockImplementationOnce((fn) => { - listenerFn = fn; - - return () => {}; - }); + const listenerAdded = useMockEventListener(); const result = render( @@ -634,9 +603,10 @@ describe("SessionAuth", () => { expect(await result.findByText(/^testClaimValue:/)).toHaveTextContent(`testClaimValue: undefined`); + const listenerFn = await listenerAdded; await Promise.all([ act(() => - listenerFn({ + listenerFn!({ action: "UNAUTHORISED", sessionContext: { doesSessionExist: false, @@ -701,3 +671,13 @@ describe("SessionAuth", () => { }); }); }); +function useMockEventListener(): Promise<(event: any) => void> { + let setListenerAdded; + const listenerAdded = new Promise<(event: any) => void>((res) => { + setListenerAdded = res; + }); + MockSession.addEventListener.mockImplementation((fn) => { + setListenerAdded(fn); + }); + return listenerAdded; +} From 3082073f90947af180630f7355a0bd356b5877f0 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Tue, 21 Nov 2023 02:01:50 +0100 Subject: [PATCH 7/7] test: skip mock mfa tests until removal --- test/end-to-end/mfa.mock.firstFactors.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/end-to-end/mfa.mock.firstFactors.test.js b/test/end-to-end/mfa.mock.firstFactors.test.js index 5207c105e..40247773b 100644 --- a/test/end-to-end/mfa.mock.firstFactors.test.js +++ b/test/end-to-end/mfa.mock.firstFactors.test.js @@ -33,7 +33,7 @@ import { TEST_CLIENT_BASE_URL, TEST_SERVER_BASE_URL } from "../constants"; /* * Tests. */ -describe("SuperTokens MFA firstFactors support", function () { +describe.skip("SuperTokens MFA firstFactors support", function () { let browser; let page; let consoleLogs = [];