├── .husky ├── pre-push └── pre-commit ├── .eslintrc.cjs ├── vitest-setup.ts ├── .gitignore ├── SECURITY.MD ├── .prettierrc ├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── main.yml ├── tsconfig.json ├── test ├── utils.ts └── index.spec.ts ├── vitest.config.ts ├── LICENSE ├── docs ├── examples.md ├── cloudflare.md ├── customization.md └── README.md ├── src ├── constants.ts ├── utils.ts └── index.ts ├── package.json ├── README.md └── CODE_OF_CONDUCT.md /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run validate && npm run build 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run validate && npm run build 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['plugin:@typescript-eslint/recommended', 'prettier'], 6 | } 7 | -------------------------------------------------------------------------------- /vitest-setup.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Remix relies on browser API's such as fetch that are not natively available in Node.js, 5 | * you may find that unit tests fail without these globals, when running with tools such as Jest. 6 | */ 7 | // installGlobals() 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Package Managers. 2 | package-lock.json 3 | yarn.lock 4 | node_modules 5 | 6 | # Editor Configs. 7 | .idea 8 | .vscode 9 | .DS_Store 10 | 11 | # Files. 12 | /.cache 13 | /dist 14 | /public/build 15 | .env 16 | 17 | # Tests. 18 | /coverage 19 | 20 | # Miscellaneous. 21 | TODO.md -------------------------------------------------------------------------------- /SECURITY.MD: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | In case of a vulnerability please reach out to active maintainers of the project and report it to them. 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 90, 4 | "semi": false, 5 | "useTabs": false, 6 | "bracketSpacing": true, 7 | "bracketSameLine": true, 8 | "singleQuote": true, 9 | "jsxSingleQuote": false, 10 | "singleAttributePerLine": false, 11 | "arrowParens": "always", 12 | "trailingComma": "all" 13 | } 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Supported funding platforms. 2 | # More info: https://docs.github.com/es/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 3 | 4 | github: [dev-xo] 5 | patreon: # ... 6 | open_collective: # ... 7 | ko_fi: # ... 8 | tidelift: # ... 9 | community_bridge: # ... 10 | liberapay: # ... 11 | issuehunt: # ... 12 | otechie: # ... 13 | lfx_crowdfunding: # ... 14 | custom: # ... 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 4 | "types": ["vitest/globals"], 5 | "target": "ES2022", 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "jsx": "react-jsx", 9 | "outDir": "./dist", 10 | "strict": true, 11 | "declaration": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import type { TOTPGenerationOptions } from '../src' 2 | 3 | /** 4 | * Constants. 5 | */ 6 | export const SECRET_ENV = 7 | 'b2FE35059924CDBF5B52A84765B8B010F5291993A9BC39410139D4F511006034' 8 | export const HOST_URL = 'https://prodserver.com' 9 | export const DEFAULT_EMAIL = 'user@gmail.com' 10 | export const MAGIC_LINK_PATH = '/magic-link' 11 | 12 | /** 13 | * Strategy Defaults. 14 | */ 15 | export const AUTH_OPTIONS = { 16 | name: 'TOTP', 17 | sessionKey: 'user', 18 | sessionErrorKey: 'error', 19 | sessionStrategyKey: 'strategy', 20 | } 21 | 22 | export const TOTP_GENERATION_DEFAULTS: Required< 23 | Pick 24 | > = { 25 | period: 60, 26 | maxAttempts: 3, 27 | } 28 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from 'vite' 5 | import tsconfigPaths from 'vite-tsconfig-paths' 6 | 7 | /** 8 | * Learn more about Vite: https://vitejs.dev/config/ 9 | */ 10 | export default defineConfig({ 11 | plugins: [tsconfigPaths()], 12 | test: { 13 | // Use APIs globally like Jest. 14 | globals: true, 15 | 16 | // Environment. 17 | environment: 'node', 18 | 19 | // Path to setup files. They will be run before each test file. 20 | setupFiles: ['./vitest-setup.ts'], 21 | 22 | // Excludes files from test. 23 | exclude: ['node_modules'], 24 | 25 | // Disable CSS if you don't have tests that relies on it. 26 | css: false, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 🎉 Publish 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | 20 | - name: Install Dependencies 21 | uses: bahmutov/npm-install@v1.8.28 22 | with: 23 | useLockFile: false 24 | 25 | - name: Run Build 26 | run: npm run build 27 | 28 | - name: Publish Package 29 | run: npm publish --access public 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stripe Stack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | A list of community examples using Remix Auth TOTP. 4 | 5 | ## v4.0 Examples 6 | 7 | - [React Router v7 - Starter](https://github.com/dev-xo/remix-auth-totp-starter) by [@dev-xo](https://github.com/dev-xo): A straightforward database-less example App. It can also serve as a foundation for your RR7 App or other projects. 8 | - [React Router v7 - Drizzle ORM](https://github.com/CyrusVorwald/react-router-playground) by [@CyrusVorwald](https://github.com/CyrusVorwald): A simple to start, Drizzle + PostgreSQL example, that aims to be a playground for your React Router v7 app. 9 | 10 | ## v3.0 Examples 11 | 12 | These are examples for the v3.0+ release. The implementation is mostly compatible with v4.0, with slight differences in how the session is handled. 13 | 14 | - [Remix Auth TOTP - Starter](https://github.com/dev-xo/totp-starter-example) by [@dev-xo](https://github.com/dev-xo): A straightforward Prisma ORM + SQLite example App. It can also serve as a foundation for your Remix App or other projects. 15 | - [Remix Auth TOTP + Cloudflare](https://github.com/mw10013/remix-auth-totp-cloudflare-example) by [@mw10013](https://github.com/mw10013): Remix Auth TOTP + Cloudflare example App. 16 | 17 | ## Contributing 18 | 19 | If you have an example you'd like to share with the community, feel free to open a PR! 20 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const STRATEGY_NAME = 'TOTP' 2 | 3 | export const FORM_FIELDS = { 4 | EMAIL: 'email', 5 | CODE: 'code', 6 | } as const 7 | 8 | export const SESSION_KEYS = { 9 | EMAIL: 'auth:email', 10 | TOTP: 'auth:totp', 11 | } as const 12 | 13 | export const ERRORS = { 14 | // Customizable errors. 15 | REQUIRED_EMAIL: 'Please enter your email address to continue.', 16 | INVALID_EMAIL: 17 | "That doesn't look like a valid email address. Please check and try again.", 18 | INVALID_TOTP: 19 | "That code didn't work. Please check and try again, or request a new code.", 20 | EXPIRED_TOTP: 'That code has expired. Please request a new one.', 21 | MISSING_SESSION_EMAIL: 22 | "We couldn't find an email to verify. Please use the same browser you started with or restart from this browser.", 23 | MISSING_SESSION_TOTP: 24 | "We couldn't find an active verification session. Please request a new code.", 25 | RATE_LIMIT_EXCEEDED: "Too many incorrect attempts. Please request a new code.", 26 | 27 | // Miscellaneous errors. 28 | REQUIRED_ENV_SECRET: 'Missing required .env secret.', 29 | USER_NOT_FOUND: 'User not found.', 30 | INVALID_MAGIC_LINK_PATH: 'Invalid magic-link expected path.', 31 | REQUIRED_EMAIL_SENT_REDIRECT_URL: 'Missing required emailSentRedirect URL.', 32 | REQUIRED_SUCCESS_REDIRECT_URL: 'Missing required successRedirect URL.', 33 | REQUIRED_FAILURE_REDIRECT_URL: 'Missing required failureRedirect URL.', 34 | } as const 35 | -------------------------------------------------------------------------------- /docs/cloudflare.md: -------------------------------------------------------------------------------- 1 | ## Cloudflare 2 | 3 | A guide to using Remix Auth TOTP with Cloudflare Pages. 4 | 5 | ### Remix v2 Compiler 6 | 7 | To use it on the Cloudflare runtime, you'll need to add the following to your `remix.config.js` file to specify the polyfills for a couple of Node built-in modules. See the Remix Docs on [supportNodeBuiltinsPolyfill](https://remix.run/docs/en/main/file-conventions/remix-config#servernodebuiltinspolyfill). 8 | 9 | ### `remix.config.js` 10 | 11 | ```js 12 | export default { 13 | serverNodeBuiltinsPolyfill: { 14 | modules: { buffer: true, crypto: true }, 15 | globals: { 16 | Buffer: true, 17 | }, 18 | }, 19 | } 20 | ``` 21 | 22 | ### AppLoadContext 23 | 24 | If you need `context` to be populated with the `AppLoadContext` in `SendTOTPOptions` or `TOTPVerifyParams`, be sure to include it in the call to `authenticate` on the remix-auth `Authenticator`. 25 | 26 | ```ts 27 | await authenticator.authenticate('TOTP', request, { 28 | successRedirect: '/verify', 29 | failureRedirect: new URL(request.url).pathname, 30 | context: appLoadContext, 31 | }) 32 | ``` 33 | 34 | ### Using Cloudflare KV for Session Storage 35 | 36 | ```ts 37 | const sessionStorage = createWorkersKVSessionStorage({ 38 | kv: KV, 39 | cookie: { 40 | name: '_auth', 41 | path: '/', 42 | sameSite: 'lax', 43 | httpOnly: true, 44 | secrets: [SESSION_SECRET], 45 | secure: ENVIRONMENT === 'production', 46 | }, 47 | }) 48 | 49 | const authenticator = new Authenticator(sessionStorage) 50 | 51 | authenticator.use( 52 | new TOTPStrategy( 53 | { 54 | secret: TOTP_SECRET, 55 | sendTOTP: async ({ email, code, magicLink }) => {}, 56 | }, 57 | async ({ email }) => {}, 58 | ), 59 | ) 60 | ``` 61 | 62 | ## Contributing 63 | 64 | If you have any suggestions you'd like to share, feel free to open a PR! 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-auth-totp", 3 | "version": "4.0.0", 4 | "type": "module", 5 | "description": "A Time-Based One-Time Password (TOTP) Authentication Strategy for Remix-Auth.", 6 | "author": "Daniel Kanem (https://twitter.com/DanielKanem)", 7 | "repository": "dev-xo/remix-auth-totp", 8 | "main": "./dist/index.js", 9 | "types": "./dist/index.d.ts", 10 | "license": "MIT", 11 | "keywords": [ 12 | "remix", 13 | "remix-run", 14 | "remix-auth", 15 | "remix-auth-totp", 16 | "authentication", 17 | "totp" 18 | ], 19 | "scripts": { 20 | "build": "tsc --project tsconfig.json", 21 | "lint": "eslint --ext .ts,.tsx src/", 22 | "typecheck": "tsc -b", 23 | "format": "prettier --write .", 24 | "test": "vitest --reporter verbose", 25 | "test:cov": "vitest run --coverage", 26 | "validate": "npm run lint && npm run typecheck && npx vitest --watch=false", 27 | "prepare": "husky install && npm run build", 28 | "prepublishOnly": "npm run validate && npm run build" 29 | }, 30 | "files": [ 31 | "dist", 32 | "package.json", 33 | "README.md" 34 | ], 35 | "eslintIgnore": [ 36 | "/node_modules", 37 | "/dist", 38 | "/public/dist" 39 | ], 40 | "dependencies": { 41 | "@epic-web/totp": "^2.0.0", 42 | "@mjackson/headers": "^0.8.0", 43 | "base32-encode": "^2.0.0", 44 | "jose": "^5.8.0" 45 | }, 46 | "peerDependencies": { 47 | "remix-auth": "^4.1.0" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "^20.16.2", 51 | "@typescript-eslint/eslint-plugin": "^5.62.0", 52 | "@typescript-eslint/parser": "^5.62.0", 53 | "@vitest/coverage-v8": "1.0.0-beta.3", 54 | "eslint": "^8.57.0", 55 | "eslint-config-prettier": "^8.10.0", 56 | "eslint-plugin-prettier": "^4.2.1", 57 | "husky": "^8.0.3", 58 | "prettier": "^2.8.8", 59 | "typescript": "^5.5.4", 60 | "vite": "^4.5.3", 61 | "vite-tsconfig-paths": "^4.3.2", 62 | "vitest": "^1.6.0" 63 | }, 64 | "engines": { 65 | "node": ">=20" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ⚙️ Workflow 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | 8 | permissions: 9 | actions: write 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: 🏗 Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Cancel Previous Runs 18 | uses: styfle/cancel-workflow-action@0.11.0 19 | 20 | - name: Checkout Repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 20 27 | 28 | - name: Install Dependencies 29 | uses: bahmutov/npm-install@v1.8.28 30 | with: 31 | useLockFile: false 32 | install-command: npm install 33 | 34 | - name: Run Build 35 | run: npm run build 36 | 37 | lint: 38 | name: ⬣ ESLint 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Cancel Previous Runs 42 | uses: styfle/cancel-workflow-action@0.11.0 43 | 44 | - name: Checkout Repository 45 | uses: actions/checkout@v3 46 | 47 | - name: Setup Node 48 | uses: actions/setup-node@v3 49 | with: 50 | node-version: 20 51 | 52 | - name: Install Dependencies 53 | uses: bahmutov/npm-install@v1.8.28 54 | with: 55 | useLockFile: false 56 | install-command: npm install 57 | 58 | - name: Run Lint 59 | run: npm run lint 60 | 61 | typecheck: 62 | name: ʦ TypeScript 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Cancel Previous Runs 66 | uses: styfle/cancel-workflow-action@0.11.0 67 | 68 | - name: Checkout Repository 69 | uses: actions/checkout@v3 70 | 71 | - name: Setup Node.js 72 | uses: actions/setup-node@v3 73 | with: 74 | node-version: 20 75 | 76 | - name: Install Dependencies 77 | uses: bahmutov/npm-install@v1.8.28 78 | with: 79 | useLockFile: false 80 | install-command: npm install 81 | 82 | - name: Run Typecheck 83 | run: npm run typecheck --if-present 84 | 85 | vitest: 86 | name: ⚡ Vitest 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Cancel Previous Runs 90 | uses: styfle/cancel-workflow-action@0.11.0 91 | 92 | - name: Checkout Repository 93 | uses: actions/checkout@v3 94 | 95 | - name: Setup Node.js 96 | uses: actions/setup-node@v3 97 | with: 98 | node-version: 20 99 | 100 | - name: Install Dependencies 101 | uses: bahmutov/npm-install@v1.8.28 102 | with: 103 | useLockFile: false 104 | install-command: npm install 105 | 106 | - name: Run Vitest 107 | run: npm run test 108 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { TOTPData, TOTPCookieData } from './index.js' 2 | import base32Encode from 'base32-encode' 3 | 4 | /** 5 | * TOTP Generation. 6 | */ 7 | export function generateSecret() { 8 | const randomBytes = new Uint8Array(32) 9 | crypto.getRandomValues(randomBytes) 10 | return base32Encode(randomBytes, 'RFC4648').toString() as string 11 | } 12 | 13 | // https://github.com/sindresorhus/uint8array-extras/blob/main/index.js#L222 14 | const hexToDecimalLookupTable = { 15 | 0: 0, 16 | 1: 1, 17 | 2: 2, 18 | 3: 3, 19 | 4: 4, 20 | 5: 5, 21 | 6: 6, 22 | 7: 7, 23 | 8: 8, 24 | 9: 9, 25 | a: 10, 26 | b: 11, 27 | c: 12, 28 | d: 13, 29 | e: 14, 30 | f: 15, 31 | A: 10, 32 | B: 11, 33 | C: 12, 34 | D: 13, 35 | E: 14, 36 | F: 15, 37 | } 38 | function hexToUint8Array(hexString: string) { 39 | if (hexString.length % 2 !== 0) { 40 | throw new Error('Invalid Hex string length.') 41 | } 42 | 43 | const resultLength = hexString.length / 2 44 | const bytes = new Uint8Array(resultLength) 45 | 46 | for (let index = 0; index < resultLength; index++) { 47 | const highNibble = 48 | hexToDecimalLookupTable[ 49 | hexString[index * 2] as keyof typeof hexToDecimalLookupTable 50 | ] 51 | const lowNibble = 52 | hexToDecimalLookupTable[ 53 | hexString[index * 2 + 1] as keyof typeof hexToDecimalLookupTable 54 | ] 55 | 56 | if (highNibble === undefined || lowNibble === undefined) { 57 | throw new Error(`Invalid Hex character encountered at position ${index * 2}`) 58 | } 59 | 60 | bytes[index] = (highNibble << 4) | lowNibble 61 | } 62 | 63 | return bytes 64 | } 65 | 66 | /** 67 | * Redirect. 68 | */ 69 | export function redirect(url: string, init: ResponseInit | number = 302) { 70 | let responseInit = init 71 | 72 | if (typeof responseInit === 'number') { 73 | responseInit = { status: responseInit } 74 | } else if (typeof responseInit.status === 'undefined') { 75 | responseInit.status = 302 76 | } 77 | 78 | const headers = new Headers(responseInit.headers) 79 | headers.set('Location', url) 80 | 81 | return new Response(null, { ...responseInit, headers }) 82 | } 83 | 84 | /** 85 | * Miscellaneous. 86 | */ 87 | export function asJweKey(secret: string) { 88 | if (!/^[0-9a-fA-F]{64}$/.test(secret)) { 89 | throw new Error('Secret must be a string with 64 hex characters.') 90 | } 91 | return hexToUint8Array(secret) 92 | } 93 | 94 | export function coerceToOptionalString(value: unknown) { 95 | if (typeof value !== 'string' && value !== undefined) { 96 | throw new Error('Value must be a string or undefined.') 97 | } 98 | return value 99 | } 100 | 101 | export function coerceToOptionalNonEmptyString(value: unknown) { 102 | if (typeof value === 'string' && value.length > 0) return value 103 | return undefined 104 | } 105 | 106 | export function coerceToOptionalTotpSessionData(value: unknown) { 107 | if ( 108 | typeof value === 'object' && 109 | value !== null && 110 | 'jwe' in value && 111 | typeof (value as { jwe: unknown }).jwe === 'string' && 112 | 'attempts' in value && 113 | typeof (value as { attempts: unknown }).attempts === 'number' 114 | ) { 115 | return value as TOTPCookieData 116 | } 117 | return undefined 118 | } 119 | 120 | export function assertTOTPData(obj: unknown): asserts obj is TOTPData { 121 | if ( 122 | typeof obj !== 'object' || 123 | obj === null || 124 | !('secret' in obj) || 125 | typeof (obj as { secret: unknown }).secret !== 'string' || 126 | !('createdAt' in obj) || 127 | typeof (obj as { createdAt: unknown }).createdAt !== 'number' 128 | ) { 129 | throw new Error('Invalid totp data.') 130 | } 131 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 📧 Remix Auth TOTP 3 |

4 | 5 |
6 |

7 | A Time-Based One-Time Password (TOTP) Authentication Strategy for Remix Auth that implements Email-Code & Magic-Link Authentication in your application. 8 |

9 |
10 | 11 |
12 |

13 | Get Started » 14 |

15 | Live Demo 16 | · 17 | Documentation 18 | · 19 | Examples 20 |

21 |
22 | 23 | ``` 24 | npm install remix-auth-totp 25 | ``` 26 | 27 | [![CI](https://img.shields.io/github/actions/workflow/status/dev-xo/remix-auth-totp/main.yml?label=Build)](https://github.com/dev-xo/remix-auth-totp/actions/workflows/main.yml) 28 | [![Release](https://img.shields.io/npm/v/remix-auth-totp.svg?&label=Release)](https://www.npmjs.com/package/remix-auth-totp) 29 | [![License](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://github.com/dev-xo/remix-auth-totp/blob/main/LICENSE) 30 | 31 | ## Features 32 | 33 | - **⭐ Remix & React Router v7** - Out of the box support. 34 | - **📧 Built-In Magic Links** - One-click authentication. 35 | - **⛅ Cloudflare Support** - Cloudflare compatibility. 36 | - **🔐 Secure** - Time-based encrypted codes. 37 | - **🛡 Bulletproof** - Crafted in strict TypeScript with high test coverage. 38 | - **😌 Easy to Set Up** - Official starter templates. 39 | - **🚀 Remix Auth Foundation** - Built on a solid foundation. 40 | 41 | ## [Live Demo](https://totp.devxo.workers.dev/) 42 | 43 | [![Remix Auth TOTP](https://raw.githubusercontent.com/dev-xo/dev-xo/main/remix-auth-totp/demo-thumbnail.png)](https://totp.devxo.workers.dev/) 44 | 45 | ## Getting Started 46 | 47 | Please, read the [Getting Started Documentation](https://github.com/dev-xo/remix-auth-totp/tree/main/docs#remix-auth-totp-documentation) in order to set up **Remix Auth TOTP** in your application. 48 | 49 | > [!TIP] 50 | > As a faster alternative, you can start with one of the [Example Templates](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/examples.md). 51 | 52 | ## Support 53 | 54 | If you found **Remix Auth TOTP** helpful, consider supporting it with a ⭐ [Star](https://github.com/dev-xo/remix-auth-totp). 55 | 56 | ### Acknowledgments 57 | 58 | Special thanks to some amazing contributors: 59 | 60 | - [@w00fz](https://github.com/w00fz) for implementing the **Magic Link** feature 61 | - [@mw10013](https://github.com/mw10013) for adding **Cloudflare Support**, shipping `v2` and `v3` releases, and their continued dedication 62 | - [@CyrusVorwald](https://github.com/CyrusVorwald) for the `v4` release and React Router v7 compatibility 63 | 64 | ## Sponsors 65 | 66 | Huge thanks to our sponsors who makes it possible to maintain and improve Remix Auth TOTP! 🙌 67 | 68 | 69 | 70 | 78 | 87 | 92 | 93 |
71 | 72 | 73 | 74 | Arcjet 75 | 76 | 77 | 79 | 80 | 81 | 82 | 83 | Convex 84 | 85 | 86 | 88 | 89 | Support us!
💝 90 |
91 |
94 | -------------------------------------------------------------------------------- /docs/customization.md: -------------------------------------------------------------------------------- 1 | ## Options and Customization 2 | 3 | The Strategy includes a few options that can be customized. 4 | 5 | ## Email Validation 6 | 7 | The email validation will match by default against a basic RegEx email pattern. 8 | Feel free to customize it by passing `validateEmail` method to the TOTPStrategy Instance. 9 | 10 | _This can be used to verify that the provided email is not a disposable one._ 11 | 12 | ```ts 13 | authenticator.use( 14 | new TOTPStrategy({ 15 | validateEmail: async (email) => { 16 | // Handle custom email validation. 17 | // ... 18 | }, 19 | }), 20 | ) 21 | ``` 22 | 23 | ## TOTP Generation 24 | 25 | The TOTP generation can customized by passing an object called `totpGeneration` to the TOTPStrategy Instance. 26 | 27 | ```ts 28 | export interface TOTPGenerationOptions { 29 | /** 30 | * The secret used to generate the TOTP. 31 | * It should be Base32 encoded (Feel free to use: https://npm.im/thirty-two). 32 | * 33 | * Defaults to a random Base32 secret. 34 | * @default random 35 | */ 36 | secret?: string 37 | 38 | /** 39 | * The algorithm used to generate the TOTP. 40 | * @default 'SHA1' 41 | */ 42 | algorithm?: string 43 | 44 | /** 45 | * The character set used to generate the TOTP. 46 | * @default 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 47 | */ 48 | charSet?: string 49 | 50 | /** 51 | * The number of digits used to generate the TOTP. 52 | * @default 6 53 | */ 54 | digits?: number 55 | 56 | /** 57 | * The number of seconds the TOTP will be valid. 58 | * @default 60 59 | */ 60 | period?: number 61 | 62 | /** 63 | * The max number of attempts the user can try to verify the TOTP. 64 | * @default 3 65 | */ 66 | maxAttempts?: number 67 | } 68 | 69 | authenticator.use( 70 | new TOTPStrategy({ 71 | totpGeneration: { 72 | digits: 6, 73 | period: 60, 74 | // ... 75 | }, 76 | }), 77 | ) 78 | ``` 79 | 80 | ## Custom Error Messages 81 | 82 | The Strategy includes a few default error messages that can be customized by passing an object called `customErrors` to the TOTPStrategy Instance. 83 | 84 | ```ts 85 | export interface CustomErrorsOptions { 86 | /** 87 | * The required email error message. 88 | */ 89 | requiredEmail?: string 90 | 91 | /** 92 | * The invalid email error message. 93 | */ 94 | invalidEmail?: string 95 | 96 | /** 97 | * The invalid TOTP error message. 98 | */ 99 | invalidTotp?: string 100 | 101 | /** 102 | * The rate limit exceeded error message. 103 | */ 104 | rateLimitExceeded?: string 105 | 106 | /** 107 | * The expired TOTP error message. 108 | */ 109 | expiredTotp?: string 110 | 111 | /** 112 | * The missing session email error message. 113 | */ 114 | missingSessionEmail?: string 115 | 116 | /** 117 | * The missing session totp error message. 118 | */ 119 | missingSessionTotp?: string 120 | } 121 | 122 | authenticator.use( 123 | new TOTPStrategy({ 124 | customErrors: { 125 | requiredEmail: 'Whoops, email is required.', 126 | }, 127 | }), 128 | ) 129 | ``` 130 | 131 | ## Strategy Options 132 | 133 | The Strategy includes a few more options that can be customized. 134 | 135 | ```ts 136 | export interface TOTPStrategyOptions { 137 | /** 138 | * The secret used to encrypt the TOTP data. 139 | * Must be string of 64 hexadecimal characters. 140 | */ 141 | secret: string 142 | 143 | /** 144 | * The optional cookie options. 145 | * @default undefined 146 | */ 147 | cookieOptions?: Omit 148 | 149 | /** 150 | * The TOTP generation configuration. 151 | */ 152 | totpGeneration?: TOTPGenerationOptions 153 | 154 | /** 155 | * The URL path for the Magic Link. 156 | * @default '/magic-link' 157 | */ 158 | magicLinkPath?: string 159 | 160 | /** 161 | * The custom errors configuration. 162 | */ 163 | customErrors?: CustomErrorsOptions 164 | 165 | /** 166 | * The form input name used to get the email address. 167 | * @default "email" 168 | */ 169 | emailFieldKey?: string 170 | 171 | /** 172 | * The form input name used to get the TOTP. 173 | * @default "code" 174 | */ 175 | codeFieldKey?: string 176 | 177 | /** 178 | * The send TOTP method. 179 | */ 180 | sendTOTP: SendTOTP 181 | 182 | /** 183 | * The validate email method. 184 | */ 185 | validateEmail?: ValidateEmail 186 | 187 | /** 188 | * The redirect URL thrown after sending email. 189 | */ 190 | emailSentRedirect: string 191 | 192 | /** 193 | * The redirect URL thrown after verification success. 194 | */ 195 | successRedirect: string 196 | 197 | /** 198 | * The redirect URL thrown after verification failure. 199 | */ 200 | failureRedirect: string 201 | } 202 | ``` 203 | 204 | ## Contributing 205 | 206 | If you have any suggestion you'd like to share, feel free to open a PR! 207 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Remix Auth TOTP Documentation 2 | 3 | Welcome to the Remix Auth TOTP Documentation! 4 | 5 | ## List of Contents 6 | 7 | - 🚀 [Live Demo](https://totp.devxo.workers.dev) - See it in action. 8 | - 🏁 [Getting Started](https://github.com/dev-xo/remix-auth-totp/tree/main/docs#getting-started) - Quick setup guide. 9 | - 🎯 [Examples](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/examples.md) - Community examples. 10 | - ⚙️ [Customization](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) - Configuration options. 11 | - ☁️ [Cloudflare](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/cloudflare.md) - Cloudflare Workers setup. 12 | 13 | ## Getting Started 14 | 15 | Remix Auth TOTP exports one required method: 16 | 17 | - `sendTOTP` - Sends the TOTP code to the user via email or any other method. 18 | 19 | The authentication flow is simple: 20 | 21 | 1. User enters their email. 22 | 2. User receives a one-time code via email. 23 | 3. User submits the code or clicks magic link. 24 | 4. Code is validated and user is authenticated. 25 |
26 | 27 | > [!NOTE] 28 | > Remix Auth TOTP is compatible with Remix v2.0+ and React Router v7. 29 | 30 | Let's see how we can implement the Strategy into our app. 31 | 32 | ## Email Service 33 | 34 | We'll require an Email Service to send the codes to our users. 35 | 36 | Feel free to use any service of choice, such as [Resend](https://resend.com), [Mailgun](https://www.mailgun.com), [Sendgrid](https://sendgrid.com), etc. The goal is to have a sender function similar to the following one. 37 | 38 | ```ts 39 | export type SendEmailBody = { 40 | sender: { 41 | name: string 42 | email: string 43 | } 44 | to: { 45 | name?: string 46 | email: string 47 | }[] 48 | subject: string 49 | htmlContent: string 50 | } 51 | 52 | export async function sendEmail(body: SendEmailBody) { 53 | const sender = { 54 | name: 'Your Name', 55 | email: 'your-email@example.com', 56 | } 57 | 58 | return fetch(`https://any-email-service.com`, { 59 | method: 'POST', 60 | headers: { 61 | Accept: 'application/json', 62 | 'content-type': 'application/json', 63 | 'api-key': process.env.EMAIL_API_KEY as string, 64 | }, 65 | body: JSON.stringify({ ...body }), 66 | }) 67 | } 68 | ``` 69 | 70 | For a working example, see the [Remix Saas - Email](https://github.com/dev-xo/remix-saas/blob/main/app/modules/email/email.server.ts) implementation using Resend API. 71 | 72 | ## Session Storage 73 | 74 | We'll require to initialize a new Session Storage to work with. This Session will store user data and everything related to the TOTP authentication. 75 | 76 | ```ts 77 | // app/lib/auth-session.server.ts 78 | import { createCookieSessionStorage } from 'react-router' // Or '@remix-run'. 79 | 80 | export const sessionStorage = createCookieSessionStorage({ 81 | cookie: { 82 | name: '_auth', 83 | sameSite: 'lax', 84 | path: '/', 85 | httpOnly: true, 86 | secrets: [process.env.SESSION_SECRET], 87 | secure: process.env.NODE_ENV === 'production', 88 | }, 89 | }) 90 | 91 | export const { getSession, commitSession, destroySession } = sessionStorage 92 | ``` 93 | 94 | ## Strategy Instance 95 | 96 | Now that we have everything set up, we can start implementing the Strategy Instance. 97 | 98 | ### 1. Implementing Strategy Instance 99 | 100 | > [!IMPORTANT] 101 | > A random 64-character hexadecimal string is required to generate the TOTP codes. 102 | > You can use a site like https://www.grc.com/passwords.htm to generate a strong secret. 103 | 104 | Add a 64-character hex string (0-9, A-F) as the `secret` property in your `.env` file. Example: 105 | `ENCRYPTION_SECRET=928F416BAFC49B969E62052F00450B6E974B03E86DC6984D1FA787B7EA533227` 106 | 107 | ```ts 108 | // app/lib/auth.server.ts 109 | import { Authenticator } from 'remix-auth' 110 | import { TOTPStrategy } from 'remix-auth-totp' 111 | import { redirect } from 'react-router' 112 | import { getSession, commitSession } from '~/lib/auth-session.server' 113 | 114 | type User = { 115 | email: string 116 | } 117 | 118 | export const authenticator = new Authenticator() 119 | 120 | authenticator.use( 121 | new TOTPStrategy( 122 | { 123 | secret: process.env.ENCRYPTION_SECRET, 124 | emailSentRedirect: '/verify', 125 | magicLinkPath: '/verify', 126 | successRedirect: '/dashboard', 127 | failureRedirect: '/verify', 128 | sendTOTP: async ({ email, code, magicLink }) => {}, 129 | }, 130 | async ({ email }) => {}, 131 | ), 132 | ) 133 | ``` 134 | 135 | > [!TIP] 136 | > You can customize the cookie behavior by passing a `cookieOptions` property to the `TOTPStrategy` instance. Check [Customization](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) to learn more. 137 | 138 | ### 2: Implementing Strategy Logic 139 | 140 | The Strategy Instance requires the following method: `sendTOTP`. 141 | 142 | ```ts 143 | authenticator.use( 144 | new TOTPStrategy( 145 | { 146 | ... 147 | sendTOTP: async ({ email, code, magicLink }) => { 148 | // Send email with TOTP code. 149 | await sendAuthEmail({ email, code, magicLink }) 150 | }, 151 | }, 152 | async ({ email }) => {}, 153 | ), 154 | ) 155 | ``` 156 | 157 | ### 3. Handling User Creation 158 | 159 | The Strategy returns a `verify` method that allows handling our own logic. This includes creating the user, updating the session, etc.
160 | 161 | > [!TIP] 162 | > When using Cloudflare D1, consider performing user lookups in the `action` or `loader` functions after committing the session. You can pass the `context` binding to a `findOrCreateUserByEmail` function to handle database operations. 163 | 164 | This should return the user data that will be stored in Session. 165 | 166 | ```ts 167 | authenticator.use( 168 | new TOTPStrategy( 169 | { 170 | ... 171 | sendTOTP: async ({ email, code, magicLink }) => {} 172 | }, 173 | async ({ email }) => { 174 | // Get user from database. 175 | let user = await db.user.findFirst({ where: { email } }) 176 | 177 | // Create a new user (if it doesn't exist). 178 | if (!user) { 179 | user = await db.user.create({ 180 | data: { email }, 181 | }) 182 | } 183 | 184 | // Store user in session. 185 | const session = await getSession(request.headers.get("Cookie")); 186 | session.set("user", user); 187 | 188 | // Commit session. 189 | const sessionCookie = await commitSession(session); 190 | 191 | // Redirect to your authenticated route. 192 | throw redirect("/dashboard", { 193 | headers: { 194 | "Set-Cookie": sessionCookie, 195 | }, 196 | }); 197 | }, 198 | ), 199 | ) 200 | ``` 201 | 202 | ## Auth Routes 203 | 204 | Last but not least, we need to create the routes for the authentication flow. 205 | 206 | We'll require the following routes: 207 | 208 | - `login.tsx` - Handles the login form submission. 209 | - `verify.tsx` - Handles the TOTP verification form submission. 210 | - `logout.tsx` - Handles the logout. 211 | - `dashboard.tsx` - Handles the authenticated route (optional). 212 | 213 | ### `login.tsx` 214 | 215 | This route is used to handle the login form submission. 216 | 217 | ```tsx 218 | // app/routes/login.tsx 219 | import { redirect } from 'react-router' 220 | import { useFetcher } from 'react-router' 221 | import { getSession } from '~/lib/auth-session.server' 222 | import { authenticator } from '~/lib/auth-server' 223 | 224 | export async function loader({ request }: Route.LoaderArgs) { 225 | // Check for existing session. 226 | const session = await getSession(request.headers.get('Cookie')) 227 | const user = session.get('user') 228 | 229 | // If the user is already authenticated, redirect to your authenticated route. 230 | if (user) return redirect('/dashboard') 231 | 232 | return null 233 | } 234 | 235 | export async function action({ request }: Route.ActionArgs) { 236 | try { 237 | // Authenticate the user via TOTP (Form submission). 238 | return await authenticator.authenticate('TOTP', request) 239 | } catch (error) { 240 | // The error from TOTP includes the redirect Response with the cookie. 241 | if (error instanceof Response) { 242 | return error 243 | } 244 | 245 | // For other errors, return with error message. 246 | console.log('error', error) 247 | 248 | return { 249 | error: 'An error occurred during login. Please try again.', 250 | } 251 | } 252 | } 253 | 254 | export default function Login() { 255 | const fetcher = useFetcher() 256 | const isSubmitting = fetcher.state !== 'idle' || fetcher.formData != null 257 | const errors = fetcher.data?.error 258 | 259 | return ( 260 |
261 | {/* Form. */} 262 | 263 | 270 | 271 | 272 | 273 | {/* Errors Handling. */} 274 | {errors &&

{errors}

} 275 |
276 | ) 277 | } 278 | ``` 279 | 280 | ### `verify.tsx` 281 | 282 | This route is used to handle the TOTP verification form submission. 283 | 284 | For the verify route, we are leveraging `@mjackson/headers` to parse the cookie. Created by Michael Jackson, the CO-Founder of Remix/React Router. 285 | 286 | ```tsx 287 | // app/routes/verify.tsx 288 | import { redirect, useLoaderData } from 'react-router' 289 | import { Cookie } from '@mjackson/headers' 290 | import { Link, useFetcher } from 'react-router' 291 | import { useState } from 'react' 292 | import { getSession } from '~/lib/auth-session.server' 293 | import { authenticator } from '~/lib/auth.server' 294 | 295 | /** 296 | * Loader function that checks if the user is already authenticated. 297 | * - If the user is already authenticated, redirect to dashboard. 298 | * - If the user is not authenticated, check if the intent is to verify via magic-link URL. 299 | */ 300 | export async function loader({ request }: Route.LoaderArgs) { 301 | // Check for existing session. 302 | const session = await getSession(request.headers.get('Cookie')) 303 | const user = session.get('user') 304 | 305 | // If the user is already authenticated, redirect to dashboard. 306 | if (user) return redirect('/profile') 307 | 308 | // Get token from the URL. 309 | const url = new URL(request.url) 310 | const token = url.searchParams.get('t') 311 | 312 | // Authenticate the user via magic-link URL. 313 | if (token) { 314 | try { 315 | return await authenticator.authenticate('TOTP', request) 316 | } catch (error) { 317 | if (error instanceof Response) return error 318 | if (error instanceof Error) { 319 | console.error(error) 320 | return { authError: error.message } 321 | } 322 | return { authError: 'Invalid TOTP' } 323 | } 324 | } 325 | 326 | // Get TOTP cookie values. 327 | const cookie = new Cookie(request.headers.get('Cookie') || '') 328 | const totpCookieValue = cookie.get('_totp') 329 | 330 | if (totpCookieValue) { 331 | const params = new URLSearchParams(totpCookieValue) 332 | return { 333 | authEmail: params.get('email'), 334 | authError: params.get('error'), 335 | } 336 | } 337 | 338 | // If the TOTP cookie is not found, redirect to the login page. 339 | throw redirect('/auth/login') 340 | } 341 | 342 | /** 343 | * Action function that handles the TOTP verification form submission. 344 | * - Authenticates the user via TOTP (Form submission). 345 | */ 346 | export async function action({ request }: Route.ActionArgs) { 347 | try { 348 | // Authenticate the user via TOTP (Form submission). 349 | return await authenticator.authenticate('TOTP', request) 350 | } catch (error) { 351 | if (error instanceof Response) { 352 | const cookie = new Cookie(error.headers.get('Set-Cookie') || '') 353 | const totpCookie = cookie.get('_totp') 354 | 355 | if (totpCookie) { 356 | const params = new URLSearchParams(totpCookie) 357 | return { error: params.get('error') } 358 | } 359 | 360 | throw error 361 | } 362 | return { error: 'Invalid TOTP' } 363 | } 364 | } 365 | 366 | export default function Verify() { 367 | const loaderData = useLoaderData() 368 | const [value, setValue] = useState('') 369 | 370 | const authEmail = 'authEmail' in loaderData ? loaderData.authEmail : undefined 371 | const authError = 'authError' in loaderData ? loaderData.authError : null 372 | 373 | const fetcher = useFetcher() 374 | const isSubmitting = fetcher.state !== 'idle' || fetcher.formData != null 375 | 376 | // Either get the error from the fetcher (action) or the loader. 377 | const errors = fetcher.data?.authError || authError 378 | 379 | return ( 380 |
381 | {/* Code Verification Form. */} 382 | 383 | setValue(e.target.value)} 388 | placeholder="Enter the 6-digit code" 389 | /> 390 | 391 | 392 | 393 | {/* Request New Code. */} 394 | {/* Email input is not required, it's already stored in Session. */} 395 | 396 | 397 | 398 | 399 | {/* Errors Handling. */} 400 | {errors &&

{errors}

} 401 |
402 | ) 403 | } 404 | ``` 405 | 406 | ### `logout.tsx` 407 | 408 | This route is used to destroy the session and redirect to the login page. 409 | 410 | ```tsx 411 | // app/routes/logout.tsx 412 | import { sessionStorage } from '~/lib/auth-session.server' 413 | import { redirect } from 'react-router' 414 | 415 | export async function loader({ request }: Route.LoaderArgs) { 416 | // Get the session. 417 | const session = await sessionStorage.getSession(request.headers.get('Cookie')) 418 | 419 | // Destroy the session and redirect to any route of your choice. 420 | return redirect('/auth/login', { 421 | headers: { 422 | 'Set-Cookie': await sessionStorage.destroySession(session), 423 | }, 424 | }) 425 | } 426 | ``` 427 | 428 | ### `dashboard.tsx` 429 | 430 | This route is used to display the authenticated user's dashboard (optional). 431 | 432 | ```tsx 433 | // app/routes/dashboard.tsx 434 | import { Link } from 'react-router' 435 | import { getSession } from '~/lib/auth-session.server' 436 | import { redirect } from 'react-router' 437 | import { useLoaderData } from 'react-router' 438 | 439 | export async function loader({ request }: Route.LoaderArgs) { 440 | const session = await getSession(request.headers.get('Cookie')) 441 | const user = session.get('user') 442 | 443 | if (!user) return redirect('/auth/login') 444 | console.log('Dashboard user', user) 445 | 446 | return { user } 447 | } 448 | 449 | export default function Account() { 450 | let { user } = useLoaderData() 451 | 452 | return ( 453 |
454 |

{user && `Welcome ${user.email}`}

455 | 456 | {/* Log out */} 457 | Log out 458 |
459 | ) 460 | } 461 | ``` 462 | 463 | ## Next Steps 464 | 465 | 🎉 Done! You've completed the basic setup. 466 | 467 | For a complete implementation example, check out the [React Router v7 Starter Template](https://github.com/dev-xo/remix-auth-totp-starter). 468 | 469 | ## Configuration Options 470 | 471 | The TOTP Strategy can be customized with various options to fit your needs. See the [customization documentation](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) for: 472 | 473 | ## Support 474 | 475 | If you found **Remix Auth TOTP** helpful, please consider supporting it with a ⭐ [Star](https://github.com/dev-xo/remix-auth-totp). 476 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'remix-auth/strategy' 2 | import { generateTOTP, verifyTOTP } from '@epic-web/totp' 3 | import { Cookie, SetCookie, type SetCookieInit } from '@mjackson/headers' 4 | import * as jose from 'jose' 5 | import { redirect } from './utils.js' 6 | import { 7 | generateSecret, 8 | coerceToOptionalString, 9 | coerceToOptionalTotpSessionData, 10 | coerceToOptionalNonEmptyString, 11 | assertTOTPData, 12 | asJweKey, 13 | } from './utils.js' 14 | import { STRATEGY_NAME, FORM_FIELDS, ERRORS } from './constants.js' 15 | 16 | /** 17 | * The TOTP JWE data containing the secret. 18 | */ 19 | export interface TOTPData { 20 | /** 21 | * The TOTP secret. 22 | */ 23 | secret: string 24 | 25 | /** 26 | * The time the TOTP was generated. 27 | */ 28 | createdAt: number 29 | } 30 | 31 | /** 32 | * The TOTP data stored in the cookie. 33 | */ 34 | export interface TOTPCookieData { 35 | /** 36 | * The TOTP JWE of TOTPData. 37 | */ 38 | jwe: string 39 | 40 | /** 41 | * The number of attempts the user tried to verify the TOTP. 42 | * @default 0 43 | */ 44 | attempts: number 45 | } 46 | 47 | /** 48 | * The TOTP generation configuration. 49 | */ 50 | export interface TOTPGenerationOptions { 51 | /** 52 | * The secret used to generate the TOTP. 53 | * It should be Base32 encoded (Feel free to use: https://npm.im/thirty-two). 54 | * 55 | * Defaults to a random Base32 secret. 56 | * @default random 57 | */ 58 | secret?: string 59 | 60 | /** 61 | * The algorithm used to generate the TOTP. 62 | * @default 'SHA-256' 63 | */ 64 | algorithm?: string 65 | 66 | /** 67 | * The character set used to generate the TOTP. 68 | * @default 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 69 | */ 70 | charSet?: string 71 | 72 | /** 73 | * The number of digits used to generate the TOTP. 74 | * @default 6 75 | */ 76 | digits?: number 77 | 78 | /** 79 | * The number of seconds the TOTP will be valid. 80 | * @default 60 81 | */ 82 | period?: number 83 | 84 | /** 85 | * The max number of attempts the user can try to verify the TOTP. 86 | * @default 3 87 | */ 88 | maxAttempts?: number 89 | } 90 | 91 | /** 92 | * The send TOTP configuration. 93 | */ 94 | export interface SendTOTPOptions { 95 | /** 96 | * The email address provided by the user. 97 | */ 98 | email: string 99 | 100 | /** 101 | * The decrypted TOTP code. 102 | */ 103 | code: string 104 | 105 | /** 106 | * The Magic Link URL. 107 | */ 108 | magicLink: string 109 | 110 | /** 111 | * The request to generate the TOTP. 112 | */ 113 | request: Request 114 | 115 | /** 116 | * The form data of the request. 117 | */ 118 | formData: FormData 119 | } 120 | 121 | /** 122 | * The sender email method. 123 | * @param options The SendTOTPOptions options. 124 | */ 125 | export interface SendTOTP { 126 | (options: SendTOTPOptions): Promise 127 | } 128 | 129 | /** 130 | * The validate email method. 131 | * Useful to ensure it's not a disposable email address. 132 | * 133 | * @param email The email address to validate. 134 | */ 135 | export interface ValidateEmail { 136 | (email: string): Promise 137 | } 138 | 139 | /** 140 | * The custom errors configuration. 141 | */ 142 | export interface CustomErrorsOptions { 143 | /** 144 | * The required email error message. 145 | */ 146 | requiredEmail?: string 147 | 148 | /** 149 | * The invalid email error message. 150 | */ 151 | invalidEmail?: string 152 | 153 | /** 154 | * The invalid TOTP error message. 155 | */ 156 | invalidTotp?: string 157 | 158 | /** 159 | * The rate limit exceeded error message. 160 | */ 161 | rateLimitExceeded?: string 162 | 163 | /** 164 | * The expired TOTP error message. 165 | */ 166 | expiredTotp?: string 167 | 168 | /** 169 | * The missing session email error message. 170 | */ 171 | missingSessionEmail?: string 172 | 173 | /** 174 | * The missing session totp error message. 175 | */ 176 | missingSessionTotp?: string 177 | } 178 | 179 | /** 180 | * The TOTP Strategy options. 181 | */ 182 | export interface TOTPStrategyOptions { 183 | /** 184 | * The secret used to encrypt the TOTP data. 185 | * Must be string of 64 hexadecimal characters. 186 | */ 187 | secret: string 188 | 189 | /** 190 | * The optional cookie options. 191 | * @default undefined 192 | */ 193 | cookieOptions?: Omit 194 | 195 | /** 196 | * The TOTP generation configuration. 197 | */ 198 | totpGeneration?: TOTPGenerationOptions 199 | 200 | /** 201 | * The URL path for the Magic Link. 202 | * @default '/magic-link' 203 | */ 204 | magicLinkPath?: string 205 | 206 | /** 207 | * The custom errors configuration. 208 | */ 209 | customErrors?: CustomErrorsOptions 210 | 211 | /** 212 | * The form input name used to get the email address. 213 | * @default "email" 214 | */ 215 | emailFieldKey?: string 216 | 217 | /** 218 | * The form input name used to get the TOTP. 219 | * @default "code" 220 | */ 221 | codeFieldKey?: string 222 | 223 | /** 224 | * The send TOTP method. 225 | */ 226 | sendTOTP: SendTOTP 227 | 228 | /** 229 | * The validate email method. 230 | */ 231 | validateEmail?: ValidateEmail 232 | 233 | /** 234 | * The redirect URL thrown after sending email. 235 | */ 236 | emailSentRedirect: string 237 | 238 | /** 239 | * The redirect URL thrown after verification success. 240 | */ 241 | successRedirect: string 242 | 243 | /** 244 | * The redirect URL thrown after verification failure. 245 | */ 246 | failureRedirect: string 247 | } 248 | 249 | /** 250 | * The verify method callback. 251 | * Returns the email user to be stored in the session. 252 | */ 253 | export interface TOTPVerifyParams { 254 | /** 255 | * The email address provided by the user. 256 | */ 257 | email: string 258 | 259 | /** 260 | * The formData object from the Request. 261 | */ 262 | formData?: FormData 263 | 264 | /** 265 | * The Request object. 266 | */ 267 | request: Request 268 | } 269 | 270 | /** 271 | * The magic link parameters. 272 | */ 273 | interface MagicLinkParams { 274 | /** 275 | * The TOTP code. 276 | */ 277 | code: string 278 | 279 | /** 280 | * The TOTP expiry date. 281 | */ 282 | expires: number 283 | } 284 | 285 | /** 286 | * A store class that manages TOTP-related state in a cookie. 287 | * Handles email, TOTP session data, and error messages. 288 | */ 289 | class TOTPStore { 290 | private email?: string 291 | private totp?: TOTPCookieData 292 | private error?: { message: string } 293 | 294 | /** The name of the cookie used to store TOTP data. */ 295 | static COOKIE_NAME = '_totp' 296 | 297 | /** 298 | * Creates a new TOTPStore instance. 299 | * @param cookie - The Cookie instance used to manage cookie data. 300 | */ 301 | constructor(private cookie: Cookie) { 302 | const raw = this.cookie.get(TOTPStore.COOKIE_NAME) 303 | if (raw) { 304 | const params = new URLSearchParams(raw) 305 | this.email = params.get('email') || undefined 306 | 307 | const totpRaw = params.get('totp') 308 | if (totpRaw) { 309 | try { 310 | this.totp = JSON.parse(totpRaw) 311 | } catch { 312 | // Silently handle invalid JSON in the TOTP data. 313 | } 314 | } 315 | 316 | const err = params.get('error') 317 | if (err) { 318 | this.error = { message: err } 319 | } 320 | } 321 | } 322 | 323 | /** 324 | * Creates a TOTPStore instance from a Request object. 325 | * @param request - The incoming request object. 326 | * @returns A new TOTPStore instance. 327 | */ 328 | static fromRequest(request: Request): TOTPStore { 329 | return new TOTPStore(new Cookie(request.headers.get('cookie') ?? '')) 330 | } 331 | 332 | /** 333 | * Gets the stored email address. 334 | * @returns The email address or undefined if not set. 335 | */ 336 | getEmail(): string | undefined { 337 | return this.email 338 | } 339 | 340 | /** 341 | * Gets the stored TOTP session data. 342 | * @returns The TOTP session data or undefined if not set. 343 | */ 344 | getTOTP(): TOTPCookieData | undefined { 345 | return this.totp 346 | } 347 | 348 | /** 349 | * Gets the stored error message. 350 | * @returns The error object or undefined if no error exists. 351 | */ 352 | getError(): { message: string } | undefined { 353 | return this.error 354 | } 355 | 356 | /** 357 | * Sets the email address in the store. 358 | * @param email - The email address to store or undefined to clear it. 359 | */ 360 | setEmail(email: string | undefined): void { 361 | this.email = email 362 | } 363 | 364 | /** 365 | * Sets the TOTP session data in the store. 366 | * @param totp - The TOTP session data to store or undefined to clear it. 367 | */ 368 | setTOTP(totp: TOTPCookieData | undefined): void { 369 | this.totp = totp 370 | } 371 | 372 | /** 373 | * Sets an error message in the store. 374 | * @param message - The error message to store or undefined to clear it. 375 | */ 376 | setError(message: string | undefined): void { 377 | if (message) { 378 | this.error = { message } 379 | } else { 380 | this.error = undefined 381 | } 382 | } 383 | 384 | /** 385 | * Commits the current store state to a cookie string. 386 | * 387 | * @param options - Optional SetCookie configuration options. 388 | * @returns A string representation of the cookie with its current values. 389 | */ 390 | commit(options: Omit = {}): string { 391 | const params = new URLSearchParams() 392 | 393 | if (this.email) { 394 | params.set('email', this.email) 395 | } 396 | 397 | if (this.totp) { 398 | params.set('totp', JSON.stringify(this.totp)) 399 | } 400 | 401 | if (this.error) { 402 | params.set('error', this.error.message) 403 | } 404 | 405 | const setCookie = new SetCookie({ 406 | name: TOTPStore.COOKIE_NAME, 407 | value: params.toString(), 408 | httpOnly: true, 409 | path: '/', 410 | sameSite: 'Lax', 411 | maxAge: options.maxAge || 60 * 5, // 5 minutes in seconds. 412 | // `secure` may be passed in options, depending on environment. 413 | ...options, 414 | }) 415 | 416 | return setCookie.toString() 417 | } 418 | } 419 | 420 | /** 421 | * The TOTP Strategy. 422 | */ 423 | export class TOTPStrategy extends Strategy { 424 | public name = STRATEGY_NAME 425 | 426 | private readonly secret: string 427 | private readonly cookieOptions: Omit | undefined 428 | private readonly totpGeneration: Pick & 429 | Required> 430 | private readonly magicLinkPath: string 431 | private readonly customErrors: Required 432 | private readonly emailFieldKey: string 433 | private readonly codeFieldKey: string 434 | private readonly sendTOTP: SendTOTP 435 | private readonly validateEmail: ValidateEmail 436 | private _emailSentRedirect: string 437 | private _successRedirect: string 438 | private _failureRedirect: string 439 | private readonly _totpGenerationDefaults = { 440 | algorithm: 'SHA-256', 441 | charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // Does not include O or 0. 442 | digits: 6, 443 | period: 60, 444 | maxAttempts: 3, 445 | } 446 | private readonly _customErrorsDefaults: Required = { 447 | requiredEmail: ERRORS.REQUIRED_EMAIL, 448 | invalidEmail: ERRORS.INVALID_EMAIL, 449 | invalidTotp: ERRORS.INVALID_TOTP, 450 | expiredTotp: ERRORS.EXPIRED_TOTP, 451 | rateLimitExceeded: ERRORS.RATE_LIMIT_EXCEEDED, 452 | missingSessionEmail: ERRORS.MISSING_SESSION_EMAIL, 453 | missingSessionTotp: ERRORS.MISSING_SESSION_TOTP, 454 | } 455 | 456 | constructor( 457 | options: TOTPStrategyOptions, 458 | verify: Strategy.VerifyFunction, 459 | ) { 460 | super(verify) 461 | this.secret = options.secret 462 | this.cookieOptions = options.cookieOptions || {} 463 | this.magicLinkPath = options.magicLinkPath ?? '/magic-link' 464 | this.emailFieldKey = options.emailFieldKey ?? FORM_FIELDS.EMAIL 465 | this.codeFieldKey = options.codeFieldKey ?? FORM_FIELDS.CODE 466 | this.sendTOTP = options.sendTOTP 467 | this.validateEmail = options.validateEmail ?? this._validateEmailDefault 468 | this._emailSentRedirect = options.emailSentRedirect 469 | this._successRedirect = options.successRedirect 470 | this._failureRedirect = options.failureRedirect 471 | this.totpGeneration = { 472 | ...this._totpGenerationDefaults, 473 | ...options.totpGeneration, 474 | } 475 | this.customErrors = { 476 | ...this._customErrorsDefaults, 477 | ...options.customErrors, 478 | } 479 | } 480 | 481 | /** Gets the email sent redirect URL. */ 482 | get emailSentRedirect(): string { 483 | return this._emailSentRedirect 484 | } 485 | 486 | /** Sets the email sent redirect URL. */ 487 | set emailSentRedirect(url: string) { 488 | if (!url) { 489 | throw new Error(ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL) 490 | } 491 | this._emailSentRedirect = url 492 | } 493 | 494 | /** Gets the success redirect URL. */ 495 | get successRedirect(): string { 496 | return this._successRedirect 497 | } 498 | 499 | /** Sets the success redirect URL. */ 500 | set successRedirect(url: string) { 501 | if (!url) { 502 | throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) 503 | } 504 | this._successRedirect = url 505 | } 506 | 507 | /** Gets the failure redirect URL. */ 508 | get failureRedirect(): string { 509 | return this._failureRedirect 510 | } 511 | 512 | /** Sets the failure redirect URL. */ 513 | set failureRedirect(url: string) { 514 | if (!url) { 515 | throw new Error(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) 516 | } 517 | this._failureRedirect = url 518 | } 519 | 520 | /** 521 | * Authenticates a user using TOTP. 522 | * If the user is already authenticated, simply returns the user. 523 | * 524 | * | Method | Email | Code | Sess. Email | Sess. TOTP | Action/Logic | 525 | * |--------|-------|------|-------------|------------|------------------------------------------| 526 | * | POST | ✓ | - | - | - | Generate/Send TOTP using form email. | 527 | * | POST | ✗ | ✗ | ✓ | - | Generate/Send TOTP using session email. | 528 | * | POST | ✗ | ✓ | ✓ | ✓ | Validate form TOTP code. | 529 | * | GET | - | - | ✓ | ✓ | Validate magic-link TOTP. | 530 | * 531 | * @param {Request} request - The request object. 532 | * @returns {Promise} The authenticated user. 533 | */ 534 | async authenticate(request: Request): Promise { 535 | if (!this.secret) throw new Error(ERRORS.REQUIRED_ENV_SECRET) 536 | if (!this._emailSentRedirect) throw new Error(ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL) 537 | if (!this._successRedirect) throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) 538 | if (!this._failureRedirect) throw new Error(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) 539 | 540 | // Retrieve the TOTP store from request. 541 | const store = TOTPStore.fromRequest(request) 542 | 543 | const formData = await this._readFormData(request) 544 | const formDataEmail = coerceToOptionalNonEmptyString(formData.get(this.emailFieldKey)) 545 | const formDataCode = coerceToOptionalNonEmptyString(formData.get(this.codeFieldKey)) 546 | const sessionEmail = coerceToOptionalString(store.getEmail()) 547 | const sessionTotp = coerceToOptionalTotpSessionData(store.getTOTP()) 548 | 549 | let email = null 550 | 551 | if (request.method === 'POST') { 552 | if (formDataEmail) { 553 | email = formDataEmail 554 | } else if (sessionEmail && !formDataCode) { 555 | email = sessionEmail 556 | } 557 | } 558 | 559 | try { 560 | if (email) { 561 | // Generate the TOTP. 562 | const { code, jwe, magicLink } = await this._generateTOTP({ email, request }) 563 | 564 | // Send the TOTP to the user. 565 | await this.sendTOTP({ 566 | email, 567 | code, 568 | magicLink, 569 | formData, 570 | request, 571 | }) 572 | 573 | // Set the TOTP data in the store. 574 | const totpData: TOTPCookieData = { jwe, attempts: 0 } 575 | store.setEmail(email) 576 | store.setTOTP(totpData) 577 | store.setError(undefined) 578 | 579 | // Redirect to the email sent URL. 580 | throw redirect(this._emailSentRedirect, { 581 | headers: { 582 | 'Set-Cookie': store.commit(this.cookieOptions), 583 | }, 584 | }) 585 | } 586 | 587 | // Try to get the TOTP code either from the form data or the magic link. 588 | const { code: linkCode, expires: linkExpires } = await this._getMagicLinkCode( 589 | request, 590 | sessionTotp, 591 | ) 592 | const code = formDataCode ?? linkCode 593 | 594 | if (code) { 595 | if (!sessionEmail) throw new Error(this.customErrors.missingSessionEmail) 596 | if (!sessionTotp) throw new Error(this.customErrors.missingSessionTotp) 597 | 598 | // Validate the TOTP. 599 | await this._validateTOTP({ code, sessionTotp, store, urlExpires: linkExpires }) 600 | 601 | // Clear TOTP data since user verified successfully. 602 | store.setEmail(undefined) 603 | store.setTOTP(undefined) 604 | store.setError(undefined) 605 | 606 | // Call the verify method, allowing developers to handle the user. 607 | await this.verify({ email: sessionEmail, formData, request }) 608 | 609 | // Redirect to the success URL. 610 | throw redirect(this._successRedirect, { 611 | headers: { 612 | 'Set-Cookie': store.commit(this.cookieOptions), 613 | }, 614 | }) 615 | } 616 | 617 | // If no email was provided, throw an error. 618 | throw new Error(this.customErrors.requiredEmail) 619 | } catch (err: unknown) { 620 | if (err instanceof Response) { 621 | const headers = new Headers(err.headers) 622 | headers.append('Set-Cookie', store.commit(this.cookieOptions)) 623 | throw new Response(err.body, { 624 | status: err.status, 625 | headers: headers, 626 | statusText: err.statusText, 627 | }) 628 | } 629 | if (err instanceof Error) { 630 | if ( 631 | err.message === this.customErrors.rateLimitExceeded || 632 | err.message === this.customErrors.expiredTotp 633 | ) { 634 | store.setTOTP(undefined) 635 | } 636 | store.setError(err.message) 637 | throw redirect(this._failureRedirect, { 638 | headers: { 639 | 'Set-Cookie': store.commit(this.cookieOptions), 640 | }, 641 | }) 642 | } 643 | throw err 644 | } 645 | } 646 | 647 | /** 648 | * Reads the form data from the request. 649 | * @param request - The request object. 650 | * @returns The form data. 651 | */ 652 | private async _readFormData(request: Request) { 653 | if (request.method !== 'POST') { 654 | return new FormData() 655 | } 656 | return await request.formData() 657 | } 658 | 659 | /** 660 | * Validates the TOTP. 661 | * @param code - The TOTP code. 662 | * @param sessionTotp - The TOTP session data. 663 | * @param store - The TOTP store. 664 | * @param urlExpires - The TOTP code expiry date in milliseconds. 665 | */ 666 | private async _validateTOTP({ 667 | code, 668 | sessionTotp, 669 | store, 670 | urlExpires, 671 | }: { 672 | code: string 673 | sessionTotp: TOTPCookieData 674 | store: TOTPStore 675 | urlExpires?: number 676 | }) { 677 | try { 678 | // Check if the TOTP is expired from the URL. 679 | if (urlExpires) { 680 | const dateNow = Date.now() 681 | if (dateNow > urlExpires) { 682 | throw new Error(this.customErrors.expiredTotp) 683 | } 684 | } 685 | 686 | // Decrypt the TOTP data from the Cookie. 687 | // https://github.com/panva/jose/blob/main/docs/jwe/compact/decrypt/functions/compactDecrypt.md 688 | const { plaintext } = await jose.compactDecrypt( 689 | sessionTotp.jwe, 690 | asJweKey(this.secret), 691 | ) 692 | const totpData = JSON.parse(new TextDecoder().decode(plaintext)) 693 | assertTOTPData(totpData) 694 | 695 | // Check if the TOTP is expired from the Cookie. 696 | const dateNow = Date.now() 697 | const isExpired = dateNow - totpData.createdAt > this.totpGeneration.period * 1000 698 | 699 | if (isExpired) { 700 | throw new Error(this.customErrors.expiredTotp) 701 | } 702 | 703 | // Check if the TOTP is valid. 704 | const isValid = await verifyTOTP({ 705 | ...this.totpGeneration, 706 | secret: totpData.secret, 707 | otp: code, 708 | }) 709 | 710 | if (!isValid) { 711 | throw new Error(this.customErrors.invalidTotp) 712 | } 713 | } catch (error) { 714 | if (error instanceof Error && error.message === this.customErrors.expiredTotp) { 715 | store.setTOTP(undefined) 716 | store.setError(this.customErrors.expiredTotp) 717 | } else { 718 | store.setError( 719 | error instanceof Error ? error.message : this.customErrors.invalidTotp, 720 | ) 721 | } 722 | 723 | // Redirect to the failure URL with the updated store. 724 | throw redirect(this._failureRedirect, { 725 | headers: { 726 | 'Set-Cookie': store.commit(this.cookieOptions), 727 | }, 728 | }) 729 | } 730 | } 731 | 732 | /** 733 | * Generates the TOTP. 734 | * @param email - The email address. 735 | * @param request - The request object. 736 | * @returns The TOTP data. 737 | */ 738 | private async _generateTOTP({ email, request }: { email: string; request: Request }) { 739 | const isValidEmail = await this.validateEmail(email) 740 | if (!isValidEmail) throw new Error(this.customErrors.invalidEmail) 741 | 742 | const { otp: code, secret } = await generateTOTP({ 743 | ...this.totpGeneration, 744 | secret: this.totpGeneration.secret ?? generateSecret(), 745 | }) 746 | const totpData: TOTPData = { secret, createdAt: Date.now() } 747 | 748 | const jwe = await new jose.CompactEncrypt( 749 | new TextEncoder().encode(JSON.stringify(totpData)), 750 | ) 751 | .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) 752 | .encrypt(asJweKey(this.secret)) 753 | 754 | const magicLink = await this._generateMagicLink({ code, request }) 755 | 756 | return { 757 | code, 758 | jwe, 759 | magicLink, 760 | } 761 | } 762 | 763 | /** 764 | * Encrypts magic link parameters. 765 | * @param params - The parameters to encrypt. 766 | * @returns The encrypted JWE token. 767 | */ 768 | private async _encryptUrlParams(params: MagicLinkParams): Promise { 769 | const payload = new TextEncoder().encode(JSON.stringify(params)) 770 | return await new jose.CompactEncrypt(payload) 771 | .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) 772 | .encrypt(asJweKey(this.secret)) 773 | } 774 | 775 | /** 776 | * Decrypts and validates magic link parameters. 777 | * @param encrypted - The encrypted JWE token. 778 | * @returns The decrypted and validated parameters. 779 | */ 780 | private async _decryptUrlParams( 781 | encrypted: string, 782 | sessionTotp?: TOTPCookieData, 783 | ): Promise { 784 | try { 785 | const { plaintext } = await jose.compactDecrypt(encrypted, asJweKey(this.secret)) 786 | const params = JSON.parse(new TextDecoder().decode(plaintext)) 787 | 788 | if (!params?.code || !params?.expires || typeof params.expires !== 'number') { 789 | throw new Error('Invalid magic-link format.') 790 | } 791 | 792 | return params 793 | } catch (error) { 794 | if (!sessionTotp || sessionTotp.attempts < this.totpGeneration.maxAttempts) { 795 | if (sessionTotp) { 796 | sessionTotp.attempts += 1 797 | } 798 | throw new Error(this.customErrors.invalidTotp) 799 | } 800 | 801 | throw new Error(this.customErrors.rateLimitExceeded) 802 | } 803 | } 804 | 805 | /** 806 | * Generates the magic link. 807 | * @param code - The TOTP code. 808 | * @param request - The request object. 809 | * @returns The magic link. 810 | */ 811 | private async _generateMagicLink({ 812 | code, 813 | request, 814 | }: { 815 | code: string 816 | request: Request 817 | }) { 818 | const url = new URL(this.magicLinkPath ?? '/', new URL(request.url).origin) 819 | 820 | const params: MagicLinkParams = { 821 | code, 822 | expires: Date.now() + this.totpGeneration.period * 1000, 823 | } 824 | 825 | const encrypted = await this._encryptUrlParams(params) 826 | url.searchParams.set('t', encrypted) 827 | 828 | return url.toString() 829 | } 830 | 831 | /** 832 | * Gets the magic link code from the request. 833 | * @param request - The request object. 834 | * @returns The magic link code. 835 | */ 836 | private async _getMagicLinkCode( 837 | request: Request, 838 | sessionTotp?: TOTPCookieData, 839 | ): Promise<{ code?: string; expires?: number }> { 840 | if (request.method === 'GET') { 841 | const url = new URL(request.url) 842 | if (url.pathname !== this.magicLinkPath) { 843 | throw new Error(ERRORS.INVALID_MAGIC_LINK_PATH) 844 | } 845 | 846 | const token = url.searchParams.get('t') 847 | if (!token) { 848 | return {} 849 | } 850 | 851 | try { 852 | const params = await this._decryptUrlParams(token, sessionTotp) 853 | return { 854 | code: params.code, 855 | expires: params.expires, 856 | } 857 | } catch (error) { 858 | throw error 859 | } 860 | } 861 | return {} 862 | } 863 | 864 | /** 865 | * Validates the email format. 866 | * @param email - The email address. 867 | * @returns Whether the email is valid. 868 | */ 869 | private async _validateEmailDefault(email: string) { 870 | const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/gm 871 | return regexEmail.test(email) 872 | } 873 | } 874 | -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import type { SendTOTPOptions, TOTPStrategyOptions } from '../src/index' 3 | import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' 4 | import { Cookie, SetCookie } from '@mjackson/headers' 5 | import { TOTPStrategy } from '../src/index' 6 | import { asJweKey } from '../src/utils' 7 | import { STRATEGY_NAME, FORM_FIELDS, ERRORS } from '../src/constants' 8 | import { 9 | SECRET_ENV, 10 | HOST_URL, 11 | TOTP_GENERATION_DEFAULTS, 12 | DEFAULT_EMAIL, 13 | MAGIC_LINK_PATH, 14 | } from './utils' 15 | 16 | /** 17 | * Mocks. 18 | */ 19 | const verify = vi.fn() 20 | const sendTOTP = vi.fn() 21 | 22 | const BASE_STRATEGY_OPTIONS: Omit< 23 | TOTPStrategyOptions, 24 | 'successRedirect' | 'failureRedirect' | 'emailSentRedirect' 25 | > = { 26 | secret: SECRET_ENV, 27 | sendTOTP, 28 | magicLinkPath: MAGIC_LINK_PATH, 29 | } 30 | 31 | beforeEach(() => { 32 | vi.useFakeTimers() 33 | }) 34 | 35 | afterEach(() => { 36 | vi.useRealTimers() 37 | vi.restoreAllMocks() 38 | }) 39 | 40 | describe('[ Basics ]', () => { 41 | test('Should contain the name of the Strategy.', async () => { 42 | const strategy = new TOTPStrategy( 43 | { 44 | ...BASE_STRATEGY_OPTIONS, 45 | successRedirect: '/verify', 46 | failureRedirect: '/login', 47 | emailSentRedirect: '/check-email', 48 | }, 49 | verify, 50 | ) 51 | expect(strategy.name).toBe(STRATEGY_NAME) 52 | }) 53 | 54 | test('Should throw an Error on missing required secret option.', async () => { 55 | const strategy = new TOTPStrategy( 56 | // @ts-expect-error - Error is expected since missing secret option. 57 | { 58 | sendTOTP, 59 | successRedirect: '/verify', 60 | failureRedirect: '/login', 61 | emailSentRedirect: '/check-email', 62 | }, 63 | verify, 64 | ) 65 | const request = new Request(`${HOST_URL}/login`, { 66 | method: 'POST', 67 | }) 68 | await expect(() => strategy.authenticate(request)).rejects.toThrow( 69 | ERRORS.REQUIRED_ENV_SECRET, 70 | ) 71 | }) 72 | 73 | test('Should throw an Error on missing required emailSentRedirect option.', async () => { 74 | const strategy = new TOTPStrategy( 75 | // @ts-expect-error - Error is expected since missing emailSentRedirect 76 | { 77 | ...BASE_STRATEGY_OPTIONS, 78 | successRedirect: '/verify', 79 | failureRedirect: '/login', 80 | }, 81 | verify, 82 | ) 83 | const request = new Request(`${HOST_URL}/login`, { 84 | method: 'POST', 85 | }) 86 | await expect(() => strategy.authenticate(request)).rejects.toThrow( 87 | ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL, 88 | ) 89 | }) 90 | 91 | test('Should throw an Error on missing required successRedirect option.', async () => { 92 | const strategy = new TOTPStrategy( 93 | // @ts-expect-error - Error is expected since missing successRedirect 94 | { 95 | ...BASE_STRATEGY_OPTIONS, 96 | failureRedirect: '/login', 97 | emailSentRedirect: '/check-email', 98 | }, 99 | verify, 100 | ) 101 | const request = new Request(`${HOST_URL}/login`, { 102 | method: 'POST', 103 | }) 104 | await expect(() => strategy.authenticate(request)).rejects.toThrow( 105 | ERRORS.REQUIRED_SUCCESS_REDIRECT_URL, 106 | ) 107 | }) 108 | 109 | test('Should throw an Error on missing required failureRedirect option.', async () => { 110 | const strategy = new TOTPStrategy( 111 | // @ts-expect-error - Error is expected since missing failureRedirect 112 | { 113 | ...BASE_STRATEGY_OPTIONS, 114 | successRedirect: '/verify', 115 | emailSentRedirect: '/check-email', 116 | }, 117 | verify, 118 | ) 119 | const request = new Request(`${HOST_URL}/login`, { 120 | method: 'POST', 121 | }) 122 | await expect(() => strategy.authenticate(request)).rejects.toThrow( 123 | ERRORS.REQUIRED_FAILURE_REDIRECT_URL, 124 | ) 125 | }) 126 | }) 127 | 128 | describe('[ TOTP ]', () => { 129 | describe('Generate/Send TOTP', () => { 130 | test('Should generate/send TOTP for form email.', async () => { 131 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 132 | expect(options.email).toBe(DEFAULT_EMAIL) 133 | expect(options.code).to.not.equal('') 134 | expect(options.request).toBeInstanceOf(Request) 135 | expect(options.formData).toBeInstanceOf(FormData) 136 | }) 137 | const strategy = new TOTPStrategy( 138 | { 139 | ...BASE_STRATEGY_OPTIONS, 140 | successRedirect: '/verify', 141 | failureRedirect: '/login', 142 | emailSentRedirect: '/verify', 143 | }, 144 | verify, 145 | ) 146 | const formData = new FormData() 147 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 148 | const request = new Request(`${HOST_URL}/login`, { 149 | method: 'POST', 150 | body: formData, 151 | }) 152 | await strategy.authenticate(request).catch(async (reason) => { 153 | if (reason instanceof Response) { 154 | expect(reason.status).toBe(302) 155 | expect(reason.headers.get('Location')).toBe('/verify') 156 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 157 | const cookie = new Cookie(setCookieHeader) 158 | const raw = cookie.get('_totp') 159 | const params = new URLSearchParams(raw!) 160 | const email = params.get('email') 161 | const totpRaw = params.get('totp') 162 | expect(email).toBe(DEFAULT_EMAIL) 163 | expect(totpRaw).toBeDefined() 164 | } else throw reason 165 | }) 166 | 167 | expect(sendTOTP).toHaveBeenCalledTimes(1) 168 | }) 169 | 170 | test('Should generate/send TOTP for form email with application form data.', async () => { 171 | const APP_FORM_FIELD = 'via' 172 | const APP_FORM_VALUE = 'whatsapp' 173 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 174 | expect(options.email).toBe(DEFAULT_EMAIL) 175 | expect(options.code).to.not.equal('') 176 | expect(options.request).toBeInstanceOf(Request) 177 | expect(options.formData).toBeInstanceOf(FormData) 178 | expect(options.formData.get(APP_FORM_FIELD)).toBe(APP_FORM_VALUE) 179 | }) 180 | const strategy = new TOTPStrategy( 181 | { 182 | ...BASE_STRATEGY_OPTIONS, 183 | successRedirect: '/verify', 184 | failureRedirect: '/login', 185 | emailSentRedirect: '/verify', 186 | }, 187 | verify, 188 | ) 189 | const formData = new FormData() 190 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 191 | formData.append(APP_FORM_FIELD, APP_FORM_VALUE) 192 | const request = new Request(`${HOST_URL}/login`, { 193 | method: 'POST', 194 | body: formData, 195 | }) 196 | await strategy.authenticate(request).catch(async (reason) => { 197 | if (reason instanceof Response) { 198 | expect(reason.status).toBe(302) 199 | expect(reason.headers.get('Location')).toBe('/verify') 200 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 201 | const cookie = new Cookie(setCookieHeader) 202 | const raw = cookie.get('_totp') 203 | const params = new URLSearchParams(raw!) 204 | const email = params.get('email') 205 | const totpRaw = params.get('totp') 206 | expect(email).toBe(DEFAULT_EMAIL) 207 | expect(totpRaw).toBeDefined() 208 | } else throw reason 209 | }) 210 | 211 | expect(sendTOTP).toHaveBeenCalledTimes(1) 212 | }) 213 | 214 | test('Should generate/send TOTP for form email ignoring form totp code.', async () => { 215 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 216 | expect(options.email).toBe(DEFAULT_EMAIL) 217 | expect(options.code).to.not.equal('') 218 | expect(options.request).toBeInstanceOf(Request) 219 | expect(options.formData).toBeInstanceOf(FormData) 220 | }) 221 | const strategy = new TOTPStrategy( 222 | { 223 | ...BASE_STRATEGY_OPTIONS, 224 | successRedirect: '/verify', 225 | failureRedirect: '/login', 226 | emailSentRedirect: '/verify', 227 | }, 228 | verify, 229 | ) 230 | const formData = new FormData() 231 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 232 | formData.append(FORM_FIELDS.CODE, '123456') 233 | const request = new Request(`${HOST_URL}/login`, { 234 | method: 'POST', 235 | body: formData, 236 | }) 237 | await strategy.authenticate(request).catch(async (reason) => { 238 | if (reason instanceof Response) { 239 | expect(reason.status).toBe(302) 240 | expect(reason.headers.get('Location')).toMatch('/verify') 241 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 242 | const cookie = new Cookie(setCookieHeader) 243 | const raw = cookie.get('_totp') 244 | const params = new URLSearchParams(raw!) 245 | const email = params.get('email') 246 | const totpRaw = params.get('totp') 247 | expect(email).toBe(DEFAULT_EMAIL) 248 | expect(totpRaw).toBeDefined() 249 | } else throw reason 250 | }) 251 | 252 | expect(sendTOTP).toHaveBeenCalledTimes(1) 253 | }) 254 | 255 | test('Should generate/send TOTP for form email ignoring session email.', async () => { 256 | const strategy = new TOTPStrategy( 257 | { 258 | ...BASE_STRATEGY_OPTIONS, 259 | successRedirect: '/verify', 260 | failureRedirect: '/login', 261 | emailSentRedirect: '/verify', 262 | }, 263 | verify, 264 | ) 265 | const formDataToPopulateSessionEmail = new FormData() 266 | formDataToPopulateSessionEmail.append(FORM_FIELDS.EMAIL, 'email@session.com') 267 | const requestToPopulateSessionEmail = new Request(`${HOST_URL}/login`, { 268 | method: 'POST', 269 | body: formDataToPopulateSessionEmail, 270 | }) 271 | let firstTOTP: string | undefined 272 | let firstResponseCookie: string | undefined 273 | await strategy.authenticate(requestToPopulateSessionEmail).catch((reason) => { 274 | if (reason instanceof Response) { 275 | expect(reason.status).toBe(302) 276 | expect(reason.headers.get('Location')).toBe('/verify') 277 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 278 | const cookie = new Cookie(setCookieHeader) 279 | const raw = cookie.get('_totp') 280 | expect(raw).toBeDefined() 281 | const params = new URLSearchParams(raw!) 282 | expect(params.get('email')).toBe('email@session.com') 283 | const totpRaw = params.get('totp') 284 | expect(totpRaw).toBeDefined() 285 | firstTOTP = totpRaw ?? undefined 286 | firstResponseCookie = setCookieHeader 287 | } else { 288 | throw reason 289 | } 290 | }) 291 | 292 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 293 | expect(options.email).toBe(DEFAULT_EMAIL) 294 | expect(options.code).not.toBe('') 295 | }) 296 | 297 | const formData = new FormData() 298 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 299 | const request = new Request(`${HOST_URL}/login`, { 300 | method: 'POST', 301 | headers: { cookie: firstResponseCookie ?? '' }, 302 | body: formData, 303 | }) 304 | await strategy.authenticate(request).catch((reason) => { 305 | if (reason instanceof Response) { 306 | expect(reason.status).toBe(302) 307 | expect(reason.headers.get('Location')).toBe('/verify') 308 | 309 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 310 | const cookie = new Cookie(setCookieHeader) 311 | const raw = cookie.get('_totp') 312 | expect(raw).toBeDefined() 313 | 314 | const params = new URLSearchParams(raw!) 315 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 316 | const newTOTP = params.get('totp') 317 | expect(newTOTP).toBeDefined() 318 | expect(newTOTP).not.toEqual(firstTOTP) 319 | } else { 320 | throw reason 321 | } 322 | }) 323 | 324 | expect(sendTOTP).toHaveBeenCalledTimes(2) 325 | }) 326 | 327 | test('Should generate/send TOTP for empty form data with session email.', async () => { 328 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 329 | expect(options.email).toBe(DEFAULT_EMAIL) 330 | expect(options.code).to.not.equal('') 331 | expect(options.request).toBeInstanceOf(Request) 332 | expect(options.formData).toBeInstanceOf(FormData) 333 | }) 334 | const strategy = new TOTPStrategy( 335 | { 336 | ...BASE_STRATEGY_OPTIONS, 337 | successRedirect: '/verify', 338 | failureRedirect: '/login', 339 | emailSentRedirect: '/verify', 340 | }, 341 | verify, 342 | ) 343 | const formData = new FormData() 344 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 345 | const requestToPopulate = new Request(`${HOST_URL}/login`, { 346 | method: 'POST', 347 | body: formData, 348 | }) 349 | let firstTOTP: string | undefined 350 | let firstCookieHeader: string | undefined 351 | await strategy.authenticate(requestToPopulate).catch(async (reason) => { 352 | if (reason instanceof Response) { 353 | expect(reason.status).toBe(302) 354 | expect(reason.headers.get('Location')).toBe('/verify') 355 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 356 | const cookie = new Cookie(setCookieHeader) 357 | const raw = cookie.get('_totp') 358 | const params = new URLSearchParams(raw!) 359 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 360 | const totpRaw = params.get('totp') 361 | expect(totpRaw).toBeDefined() 362 | firstTOTP = totpRaw ?? undefined 363 | firstCookieHeader = setCookieHeader 364 | } else throw reason 365 | }) 366 | if (!firstTOTP) throw new Error('Undefined session.') 367 | const emptyFormRequest = new Request(`${HOST_URL}/login`, { 368 | method: 'POST', 369 | headers: { 370 | cookie: firstCookieHeader ?? '', 371 | }, 372 | body: new FormData(), 373 | }) 374 | await strategy.authenticate(emptyFormRequest).catch(async (reason) => { 375 | if (reason instanceof Response) { 376 | expect(reason.status).toBe(302) 377 | expect(reason.headers.get('Location')).toBe('/verify') 378 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 379 | const cookie = new Cookie(setCookieHeader) 380 | const raw = cookie.get('_totp') 381 | expect(raw).toBeDefined() 382 | const params = new URLSearchParams(raw!) 383 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 384 | const newTOTP = params.get('totp') 385 | expect(newTOTP).toBeDefined() 386 | expect(newTOTP).not.toEqual(firstTOTP) 387 | } else throw reason 388 | }) 389 | expect(sendTOTP).toHaveBeenCalledTimes(2) 390 | }) 391 | 392 | test('Should generate/send TOTP for application form data with session email.', async () => { 393 | const APP_FORM_FIELD = 'via' 394 | const APP_FORM_VALUE = 'whatsapp' 395 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 396 | expect(options.email).toBe(DEFAULT_EMAIL) 397 | expect(options.code).to.not.equal('') 398 | expect(options.request).toBeInstanceOf(Request) 399 | expect(options.formData).toBeInstanceOf(FormData) 400 | expect(options.formData.get(APP_FORM_FIELD)).toBe(APP_FORM_VALUE) 401 | }) 402 | let firstTOTP: string | undefined 403 | let firstCookieHeader: string | undefined 404 | const strategy = new TOTPStrategy( 405 | { 406 | ...BASE_STRATEGY_OPTIONS, 407 | successRedirect: '/verify', 408 | failureRedirect: '/login', 409 | emailSentRedirect: '/verify', 410 | }, 411 | verify, 412 | ) 413 | const formData = new FormData() 414 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 415 | formData.append(APP_FORM_FIELD, APP_FORM_VALUE) 416 | const requestToPopulateEmail = new Request(`${HOST_URL}/login`, { 417 | method: 'POST', 418 | body: formData, 419 | }) 420 | await strategy.authenticate(requestToPopulateEmail).catch(async (reason) => { 421 | if (reason instanceof Response) { 422 | expect(reason.status).toBe(302) 423 | expect(reason.headers.get('Location')).toBe('/verify') 424 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 425 | const cookie = new Cookie(setCookieHeader) 426 | const raw = cookie.get('_totp') 427 | expect(raw).toBeDefined() 428 | const params = new URLSearchParams(raw!) 429 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 430 | const totpRaw = params.get('totp') 431 | expect(totpRaw).toBeDefined() 432 | firstTOTP = totpRaw ?? undefined 433 | firstCookieHeader = setCookieHeader 434 | } else throw reason 435 | }) 436 | const appFormData = new FormData() 437 | appFormData.append(APP_FORM_FIELD, APP_FORM_VALUE) 438 | const appFormRequest = new Request(`${HOST_URL}/login`, { 439 | method: 'POST', 440 | headers: { 441 | cookie: firstCookieHeader ?? '', 442 | }, 443 | body: appFormData, 444 | }) 445 | await strategy.authenticate(appFormRequest).catch(async (reason) => { 446 | if (reason instanceof Response) { 447 | expect(reason.status).toBe(302) 448 | expect(reason.headers.get('Location')).toBe('/verify') 449 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 450 | const cookie = new Cookie(setCookieHeader) 451 | const raw = cookie.get('_totp') 452 | expect(raw).toBeDefined() 453 | const params = new URLSearchParams(raw!) 454 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 455 | const newTOTP = params.get('totp') 456 | expect(newTOTP).toBeDefined() 457 | expect(newTOTP).not.toEqual(firstTOTP) 458 | } else throw reason 459 | }) 460 | expect(sendTOTP).toHaveBeenCalledTimes(2) 461 | }) 462 | 463 | test('Should failure redirect on invalid email.', async () => { 464 | const strategy = new TOTPStrategy( 465 | { 466 | ...BASE_STRATEGY_OPTIONS, 467 | successRedirect: '/verify', 468 | failureRedirect: '/login', 469 | emailSentRedirect: '/check-email', 470 | }, 471 | verify, 472 | ) 473 | const formData = new FormData() 474 | formData.append(FORM_FIELDS.EMAIL, '@invalid-email') 475 | const request = new Request(`${HOST_URL}/login`, { 476 | method: 'POST', 477 | body: formData, 478 | }) 479 | 480 | await strategy.authenticate(request).catch(async (reason) => { 481 | if (reason instanceof Response) { 482 | expect(reason.status).toBe(302) 483 | expect(reason.headers.get('Location')).toBe('/login') 484 | 485 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 486 | const cookie = new Cookie(setCookieHeader) 487 | const raw = cookie.get('_totp') 488 | expect(raw).toBeDefined() 489 | 490 | const params = new URLSearchParams(raw!) 491 | expect(params.get('error')).toBe(ERRORS.INVALID_EMAIL) 492 | } else throw reason 493 | }) 494 | }) 495 | 496 | test('Should failure redirect on invalid email with custom error.', async () => { 497 | const CUSTOM_ERROR = 'TEST: Invalid email.' 498 | const strategy = new TOTPStrategy( 499 | { 500 | ...BASE_STRATEGY_OPTIONS, 501 | successRedirect: '/verify', 502 | failureRedirect: '/login', 503 | emailSentRedirect: '/check-email', 504 | customErrors: { 505 | invalidEmail: CUSTOM_ERROR, 506 | }, 507 | }, 508 | verify, 509 | ) 510 | 511 | const formData = new FormData() 512 | formData.append(FORM_FIELDS.EMAIL, '@invalid-email') 513 | const request = new Request(`${HOST_URL}/login`, { 514 | method: 'POST', 515 | body: formData, 516 | }) 517 | 518 | await strategy.authenticate(request).catch(async (reason) => { 519 | if (reason instanceof Response) { 520 | expect(reason.status).toBe(302) 521 | expect(reason.headers.get('Location')).toBe('/login') 522 | 523 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 524 | const cookie = new Cookie(setCookieHeader) 525 | const raw = cookie.get('_totp') 526 | expect(raw).toBeDefined() 527 | 528 | const params = new URLSearchParams(raw!) 529 | expect(params.get('error')).toBe(CUSTOM_ERROR) 530 | } else throw reason 531 | }) 532 | }) 533 | 534 | test('Should failure redirect when custom validateEmail returns false.', async () => { 535 | const ERROR_MESSAGE = 'TEST: Invalid email.' 536 | const strategy = new TOTPStrategy( 537 | { 538 | ...BASE_STRATEGY_OPTIONS, 539 | successRedirect: '/verify', 540 | failureRedirect: '/login', 541 | emailSentRedirect: '/check-email', 542 | customErrors: { 543 | invalidEmail: ERROR_MESSAGE, 544 | }, 545 | validateEmail: async () => false, 546 | }, 547 | verify, 548 | ) 549 | 550 | const formData = new FormData() 551 | formData.append(FORM_FIELDS.EMAIL, '@invalid-email') 552 | const request = new Request(`${HOST_URL}/login`, { 553 | method: 'POST', 554 | body: formData, 555 | }) 556 | 557 | await strategy.authenticate(request).catch(async (reason) => { 558 | if (reason instanceof Response) { 559 | expect(reason.status).toBe(302) 560 | expect(reason.headers.get('Location')).toBe('/login') 561 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 562 | const cookie = new Cookie(setCookieHeader) 563 | const raw = cookie.get('_totp') 564 | expect(raw).toBeDefined() 565 | const params = new URLSearchParams(raw!) 566 | expect(params.get('error')).toBe(ERROR_MESSAGE) 567 | } else throw reason 568 | }) 569 | }) 570 | 571 | test('Should failure redirect on missing email.', async () => { 572 | const strategy = new TOTPStrategy( 573 | { 574 | ...BASE_STRATEGY_OPTIONS, 575 | successRedirect: '/verify', 576 | failureRedirect: '/login', 577 | emailSentRedirect: '/check-email', 578 | }, 579 | verify, 580 | ) 581 | const request = new Request(`${HOST_URL}/login`, { 582 | method: 'POST', 583 | body: new FormData(), 584 | }) 585 | await strategy.authenticate(request).catch(async (reason) => { 586 | if (reason instanceof Response) { 587 | expect(reason.status).toBe(302) 588 | expect(reason.headers.get('Location')).toBe('/login') 589 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 590 | const cookie = new Cookie(setCookieHeader) 591 | const raw = cookie.get('_totp') 592 | expect(raw).toBeDefined() 593 | const params = new URLSearchParams(raw!) 594 | expect(params.get('error')).toBe(ERRORS.REQUIRED_EMAIL) 595 | } else throw reason 596 | }) 597 | }) 598 | }) 599 | 600 | describe('Validate TOTP', () => { 601 | async function setupGenerateSendTOTP( 602 | totpStrategyOptions: Partial = {}, 603 | ) { 604 | const user = { name: 'Joe Schmoe' } 605 | let sendTOTPOptions: SendTOTPOptions | undefined 606 | let totpCookie: string | undefined 607 | 608 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 609 | sendTOTPOptions = options 610 | expect(options.email).toBe(DEFAULT_EMAIL) 611 | expect(options.code).to.not.equal('') 612 | expect(options.magicLink).toMatch(new RegExp(`^${HOST_URL}${MAGIC_LINK_PATH}\\?t=`)) 613 | }) 614 | 615 | const strategy = new TOTPStrategy( 616 | { 617 | ...BASE_STRATEGY_OPTIONS, 618 | successRedirect: '/verify', 619 | failureRedirect: '/login', 620 | emailSentRedirect: '/verify', 621 | ...totpStrategyOptions, 622 | }, 623 | async ({ email, formData, request }) => { 624 | expect(email).toBe(DEFAULT_EMAIL); 625 | expect(request).toBeInstanceOf(Request); 626 | if (formData) { 627 | expect(formData).toBeInstanceOf(FormData); 628 | } else { 629 | expect(request.method).toBe('GET'); 630 | } 631 | return Promise.resolve(user); 632 | } 633 | ) 634 | const formData = new FormData() 635 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 636 | const request = new Request(`${HOST_URL}/login`, { 637 | method: 'POST', 638 | body: formData, 639 | }) 640 | await strategy.authenticate(request).catch(async (reason) => { 641 | if (reason instanceof Response) { 642 | expect(reason.status).toBe(302) 643 | expect(reason.headers.get('Location')).toBe('/verify') 644 | totpCookie = reason.headers.get('Set-Cookie') ?? '' 645 | const cookie = new Cookie(totpCookie) 646 | const raw = cookie.get('_totp') 647 | expect(raw).toBeDefined() 648 | const params = new URLSearchParams(raw!) 649 | expect(params.get('email')).toBe(DEFAULT_EMAIL) 650 | expect(params.get('totp')).toBeDefined() 651 | } else throw reason 652 | }) 653 | 654 | expect(sendTOTP).toHaveBeenCalledTimes(1) 655 | expect(sendTOTPOptions).toBeDefined() 656 | if (!sendTOTPOptions) throw new Error('Undefined sendTOTPOptions.') 657 | expect(totpCookie).toBeDefined() 658 | if (!totpCookie) throw new Error('Undefined cookie.') 659 | return { strategy, sendTOTPOptions, totpCookie, user } 660 | } 661 | 662 | test('Should successfully validate totp code.', async () => { 663 | const { sendTOTPOptions, totpCookie, user } = await setupGenerateSendTOTP() 664 | const strategy = new TOTPStrategy( 665 | { 666 | ...BASE_STRATEGY_OPTIONS, 667 | successRedirect: '/account', 668 | failureRedirect: '/login', 669 | emailSentRedirect: '/check-email', 670 | }, 671 | async ({ email, formData, request }) => { 672 | expect(email).toBe(DEFAULT_EMAIL); 673 | expect(request).toBeInstanceOf(Request); 674 | if (formData) { 675 | expect(formData).toBeInstanceOf(FormData); 676 | } else { 677 | expect(request.method).toBe('GET'); 678 | } 679 | return Promise.resolve(user); 680 | }, 681 | ) 682 | const formData = new FormData() 683 | formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code) 684 | const request = new Request(`${HOST_URL}/verify`, { 685 | method: 'POST', 686 | headers: { 687 | cookie: totpCookie, 688 | }, 689 | body: formData, 690 | }) 691 | await strategy.authenticate(request).catch(async (reason) => { 692 | if (reason instanceof Response) { 693 | expect(reason.status).toBe(302) 694 | expect(reason.headers.get('Location')).toBe(`/account`) 695 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 696 | const cookie = new Cookie(setCookieHeader) 697 | const raw = cookie.get('_totp') 698 | expect(raw).toBeDefined() 699 | const params = new URLSearchParams(raw!) 700 | expect(params.get('email')).toBeNull() 701 | expect(params.get('totp')).toBeNull() 702 | expect(params.get('error')).toBeNull() 703 | } else throw reason 704 | }) 705 | }) 706 | 707 | test('Should failure redirect on invalid totp code.', async () => { 708 | const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 709 | const strategy = new TOTPStrategy( 710 | { 711 | ...BASE_STRATEGY_OPTIONS, 712 | successRedirect: '/account', 713 | failureRedirect: '/verify', 714 | emailSentRedirect: '/check-email', 715 | }, 716 | async ({ email, formData, request }) => { 717 | expect(email).toBe(DEFAULT_EMAIL); 718 | expect(request).toBeInstanceOf(Request); 719 | if (formData) { 720 | expect(formData).toBeInstanceOf(FormData); 721 | } else { 722 | expect(request.method).toBe('GET'); 723 | } 724 | return Promise.resolve(user); 725 | }, 726 | ) 727 | const formData = new FormData() 728 | formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code + 'INVALID') 729 | const request = new Request(`${HOST_URL}/verify`, { 730 | method: 'POST', 731 | headers: { 732 | cookie: totpCookie, 733 | }, 734 | body: formData, 735 | }) 736 | await strategy.authenticate(request).catch(async (reason) => { 737 | if (reason instanceof Response) { 738 | expect(reason.status).toBe(302) 739 | expect(reason.headers.get('Location')).toBe(`/verify`) 740 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 741 | const cookie = new Cookie(setCookieHeader) 742 | const raw = cookie.get('_totp') 743 | expect(raw).toBeDefined() 744 | 745 | const params = new URLSearchParams(raw!) 746 | expect(params.get('error')).toBe(ERRORS.INVALID_TOTP) 747 | } else throw reason 748 | }) 749 | }) 750 | 751 | test('Should failure redirect on invalid totp code with custom error.', async () => { 752 | const CUSTOM_ERROR = 'TEST: invalid totp code' 753 | const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 754 | const strategy = new TOTPStrategy( 755 | { 756 | ...BASE_STRATEGY_OPTIONS, 757 | successRedirect: '/account', 758 | failureRedirect: '/verify', 759 | emailSentRedirect: '/check-email', 760 | customErrors: { 761 | invalidTotp: CUSTOM_ERROR, 762 | }, 763 | }, 764 | async ({ email, formData, request }) => { 765 | expect(email).toBe(DEFAULT_EMAIL); 766 | expect(request).toBeInstanceOf(Request); 767 | if (formData) { 768 | expect(formData).toBeInstanceOf(FormData); 769 | } else { 770 | expect(request.method).toBe('GET'); 771 | } 772 | return Promise.resolve(user); 773 | }, 774 | ) 775 | const formData = new FormData() 776 | formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code + 'INVALID') 777 | const request = new Request(`${HOST_URL}/verify`, { 778 | method: 'POST', 779 | headers: { 780 | cookie: totpCookie, 781 | }, 782 | body: formData, 783 | }) 784 | await strategy.authenticate(request).catch(async (reason) => { 785 | if (reason instanceof Response) { 786 | expect(reason.status).toBe(302) 787 | expect(reason.headers.get('Location')).toBe(`/verify`) 788 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 789 | const cookie = new Cookie(setCookieHeader) 790 | const raw = cookie.get('_totp') 791 | expect(raw).toBeDefined() 792 | const params = new URLSearchParams(raw!) 793 | expect(params.get('error')).toBe(CUSTOM_ERROR) 794 | } else throw reason 795 | }) 796 | }) 797 | 798 | test('Should failure redirect on invalid and max TOTP attempts.', async () => { 799 | // eslint-disable-next-line prefer-const 800 | let { user, totpCookie, sendTOTPOptions } = await setupGenerateSendTOTP() 801 | 802 | const strategy = new TOTPStrategy( 803 | { 804 | ...BASE_STRATEGY_OPTIONS, 805 | successRedirect: '/account', 806 | failureRedirect: '/verify', 807 | emailSentRedirect: '/check-email', 808 | }, 809 | async ({ email, formData, request }) => { 810 | expect(email).toBe(DEFAULT_EMAIL); 811 | expect(request).toBeInstanceOf(Request); 812 | if (formData) { 813 | expect(formData).toBeInstanceOf(FormData); 814 | } else { 815 | expect(request.method).toBe('GET'); 816 | } 817 | return Promise.resolve(user); 818 | }, 819 | ) 820 | 821 | const url = new URL(sendTOTPOptions.magicLink) 822 | const invalidToken = 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..invalid.token' 823 | 824 | for (let i = 0; i <= TOTP_GENERATION_DEFAULTS.maxAttempts; i++) { 825 | url.searchParams.set('t', invalidToken) 826 | 827 | const request = new Request(url.toString(), { 828 | method: 'GET', 829 | headers: { 830 | cookie: totpCookie, 831 | }, 832 | }) 833 | await strategy.authenticate(request).catch(async (reason) => { 834 | if (reason instanceof Response) { 835 | expect(reason.status).toBe(302) 836 | expect(reason.headers.get('Location')).toBe(`/verify`) 837 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 838 | totpCookie = setCookieHeader 839 | const cookie = new Cookie(setCookieHeader) 840 | const raw = cookie.get('_totp') 841 | expect(raw).toBeDefined() 842 | const params = new URLSearchParams(raw!) 843 | expect(params.get('error')).toBe( 844 | i < TOTP_GENERATION_DEFAULTS.maxAttempts 845 | ? ERRORS.INVALID_TOTP 846 | : ERRORS.RATE_LIMIT_EXCEEDED, 847 | ) 848 | if (i >= TOTP_GENERATION_DEFAULTS.maxAttempts) { 849 | expect(params.get('totp')).toBeNull() 850 | } 851 | } else throw reason 852 | }) 853 | } 854 | }) 855 | 856 | test('Should failure redirect on expired totp code.', async () => { 857 | const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 858 | const strategy = new TOTPStrategy( 859 | { 860 | ...BASE_STRATEGY_OPTIONS, 861 | successRedirect: '/account', 862 | failureRedirect: '/verify', 863 | emailSentRedirect: '/check-email', 864 | }, 865 | async ({ email, formData, request }) => { 866 | expect(email).toBe(DEFAULT_EMAIL); 867 | expect(request).toBeInstanceOf(Request); 868 | if (formData) { 869 | expect(formData).toBeInstanceOf(FormData); 870 | } else { 871 | expect(request.method).toBe('GET'); 872 | } 873 | return Promise.resolve(user); 874 | }, 875 | ) 876 | vi.setSystemTime( 877 | new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), 878 | ) 879 | const formData = new FormData() 880 | formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code) 881 | const request = new Request(`${HOST_URL}/verify`, { 882 | method: 'POST', 883 | headers: { 884 | cookie: totpCookie, 885 | }, 886 | body: formData, 887 | }) 888 | await strategy.authenticate(request).catch(async (reason) => { 889 | if (reason instanceof Response) { 890 | expect(reason.status).toBe(302) 891 | expect(reason.headers.get('Location')).toBe(`/verify`) 892 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 893 | const cookie = new Cookie(setCookieHeader) 894 | const raw = cookie.get('_totp') 895 | expect(raw).toBeDefined() 896 | const params = new URLSearchParams(raw!) 897 | expect(params.get('error')).toBe(ERRORS.EXPIRED_TOTP) 898 | expect(params.get('totp')).toBeNull() 899 | } else throw reason 900 | }) 901 | }) 902 | 903 | test('Should failure redirect on expired totp code with custom error.', async () => { 904 | const CUSTOM_ERROR = 'TEST: expired totp code' 905 | const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 906 | const strategy = new TOTPStrategy( 907 | { 908 | ...BASE_STRATEGY_OPTIONS, 909 | successRedirect: '/account', 910 | failureRedirect: '/verify', 911 | emailSentRedirect: '/check-email', 912 | customErrors: { 913 | expiredTotp: CUSTOM_ERROR, 914 | }, 915 | }, 916 | async ({ email, formData, request }) => { 917 | expect(email).toBe(DEFAULT_EMAIL); 918 | expect(request).toBeInstanceOf(Request); 919 | if (formData) { 920 | expect(formData).toBeInstanceOf(FormData); 921 | } else { 922 | expect(request.method).toBe('GET'); 923 | } 924 | return Promise.resolve(user); 925 | }, 926 | ) 927 | vi.setSystemTime( 928 | new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), 929 | ) 930 | const formData = new FormData() 931 | formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code) 932 | const request = new Request(`${HOST_URL}/verify`, { 933 | method: 'POST', 934 | headers: { 935 | cookie: totpCookie, 936 | }, 937 | body: formData, 938 | }) 939 | await strategy.authenticate(request).catch(async (reason) => { 940 | if (reason instanceof Response) { 941 | expect(reason.status).toBe(302) 942 | expect(reason.headers.get('Location')).toBe(`/verify`) 943 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 944 | const cookie = new Cookie(setCookieHeader) 945 | const raw = cookie.get('_totp') 946 | expect(raw).toBeDefined() 947 | const params = new URLSearchParams(raw!) 948 | expect(params.get('error')).toBe(CUSTOM_ERROR) 949 | expect(params.get('totp')).toBeNull() 950 | } else throw reason 951 | }) 952 | }) 953 | 954 | test('Should successfully validate magic-link.', async () => { 955 | const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 956 | const strategy = new TOTPStrategy( 957 | { 958 | ...BASE_STRATEGY_OPTIONS, 959 | successRedirect: '/account', 960 | failureRedirect: '/verify', 961 | emailSentRedirect: '/check-email', 962 | }, 963 | async ({ email, formData, request }) => { 964 | expect(email).toBe(DEFAULT_EMAIL); 965 | expect(request).toBeInstanceOf(Request); 966 | if (formData) { 967 | expect(formData).toBeInstanceOf(FormData); 968 | } else { 969 | expect(request.method).toBe('GET'); 970 | } 971 | return Promise.resolve(user); 972 | }, 973 | ) 974 | expect(sendTOTPOptions.magicLink).toBeDefined() 975 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 976 | const request = new Request(sendTOTPOptions.magicLink, { 977 | method: 'GET', 978 | headers: { 979 | cookie: totpCookie, 980 | }, 981 | }) 982 | await strategy.authenticate(request).catch(async (reason) => { 983 | if (reason instanceof Response) { 984 | expect(reason.status).toBe(302) 985 | expect(reason.headers.get('Location')).toBe(`/account`) 986 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 987 | const cookie = new Cookie(setCookieHeader) 988 | const raw = cookie.get('_totp') 989 | expect(raw).toBeDefined() 990 | const params = new URLSearchParams(raw!) 991 | expect(params.get('email')).toBeNull() 992 | expect(params.get('totp')).toBeNull() 993 | expect(params.get('error')).toBeNull() 994 | } else throw reason 995 | }) 996 | }) 997 | 998 | test('Should failure redirect on invalid magic-link code.', async () => { 999 | const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 1000 | expect(sendTOTPOptions.magicLink).toBeDefined() 1001 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 1002 | const request = new Request(sendTOTPOptions.magicLink + 'INVALID', { 1003 | method: 'GET', 1004 | headers: { 1005 | cookie: totpCookie, 1006 | }, 1007 | }) 1008 | await strategy.authenticate(request).catch(async (reason) => { 1009 | if (reason instanceof Response) { 1010 | expect(reason.status).toBe(302) 1011 | expect(reason.headers.get('Location')).toBe(`/login`) 1012 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1013 | const cookie = new Cookie(setCookieHeader) 1014 | const raw = cookie.get('_totp') 1015 | expect(raw).toBeDefined() 1016 | const params = new URLSearchParams(raw!) 1017 | expect(params.get('error')).toBe(ERRORS.INVALID_TOTP) 1018 | } else throw reason 1019 | }) 1020 | }) 1021 | 1022 | test('Should failure redirect on expired magic-link.', async () => { 1023 | const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 1024 | expect(sendTOTPOptions.magicLink).toBeDefined() 1025 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 1026 | vi.setSystemTime( 1027 | new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), 1028 | ) 1029 | const request = new Request(sendTOTPOptions.magicLink, { 1030 | method: 'GET', 1031 | headers: { 1032 | cookie: totpCookie, 1033 | }, 1034 | }) 1035 | await strategy.authenticate(request).catch(async (reason) => { 1036 | if (reason instanceof Response) { 1037 | expect(reason.status).toBe(302) 1038 | expect(reason.headers.get('Location')).toBe(`/login`) 1039 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1040 | const cookie = new Cookie(setCookieHeader) 1041 | const raw = cookie.get('_totp') 1042 | expect(raw).toBeDefined() 1043 | const params = new URLSearchParams(raw!) 1044 | expect(params.get('error')).toBe(ERRORS.EXPIRED_TOTP) 1045 | } else throw reason 1046 | }) 1047 | }) 1048 | 1049 | test('Should failure redirect on invalid magic-link path.', async () => { 1050 | const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 1051 | expect(sendTOTPOptions.magicLink).toBeDefined() 1052 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 1053 | expect(sendTOTPOptions.magicLink).toMatch(MAGIC_LINK_PATH) 1054 | const request = new Request( 1055 | sendTOTPOptions.magicLink.replace(MAGIC_LINK_PATH, '/invalid-magic-link'), 1056 | { 1057 | method: 'GET', 1058 | headers: { 1059 | cookie: totpCookie, 1060 | }, 1061 | }, 1062 | ) 1063 | await strategy.authenticate(request).catch(async (reason) => { 1064 | if (reason instanceof Response) { 1065 | expect(reason.status).toBe(302) 1066 | expect(reason.headers.get('Location')).toBe(`/login`) 1067 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1068 | const cookie = new Cookie(setCookieHeader) 1069 | const raw = cookie.get('_totp') 1070 | expect(raw).toBeDefined() 1071 | 1072 | const params = new URLSearchParams(raw!) 1073 | expect(params.get('error')).toBe(ERRORS.INVALID_MAGIC_LINK_PATH) 1074 | } else throw reason 1075 | }) 1076 | }) 1077 | 1078 | test('Should failure redirect on missing email.', async () => { 1079 | const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 1080 | 1081 | expect(sendTOTPOptions.magicLink).toBeDefined() 1082 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 1083 | 1084 | const modifiedCookie = new Cookie(totpCookie) 1085 | const raw = modifiedCookie.get('_totp') 1086 | expect(raw).toBeDefined() 1087 | 1088 | const params = new URLSearchParams(raw!) 1089 | params.delete('email') 1090 | const newCookie = new SetCookie({ 1091 | name: '_totp', 1092 | value: params.toString(), 1093 | httpOnly: true, 1094 | path: '/', 1095 | sameSite: 'Lax', 1096 | secure: true, 1097 | }) 1098 | 1099 | const request = new Request(sendTOTPOptions.magicLink, { 1100 | method: 'GET', 1101 | headers: { 1102 | cookie: newCookie.toString(), 1103 | }, 1104 | }) 1105 | 1106 | await strategy.authenticate(request).catch(async (reason) => { 1107 | if (reason instanceof Response) { 1108 | expect(reason.status).toBe(302) 1109 | expect(reason.headers.get('Location')).toBe(`/login`) 1110 | 1111 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1112 | const cookie = new Cookie(setCookieHeader) 1113 | const raw = cookie.get('_totp') 1114 | expect(raw).toBeDefined() 1115 | 1116 | const params = new URLSearchParams(raw!) 1117 | expect(params.get('error')).toBe(ERRORS.MISSING_SESSION_EMAIL) 1118 | } else throw reason 1119 | }) 1120 | }) 1121 | 1122 | test('Should failure redirect on stale magic-link.', async () => { 1123 | const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() 1124 | 1125 | expect(sendTOTPOptions.magicLink).toBeDefined(); 1126 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.'); 1127 | 1128 | const modifiedCookie = new Cookie(totpCookie) 1129 | const raw = modifiedCookie.get('_totp') 1130 | expect(raw).toBeDefined() 1131 | 1132 | const params = new URLSearchParams(raw!) 1133 | params.delete('totp') 1134 | const newCookie = new SetCookie({ 1135 | name: '_totp', 1136 | value: params.toString(), 1137 | httpOnly: true, 1138 | path: '/', 1139 | sameSite: 'Lax', 1140 | secure: true, 1141 | }) 1142 | 1143 | const request = new Request(sendTOTPOptions.magicLink, { 1144 | method: 'GET', 1145 | headers: { 1146 | cookie: newCookie.toString(), 1147 | }, 1148 | }) 1149 | 1150 | await strategy.authenticate(request).catch(async (reason) => { 1151 | if (reason instanceof Response) { 1152 | expect(reason.status).toBe(302) 1153 | expect(reason.headers.get('Location')).toBe(`/login`) 1154 | 1155 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1156 | const cookie = new Cookie(setCookieHeader) 1157 | const raw = cookie.get('_totp') 1158 | expect(raw).toBeDefined() 1159 | 1160 | const params = new URLSearchParams(raw!) 1161 | expect(params.get('error')).toBe(ERRORS.MISSING_SESSION_TOTP) 1162 | } else throw reason 1163 | }) 1164 | }) 1165 | 1166 | test('Should failure redirect on magic-link invalid and max TOTP attempts.', async () => { 1167 | // eslint-disable-next-line prefer-const 1168 | let { user, totpCookie, sendTOTPOptions } = await setupGenerateSendTOTP() 1169 | const strategy = new TOTPStrategy( 1170 | { 1171 | ...BASE_STRATEGY_OPTIONS, 1172 | successRedirect: '/account', 1173 | failureRedirect: '/verify', 1174 | emailSentRedirect: '/check-email', 1175 | }, 1176 | async ({ email, formData, request }) => { 1177 | expect(email).toBe(DEFAULT_EMAIL); 1178 | expect(request).toBeInstanceOf(Request); 1179 | if (formData) { 1180 | expect(formData).toBeInstanceOf(FormData); 1181 | } else { 1182 | expect(request.method).toBe('GET'); 1183 | } 1184 | return Promise.resolve(user); 1185 | }, 1186 | ) 1187 | expect(sendTOTPOptions.magicLink).toBeDefined() 1188 | if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') 1189 | 1190 | for (let i = 0; i <= TOTP_GENERATION_DEFAULTS.maxAttempts; i++) { 1191 | const request = new Request(sendTOTPOptions.magicLink + 'INVALID', { 1192 | method: 'GET', 1193 | headers: { 1194 | cookie: totpCookie, 1195 | }, 1196 | }) 1197 | 1198 | await strategy.authenticate(request).catch(async (reason) => { 1199 | if (reason instanceof Response) { 1200 | expect(reason.status).toBe(302) 1201 | expect(reason.headers.get('Location')).toBe(`/verify`) 1202 | 1203 | const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' 1204 | totpCookie = setCookieHeader 1205 | const cookie = new Cookie(setCookieHeader) 1206 | const raw = cookie.get('_totp') 1207 | expect(raw).toBeDefined() 1208 | 1209 | const params = new URLSearchParams(raw!) 1210 | expect(params.get('error')).toBe( 1211 | i < TOTP_GENERATION_DEFAULTS.maxAttempts 1212 | ? ERRORS.INVALID_TOTP 1213 | : ERRORS.RATE_LIMIT_EXCEEDED, 1214 | ) 1215 | 1216 | if (i >= TOTP_GENERATION_DEFAULTS.maxAttempts) { 1217 | expect(params.get('totp')).toBeNull() 1218 | } 1219 | } else throw reason 1220 | }) 1221 | } 1222 | }) 1223 | }) 1224 | }) 1225 | 1226 | describe('[ Utils ]', () => { 1227 | test('Should use the origin from the request for the magic-link.', async () => { 1228 | const samples: Array<[string, string]> = [ 1229 | ['http://localhost/login', 'http://localhost/magic-link\\?t='], 1230 | ['http://localhost:3000/login', 'http://localhost:3000/magic-link\\?t='], 1231 | ['http://127.0.0.1/login', 'http://127\\.0\\.0\\.1/magic-link\\?t='], 1232 | ['http://127.0.0.1:3000/login', 'http://127\\.0\\.0\\.1:3000/magic-link\\?t='], 1233 | ['http://localhost:8788/signin', 'http://localhost:8788/magic-link\\?t='], 1234 | ['https://host.com/login', 'https://host\\.com/magic-link\\?t='], 1235 | ['https://host.com:3000/login', 'https://host\\.com:3000/magic-link\\?t='], 1236 | ] 1237 | 1238 | for (const [requestUrl, magicLinkBase] of samples) { 1239 | let capturedMagicLink: string | undefined 1240 | 1241 | // Mock sendTOTP to capture the generated magic link. 1242 | sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { 1243 | capturedMagicLink = options.magicLink 1244 | }) 1245 | 1246 | const strategy = new TOTPStrategy( 1247 | { 1248 | ...BASE_STRATEGY_OPTIONS, 1249 | successRedirect: '/verify', 1250 | failureRedirect: '/login', 1251 | emailSentRedirect: '/check-email', 1252 | }, 1253 | verify, 1254 | ) 1255 | 1256 | const formData = new FormData() 1257 | formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) 1258 | const request = new Request(requestUrl, { 1259 | method: 'POST', 1260 | body: formData, 1261 | }) 1262 | 1263 | await strategy.authenticate(request).catch(() => { 1264 | // We expect this to throw since it redirects. 1265 | expect(capturedMagicLink).toBeDefined() 1266 | const regex = new RegExp(`^${magicLinkBase}[A-Za-z0-9_\\-\\.]+$`) 1267 | expect(capturedMagicLink).toMatch(regex) 1268 | }) 1269 | } 1270 | }) 1271 | 1272 | test('Should throw an error on invalid secret.', async () => { 1273 | const secrets = [ 1274 | 'b2FE35059924CDBF5B52A84765B8B010F5291993A9BC39410139D4F5110060', 1275 | 'b2FE35059924CDBF5B52A84765B8B010F5291993A9BC39410139D4F511006034a', 1276 | 'b2FE35059924CDBF5B52A84765B8B010F5291993A9BC39410139D4F51100603#', 1277 | ] 1278 | 1279 | for (const secret of secrets) { 1280 | expect(() => asJweKey(secret)).toThrow() 1281 | } 1282 | }) 1283 | }) --------------------------------------------------------------------------------