diff --git a/.github/workflows/pre-commit-hook-run.yml b/.github/workflows/pre-commit-hook-run.yml index 968977f92..71669f1b7 100644 --- a/.github/workflows/pre-commit-hook-run.yml +++ b/.github/workflows/pre-commit-hook-run.yml @@ -12,15 +12,8 @@ jobs: pr-title: name: Pre commit hook check runs-on: ubuntu-latest - container: rishabhpoddar/supertokens_website_sdk_testing_node_16 steps: - - uses: actions/checkout@v2 - - run: git init && git add --all && git -c user.name='test' -c user.email='test@example.com' commit -m 'init for pr action' - - run: npm i --force || true - # the below command is there cause otherwise running npm run check-circular-dependencies gives an error like: - # Your cache folder contains root-owned files, due to a bug in - # npm ERR! previous versions of npm which has since been addressed. - - run: chown -R 1001:121 "/github/home/.npm" - - run: npm i --force - - run: cd test/with-typescript && npm i --force + - uses: actions/checkout@v4 + - run: npm ci + - run: cd test/with-typescript && npm ci - run: ./hooks/pre-commit.sh diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index a799a33da..92d5b9229 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -6,7 +6,7 @@ jobs: outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: bash test/findExamplesWithTests.sh - id: set-matrix run: echo "::set-output name=matrix::{\"include\":$(bash test/findExamplesWithTests.sh)}" @@ -24,7 +24,7 @@ jobs: run: working-directory: ${{ matrix.examplePath }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: bash ../../test/updateExampleAppDeps.sh . - run: npm install mocha@6.1.4 jsdom-global@3.0.2 puppeteer@^11.0.0 isomorphic-fetch@^3.0.0 - run: npm run build || true @@ -36,7 +36,7 @@ jobs: ) - name: The job has failed if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: screenshots path: ./**/*screenshot.jpeg diff --git a/.github/workflows/tests-pass-check-pr.yml b/.github/workflows/tests-pass-check-pr.yml deleted file mode 100644 index cfa2cbaec..000000000 --- a/.github/workflows/tests-pass-check-pr.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: 'Check if "Run tests" action succeeded' - -on: - pull_request: - types: - - opened - - reopened - - edited - - synchronize - -jobs: - pr-run-test-action: - name: Check if "Run tests" action succeeded - timeout-minutes: 60 - concurrency: - group: ${{ github.head_ref }} - cancel-in-progress: true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: node install - run: cd ./.github/helpers && npm i - - name: Calling github API - run: cd ./.github/helpers && GITHUB_TOKEN=${{ github.token }} REPO=${{ github.repository }} RUN_ID=${{ github.run_id }} BRANCH=${{ github.head_ref }} JOB_ID=${{ github.job }} SOURCE_OWNER=${{ github.event.pull_request.head.repo.owner.login }} CURRENT_SHA=${{ github.event.pull_request.head.sha }} node node_modules/github-workflow-helpers/test-pass-check-pr.js diff --git a/examples/with-account-linking/frontend/src/LinkingPage/index.tsx b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx index 1e116dd3f..8fd9051ec 100644 --- a/examples/with-account-linking/frontend/src/LinkingPage/index.tsx +++ b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx @@ -54,6 +54,7 @@ export const LinkingPage: React.FC = () => { try { let response = await Passwordless.createCode({ phoneNumber, + shouldTryLinkingWithSessionUser: true, }); if (cancel) { diff --git a/examples/with-multifactorauth-phone-chooser/frontend/package.json b/examples/with-multifactorauth-phone-chooser/frontend/package.json index 9bd476ef3..8d12ddcb7 100644 --- a/examples/with-multifactorauth-phone-chooser/frontend/package.json +++ b/examples/with-multifactorauth-phone-chooser/frontend/package.json @@ -15,7 +15,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.2.1", "react-scripts": "5.0.1", - "supertokens-auth-react": "github:supertokens/supertokens-auth-react#feat/mfa_redirect", + "supertokens-auth-react": "github:supertokens/supertokens-auth-react", "supertokens-web-js": "latest", "typescript": "^4.8.2", "web-vitals": "^2.1.4" diff --git a/examples/with-multifactorauth-phone-chooser/frontend/src/SelectPhone/index.tsx b/examples/with-multifactorauth-phone-chooser/frontend/src/SelectPhone/index.tsx index 7feaec211..34181bbbf 100644 --- a/examples/with-multifactorauth-phone-chooser/frontend/src/SelectPhone/index.tsx +++ b/examples/with-multifactorauth-phone-chooser/frontend/src/SelectPhone/index.tsx @@ -23,7 +23,10 @@ export default function SelectPhone() { navigate: nav, }); } else if (loadedInfo.user.phoneNumbers.length === 1) { - await Passwordless.createCode({ phoneNumber: loadedInfo.user.phoneNumbers[0] }); + await Passwordless.createCode({ + phoneNumber: loadedInfo.user.phoneNumbers[0], + shouldTryLinkingWithSessionUser: true, + }); await MultiFactorAuth.redirectToFactor({ factorId: MultiFactorAuth.FactorIds.OTP_PHONE, redirectBack: false, @@ -60,7 +63,7 @@ export default function SelectPhone() {
  • { - Passwordless.createCode({ phoneNumber: number }) + Passwordless.createCode({ phoneNumber: number, shouldTryLinkingWithSessionUser: true }) .then(async (info) => { if (info.status !== "OK") { setError(info.reason); @@ -72,6 +75,7 @@ export default function SelectPhone() { contactMethod: "PHONE", contactInfo: number, hasOtherPhoneNumbers: true, + shouldTryLinkingWithSessionUser: true, }, }); return MultiFactorAuth.redirectToFactor({ diff --git a/examples/with-multiple-email-sign-in/api-server/epOverride.ts b/examples/with-multiple-email-sign-in/api-server/epOverride.ts index b37bd0db3..13c151fa1 100644 --- a/examples/with-multiple-email-sign-in/api-server/epOverride.ts +++ b/examples/with-multiple-email-sign-in/api-server/epOverride.ts @@ -7,7 +7,7 @@ export function epOverride(oI: APIInterface): APIInterface { signInPOST: async function (input) { const emailField = input.formFields.find((f) => f.id === "email")!; - let primaryEmail = getPrimaryEmailFromInputEmail(emailField.value); + let primaryEmail = getPrimaryEmailFromInputEmail(emailField.value as string); if (primaryEmail !== undefined) { emailField.value = primaryEmail; } @@ -16,7 +16,7 @@ export function epOverride(oI: APIInterface): APIInterface { signUpPOST: async function (input) { const emailField = input.formFields.find((f) => f.id === "email")!; - let primaryEmail = getPrimaryEmailFromInputEmail(emailField.value); + let primaryEmail = getPrimaryEmailFromInputEmail(emailField.value as string); if (primaryEmail !== undefined) { emailField.value = primaryEmail; } diff --git a/examples/with-phone-password-mfa/api-server/index.ts b/examples/with-phone-password-mfa/api-server/index.ts index 213df7df6..61d3aa417 100644 --- a/examples/with-phone-password-mfa/api-server/index.ts +++ b/examples/with-phone-password-mfa/api-server/index.ts @@ -77,7 +77,7 @@ supertokens.init({ // We format the phone number here to get it to a standard format const emailField = input.formFields.find((field) => field.id === "email"); if (emailField) { - const phoneNumber = parsePhoneNumber(emailField.value); + const phoneNumber = parsePhoneNumber(emailField.value as string); if (phoneNumber !== undefined && phoneNumber.isValid()) { emailField.value = phoneNumber.number; } @@ -93,7 +93,7 @@ supertokens.init({ // We format the phone number here to get it to a standard format const emailField = input.formFields.find((field) => field.id === "email"); if (emailField) { - const phoneNumber = parsePhoneNumber(emailField.value); + const phoneNumber = parsePhoneNumber(emailField.value as string); if (phoneNumber !== undefined && phoneNumber.isValid()) { emailField.value = phoneNumber.number; } diff --git a/examples/with-phone-password/api-server/index.ts b/examples/with-phone-password/api-server/index.ts index 6a697598a..e7b6c7e0b 100644 --- a/examples/with-phone-password/api-server/index.ts +++ b/examples/with-phone-password/api-server/index.ts @@ -75,7 +75,7 @@ supertokens.init({ // We format the phone number here to get it to a standard format const emailField = input.formFields.find((field) => field.id === "email"); if (emailField) { - const phoneNumber = parsePhoneNumber(emailField.value); + const phoneNumber = parsePhoneNumber(emailField.value as string); if (phoneNumber !== undefined && phoneNumber.isValid()) { emailField.value = phoneNumber.number; } @@ -91,7 +91,7 @@ supertokens.init({ // We format the phone number here to get it to a standard format const emailField = input.formFields.find((field) => field.id === "email"); if (emailField) { - const phoneNumber = parsePhoneNumber(emailField.value); + const phoneNumber = parsePhoneNumber(emailField.value as string); if (phoneNumber !== undefined && phoneNumber.isValid()) { emailField.value = phoneNumber.number; } diff --git a/examples/with-svelte-react-thirdpartyemailpassword/package.json b/examples/with-svelte-react-thirdpartyemailpassword/package.json index a744965ad..c89e4172a 100644 --- a/examples/with-svelte-react-thirdpartyemailpassword/package.json +++ b/examples/with-svelte-react-thirdpartyemailpassword/package.json @@ -43,6 +43,6 @@ "sirv-cli": "^2.0.0", "supertokens-auth-react": "latest", "supertokens-node": "latest", - "svelte-navigator": "^3.1.5" + "svelte-navigator": "latest" } } diff --git a/test/end-to-end/accountlinking.test.js b/test/end-to-end/accountlinking.test.js index 0c4e03adc..71be719c1 100644 --- a/test/end-to-end/accountlinking.test.js +++ b/test/end-to-end/accountlinking.test.js @@ -831,7 +831,7 @@ async function tryEmailPasswordSignUp(page, email) { await new Promise((res) => setTimeout(res, 250)); } -async function tryPasswordlessSignInUp(page, email) { +export async function tryPasswordlessSignInUp(page, email) { await page.evaluate(() => localStorage.removeItem("supertokens-passwordless-loginAttemptInfo")); await Promise.all([ page.goto(`${TEST_CLIENT_BASE_URL}/auth/?authRecipe=passwordless`), diff --git a/test/end-to-end/webauthn.accountlinking.test.js b/test/end-to-end/webauthn.accountlinking.test.js new file mode 100644 index 000000000..8199a2b03 --- /dev/null +++ b/test/end-to-end/webauthn.accountlinking.test.js @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2022, SuperTokens.com + * All rights reserved. + */ + +import { TEST_CLIENT_BASE_URL } from "../constants"; +import { + setupBrowser, + screenshotOnFailure, + clearBrowserCookiesWithoutAffectingConsole, + toggleSignInSignUp, + getTestEmail, + waitForSTElement, + submitForm, + setInputValues, + getPasswordlessDevice, + waitForUrl, + changeEmail, + getLatestURLWithToken, + getUserIdWithFetch, + submitFormUnsafe, + backendHook, + setupCoreApp, + setupST, + isWebauthnSupported, +} from "../helpers"; +import { + openRecoveryAccountPage, + tryWebauthnSignUp, + getTokenFromEmail, + openRecoveryWithToken, + tryWebauthnSignIn, +} from "./webauthn.helpers"; +import { tryPasswordlessSignInUp } from "./accountlinking.test"; +import assert from "assert"; + +/* + * Test case: + * 1. The app has account linking disabled + * 2. A user signs up using a non-webauthn factor (e.g.: passwordless) + * 3. The user now tries signing up with webauthn using the same email + * -> this should work and create an entirely separate user with an unverified email address + */ +describe("SuperTokens WebAuthn Account Linking", function () { + let browser; + let page; + let consoleLogs = []; + let skipped = false; + const appConfig = { + enabledRecipes: [ + "webauthn", + "emailpassword", + "session", + "dashboard", + "userroles", + "multifactorauth", + "passwordless", + "emailverification", + "accountlinking", + ], + }; + + before(async function () { + if (!(await isWebauthnSupported())) { + skipped = true; + this.skip(); + } + + await backendHook("before"); + const coreUrl = await setupCoreApp(); + appConfig.coreUrl = coreUrl; + await setupST(appConfig); + + browser = await setupBrowser(); + page = await browser.newPage(); + page.on("console", (consoleObj) => { + const log = consoleObj.text(); + if (log.startsWith("ST_LOGS")) { + consoleLogs.push(log); + } + }); + }); + + after(async function () { + if (skipped) { + return; + } + + await page?.close(); + await browser?.close(); + await backendHook("after"); + }); + + afterEach(async function () { + await screenshotOnFailure(this, browser); + await backendHook("afterEach"); + }); + + beforeEach(async function () { + await backendHook("beforeEach"); + consoleLogs = []; + consoleLogs = await clearBrowserCookiesWithoutAffectingConsole(page, consoleLogs); + await toggleSignInSignUp(page); + }); + + it("Should create separate users when signing up with same email using different auth methods (account linking disabled)", async function () { + // Disable account linking + await setupST({ + ...appConfig, + accountLinkingConfig: { + enabled: true, + shouldAutoLink: { + shouldAutomaticallyLink: false, + shouldRequireVerification: false, + }, + }, + }); + const email = await getTestEmail(); + + await Promise.all([ + page.goto(`${TEST_CLIENT_BASE_URL}/auth?authRecipe=passwordless`), + page.waitForNavigation({ waitUntil: "networkidle0" }), + ]); + + // Signup using the email + await setInputValues(page, [{ name: "email", value: email }]); + 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 page.waitForTimeout(2000); + + // We want to parse the text inside the session-context-userId div + const userId1 = await page.evaluate(() => document.querySelector(".session-context-userId").textContent); + + // Find the div with classname logoutButton and click it using normal + // puppeteer selector + const logoutButton = await page.waitForSelector("div.logoutButton"); + await logoutButton.click(); + await new Promise((res) => setTimeout(res, 1000)); + + await tryWebauthnSignUp(page, email); + + // We should be in the confirmation page now. + await submitForm(page); + await page.waitForTimeout(4000); + + // Extract second userId from console logs + const userId2 = await page.evaluate(() => document.querySelector(".session-context-userId").textContent); + + // Verify that two different users were created + assert.notStrictEqual( + userId1, + userId2, + "Different auth methods with same email should create separate users when account linking is disabled" + ); + }); + + it("should handle email updates correctly for user that signed up with webauthn", async () => { + await page.evaluate(() => window.localStorage.setItem("mode", "REQUIRED")); + await setupST({ + ...appConfig, + accountLinkingConfig: { + enabled: true, + shouldAutoLink: { + shouldAutomaticallyLink: false, + shouldRequireVerification: false, + }, + }, + }); + const email = await getTestEmail(); + + await tryWebauthnSignUp(page, email); + + // We should be in the confirmation page now. + await submitForm(page); + + await waitForUrl(page, "/auth/verify-email"); + + // we wait for email to be created + await new Promise((r) => setTimeout(r, 1000)); + + // we fetch the email verification link and go to that + const latestURLWithToken = await getLatestURLWithToken(); + await Promise.all([page.waitForNavigation({ waitUntil: "networkidle0" }), page.goto(latestURLWithToken)]); + + // click on the continue button + await Promise.all([submitForm(page), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForUrl(page, "/dashboard"); + + await page.waitForTimeout(4000); + + // Change the email for the webauthn user + await Promise.all([page.waitForSelector(".sessionInfo-user-id"), page.waitForNetworkIdle()]); + const recipeUserId = await getUserIdWithFetch(page); + assert.ok(recipeUserId); + + // Find the div with classname logoutButton and click it using normal + // puppeteer selector + const logoutButton = await page.waitForSelector("div.logoutButton"); + await logoutButton.click(); + await new Promise((res) => setTimeout(res, 1000)); + + const newEmail = getTestEmail("new"); + const res = await changeEmail("webauthn", recipeUserId, newEmail, null); + + // Sign in with the new email + await tryWebauthnSignIn(page); + + // Since mode is required, user should be redirected to verify email + // screen as the email was changed and the new email is not verified. + await waitForUrl(page, "/auth/verify-email"); + + await page.waitForTimeout(4000); + }); + + it("should allow same emails to be linked but requiring verification", async () => { + await page.evaluate(() => window.localStorage.setItem("mode", "REQUIRED")); + await setupST({ + ...appConfig, + accountLinkingConfig: { + enabled: true, + shouldAutoLink: { + shouldAutomaticallyLink: true, + shouldRequireVerification: true, + }, + }, + }); + const email = await getTestEmail(); + + await tryPasswordlessSignInUp(page, email); + await page.waitForTimeout(1000); + + await Promise.all([page.waitForSelector(".sessionInfo-user-id"), page.waitForNetworkIdle()]); + const userId1 = await getUserIdWithFetch(page); + assert.ok(userId1); + + await page.waitForTimeout(1000); + + // Find the div with classname logoutButton and click it using normal + // puppeteer selector + const logoutButton = await page.waitForSelector("div.logoutButton"); + await logoutButton.click(); + await new Promise((res) => setTimeout(res, 1000)); + + // Try to signup with the same email through webauthn now + await tryWebauthnSignUp(page, email); + + // We should be in the confirmation page now. + await submitForm(page); + + await page.waitForTimeout(1000); + await waitForSTElement(page, "[data-supertokens~='generalError']"); + + // Try to recover the webauthn account using the same email + await openRecoveryAccountPage(page, email, true); + await page.waitForTimeout(1000); + + // Get the token from the email + const token = await getTokenFromEmail(email); + assert.ok(token); + + // Use the token to recover the account + await openRecoveryWithToken(page, token); + + // We should be in the recovery page now, click the continue button + await submitFormUnsafe(page); + + await new Promise((res) => setTimeout(res, 2000)); + + const successContainer = await waitForSTElement(page, "[data-supertokens~='headerText']"); + const headerText = await successContainer.evaluate((el) => el.textContent); + + // Assert the text contains "Account recovered successfully!" + assert.deepStrictEqual(headerText, "Account recovered successfully!"); + }); +}); diff --git a/test/updateExampleAppDeps.sh b/test/updateExampleAppDeps.sh index be8602111..4ea3f129d 100755 --- a/test/updateExampleAppDeps.sh +++ b/test/updateExampleAppDeps.sh @@ -1,7 +1,7 @@ #!/bin/bash cd $1; -npm i; +npm i --ignore-scripts; npm install git+https://github.com:supertokens/supertokens-auth-react.git#$GITHUB_SHA; if [ -d "frontend" ]; then