diff --git a/examples/with-multifactorauth-recovery-codes/README.md b/examples/with-multifactorauth-recovery-codes/README.md new file mode 100644 index 000000000..cd4ab4501 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/README.md @@ -0,0 +1,65 @@ +![SuperTokens banner](https://raw.githubusercontent.com/supertokens/supertokens-logo/master/images/Artboard%20%E2%80%93%2027%402x.png) + +# SuperTokens Google one tap Demo app + +This demo app demonstrates the following use cases: + +- Thirdparty Login / Sign-up +- Email Password Login / Sign-up +- Logout +- Session management & Calling APIs +- Account linking + +## Project setup + +Clone the repo, enter the directory, and use `npm` to install the project dependencies: + +```bash +git clone https://github.com/supertokens/supertokens-auth-react +cd supertokens-auth-react/examples/with-account-linking +npm install +cd frontend && npm install && cd ../ +cd backend && npm install && cd ../ +``` + +## Run the demo app + +This compiles and serves the React app and starts the backend API server on port 3001. + +```bash +npm run start +``` + +The app will start on `http://localhost:3000` + +## How it works + +We are adding a new (`/link`) page where the user can add new login methods to their current user, plus enabling automatic account linking. + +### On the frontend + +The demo uses the pre-built UI, but you can always build your own UI instead. + +- We do not need any extra configuration to enable account linking +- To enable manual linking through a custom callback page, we add `getRedirectURL` to the configuration of the social login providers. +- We add a custom page (`/link`) that will: + - Get and show the login methods belonging to the current user + - Show a password form (if available) that calls `/addPassword` to add an email+password login method to the current user. + - Show a phone number form (if available) that calls `/addPhoneNumber` to associate a phone number with the current user. + - Show an "Add Google account" that start a login process through Google +- We add a custom page (`/link/tpcallback/:thirdPartyId`) that will: + - Call `/addThirdPartyUser` through a customized `ThirdPartyEmailPassword.thirdPartySignInAndUp` call + +### On the backend + +- We enable account linking by initializing the recipe and providing a `shouldDoAutomaticAccountLinking` implementation +- We add `/addPassword`, `/addPhoneNumber` and `/addThirdPartyUser` to enable manual linking from the frontend +- We add `/userInfo` so the frontend can list/show the login methods belonging to the current user. + +## Author + +Created with :heart: by the folks at supertokens.com. + +## License + +This project is licensed under the Apache 2.0 license. diff --git a/examples/with-multifactorauth-recovery-codes/backend/config.ts b/examples/with-multifactorauth-recovery-codes/backend/config.ts new file mode 100644 index 000000000..b17e40c72 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/backend/config.ts @@ -0,0 +1,115 @@ +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import Session from "supertokens-node/recipe/session"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import UserMetadata from "supertokens-node/recipe/usermetadata"; +import EmailVerification from "supertokens-node/recipe/emailverification"; +import { TypeInput } from "supertokens-node/types"; +import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"; +import TOTP from "supertokens-node/recipe/totp"; +import Dashboard from "supertokens-node/recipe/dashboard"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig: TypeInput = { + supertokens: { + // this is the location of the SuperTokens core. + // connectionURI: "https://try.supertokens.com", + connectionURI: "http://localhost:3567", + }, + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + UserMetadata.init(), + EmailVerification.init({ + mode: "REQUIRED", + }), + TOTP.init({ + override: { + apis: (oI) => ({ + ...oI, + verifyDevicePOST: async (input) => { + const resp = await oI.verifyDevicePOST(input); + if (resp.status === "OK") { + const payload = input.session.getAccessTokenPayload(); + const recoveryCodeHash = payload.recoveryCodeHash; + if (recoveryCodeHash) { + // await input.session.mergeIntoAccessTokenPayload({ recoveryCodeHash: null }); + await UserMetadata.updateUserMetadata(input.session.getUserId(), { + recoveryCodeHash: null, + }); + } + } + return resp; + }, + }), + }, + }), + MultiFactorAuth.init({ + firstFactors: ["thirdparty", "emailpassword"], // This is basically disallows using passwordless to sign in + override: { + functions: (oI) => ({ + ...oI, + getMFARequirementsForAuth: async () => ["totp"], + isAllowedToSetupFactor: async (input) => { + const resp = await oI.isAllowedToSetupFactor(input); + + if (resp) { + return resp; + } + + const payload = input.session.getAccessTokenPayload(); + const recoveryCodeHash = payload.recoveryCodeHash; + const userId = input.session.getUserId(); + const { metadata } = await UserMetadata.getUserMetadata(userId); + return metadata.recoveryCodeHash === recoveryCodeHash; + }, + }), + }, + }), + ThirdPartyEmailPassword.init({ + providers: [ + // We have provided you with development keys which you can use for testing. + // IMPORTANT: Please replace them with your own OAuth keys for production use. + { + config: { + thirdPartyId: "google", + clients: [ + { + clientId: "1060725074195-kmeum4crr01uirfl2op9kd5acmi9jutn.apps.googleusercontent.com", + clientSecret: "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", + }, + ], + }, + }, + { + config: { + thirdPartyId: "github", + clients: [ + { + clientId: "467101b197249757c71f", + clientSecret: "e97051221f4b6426e8fe8d51486396703012f5bd", + }, + ], + }, + }, + ], + }), + Session.init(), + Dashboard.init(), + ], +}; diff --git a/examples/with-multifactorauth-recovery-codes/backend/index.ts b/examples/with-multifactorauth-recovery-codes/backend/index.ts new file mode 100644 index 000000000..c7c45dbf8 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/backend/index.ts @@ -0,0 +1,94 @@ +import express from "express"; +import cors from "cors"; +import crypto from "crypto"; +import supertokens, { getUser } from "supertokens-node"; +import { verifySession } from "supertokens-node/recipe/session/framework/express"; +import { middleware, errorHandler, SessionRequest } from "supertokens-node/framework/express"; +import { getWebsiteDomain, SuperTokensConfig } from "./config"; +import Session from "supertokens-node/recipe/session"; +import { getUserMetadata, updateUserMetadata } from "supertokens-node/recipe/usermetadata"; + +supertokens.init(SuperTokensConfig); + +const app = express(); + +app.use( + cors({ + origin: getWebsiteDomain(), + allowedHeaders: ["content-type", ...supertokens.getAllCORSHeaders()], + methods: ["GET", "PUT", "POST", "DELETE"], + credentials: true, + }) +); + +// This exposes all the APIs from SuperTokens to the client. +app.use(middleware()); +app.use(express.json()); + +// An example API that requires session verification +app.get("/sessioninfo", verifySession(), async (req: SessionRequest, res) => { + let session = req.session; + res.send({ + sessionHandle: session!.getHandle(), + userId: session!.getUserId(), + accessTokenPayload: session!.getAccessTokenPayload(), + }); +}); + +app.get("/userInfo", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + const metadata = await getUserMetadata(user.id); + + res.json({ + user: user.toJson(), + metadata: metadata.metadata, + }); +}); + +app.post("/createRecoveryCode", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + + const recoveryCode = crypto.randomUUID(); + + const updateRes = await updateUserMetadata(session.getUserId(), { + recoveryCodeHash: hash(recoveryCode), + }); + + return res.json({ status: updateRes.status, recoveryCode }); +}); + +app.post( + "/useRecoveryCode", + verifySession({ overrideGlobalClaimValidators: () => [] }), + async (req: SessionRequest, res) => { + const session = req.session!; + const userId = session.getUserId(); + + const recoveryCode = req.body.recoveryCode; + const recoveryCodeHash = hash(recoveryCode); + + const metadata = await getUserMetadata(userId); + + if (metadata.metadata.recoveryCodeHash === recoveryCodeHash) { + await session.mergeIntoAccessTokenPayload({ recoveryCodeHash }); + + return res.json({ status: "OK" }); + } + + return res.json({ status: "ERROR" }); + } +); + +// In case of session related errors, this error handler +// returns 401 to the client. +app.use(errorHandler()); + +app.listen(3001, () => console.log(`API Server listening on port 3001`)); + +function hash(recoveryCode: string) { + return crypto.createHash("sha256").update(recoveryCode).digest("base64"); +} diff --git a/examples/with-multifactorauth-recovery-codes/backend/package.json b/examples/with-multifactorauth-recovery-codes/backend/package.json new file mode 100644 index 000000000..d9a8f8a3c --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "supertokens-node", + "version": "0.0.1", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "start": "npx ts-node-dev --project ./tsconfig.json ./index.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.1", + "helmet": "^5.1.0", + "morgan": "^1.10.0", + "npm-run-all": "^4.1.5", + "supertokens-node": "github:supertokens/supertokens-node#mfa-impl", + "ts-node-dev": "^2.0.0", + "typescript": "^4.7.2" + }, + "devDependencies": { + "@types/cors": "^2.8.12", + "@types/express": "^4.17.17", + "@types/morgan": "^1.9.3", + "@types/node": "^16.11.38", + "nodemon": "^2.0.16" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/examples/with-multifactorauth-recovery-codes/backend/tsconfig.json b/examples/with-multifactorauth-recovery-codes/backend/tsconfig.json new file mode 100644 index 000000000..8a91acaae --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/backend/tsconfig.json @@ -0,0 +1,62 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/.env b/examples/with-multifactorauth-recovery-codes/frontend/.env new file mode 100644 index 000000000..7d910f148 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/with-multifactorauth-recovery-codes/frontend/.gitignore b/examples/with-multifactorauth-recovery-codes/frontend/.gitignore new file mode 100644 index 000000000..4d29575de --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/with-multifactorauth-recovery-codes/frontend/LICENSE.md b/examples/with-multifactorauth-recovery-codes/frontend/LICENSE.md new file mode 100644 index 000000000..588f27e68 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/LICENSE.md @@ -0,0 +1,192 @@ +Copyright (c) 2020, 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 software except in compliance with the License. A copy +of the License is available below the line. + +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. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/examples/with-multifactorauth-recovery-codes/frontend/package.json b/examples/with-multifactorauth-recovery-codes/frontend/package.json new file mode 100644 index 000000000..34929bb5f --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "supertokens-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.11.56", + "@types/react": "^18.0.18", + "@types/react-dom": "^18.0.6", + "axios": "^0.21.0", + "react": "^18.2.0", + "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/tests", + "supertokens-web-js": "github:supertokens/supertokens-web-js#feat/mfa", + "typescript": "^4.8.2", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/public/favicon.ico b/examples/with-multifactorauth-recovery-codes/frontend/public/favicon.ico new file mode 100644 index 000000000..a11777cc4 Binary files /dev/null and b/examples/with-multifactorauth-recovery-codes/frontend/public/favicon.ico differ diff --git a/examples/with-multifactorauth-recovery-codes/frontend/public/index.html b/examples/with-multifactorauth-recovery-codes/frontend/public/index.html new file mode 100644 index 000000000..6f1f7cb51 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/public/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + React App + + + +
+ + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/public/manifest.json b/examples/with-multifactorauth-recovery-codes/frontend/public/manifest.json new file mode 100644 index 000000000..f01493ff0 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/public/robots.txt b/examples/with-multifactorauth-recovery-codes/frontend/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/App.css b/examples/with-multifactorauth-recovery-codes/frontend/src/App.css new file mode 100644 index 000000000..8a98a2341 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/App.css @@ -0,0 +1,27 @@ +.App { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + font-family: Rubik; +} + +.fill { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; +} + +.sessionButton { + padding-left: 13px; + padding-right: 13px; + padding-top: 8px; + padding-bottom: 8px; + background-color: black; + border-radius: 10px; + cursor: pointer; + color: white; + font-weight: bold; + font-size: 17px; +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/App.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/App.tsx new file mode 100644 index 000000000..c9907195e --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/App.tsx @@ -0,0 +1,80 @@ +import "./App.css"; +import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react"; +import { getSuperTokensRoutesForReactRouterDom } from "supertokens-auth-react/ui"; +import { SessionAuth } from "supertokens-auth-react/recipe/session"; +import { Routes, BrowserRouter as Router, Route, NavLink } from "react-router-dom"; +import Home from "./Home"; +import { PreBuiltUIList, SuperTokensConfig } from "./config"; +import { MultiFactorAuthClaim } from "supertokens-auth-react/recipe/multifactorauth"; +import RecoveryCode from "./RecoveryCode"; +import { TOTPComponentsOverrideProvider } from "supertokens-auth-react/recipe/totp"; + +SuperTokens.init(SuperTokensConfig); + +function App() { + return ( + + { + return ( +
+ + Lost my device + + +
+ ); + }, + }}> +
+ +
+ + {/* This shows the login UI on "/auth" route */} + {getSuperTokensRoutesForReactRouterDom(require("react-router-dom"), PreBuiltUIList)} + + only if the user is logged in. + Else it redirects the user to "/auth" */ + []}> + + + } + /> + only if the user is logged in. + Else it redirects the user to "/auth" */ + + + + } + /> + +
+
+
+
+
+ ); +} + +export default App; diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/Home/CallAPIView.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/CallAPIView.tsx new file mode 100644 index 000000000..aa2ab8c28 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/CallAPIView.tsx @@ -0,0 +1,41 @@ +import axios from "axios"; +import { getApiDomain } from "../config"; +import { useState } from "react"; + +export default function CallAPIView() { + const [recoveryCode, setRecoveryCode] = useState(); + + async function callAPIClicked() { + let response = await axios.get(getApiDomain() + "/sessioninfo"); + window.alert("Session Information:\n" + JSON.stringify(response.data, null, 2)); + } + + async function createRecoveryCode() { + try { + let response = await axios.post(getApiDomain() + "/createRecoveryCode"); + setRecoveryCode(response.data.recoveryCode); + } catch (err: any) { + if (err.isAxiosError) { + window.alert( + `Call failed: ${err.response.statusText}(${err.response.status}): ${err.response.data.message}` + ); + } else { + window.alert(`Call failed`); + } + } + } + + return ( + <> + {recoveryCode &&
{recoveryCode}
} +
+
+ Get session info +
+
+ Create recovery code +
+
+ + ); +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/Home/Home.css b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/Home.css new file mode 100644 index 000000000..0e6dfbd64 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/Home.css @@ -0,0 +1,219 @@ +@font-face { + font-family: Menlo; + src: url("../assets/fonts/MenloRegular.ttf"); +} + +.app-container { + font-family: Rubik, sans-serif; +} + +.app-container * { + box-sizing: border-box; +} + +.bold-400 { + font-variation-settings: "wght" 400; +} + +.bold-500 { + font-variation-settings: "wght" 500; +} + +.bold-600 { + font-variation-settings: "wght" 600; +} + +#home-container { + align-items: center; + min-height: 100vh; + background: url("../assets/images/background.png"); + background-size: cover; +} + +.bold-700 { + font-variation-settings: "wght" 700; +} + +.app-container .main-container { + box-shadow: 0px 0px 60px 0px rgba(0, 0, 0, 0.16); + width: min(635px, calc(100% - 24px)); + border-radius: 16px; + margin-block-end: 159px; + background-color: #ffffff; +} + +.main-container .success-title { + line-height: 1; + padding-block: 26px; + background-color: #e7ffed; + text-align: center; + color: #3eb655; + display: flex; + justify-content: center; + align-items: flex-end; + font-size: 20px; +} + +.success-title img.success-icon { + margin-right: 8px; +} + +.recovery-code { + line-height: 1; + padding: 1em; + background-color: #e7ffed; + text-align: center; + color: #3eb655; + display: flex; + justify-content: center; + font-size: 20px; + border-radius: 6px; +} + +.main-container .inner-content { + padding-block: 48px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.inner-content #user-id { + position: relative; + padding: 14px 17px; + border-image-slice: 1; + width: min(430px, calc(100% - 30px)); + margin-inline: auto; + margin-block: 11px 23px; + border-radius: 9px; + line-height: 1; + font-family: Menlo, serif; + cursor: text; +} + +.inner-content #user-id:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 9px; + padding: 2px; + background: linear-gradient(90.31deg, #ff9933 0.11%, #ff3f33 99.82%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.main-container > .top-band, +.main-container > .bottom-band { + border-radius: inherit; +} + +.main-container .top-band { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.main-container .bottom-band { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.main-container .sessionButton { + box-sizing: border-box; + background: #ff9933; + border: 1px solid #ff8a15; + box-shadow: 0px 3px 6px rgba(255, 153, 51, 0.16); + border-radius: 6px; + font-size: 16px; + margin: 0.5em; + text-decoration: none; +} + +.bottom-cta-container { + display: flex; + justify-content: flex-end; + padding-inline: 21px; + background-color: #212d4f; +} + +.bottom-cta-container .view-code { + padding-block: 11px; + color: #bac9f5; + cursor: pointer; + font-size: 14px; +} + +.mfa-button-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-bottom: 22px; +} + +.userInfoJSON { + text-align: left; + overflow-x: auto; + max-width: 100%; +} + +.bottom-links-container { + display: grid; + grid-template-columns: repeat(4, auto); + margin-bottom: 22px; +} + +.bottom-links-container .link { + display: flex; + align-items: center; + margin-inline-end: 68px; + cursor: pointer; +} + +.bottom-links-container .link:last-child { + margin-right: 0; +} + +.truncate { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.separator-line { + max-width: 100%; +} + +.link .link-icon { + width: 15px; + margin-right: 5px; +} + +.mfaInfo { + margin-bottom: 22px; +} + +@media screen and (max-width: 768px) { + .bottom-links-container { + grid-template-columns: repeat(2, 1fr); + column-gap: 64px; + row-gap: 34px; + } + + .bottom-links-container .link { + margin-inline-end: 0; + } + + .separator-line { + max-width: 200px; + } +} + +@media screen and (max-width: 480px) { + #home-container { + justify-content: start; + padding-block-start: 25px; + } + + .app-container .main-container { + margin-block-end: 90px; + } +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/Home/SuccessView.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/SuccessView.tsx new file mode 100644 index 000000000..a3ef2382f --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/SuccessView.tsx @@ -0,0 +1,72 @@ +import { NavLink, useNavigate } from "react-router-dom"; +import { signOut } from "supertokens-auth-react/recipe/session"; +import { recipeDetails } from "../config"; +import CallAPIView from "./CallAPIView"; +import { BlogsIcon, CelebrateIcon, GuideIcon, SeparatorLine, SignOutIcon } from "../assets/images"; + +interface ILink { + name: string; + onClick: () => void; + icon: string; +} + +export default function SuccessView(props: { userId: string; mfaRequirement: string }) { + let userId = props.userId; + + const navigate = useNavigate(); + + async function logoutClicked() { + await signOut(); + navigate("/auth"); + } + + function openLink(url: string) { + window.open(url, "_blank"); + } + + const links: ILink[] = [ + { + name: "Blogs", + onClick: () => openLink("https://supertokens.com/blog"), + icon: BlogsIcon, + }, + { + name: "Documentation", + onClick: () => openLink(recipeDetails.docsLink), + icon: GuideIcon, + }, + { + name: "Sign Out", + onClick: logoutClicked, + icon: SignOutIcon, + }, + ]; + + return ( + <> +
+
+ Login successful Login successful +
+
+
Your userID is:
+
+ {userId} +
+ +
+
+
+ {links.map((link) => ( +
+ {link.name} +
+ {link.name} +
+
+ ))} +
+ separator + + ); +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/Home/index.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/index.tsx new file mode 100644 index 000000000..f52a713ac --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/Home/index.tsx @@ -0,0 +1,17 @@ +import SuccessView from "./SuccessView"; +import { useSessionContext } from "supertokens-auth-react/recipe/session"; +import "./Home.css"; + +export default function Home(props: { mfaRequirements: string }) { + const sessionContext = useSessionContext(); + + if (sessionContext.loading === true) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/RecoveryCode.css b/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/RecoveryCode.css new file mode 100644 index 000000000..bdfa490f6 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/RecoveryCode.css @@ -0,0 +1,181 @@ +@font-face { + font-family: Menlo; + src: url("../assets/fonts/MenloRegular.ttf"); +} + +.app-container { + font-family: Rubik, sans-serif; +} + +.app-container * { + box-sizing: border-box; +} + +.bold-400 { + font-variation-settings: "wght" 400; +} + +.bold-500 { + font-variation-settings: "wght" 500; +} + +.bold-600 { + font-variation-settings: "wght" 600; +} + +#home-container { + align-items: center; + min-height: 100vh; + background: url("../assets/images/background.png"); + background-size: cover; +} + +.bold-700 { + font-variation-settings: "wght" 700; +} + +.app-container .main-container { + box-shadow: 0px 0px 60px 0px rgba(0, 0, 0, 0.16); + width: min(635px, calc(100% - 24px)); + border-radius: 16px; + margin-block-end: 159px; + background-color: #ffffff; +} + +.main-container .success-title { + line-height: 1; + padding-block: 26px; + background-color: #e7ffed; + text-align: center; + color: #3eb655; + display: flex; + justify-content: center; + align-items: flex-end; + font-size: 20px; +} + +.success-title img.success-icon { + margin-right: 8px; +} + +.main-container .inner-content { + padding-block: 48px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.inner-content #user-id { + position: relative; + padding: 14px 17px; + border-image-slice: 1; + width: min(430px, calc(100% - 30px)); + margin-inline: auto; + margin-block: 11px 23px; + border-radius: 9px; + line-height: 1; + font-family: Menlo, serif; + cursor: text; +} + +.inner-content #user-id:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 9px; + padding: 2px; + background: linear-gradient(90.31deg, #ff9933 0.11%, #ff3f33 99.82%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.main-container > .top-band, +.main-container > .bottom-band { + border-radius: inherit; +} + +.main-container .top-band { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.main-container .bottom-band { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.main-container .sessionButton { + box-sizing: border-box; + background: #ff9933; + border: 1px solid #ff8a15; + box-shadow: 0px 3px 6px rgba(255, 153, 51, 0.16); + border-radius: 6px; + font-size: 16px; + margin: 0.5em; + text-decoration: none; +} + +.recoveryForm { + margin: 1.25em; + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; +} + +.recoveryForm .inputGroup { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5em; +} + +.recoveryForm a { + cursor: pointer; +} + +button { + box-sizing: border-box; + border-radius: 6px; + font-size: 16px; + margin: 0.5em; + border: 1px solid black; + text-decoration: none; +} + +.error { + padding: 0.5em; + border-radius: 6px; + border: rgba(255, 23, 23, 0.3) 1px solid; + background-color: rgb(255, 241, 235); + color: rgb(255, 23, 23); +} + +@media screen and (max-width: 768px) { + .bottom-links-container { + grid-template-columns: repeat(2, 1fr); + column-gap: 64px; + row-gap: 34px; + } + + .bottom-links-container .link { + margin-inline-end: 0; + } + + .separator-line { + max-width: 200px; + } +} + +@media screen and (max-width: 480px) { + #home-container { + justify-content: start; + padding-block-start: 25px; + } + + .app-container .main-container { + margin-block-end: 90px; + } +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/index.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/index.tsx new file mode 100644 index 000000000..6c9f8b29b --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/RecoveryCode/index.tsx @@ -0,0 +1,45 @@ +import "./RecoveryCode.css"; +import { useAsyncCall } from "../useAsyncCallOnMount"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { getApiDomain } from "../config"; +import { User } from "supertokens-web-js/types"; +import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth"; +import Passwordless from "supertokens-auth-react/recipe/passwordless"; +import axios from "axios"; + +export default function RecoveryCode() { + const nav = useNavigate(); + const [code, setCode] = useState(); + const [error, setError] = useState(); + async function consumeCode() { + const reset = await axios.post(getApiDomain() + "/useRecoveryCode", { recoveryCode: code }); + if (reset.data.status !== "OK") { + setError("Recovery code not found"); + } else { + await MultiFactorAuth.redirectToFactor("totp", true, false, nav); + } + } + + return ( +
+ {error &&
{error}
} +
{ + consumeCode().catch((err) => setError(err.message)); + ev.preventDefault(); + return false; + }} + className="recoveryForm"> +
+ + setCode(ev.currentTarget.value)}> +
+ + nav(-1)}> Back +
+
+ ); +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/fonts/MenloRegular.ttf b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/fonts/MenloRegular.ttf new file mode 100644 index 000000000..033dc6d21 Binary files /dev/null and b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/fonts/MenloRegular.ttf differ diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/arrow-right-icon.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/arrow-right-icon.svg new file mode 100644 index 000000000..95aa1fec6 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/arrow-right-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/background.png b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/background.png new file mode 100644 index 000000000..2147c15c2 Binary files /dev/null and b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/background.png differ diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/blogs-icon.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/blogs-icon.svg new file mode 100644 index 000000000..a2fc9dd62 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/blogs-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/celebrate-icon.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/celebrate-icon.svg new file mode 100644 index 000000000..3b40b1efa --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/celebrate-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/guide-icon.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/guide-icon.svg new file mode 100644 index 000000000..bd85af72b --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/guide-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/index.ts b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/index.ts new file mode 100644 index 000000000..7adf036c4 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/index.ts @@ -0,0 +1,8 @@ +import SeparatorLine from "./separator-line.svg"; +import ArrowRight from "./arrow-right-icon.svg"; +import SignOutIcon from "./sign-out-icon.svg"; +import GuideIcon from "./guide-icon.svg"; +import BlogsIcon from "./blogs-icon.svg"; +import CelebrateIcon from "./celebrate-icon.svg"; + +export { SeparatorLine, ArrowRight, SignOutIcon, GuideIcon, BlogsIcon, CelebrateIcon }; diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/separator-line.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/separator-line.svg new file mode 100644 index 000000000..7127a00dc --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/separator-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/sign-out-icon.svg b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/sign-out-icon.svg new file mode 100644 index 000000000..6cc4f85fd --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/assets/images/sign-out-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/config.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/config.tsx new file mode 100644 index 000000000..b32f57126 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/config.tsx @@ -0,0 +1,78 @@ +import ThirdPartyEmailPassword, { Google, Github, Apple } from "supertokens-auth-react/recipe/thirdpartyemailpassword"; +import EmailVerification from "supertokens-auth-react/recipe/emailverification"; +import { ThirdPartyEmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/thirdpartyemailpassword/prebuiltui"; +import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui"; +import { EmailVerificationPreBuiltUI } from "supertokens-auth-react/recipe/emailverification/prebuiltui"; +import Session from "supertokens-auth-react/recipe/session"; +import Passwordless from "supertokens-auth-react/recipe/passwordless"; +import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth"; +import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui"; +import TOTP from "supertokens-auth-react/recipe/totp"; +import { TOTPPreBuiltUI } from "supertokens-auth-react/recipe/totp/prebuiltui"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig = { + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + EmailVerification.init({ + mode: "REQUIRED", + }), + MultiFactorAuth.init(), + TOTP.init(), + ThirdPartyEmailPassword.init({ + signInAndUpFeature: { + providers: [ + Github.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + Google.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + ], + }, + }), + Passwordless.init({ + contactMethod: "EMAIL_OR_PHONE", + }), + Session.init(), + ], +}; + +export const recipeDetails = { + docsLink: "https://supertokens.com/docs/thirdpartyemailpassword/introduction", +}; + +export const PreBuiltUIList = [ + ThirdPartyEmailPasswordPreBuiltUI, + PasswordlessPreBuiltUI, + EmailVerificationPreBuiltUI, + MultiFactorAuthPreBuiltUI, + TOTPPreBuiltUI, +]; diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/index.css b/examples/with-multifactorauth-recovery-codes/frontend/src/index.css new file mode 100644 index 000000000..04146b5e7 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/index.tsx b/examples/with-multifactorauth-recovery-codes/frontend/src/index.tsx new file mode 100644 index 000000000..399c737cd --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + +); diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/react-app-env.d.ts b/examples/with-multifactorauth-recovery-codes/frontend/src/react-app-env.d.ts new file mode 100644 index 000000000..6431bc5fc --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/with-multifactorauth-recovery-codes/frontend/src/useAsyncCallOnMount.ts b/examples/with-multifactorauth-recovery-codes/frontend/src/useAsyncCallOnMount.ts new file mode 100644 index 000000000..be17631c4 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/src/useAsyncCallOnMount.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from "react"; + +export const useAsyncCall = ( + func: () => Promise, + handler: (res: T) => void, + errorHandler?: (err: any) => void +) => { + const [key, setKey] = useState(Date.now()); + const signInUpPromise = useRef<[any, Promise] | undefined>(undefined); + + useEffect(() => { + if (signInUpPromise.current === undefined || signInUpPromise.current[0] !== key) { + signInUpPromise.current = [key, func()]; + } + const abort = new AbortController(); + + signInUpPromise.current[1].then( + (resp) => { + if (abort.signal.aborted) { + return; + } + handler(resp); + }, + (err) => { + if (abort.signal.aborted) { + return; + } + if (errorHandler !== undefined) { + errorHandler(err); + } + } + ); + + return () => abort.abort(); + }, [handler, errorHandler, key]); + + return () => setKey(Date.now()); +}; diff --git a/examples/with-multifactorauth-recovery-codes/frontend/tsconfig.json b/examples/with-multifactorauth-recovery-codes/frontend/tsconfig.json new file mode 100644 index 000000000..c0555cbc6 --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/examples/with-multifactorauth-recovery-codes/package.json b/examples/with-multifactorauth-recovery-codes/package.json new file mode 100644 index 000000000..dc5c0533b --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/package.json @@ -0,0 +1,23 @@ +{ + "name": "with-account-linking", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "start:frontend": "cd frontend && npm run start", + "start:frontend-live-demo-app": "cd frontend && npx serve -s build", + "start:backend": "cd backend && npm run start", + "start:backend-live-demo-app": "cd backend && ./startLiveDemoApp.sh", + "start": "npm-run-all --parallel start:frontend start:backend", + "start-live-demo-app": "npx npm-run-all --parallel start:frontend-live-demo-app start:backend-live-demo-app" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "npm-run-all": "^4.1.5" + }, + "devDependencies": { + "otpauth": "^9.2.1" + } +} diff --git a/examples/with-multifactorauth-recovery-codes/test/basic.test.js b/examples/with-multifactorauth-recovery-codes/test/basic.test.js new file mode 100644 index 000000000..81f81f92f --- /dev/null +++ b/examples/with-multifactorauth-recovery-codes/test/basic.test.js @@ -0,0 +1,180 @@ +/* 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 + */ + +const assert = require("assert"); +const puppeteer = require("puppeteer"); +const { + getTestEmail, + getTestPhoneNumber, + setInputValues, + submitForm, + toggleSignInSignUp, + waitForSTElement, + chooseFactor, +} = require("../../../test/exampleTestHelpers"); + +const SuperTokensNode = require("../backend/node_modules/supertokens-node"); +const Session = require("../backend/node_modules/supertokens-node/recipe/session"); +const EmailVerification = require("../backend/node_modules/supertokens-node/recipe/emailverification"); +const EmailPassword = require("../backend/node_modules/supertokens-node/recipe/emailpassword"); +const Passwordless = require("../backend/node_modules/supertokens-node/recipe/passwordless"); + +const OTPAuth = require("otpauth"); + +// Run the tests in a DOM environment. +require("jsdom-global")(); + +const apiDomain = "http://localhost:3001"; +const websiteDomain = "http://localhost:3000"; +SuperTokensNode.init({ + supertokens: { + // We are running these tests without running a local ST instance + connectionURI: "http://localhost:3567", + }, + appInfo: { + // These largely shouldn't matter except for creating links which we can change anyway + apiDomain: apiDomain, + websiteDomain: websiteDomain, + appName: "testNode", + }, + recipeList: [ + EmailVerification.init({ mode: "OPTIONAL" }), + EmailPassword.init(), + Session.init(), + Passwordless.init({ + contactMethod: "EMAIL_OR_PHONE", + flowType: "USER_INPUT_CODE_AND_MAGIC_LINK", + }), + ], +}); + +describe("SuperTokens Example Basic tests", function () { + let browser; + let page; + const email = getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + const testPW = "Str0ngP@ssw0rd"; + + before(async function () { + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: false, + }); + page = await browser.newPage(); + }); + + after(async function () { + await browser.close(); + }); + + describe("MultiFactorAuth", function () { + it("Successful signup with multiple credentials", async function () { + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + + // redirected to /auth + await toggleSignInSignUp(page); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + + // Redirected to email verification screen + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + const userId = await page.evaluate(() => window.__supertokensSessionRecipe.getUserId()); + + // Attempt reloading Home + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + + // Create a new token and use it (we don't have access to the originally sent one) + const tokenInfo = await EmailVerification.createEmailVerificationToken( + "public", + SuperTokensNode.convertToRecipeUserId(userId), + email + ); + await page.goto(`${websiteDomain}/auth/verify-email?token=${tokenInfo.token}`); + + await submitForm(page); + + const origTOTPSecret = await getTOTPSecret(page); + await completeTOTP(page, origTOTPSecret); + + const addRecoveryCode = await page.waitForSelector(".createRecoveryCode"); + await addRecoveryCode.click(); + + const recoveryCodeDiv = await page.waitForSelector(".recovery-code", { visible: true }); + const recoveryCode = await recoveryCodeDiv.evaluate((e) => e.textContent); + + // Log out + await page.click("div.bottom-links-container > div:nth-child(3) > div"); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + + const recoveryLink = await waitForSTElement(page, ["[data-supertokens~=lostDevice]"]); + await recoveryLink.click(); + + const recoveryCodeInput = await page.waitForSelector("input[name=recoveryCode]"); + await recoveryCodeInput.type(recoveryCode); + await page.click(".recoveryForm .sessionButton"); + + const newTOTPSecret = await getTOTPSecret(page); + await completeTOTP(page, newTOTPSecret); + await page.waitForSelector(".createRecoveryCode"); + + await page.click("div.bottom-links-container > div:nth-child(3) > div"); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + await completeTOTP(page, newTOTPSecret, 1); + await page.waitForSelector(".createRecoveryCode"); + + await page.click("div.bottom-links-container > div:nth-child(3) > div"); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + await completeTOTP(page, origTOTPSecret, 1); + await page.waitForSelector(".createRecoveryCode"); + }); + }); +}); + +async function getTOTPSecret(page) { + 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); + return secret; +} + +async function completeTOTP(page, secret, offset = 0) { + const totp = new OTPAuth.TOTP({ secret }); + const currCode = totp.generate({ timestamp: Date.now() + offset * 30000 }); + + await setInputValues(page, [{ name: "totp", value: currCode }]); + await submitForm(page); +} diff --git a/examples/with-multifactorauth/README.md b/examples/with-multifactorauth/README.md new file mode 100644 index 000000000..cd4ab4501 --- /dev/null +++ b/examples/with-multifactorauth/README.md @@ -0,0 +1,65 @@ +![SuperTokens banner](https://raw.githubusercontent.com/supertokens/supertokens-logo/master/images/Artboard%20%E2%80%93%2027%402x.png) + +# SuperTokens Google one tap Demo app + +This demo app demonstrates the following use cases: + +- Thirdparty Login / Sign-up +- Email Password Login / Sign-up +- Logout +- Session management & Calling APIs +- Account linking + +## Project setup + +Clone the repo, enter the directory, and use `npm` to install the project dependencies: + +```bash +git clone https://github.com/supertokens/supertokens-auth-react +cd supertokens-auth-react/examples/with-account-linking +npm install +cd frontend && npm install && cd ../ +cd backend && npm install && cd ../ +``` + +## Run the demo app + +This compiles and serves the React app and starts the backend API server on port 3001. + +```bash +npm run start +``` + +The app will start on `http://localhost:3000` + +## How it works + +We are adding a new (`/link`) page where the user can add new login methods to their current user, plus enabling automatic account linking. + +### On the frontend + +The demo uses the pre-built UI, but you can always build your own UI instead. + +- We do not need any extra configuration to enable account linking +- To enable manual linking through a custom callback page, we add `getRedirectURL` to the configuration of the social login providers. +- We add a custom page (`/link`) that will: + - Get and show the login methods belonging to the current user + - Show a password form (if available) that calls `/addPassword` to add an email+password login method to the current user. + - Show a phone number form (if available) that calls `/addPhoneNumber` to associate a phone number with the current user. + - Show an "Add Google account" that start a login process through Google +- We add a custom page (`/link/tpcallback/:thirdPartyId`) that will: + - Call `/addThirdPartyUser` through a customized `ThirdPartyEmailPassword.thirdPartySignInAndUp` call + +### On the backend + +- We enable account linking by initializing the recipe and providing a `shouldDoAutomaticAccountLinking` implementation +- We add `/addPassword`, `/addPhoneNumber` and `/addThirdPartyUser` to enable manual linking from the frontend +- We add `/userInfo` so the frontend can list/show the login methods belonging to the current user. + +## Author + +Created with :heart: by the folks at supertokens.com. + +## License + +This project is licensed under the Apache 2.0 license. diff --git a/examples/with-multifactorauth/backend/config.ts b/examples/with-multifactorauth/backend/config.ts new file mode 100644 index 000000000..10c4bf5fd --- /dev/null +++ b/examples/with-multifactorauth/backend/config.ts @@ -0,0 +1,103 @@ +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import Session from "supertokens-node/recipe/session"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import UserMetadata from "supertokens-node/recipe/usermetadata"; +import EmailVerification from "supertokens-node/recipe/emailverification"; +import { TypeInput } from "supertokens-node/types"; +import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"; +import TOTP from "supertokens-node/recipe/totp"; +import Dashboard from "supertokens-node/recipe/dashboard"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig: TypeInput = { + supertokens: { + // this is the location of the SuperTokens core. + // connectionURI: "https://try.supertokens.com", + connectionURI: "http://localhost:3567", + }, + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + UserMetadata.init(), + EmailVerification.init({ + mode: "REQUIRED", + }), + TOTP.init(), + MultiFactorAuth.init({ + firstFactors: ["thirdparty", "emailpassword"], // This is basically disallows using passwordless to sign in + override: { + functions: (oI) => ({ + ...oI, + getMFARequirementsForAuth: async (input) => { + const userData = await UserMetadata.getUserMetadata(input.user.id); + if (userData.metadata.enable3FA) { + let secondaryFactors = ["otp-email", "otp-phone", "totp"]; + let remainingFactors = secondaryFactors.filter((factor) => !input.completedFactors[factor]); + if (secondaryFactors.length - remainingFactors.length < 2) { + return [{ oneOf: remainingFactors }]; + } else { + return []; + } + } + + if (userData.metadata.enable2FA) { + // We can't directly return input.factorsSetUpForUser because that also contains the first factors + return [{ oneOf: ["otp-email", "otp-phone", "totp"] }]; + } + return []; + }, + }), + }, + }), + ThirdPartyEmailPassword.init({ + providers: [ + // We have provided you with development keys which you can use for testing. + // IMPORTANT: Please replace them with your own OAuth keys for production use. + { + config: { + thirdPartyId: "google", + clients: [ + { + clientId: "1060725074195-kmeum4crr01uirfl2op9kd5acmi9jutn.apps.googleusercontent.com", + clientSecret: "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", + }, + ], + }, + }, + { + config: { + thirdPartyId: "github", + clients: [ + { + clientId: "467101b197249757c71f", + clientSecret: "e97051221f4b6426e8fe8d51486396703012f5bd", + }, + ], + }, + }, + ], + }), + Passwordless.init({ + contactMethod: "EMAIL_OR_PHONE", + flowType: "USER_INPUT_CODE_AND_MAGIC_LINK", + }), + Session.init(), + Dashboard.init(), + ], +}; diff --git a/examples/with-multifactorauth/backend/index.ts b/examples/with-multifactorauth/backend/index.ts new file mode 100644 index 000000000..1b23e4a45 --- /dev/null +++ b/examples/with-multifactorauth/backend/index.ts @@ -0,0 +1,106 @@ +import express from "express"; +import cors from "cors"; +import supertokens, { getUser, listUsersByAccountInfo } from "supertokens-node"; +import { verifySession } from "supertokens-node/recipe/session/framework/express"; +import { middleware, errorHandler, SessionRequest } from "supertokens-node/framework/express"; +import { getWebsiteDomain, SuperTokensConfig } from "./config"; +import EmailVerification from "supertokens-node/recipe/emailverification"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import Session from "supertokens-node/recipe/session"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import { MultiFactorAuthClaim } from "supertokens-node/recipe/multifactorauth"; +import { getUserMetadata, updateUserMetadata } from "supertokens-node/recipe/usermetadata"; + +supertokens.init(SuperTokensConfig); + +const app = express(); + +app.use( + cors({ + origin: getWebsiteDomain(), + allowedHeaders: ["content-type", ...supertokens.getAllCORSHeaders()], + methods: ["GET", "PUT", "POST", "DELETE"], + credentials: true, + }) +); + +// This exposes all the APIs from SuperTokens to the client. +app.use(middleware()); +app.use(express.json()); + +// An example API that requires session verification +app.get("/sessioninfo", verifySession(), async (req: SessionRequest, res) => { + let session = req.session; + res.send({ + sessionHandle: session!.getHandle(), + userId: session!.getUserId(), + accessTokenPayload: session!.getAccessTokenPayload(), + }); +}); + +app.get( + "/sessioninfo-2fa", + verifySession({ + overrideGlobalClaimValidators: (gv) => [ + ...gv, + MultiFactorAuthClaim.validators.hasCompletedFactors([{ oneOf: ["otp-phone", "otp-email", "totp"] }]), + ], + }), + async (req: SessionRequest, res) => { + let session = req.session; + res.send({ + sessionHandle: session!.getHandle(), + userId: session!.getUserId(), + accessTokenPayload: session!.getAccessTokenPayload(), + }); + } +); + +app.get( + "/sessioninfo-3fa", + verifySession({ + overrideGlobalClaimValidators: (gv) => [ + ...gv, + MultiFactorAuthClaim.validators.hasCompletedFactors(["totp", { oneOf: ["otp-phone", "otp-email"] }]), + ], + }), + async (req: SessionRequest, res) => { + let session = req.session; + res.send({ + sessionHandle: session!.getHandle(), + userId: session!.getUserId(), + accessTokenPayload: session!.getAccessTokenPayload(), + }); + } +); + +app.get("/userInfo", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + const metadata = await getUserMetadata(user.id); + + res.json({ + user: user.toJson(), + metadata: metadata.metadata, + }); +}); + +app.post("/updateMFA", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + + const updateRes = await updateUserMetadata(session.getUserId(), { + enable2FA: !!req.body.enable2FA, + enable3FA: !!req.body.enable3FA, + }); + + return res.json(updateRes); +}); + +// In case of session related errors, this error handler +// returns 401 to the client. +app.use(errorHandler()); + +app.listen(3001, () => console.log(`API Server listening on port 3001`)); diff --git a/examples/with-multifactorauth/backend/package.json b/examples/with-multifactorauth/backend/package.json new file mode 100644 index 000000000..d9a8f8a3c --- /dev/null +++ b/examples/with-multifactorauth/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "supertokens-node", + "version": "0.0.1", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "start": "npx ts-node-dev --project ./tsconfig.json ./index.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.1", + "helmet": "^5.1.0", + "morgan": "^1.10.0", + "npm-run-all": "^4.1.5", + "supertokens-node": "github:supertokens/supertokens-node#mfa-impl", + "ts-node-dev": "^2.0.0", + "typescript": "^4.7.2" + }, + "devDependencies": { + "@types/cors": "^2.8.12", + "@types/express": "^4.17.17", + "@types/morgan": "^1.9.3", + "@types/node": "^16.11.38", + "nodemon": "^2.0.16" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/examples/with-multifactorauth/backend/tsconfig.json b/examples/with-multifactorauth/backend/tsconfig.json new file mode 100644 index 000000000..8a91acaae --- /dev/null +++ b/examples/with-multifactorauth/backend/tsconfig.json @@ -0,0 +1,62 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} diff --git a/examples/with-multifactorauth/frontend/.env b/examples/with-multifactorauth/frontend/.env new file mode 100644 index 000000000..7d910f148 --- /dev/null +++ b/examples/with-multifactorauth/frontend/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/with-multifactorauth/frontend/.gitignore b/examples/with-multifactorauth/frontend/.gitignore new file mode 100644 index 000000000..4d29575de --- /dev/null +++ b/examples/with-multifactorauth/frontend/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/with-multifactorauth/frontend/LICENSE.md b/examples/with-multifactorauth/frontend/LICENSE.md new file mode 100644 index 000000000..588f27e68 --- /dev/null +++ b/examples/with-multifactorauth/frontend/LICENSE.md @@ -0,0 +1,192 @@ +Copyright (c) 2020, 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 software except in compliance with the License. A copy +of the License is available below the line. + +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. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/examples/with-multifactorauth/frontend/package.json b/examples/with-multifactorauth/frontend/package.json new file mode 100644 index 000000000..34929bb5f --- /dev/null +++ b/examples/with-multifactorauth/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "supertokens-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.11.56", + "@types/react": "^18.0.18", + "@types/react-dom": "^18.0.6", + "axios": "^0.21.0", + "react": "^18.2.0", + "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/tests", + "supertokens-web-js": "github:supertokens/supertokens-web-js#feat/mfa", + "typescript": "^4.8.2", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/with-multifactorauth/frontend/public/favicon.ico b/examples/with-multifactorauth/frontend/public/favicon.ico new file mode 100644 index 000000000..a11777cc4 Binary files /dev/null and b/examples/with-multifactorauth/frontend/public/favicon.ico differ diff --git a/examples/with-multifactorauth/frontend/public/index.html b/examples/with-multifactorauth/frontend/public/index.html new file mode 100644 index 000000000..6f1f7cb51 --- /dev/null +++ b/examples/with-multifactorauth/frontend/public/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + React App + + + +
+ + diff --git a/examples/with-multifactorauth/frontend/public/manifest.json b/examples/with-multifactorauth/frontend/public/manifest.json new file mode 100644 index 000000000..f01493ff0 --- /dev/null +++ b/examples/with-multifactorauth/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/with-multifactorauth/frontend/public/robots.txt b/examples/with-multifactorauth/frontend/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/examples/with-multifactorauth/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/with-multifactorauth/frontend/src/App.css b/examples/with-multifactorauth/frontend/src/App.css new file mode 100644 index 000000000..8a98a2341 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/App.css @@ -0,0 +1,27 @@ +.App { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + font-family: Rubik; +} + +.fill { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; +} + +.sessionButton { + padding-left: 13px; + padding-right: 13px; + padding-top: 8px; + padding-bottom: 8px; + background-color: black; + border-radius: 10px; + cursor: pointer; + color: white; + font-weight: bold; + font-size: 17px; +} diff --git a/examples/with-multifactorauth/frontend/src/App.tsx b/examples/with-multifactorauth/frontend/src/App.tsx new file mode 100644 index 000000000..0e4032cdb --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/App.tsx @@ -0,0 +1,41 @@ +import "./App.css"; +import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react"; +import { getSuperTokensRoutesForReactRouterDom } from "supertokens-auth-react/ui"; +import { SessionAuth } from "supertokens-auth-react/recipe/session"; +import { Routes, BrowserRouter as Router, Route } from "react-router-dom"; +import Home from "./Home"; +import { PreBuiltUIList, SuperTokensConfig } from "./config"; +import { MultiFactorAuthClaim } from "supertokens-auth-react/recipe/multifactorauth"; + +SuperTokens.init(SuperTokensConfig); + +function App() { + return ( + +
+ +
+ + {/* This shows the login UI on "/auth" route */} + {getSuperTokensRoutesForReactRouterDom(require("react-router-dom"), PreBuiltUIList)} + + only if the user is logged in. + Else it redirects the user to "/auth" */ + + + + } + /> + +
+
+
+
+ ); +} + +export default App; diff --git a/examples/with-multifactorauth/frontend/src/Home/CallAPIView.tsx b/examples/with-multifactorauth/frontend/src/Home/CallAPIView.tsx new file mode 100644 index 000000000..4a1d22329 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/Home/CallAPIView.tsx @@ -0,0 +1,54 @@ +import axios from "axios"; +import { getApiDomain } from "../config"; + +export default function CallAPIView() { + async function callAPIClicked() { + let response = await axios.get(getApiDomain() + "/sessioninfo"); + window.alert("Session Information:\n" + JSON.stringify(response.data, null, 2)); + } + + async function call2FAProtectedAPIClicked() { + try { + let response = await axios.get(getApiDomain() + "/sessioninfo-2fa"); + window.alert("Session Information:\n" + JSON.stringify(response.data, null, 2)); + } catch (err: any) { + if (err.isAxiosError) { + window.alert( + `Call failed: ${err.response.statusText}(${err.response.status}): ${err.response.data.message}` + ); + } else { + window.alert(`Call failed`); + } + } + } + + async function call3FAProtectedAPIClicked() { + try { + let response = await axios.get(getApiDomain() + "/sessioninfo-3fa"); + window.alert("Session Information:\n" + JSON.stringify(response.data, null, 2)); + } catch (err: any) { + if (err.isAxiosError) { + window.alert( + `Call failed: ${err.response.statusText}(${err.response.status}): ${err.response.data.message}` + ); + } else { + window.alert(`Call failed`); + } + } + } + + return ( +
+ +
+ Get session info +
+
+ Call 2FA protected API +
+
+ Call 3FA protected API +
+
+ ); +} diff --git a/examples/with-multifactorauth/frontend/src/Home/Home.css b/examples/with-multifactorauth/frontend/src/Home/Home.css new file mode 100644 index 000000000..580a93806 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/Home/Home.css @@ -0,0 +1,211 @@ +@font-face { + font-family: Menlo; + src: url("../assets/fonts/MenloRegular.ttf"); +} + +.app-container { + font-family: Rubik, sans-serif; +} + +.app-container * { + box-sizing: border-box; +} + +.bold-400 { + font-variation-settings: "wght" 400; +} + +.bold-500 { + font-variation-settings: "wght" 500; +} + +.bold-600 { + font-variation-settings: "wght" 600; +} + +#home-container { + align-items: center; + min-height: 100vh; + background: url("../assets/images/background.png"); + background-size: cover; +} + +.bold-700 { + font-variation-settings: "wght" 700; +} + +.app-container .main-container { + box-shadow: 0px 0px 60px 0px rgba(0, 0, 0, 0.16); + width: min(635px, calc(100% - 24px)); + border-radius: 16px; + margin-block-end: 159px; + background-color: #ffffff; +} + +.main-container .success-title { + line-height: 1; + padding-block: 26px; + background-color: #e7ffed; + text-align: center; + color: #3eb655; + display: flex; + justify-content: center; + align-items: flex-end; + font-size: 20px; +} + +.success-title img.success-icon { + margin-right: 8px; +} + +.main-container .inner-content { + padding-block: 48px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.inner-content #user-id { + position: relative; + padding: 14px 17px; + border-image-slice: 1; + width: min(430px, calc(100% - 30px)); + margin-inline: auto; + margin-block: 11px 23px; + border-radius: 9px; + line-height: 1; + font-family: Menlo, serif; + cursor: text; +} + +.inner-content #user-id:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 9px; + padding: 2px; + background: linear-gradient(90.31deg, #ff9933 0.11%, #ff3f33 99.82%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.main-container > .top-band, +.main-container > .bottom-band { + border-radius: inherit; +} + +.main-container .top-band { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.main-container .bottom-band { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.main-container .sessionButton { + box-sizing: border-box; + background: #ff9933; + border: 1px solid #ff8a15; + box-shadow: 0px 3px 6px rgba(255, 153, 51, 0.16); + border-radius: 6px; + font-size: 16px; + margin: 0.5em; + text-decoration: none; +} + +.bottom-cta-container { + display: flex; + justify-content: flex-end; + padding-inline: 21px; + background-color: #212d4f; +} + +.bottom-cta-container .view-code { + padding-block: 11px; + color: #bac9f5; + cursor: pointer; + font-size: 14px; +} + +.mfa-button-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-bottom: 22px; +} + +.mfa-button-container label { + grid-column: 1 / 4; +} + +.userInfoJSON { + text-align: left; + overflow-x: auto; + max-width: 100%; +} + +.bottom-links-container { + display: grid; + grid-template-columns: repeat(4, auto); + margin-bottom: 22px; +} + +.bottom-links-container .link { + display: flex; + align-items: center; + margin-inline-end: 68px; + cursor: pointer; +} + +.bottom-links-container .link:last-child { + margin-right: 0; +} + +.truncate { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.separator-line { + max-width: 100%; +} + +.link .link-icon { + width: 15px; + margin-right: 5px; +} + +.mfaInfo { + margin-bottom: 22px; +} + +@media screen and (max-width: 768px) { + .bottom-links-container { + grid-template-columns: repeat(2, 1fr); + column-gap: 64px; + row-gap: 34px; + } + + .bottom-links-container .link { + margin-inline-end: 0; + } + + .separator-line { + max-width: 200px; + } +} + +@media screen and (max-width: 480px) { + #home-container { + justify-content: start; + padding-block-start: 25px; + } + + .app-container .main-container { + margin-block-end: 90px; + } +} diff --git a/examples/with-multifactorauth/frontend/src/Home/MFASettings.tsx b/examples/with-multifactorauth/frontend/src/Home/MFASettings.tsx new file mode 100644 index 000000000..018bdca45 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/Home/MFASettings.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react"; +import { useAsyncCall } from "../useAsyncCallOnMount"; +import { getApiDomain } from "../config"; +import { NavLink, useNavigate } from "react-router-dom"; +import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth"; + +export default function MFASettings({ mfaRequirement }: { mfaRequirement: string }) { + const nav = useNavigate(); + const [userInfo, setUserInfo] = useState<{ metadata: { enable3FA: boolean; enable2FA: boolean } } | undefined>(); + const [error, setError] = useState(); + const handleUserInfo = useCallback( + async (res: Response): Promise => { + if (res.status === 200) { + setUserInfo(await res.clone().json()); + } else { + setError(new Error("Failed to load user info")); + } + }, + [setUserInfo, setError] + ); + const updateUserInfo = useAsyncCall(async () => fetch(`${getApiDomain()}/userinfo`), handleUserInfo, setError); + + const submitMFA = (mfaInfo: { enable2FA: boolean; enable3FA: boolean }) => { + fetch(`${getApiDomain()}/updateMFA`, { + method: "POST", + headers: new Headers([["content-type", "application/json"]]), + body: JSON.stringify({ + ...mfaInfo, + }), + }).then(() => { + updateUserInfo(); + }); + }; + + if (error) { + throw error; + } + + return ( + <> + {/*
{userInfo && JSON.stringify(userInfo, null, 2)}
*/} +
This page requires {mfaRequirement}
+
+ {!userInfo + ? "Loading" + : userInfo.metadata.enable3FA + ? "3FA required during sign in" + : userInfo.metadata.enable2FA + ? "2FA required during sign in" + : "No MFA requirements for sign in"} +
+
+ +
submitMFA({ enable2FA: false, enable3FA: false })} className="sessionButton"> + No MFA +
+
submitMFA({ enable2FA: true, enable3FA: false })} className="sessionButton"> + 2FA +
+
submitMFA({ enable2FA: false, enable3FA: true })} className="sessionButton"> + 3FA +
+
+
+ +
MultiFactorAuth.redirectToFactorChooser(true, undefined, nav)} + className="sessionButton"> + Factor chooser +
+
MultiFactorAuth.redirectToFactor("otp-phone", true, true, nav)} + className="sessionButton"> + Add phone-based OTP +
+
MultiFactorAuth.redirectToFactor("totp", true, true, nav)} + className="sessionButton"> + Add TOTP +
+
+ + ); +} diff --git a/examples/with-multifactorauth/frontend/src/Home/SuccessView.tsx b/examples/with-multifactorauth/frontend/src/Home/SuccessView.tsx new file mode 100644 index 000000000..903a61fa4 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/Home/SuccessView.tsx @@ -0,0 +1,74 @@ +import { NavLink, useNavigate } from "react-router-dom"; +import { signOut } from "supertokens-auth-react/recipe/session"; +import { recipeDetails } from "../config"; +import CallAPIView from "./CallAPIView"; +import { BlogsIcon, CelebrateIcon, GuideIcon, SeparatorLine, SignOutIcon } from "../assets/images"; +import MFASettings from "./MFASettings"; + +interface ILink { + name: string; + onClick: () => void; + icon: string; +} + +export default function SuccessView(props: { userId: string; mfaRequirement: string }) { + let userId = props.userId; + + const navigate = useNavigate(); + + async function logoutClicked() { + await signOut(); + navigate("/auth"); + } + + function openLink(url: string) { + window.open(url, "_blank"); + } + + const links: ILink[] = [ + { + name: "Blogs", + onClick: () => openLink("https://supertokens.com/blog"), + icon: BlogsIcon, + }, + { + name: "Documentation", + onClick: () => openLink(recipeDetails.docsLink), + icon: GuideIcon, + }, + { + name: "Sign Out", + onClick: logoutClicked, + icon: SignOutIcon, + }, + ]; + + return ( + <> +
+
+ Login successful Login successful +
+
+
Your userID is:
+
+ {userId} +
+ + +
+
+
+ {links.map((link) => ( +
+ {link.name} +
+ {link.name} +
+
+ ))} +
+ separator + + ); +} diff --git a/examples/with-multifactorauth/frontend/src/Home/index.tsx b/examples/with-multifactorauth/frontend/src/Home/index.tsx new file mode 100644 index 000000000..f52a713ac --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/Home/index.tsx @@ -0,0 +1,17 @@ +import SuccessView from "./SuccessView"; +import { useSessionContext } from "supertokens-auth-react/recipe/session"; +import "./Home.css"; + +export default function Home(props: { mfaRequirements: string }) { + const sessionContext = useSessionContext(); + + if (sessionContext.loading === true) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/examples/with-multifactorauth/frontend/src/assets/fonts/MenloRegular.ttf b/examples/with-multifactorauth/frontend/src/assets/fonts/MenloRegular.ttf new file mode 100644 index 000000000..033dc6d21 Binary files /dev/null and b/examples/with-multifactorauth/frontend/src/assets/fonts/MenloRegular.ttf differ diff --git a/examples/with-multifactorauth/frontend/src/assets/images/arrow-right-icon.svg b/examples/with-multifactorauth/frontend/src/assets/images/arrow-right-icon.svg new file mode 100644 index 000000000..95aa1fec6 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/arrow-right-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth/frontend/src/assets/images/background.png b/examples/with-multifactorauth/frontend/src/assets/images/background.png new file mode 100644 index 000000000..2147c15c2 Binary files /dev/null and b/examples/with-multifactorauth/frontend/src/assets/images/background.png differ diff --git a/examples/with-multifactorauth/frontend/src/assets/images/blogs-icon.svg b/examples/with-multifactorauth/frontend/src/assets/images/blogs-icon.svg new file mode 100644 index 000000000..a2fc9dd62 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/blogs-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth/frontend/src/assets/images/celebrate-icon.svg b/examples/with-multifactorauth/frontend/src/assets/images/celebrate-icon.svg new file mode 100644 index 000000000..3b40b1efa --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/celebrate-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/with-multifactorauth/frontend/src/assets/images/guide-icon.svg b/examples/with-multifactorauth/frontend/src/assets/images/guide-icon.svg new file mode 100644 index 000000000..bd85af72b --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/guide-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth/frontend/src/assets/images/index.ts b/examples/with-multifactorauth/frontend/src/assets/images/index.ts new file mode 100644 index 000000000..7adf036c4 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/index.ts @@ -0,0 +1,8 @@ +import SeparatorLine from "./separator-line.svg"; +import ArrowRight from "./arrow-right-icon.svg"; +import SignOutIcon from "./sign-out-icon.svg"; +import GuideIcon from "./guide-icon.svg"; +import BlogsIcon from "./blogs-icon.svg"; +import CelebrateIcon from "./celebrate-icon.svg"; + +export { SeparatorLine, ArrowRight, SignOutIcon, GuideIcon, BlogsIcon, CelebrateIcon }; diff --git a/examples/with-multifactorauth/frontend/src/assets/images/separator-line.svg b/examples/with-multifactorauth/frontend/src/assets/images/separator-line.svg new file mode 100644 index 000000000..7127a00dc --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/separator-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/with-multifactorauth/frontend/src/assets/images/sign-out-icon.svg b/examples/with-multifactorauth/frontend/src/assets/images/sign-out-icon.svg new file mode 100644 index 000000000..6cc4f85fd --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/assets/images/sign-out-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-multifactorauth/frontend/src/config.tsx b/examples/with-multifactorauth/frontend/src/config.tsx new file mode 100644 index 000000000..b32f57126 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/config.tsx @@ -0,0 +1,78 @@ +import ThirdPartyEmailPassword, { Google, Github, Apple } from "supertokens-auth-react/recipe/thirdpartyemailpassword"; +import EmailVerification from "supertokens-auth-react/recipe/emailverification"; +import { ThirdPartyEmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/thirdpartyemailpassword/prebuiltui"; +import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui"; +import { EmailVerificationPreBuiltUI } from "supertokens-auth-react/recipe/emailverification/prebuiltui"; +import Session from "supertokens-auth-react/recipe/session"; +import Passwordless from "supertokens-auth-react/recipe/passwordless"; +import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth"; +import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui"; +import TOTP from "supertokens-auth-react/recipe/totp"; +import { TOTPPreBuiltUI } from "supertokens-auth-react/recipe/totp/prebuiltui"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig = { + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + EmailVerification.init({ + mode: "REQUIRED", + }), + MultiFactorAuth.init(), + TOTP.init(), + ThirdPartyEmailPassword.init({ + signInAndUpFeature: { + providers: [ + Github.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + Google.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + ], + }, + }), + Passwordless.init({ + contactMethod: "EMAIL_OR_PHONE", + }), + Session.init(), + ], +}; + +export const recipeDetails = { + docsLink: "https://supertokens.com/docs/thirdpartyemailpassword/introduction", +}; + +export const PreBuiltUIList = [ + ThirdPartyEmailPasswordPreBuiltUI, + PasswordlessPreBuiltUI, + EmailVerificationPreBuiltUI, + MultiFactorAuthPreBuiltUI, + TOTPPreBuiltUI, +]; diff --git a/examples/with-multifactorauth/frontend/src/index.css b/examples/with-multifactorauth/frontend/src/index.css new file mode 100644 index 000000000..04146b5e7 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/examples/with-multifactorauth/frontend/src/index.tsx b/examples/with-multifactorauth/frontend/src/index.tsx new file mode 100644 index 000000000..399c737cd --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + +); diff --git a/examples/with-multifactorauth/frontend/src/react-app-env.d.ts b/examples/with-multifactorauth/frontend/src/react-app-env.d.ts new file mode 100644 index 000000000..6431bc5fc --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/with-multifactorauth/frontend/src/useAsyncCallOnMount.ts b/examples/with-multifactorauth/frontend/src/useAsyncCallOnMount.ts new file mode 100644 index 000000000..be17631c4 --- /dev/null +++ b/examples/with-multifactorauth/frontend/src/useAsyncCallOnMount.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from "react"; + +export const useAsyncCall = ( + func: () => Promise, + handler: (res: T) => void, + errorHandler?: (err: any) => void +) => { + const [key, setKey] = useState(Date.now()); + const signInUpPromise = useRef<[any, Promise] | undefined>(undefined); + + useEffect(() => { + if (signInUpPromise.current === undefined || signInUpPromise.current[0] !== key) { + signInUpPromise.current = [key, func()]; + } + const abort = new AbortController(); + + signInUpPromise.current[1].then( + (resp) => { + if (abort.signal.aborted) { + return; + } + handler(resp); + }, + (err) => { + if (abort.signal.aborted) { + return; + } + if (errorHandler !== undefined) { + errorHandler(err); + } + } + ); + + return () => abort.abort(); + }, [handler, errorHandler, key]); + + return () => setKey(Date.now()); +}; diff --git a/examples/with-multifactorauth/frontend/tsconfig.json b/examples/with-multifactorauth/frontend/tsconfig.json new file mode 100644 index 000000000..c0555cbc6 --- /dev/null +++ b/examples/with-multifactorauth/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/examples/with-multifactorauth/package.json b/examples/with-multifactorauth/package.json new file mode 100644 index 000000000..55d8979fe --- /dev/null +++ b/examples/with-multifactorauth/package.json @@ -0,0 +1,20 @@ +{ + "name": "with-account-linking", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "start:frontend": "cd frontend && npm run start", + "start:frontend-live-demo-app": "cd frontend && npx serve -s build", + "start:backend": "cd backend && npm run start", + "start:backend-live-demo-app": "cd backend && ./startLiveDemoApp.sh", + "start": "npm-run-all --parallel start:frontend start:backend", + "start-live-demo-app": "npx npm-run-all --parallel start:frontend-live-demo-app start:backend-live-demo-app" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "npm-run-all": "^4.1.5" + } +} diff --git a/examples/with-multifactorauth/test/basic.test.js b/examples/with-multifactorauth/test/basic.test.js new file mode 100644 index 000000000..699c7c1b7 --- /dev/null +++ b/examples/with-multifactorauth/test/basic.test.js @@ -0,0 +1,201 @@ +/* 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 + */ + +const assert = require("assert"); +const puppeteer = require("puppeteer"); +const { + getTestEmail, + getTestPhoneNumber, + setInputValues, + submitForm, + toggleSignInSignUp, + waitForSTElement, + chooseFactor, +} = require("../../../test/exampleTestHelpers"); + +const SuperTokensNode = require("../backend/node_modules/supertokens-node"); +const Session = require("../backend/node_modules/supertokens-node/recipe/session"); +const EmailVerification = require("../backend/node_modules/supertokens-node/recipe/emailverification"); +const EmailPassword = require("../backend/node_modules/supertokens-node/recipe/emailpassword"); +const Passwordless = require("../backend/node_modules/supertokens-node/recipe/passwordless"); + +// Run the tests in a DOM environment. +require("jsdom-global")(); + +const apiDomain = "http://localhost:3001"; +const websiteDomain = "http://localhost:3000"; +SuperTokensNode.init({ + supertokens: { + // We are running these tests without running a local ST instance + connectionURI: "http://localhost:3567", + }, + appInfo: { + // These largely shouldn't matter except for creating links which we can change anyway + apiDomain: apiDomain, + websiteDomain: websiteDomain, + appName: "testNode", + }, + recipeList: [ + EmailVerification.init({ mode: "OPTIONAL" }), + EmailPassword.init(), + Session.init(), + Passwordless.init({ + contactMethod: "EMAIL_OR_PHONE", + flowType: "USER_INPUT_CODE_AND_MAGIC_LINK", + }), + ], +}); + +describe("SuperTokens Example Basic tests", function () { + let browser; + let page; + const email = getTestEmail(); + const phoneNumber = getTestPhoneNumber(); + const testPW = "Str0ngP@ssw0rd"; + + before(async function () { + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: true, + }); + page = await browser.newPage(); + }); + + after(async function () { + await browser.close(); + }); + + describe("MultiFactorAuth", function () { + it("Successful signup with multiple credentials", async function () { + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + + // redirected to /auth + await toggleSignInSignUp(page); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + + // Redirected to email verification screen + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + const userId = await page.evaluate(() => window.__supertokensSessionRecipe.getUserId()); + + // Attempt reloading Home + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + + // Create a new token and use it (we don't have access to the originally sent one) + const tokenInfo = await EmailVerification.createEmailVerificationToken( + "public", + SuperTokensNode.convertToRecipeUserId(userId), + email + ); + await page.goto(`${websiteDomain}/auth/verify-email?token=${tokenInfo.token}`); + + await submitForm(page); + + const btnSelector1FA = ".call-api div:nth-of-type(1)"; + const btnSelector2FA = ".call-api div:nth-of-type(2)"; + const btnSelector3FA = ".call-api div:nth-of-type(3)"; + await checkCallAPIBtn(page, btnSelector1FA, true); + await checkCallAPIBtn(page, btnSelector2FA, false); + await checkCallAPIBtn(page, btnSelector3FA, false); + + const addPhone = await page.waitForSelector(".factor-redirection div:nth-of-type(2)"); + await addPhone.click(); + + await setInputValues(page, [{ name: "phoneNumber_text", value: phoneNumber }]); + + await submitForm(page); + + await completeOTP(page); + + await checkCallAPIBtn(page, btnSelector1FA, true); + await checkCallAPIBtn(page, btnSelector2FA, true); + await checkCallAPIBtn(page, btnSelector3FA, false); + + // set to 3fa + await page.click(".set-requirements div:nth-of-type(3)"); + await page.waitForFunction(() => { + const reqs = document.querySelector(".login-requirements"); + + return reqs.textContent === "3FA required during sign in"; + }); + + // Log out + await page.click("div.bottom-links-container > div:nth-child(3) > div"); + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + await chooseFactor(page, "otp-phone"); + await completeOTP(page); + await new Promise((res) => setTimeout(res, 500)); + // complete otp-email as well + await completeOTP(page); + + const factorChooser = await page.waitForSelector(".factor-redirection div:nth-of-type(1)"); + await factorChooser.click(); + await chooseFactor(page, "totp"); + await waitForSTElement(page, "[data-supertokens~=totpDeviceQR]"); + }); + }); +}); + +async function checkCallAPIBtn(page, btnSelector, shouldSucceed) { + const callApiBtn = await page.waitForSelector(btnSelector); + let setAlertContent; + let alertContent = new Promise((res) => (setAlertContent = res)); + const dialogHandler = async (dialog) => { + setAlertContent(dialog.message()); + await dialog.dismiss(); + }; + page.on("dialog", dialogHandler); + await callApiBtn.click(); + await alertContent; + page.off("dialog", dialogHandler); + + const alertText = await alertContent; + if (shouldSucceed) { + assert(alertText.startsWith("Session Information:")); + } else { + assert(alertText.includes("invalid claim")); + } +} + +async function completeOTP(page) { + await waitForSTElement(page, "[data-supertokens~=input][name=userInputCode]"); + await new Promise((res) => setTimeout(res, 500)); + + const loginAttemptInfo = JSON.parse( + await page.evaluate(() => localStorage.getItem("supertokens-passwordless-loginAttemptInfo")) + ); + + const resend = await Passwordless.createNewCodeForDevice({ + deviceId: loginAttemptInfo.deviceId, + tenantId: loginAttemptInfo.tenantId ?? "public", + }); + + assert.strictEqual(resend.status, "OK"); + + await setInputValues(page, [{ name: "userInputCode", value: resend.userInputCode }]); + await submitForm(page); +}