diff --git a/README.md b/README.md index e082c80..9b4e917 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- Remix Auth TOTP is a Time-Based One-Time Password (TOTP) Authentication Strategy for Remix Auth that implements Email-Code Verification & Magic-Link Authentication in your application. + A Time-Based One-Time Password (TOTP) Authentication Strategy for Remix Auth that implements Email-Code & Magic-Link Authentication in your application.

@@ -30,6 +30,7 @@ npm install remix-auth-totp ## Features +- **⭐ Remix & React Router v7** - Out of the box support. - **📧 Built-In Magic Link** - Authenticate users with a Click. - **⛅ Cloudflare Support** - Works with Cloudflare Pages. - **🔐 Secure** - Encrypted Time-Based Codes. @@ -50,7 +51,7 @@ Please, read the [Getting Started Documentation](https://github.com/dev-xo/remix ## Support -If you found **Remix Auth TOTP** helpful, please consider supporting it with a ⭐ [Star](https://github.com/dev-xo/remix-auth-totp). It helps the repository grow and provides the required motivation to continue maintaining the project. +If you found **Remix Auth TOTP** helpful, please consider supporting it with a ⭐ [Star](https://github.com/dev-xo/remix-auth-totp). ### Acknowledgments @@ -58,6 +59,8 @@ Big thanks to [@w00fz](https://github.com/w00fz) for its amazing implementation Special thanks to [@mw10013](https://github.com/mw10013) for the **Cloudflare Support** implementation, the `v2` and `v3` **Releases**, and all the dedication and effort set into the project. +Huge thanks to [@CyrusVorwald](https://github.com/CyrusVorwald) for the **v4** release, and the effort to make it React Router v7 compatible. + ## License Licensed under the [MIT license](https://github.com/dev-xo/remix-auth-totp/blob/main/LICENSE). diff --git a/docs/README.md b/docs/README.md index d557ce9..9e57297 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,6 @@ Welcome to the Remix Auth TOTP Documentation! - [Getting Started](https://github.com/dev-xo/remix-auth-totp/tree/main/docs#getting-started) - A quick start guide to get you up and running. - [Examples](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/examples.md) - A list of community examples using Remix Auth TOTP. - [Customization](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) - A detailed guide of all the available options and customizations. -- [Migration](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/migration.md) - A `v2` to `v3` migration guide. - [Cloudflare](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/cloudflare.md) - A guide to using Remix Auth TOTP with Cloudflare Workers. ## Getting Started @@ -54,18 +53,20 @@ export async function sendEmail(body: SendEmailBody) { } ``` -In the [Starter Example](https://github.com/dev-xo/totp-starter-example) project, we can find a straightforward `sendEmail` implementation using [Resend](https://resend.com). +In the [Starter Example](https://github.com/dev-xo/totp-starter-example/blob/main/app/modules/email/email.server.ts) project, we can find a straightforward `sendEmail` implementation using [Resend](https://resend.com). ## Session Storage -We'll require to initialize a new Cookie Session Storage to work with. This Session will store user data and everything related to authentication. +We'll require to initialize a new Session Storage to work with. This Session will store user data and everything related to authentication. Create a file called `session.server.ts` wherever you want.
Implement the following code and replace the `secrets` property with a strong string into your `.env` file. +Same applies for Remix or React Router v7. + ```ts // app/modules/auth/session.server.ts -import { createCookieSessionStorage } from '@remix-run/node' +import { createCookieSessionStorage } from '@remix-run/node' // Or 'react-router'. export const sessionStorage = createCookieSessionStorage({ cookie: { @@ -114,6 +115,10 @@ authenticator.use( new TOTPStrategy( { secret: process.env.ENCRYPTION_SECRET || 'NOT_A_STRONG_SECRET', + emailSentRedirect: '/verify', + magicLinkPath: '/verify', + successRedirect: '/dashboard', + failureRedirect: '/verify', sendTOTP: async ({ email, code, magicLink }) => {}, }, async ({ email }) => {}, @@ -122,7 +127,7 @@ authenticator.use( ``` > [!TIP] -> You can specify session duration with `maxAge` in seconds. Default is `undefined`, persisting across browser restarts. +> You can customize the cookie behavior by passing `cookieOptions` to the `sessionStorage` instance. Check [Customization](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) to learn more. ### 2: Implementing the Strategy Logic. @@ -132,8 +137,7 @@ The Strategy Instance requires the following method: `sendTOTP`. authenticator.use( new TOTPStrategy( { - secret: process.env.ENCRYPTION_SECRET, - + ... sendTOTP: async ({ email, code, magicLink }) => { // Send the TOTP code to the user. await sendEmail({ email, code, magicLink }) @@ -146,7 +150,7 @@ authenticator.use( ### 3. Creating and Storing the User. -The Strategy returns a `verify` method that allows handling our own logic. This includes creating the user, updating the user, etc.
+The Strategy returns a `verify` method that allows handling our own logic. This includes creating the user, updating the session, etc.
This should return the user data that will be stored in Session. @@ -154,22 +158,35 @@ This should return the user data that will be stored in Session. authenticator.use( new TOTPStrategy( { - // createTOTP: async (data) => {}, - // ... + ... + sendTOTP: async ({ email, code, magicLink }) => {} }, async ({ email }) => { // Get user from database. let user = await db.user.findFirst({ where: { email }, }) + // Create a new user if it doesn't exist. if (!user) { user = await db.user.create({ data: { email }, }) } - // Return user as Session. - return user + + // Store user in session. + const session = await getSession(request.headers.get("Cookie")); + session.set("user", user); + + // Commit session. + const sessionCookie = await commitSession(session); + + // Redirect to your authenticated route. + throw redirect("/dashboard", { + headers: { + "Set-Cookie": sessionCookie, + }, + }); }, ), ) @@ -183,56 +200,62 @@ Last but not least, we'll require to create the routes that will handle the auth ```tsx // app/routes/login.tsx -import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node' -import { json } from '@remix-run/node' -import { Form, useLoaderData } from '@remix-run/react' -import { authenticator } from '~/modules/auth/auth.server' -import { getSession, commitSession } from '~/modules/auth/session.server' - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: '/account', - }) +import { redirect } from 'react-router' +import { useFetcher } from 'react-router' +import { getSession } from '~/lib/session.server' +import { authenticator } from '~/lib/auth.server' + +export async function loader({ request }: Route.LoaderArgs) { + // Check for existing session. const session = await getSession(request.headers.get('Cookie')) - const authError = session.get(authenticator.sessionErrorKey) + const user = session.get('user') - // Commit session to clear any `flash` error message. - return json( - { authError }, - { - headers: { - 'set-cookie': await commitSession(session), - }, - }, - ) -} + // If the user is already authenticated, redirect to dashboard. + if (user) return redirect('/dashboard') -export async function action({ request }: ActionFunctionArgs) { - await authenticator.authenticate('TOTP', request, { - // The `successRedirect` route will be used to verify the OTP code. - // This could be the current pathname or any other route that renders the verification form. - successRedirect: '/verify', + return null +} - // The `failureRedirect` route will be used to render any possible error. - // This could be the current pathname or any other route that renders the login form. - failureRedirect: '/login', - }) +export async function action({ request }: Route.ActionArgs) { + try { + // Authenticate the user via TOTP (Form submission). + return await authenticator.authenticate('TOTP', request) + } catch (error) { + console.log('error', error) + + // The error from TOTP includes the redirect Response with the cookie. + if (error instanceof Response) { + return error + } + + // For other errors, return with error message. + return { + error: 'An error occurred during login. Please try again.', + } + } } export default function Login() { - let { authError } = useLoaderData() + const fetcher = useFetcher() + const isSubmitting = fetcher.state !== 'idle' || fetcher.formData != null + const errors = fetcher.data?.error return (
- {/* Login Form. */} -
- - + {/* Form. */} + + - +
- {/* Login Errors Handling. */} - {authError?.message} + {/* Errors Handling. */} + {errors &&

{errors}

}
) } @@ -242,82 +265,136 @@ export default function Login() { ```tsx // app/routes/verify.tsx -import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node' -import { json, redirect } from '@remix-run/node' -import { Form, useLoaderData } from '@remix-run/react' -import { authenticator } from '~/modules/auth/auth.server.ts' -import { getSession, commitSession } from '~/modules/auth/auth-session.server.ts' - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: '/account', - }) +import { redirect, useLoaderData } from 'react-router' +import { Cookie } from '@mjackson/headers' +import { Link, useFetcher } from 'react-router' +import { useState } from 'react' +import { getSession } from '~/lib/session.server' +import { authenticator } from '~/lib/auth.server' + +/** + * Loader function that checks if the user is already authenticated. + * - If the user is already authenticated, redirect to dashboard. + * - If the user is not authenticated, check if the intent is to verify via magic-link URL. + */ +export async function loader({ request }: Route.LoaderArgs) { + // Check for existing session. + const session = await getSession(request.headers.get('Cookie')) + const user = session.get('user') - const session = await getSession(request.headers.get('cookie')) - const authEmail = session.get('auth:email') - const authError = session.get(authenticator.sessionErrorKey) - if (!authEmail) return redirect('/login') + // If the user is already authenticated, redirect to dashboard. + if (user) return redirect('/dashboard') - // Commit session to clear any `flash` error message. - return json( - { authError }, - { - headers: { - 'set-cookie': await commitSession(session), - }, - }, - ) -} + // Get the TOTP cookie and the token from the URL. + const cookie = new Cookie(request.headers.get('Cookie') || '') + const totpCookie = cookie.get('_totp') -export async function action({ request }: ActionFunctionArgs) { const url = new URL(request.url) - const currentPath = url.pathname + const token = url.searchParams.get('t') + + // Authenticate the user via magic-link URL. + if (token) { + try { + return await authenticator.authenticate('TOTP', request) + } catch (error) { + if (error instanceof Response) return error + if (error instanceof Error) return { error: error.message } + return { error: 'Invalid TOTP' } + } + } + + // Get the email from the TOTP cookie. + let email = null + if (totpCookie) { + const params = new URLSearchParams(totpCookie) + email = params.get('email') + } + + // If no email is found, redirect to login. + if (!email) return redirect('/auth/login') + + return { email } +} - await authenticator.authenticate('TOTP', request, { - successRedirect: currentPath, - failureRedirect: currentPath, - }) +/** + * Action function that handles the TOTP verification form submission. + * - Authenticates the user via TOTP (Form submission). + */ +export async function action({ request }: Route.ActionArgs) { + try { + // Authenticate the user via TOTP (Form submission). + return await authenticator.authenticate('TOTP', request) + } catch (error) { + if (error instanceof Response) { + const cookie = new Cookie(error.headers.get('Set-Cookie') || '') + const totpCookie = cookie.get('_totp') + if (totpCookie) { + const params = new URLSearchParams(totpCookie) + return { error: params.get('error') } + } + + throw error + } + return { error: 'Invalid TOTP' } + } } export default function Verify() { - const { authError } = useLoaderData() + const loaderData = useLoaderData() + + const [value, setValue] = useState('') + const fetcher = useFetcher() + const isSubmitting = fetcher.state !== 'idle' || fetcher.formData != null + + const code = 'code' in loaderData ? loaderData.code : undefined + const email = 'email' in loaderData ? loaderData.email : undefined + const error = 'error' in loaderData ? loaderData.error : null + const errors = fetcher.data?.error || error return (
{/* Code Verification Form */} -
- - + + setValue(e.target.value)} + disabled={isSubmitting} + placeholder="Enter the 6-digit code" + /> - +
{/* Renders the form that requests a new code. */} {/* Email input is not required, it's already stored in Session. */} -
+ - +
{/* Errors Handling. */} - {authError?.message} + {errors &&

{errors}

}
) } ``` -### `account.tsx` +### `dashboard.tsx` ```tsx -// app/routes/account.tsx -import type { LoaderFunctionArgs } from '@remix-run/node' -import { json } from '@remix-run/node' -import { Form, useLoaderData } from '@remix-run/react' -import { authenticator } from '~/modules/auth/auth.server' - -export async function loader({ request }: LoaderFunctionArgs) { - const user = await authenticator.isAuthenticated(request, { - failureRedirect: '/', - }) - return json({ user }) +// app/routes/dashboard.tsx +import { Link } from 'react-router' +import { getSession } from '../lib/session.server' +import { redirect } from 'react-router' +import { useLoaderData } from 'react-router' + +export async function loader({ request }: Route.LoaderArgs) { + const session = await getSession(request.headers.get('Cookie')) + const user = session.get('user') + + if (!user) return redirect('/auth/login') + console.log('Dashboard user', user) + + return { user } } export default function Account() { @@ -326,44 +403,35 @@ export default function Account() { return (

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

-
- -
+ + {/* Log out */} + Log out
) } ``` -### `magic-link.tsx` - -```tsx -// app/routes/magic-link.tsx -import type { LoaderFunctionArgs } from '@remix-run/node' -import { authenticator } from '~/modules/auth/auth.server' - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.authenticate('TOTP', request, { - successRedirect: '/account', - failureRedirect: '/login', - }) -} -``` - ### `logout.tsx` ```tsx // app/routes/logout.tsx -import type { ActionFunctionArgs } from '@remix-run/node' -import { authenticator } from '~/modules/auth/auth.server' +import { sessionStorage } from '~/lib/session.server' +import { redirect } from 'react-router' + +export async function loader({ request }: Route.LoaderArgs) { + // Get the session. + const session = await sessionStorage.getSession(request.headers.get('Cookie')) -export async function action({ request }: ActionFunctionArgs) { - return await authenticator.logout(request, { - redirectTo: '/', + // Destroy the session and redirect to login. + return redirect('/auth/login', { + headers: { + 'Set-Cookie': await sessionStorage.destroySession(session), + }, }) } ``` -Done! 🎉 Feel free to check the [Starter Example](https://github.com/dev-xo/totp-starter-example) for a detailed implementation. +Done! 🎉 Feel free to check the [Starter Example for React Router v7](https://github.com/dev-xo/remix-auth-totp-v4-starter) for a detailed implementation. ## [Options and Customization](https://github.com/dev-xo/remix-auth-totp/blob/main/docs/customization.md) diff --git a/docs/customization.md b/docs/customization.md index 2d6c064..b856f0b 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -2,25 +2,6 @@ The Strategy includes a few options that can be customized. -## Passing a pre-read FormData Object - -Because you may want to do validations or read values from the FormData before calling `authenticate`, `remix-auth-totp` allows you to pass a FormData object as part of the optional context. - -```ts -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData() - await authenticator.authenticate(type, request, { - successRedirect: formData.get('redirectTo'), - failureRedirect: '/login', - context: { formData }, // Pass pre-read formData. - }) -} -``` - -This way, you don't need to clone the request yourself. - -See https://github.com/sergiodxa/remix-auth-form?tab=readme-ov-file#passing-a-pre-read-formdata-object - ## Email Validation The email validation will match by default against a basic RegEx email pattern. @@ -46,36 +27,43 @@ The TOTP generation can customized by passing an object called `totpGeneration` ```ts export interface TOTPGenerationOptions { /** - * The secret used to generate the OTP. + * The secret used to generate the TOTP. * It should be Base32 encoded (Feel free to use: https://npm.im/thirty-two). - * @default Random Base32 secret. + * + * Defaults to a random Base32 secret. + * @default random */ secret?: string + /** - * The algorithm used to generate the OTP. + * The algorithm used to generate the TOTP. * @default 'SHA1' */ algorithm?: string + /** - * The character set used to generate the OTP. + * The character set used to generate the TOTP. * @default 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' */ charSet?: string + /** - * The number of digits the OTP will have. + * The number of digits used to generate the TOTP. * @default 6 */ digits?: number + /** - * The number of seconds the OTP will be valid. + * The number of seconds the TOTP will be valid. * @default 60 */ period?: number + /** - * The number of attempts the user has to verify the OTP. + * The max number of attempts the user can try to verify the TOTP. * @default 3 */ - maxAttempts: number + maxAttempts?: number } authenticator.use( @@ -99,22 +87,36 @@ export interface CustomErrorsOptions { * The required email error message. */ requiredEmail?: string + /** * The invalid email error message. */ invalidEmail?: string + /** * The invalid TOTP error message. */ invalidTotp?: string + + /** + * The rate limit exceeded error message. + */ + rateLimitExceeded?: string + /** * The expired TOTP error message. */ expiredTotp?: string + /** * The missing session email error message. */ missingSessionEmail?: string + + /** + * The missing session totp error message. + */ + missingSessionTotp?: string } authenticator.use( @@ -126,46 +128,76 @@ authenticator.use( ) ``` -## More Options +## Strategy Options The Strategy includes a few more options that can be customized. ```ts -export interface TOTPStrategyOptions { +export interface TOTPStrategyOptions { /** - * The secret used to encrypt the session. + * The secret used to encrypt the TOTP data. + * Must be string of 64 hexadecimal characters. */ secret: string + /** - * The maximum age of the session in seconds. + * The optional cookie options. * @default undefined */ - maxAge?: number + cookieOptions?: Omit + + /** + * The TOTP generation configuration. + */ + totpGeneration?: TOTPGenerationOptions + + /** + * The URL path for the Magic Link. + * @default '/magic-link' + */ + magicLinkPath?: string + + /** + * The custom errors configuration. + */ + customErrors?: CustomErrorsOptions + /** * The form input name used to get the email address. * @default "email" */ emailFieldKey?: string + /** * The form input name used to get the TOTP. * @default "code" */ codeFieldKey?: string + /** - * The session key that stores the email address. - * @default "auth:email" + * The send TOTP method. */ - sessionEmailKey?: string + sendTOTP: SendTOTP + /** - * The session key that stores the TOTP data. - * @default "auth:totp" + * The validate email method. */ - sessionTotpKey?: string + validateEmail?: ValidateEmail + /** - * The URL path for the Magic Link. - * @default '/magic-link' + * The redirect URL thrown after sending email. */ - magicLinkPath?: string + emailSentRedirect: string + + /** + * The redirect URL thrown after verification success. + */ + successRedirect: string + + /** + * The redirect URL thrown after verification failure. + */ + failureRedirect: string } ``` diff --git a/docs/examples.md b/docs/examples.md index 6adc64f..a49e459 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,6 +2,15 @@ A list of community examples using Remix Auth TOTP. +## v4.0 Examples + +- [React Router v7 - Starter](https://github.com/dev-xo/remix-auth-totp-v4-starter) by [@dev-xo](https://github.com/dev-xo): A straightforward database-less example App. It can also serve as a foundation for your Remix App or other projects. +- [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. + +## v3.0 Examples + +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. + - [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 own projects or other examples. - [Remix Auth TOTP + Remix Flat Routes](https://github.com/dev-xo/totp-flat-routes-example) by [@dev-xo](https://github.com/dev-xo): Remix Auth TOTP + Remix Flat Routes example App. - [Remix Auth TOTP + Conform](https://github.com/dev-xo/totp-conform-example) by [@dev-xo](https://github.com/dev-xo): Remix Auth TOTP + Conform example App. diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index ae23370..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,31 +0,0 @@ -## Migration - -This document aims to assist you in migrating your `remix-auth-totp` implementation from `v2` to `v3`. - -### Database - -Remove `Totp` model from database if one exists. - -### Implement `remix-auth-totp` API - -- Remove `createTOTP`, `readTOTP` and `updateTOTP` from `TOTPStrategy` options. -- Change `form` to `formData` if you are using it in `sendTOTP` and `verify` functions. -- Remove deprecated parameters from `verify` function. - -```ts -authenticator.use( - new TOTPStrategy( - { - secret: process.env.ENCRYPTION_SECRET, - - // ❗`createTOTP`, `readTOTP` and `updateTOTP` are no longer needed (removed). - - // Change `form` to `formData` if you are using it. - sendTOTP: async ({ email, formData }) => {}, - }, - // Remove deprecated parameters. - // Change `form` to `formData` if you are using it. - async ({ email, formData, request }) => {}, - ), -) -``` diff --git a/package.json b/package.json index 50ce6ad..6c7834e 100644 --- a/package.json +++ b/package.json @@ -39,15 +39,14 @@ ], "dependencies": { "@epic-web/totp": "^2.0.0", + "@mjackson/headers": "^0.8.0", "base32-encode": "^2.0.0", "jose": "^5.8.0" }, "peerDependencies": { - "remix-auth": "^3.6.0" + "remix-auth": "^4.1.0" }, "devDependencies": { - "@remix-run/node": "^2.11.2", - "@remix-run/server-runtime": "^2.11.2", "@types/node": "^20.16.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -57,7 +56,6 @@ "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.3", "prettier": "^2.8.8", - "tiny-invariant": "^1.3.3", "typescript": "^5.5.4", "vite": "^4.5.3", "vite-tsconfig-paths": "^4.3.2", diff --git a/src/constants.ts b/src/constants.ts index 55991a9..4ffaf2e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,17 +12,23 @@ export const SESSION_KEYS = { export const ERRORS = { // Customizable errors. - REQUIRED_EMAIL: 'Email is required.', - INVALID_EMAIL: 'Email is not valid.', - INVALID_TOTP: 'Code is not valid.', - EXPIRED_TOTP: 'Code has expired.', + REQUIRED_EMAIL: 'Please enter your email address to continue.', + INVALID_EMAIL: + "That doesn't look like a valid email address. Please check and try again.", + INVALID_TOTP: + "That code didn't work. Please check and try again, or request a new code.", + EXPIRED_TOTP: 'That code has expired. Please request a new one.', MISSING_SESSION_EMAIL: - 'Missing email to verify. Check that same browser used for verification.', + "We couldn't find an email to verify. Please use the same browser you started with or restart from this browser.", + MISSING_SESSION_TOTP: + "We couldn't find an active verification session. Please request a new code.", + RATE_LIMIT_EXCEEDED: "Too many incorrect attempts. Please request a new code.", // Miscellaneous errors. REQUIRED_ENV_SECRET: 'Missing required .env secret.', USER_NOT_FOUND: 'User not found.', INVALID_MAGIC_LINK_PATH: 'Invalid magic-link expected path.', + REQUIRED_EMAIL_SENT_REDIRECT_URL: 'Missing required emailSentRedirect URL.', REQUIRED_SUCCESS_REDIRECT_URL: 'Missing required successRedirect URL.', REQUIRED_FAILURE_REDIRECT_URL: 'Missing required failureRedirect URL.', } as const diff --git a/src/index.ts b/src/index.ts index 6faa1f9..fffca0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,47 @@ -import type { Session, SessionStorage, AppLoadContext } from '@remix-run/server-runtime' -import type { AuthenticateOptions, StrategyVerifyCallback } from 'remix-auth' - -import { redirect } from '@remix-run/server-runtime' -import { Strategy } from 'remix-auth' +import { Strategy } from 'remix-auth/strategy' import { generateTOTP, verifyTOTP } from '@epic-web/totp' +import { Cookie, SetCookie, type SetCookieInit } from '@mjackson/headers' import * as jose from 'jose' +import { redirect } from './utils.js' import { generateSecret, - generateMagicLink, coerceToOptionalString, coerceToOptionalTotpSessionData, coerceToOptionalNonEmptyString, - assertIsRequiredAuthenticateOptions, - RequiredAuthenticateOptions, assertTOTPData, asJweKey, } from './utils.js' -import { STRATEGY_NAME, FORM_FIELDS, SESSION_KEYS, ERRORS } from './constants.js' +import { STRATEGY_NAME, FORM_FIELDS, ERRORS } from './constants.js' /** - * The TOTP data stored in the session. + * The TOTP JWE data containing the secret. */ -export interface TOTPSessionData { +export interface TOTPData { /** - * The TOTP JWE of TOTPData. + * The TOTP secret. */ - jwe: string + secret: string /** - * The number of attempts the user tried to verify the TOTP. - * @default 0 + * The time the TOTP was generated. */ - attempts: number + createdAt: number } /** - * The TOTP JWE data containing the secret. + * The TOTP data stored in the cookie. */ -export interface TOTPData { +export interface TOTPCookieData { /** - * The TOTP secret. + * The TOTP JWE of TOTPData. */ - secret: string + jwe: string /** - * The time the TOTP was generated. + * The number of attempts the user tried to verify the TOTP. + * @default 0 */ - createdAt: number + attempts: number } /** @@ -57,7 +52,8 @@ export interface TOTPGenerationOptions { * The secret used to generate the TOTP. * It should be Base32 encoded (Feel free to use: https://npm.im/thirty-two). * - * @default random Base32 secret. + * Defaults to a random Base32 secret. + * @default random */ secret?: string @@ -120,18 +116,11 @@ export interface SendTOTPOptions { * The form data of the request. */ formData: FormData - - /** - * The context object received by the loader or action. - * Defaults to undefined. - * Explicitly include it in the options to authenticate if you need it. - */ - context?: AppLoadContext } /** * The sender email method. - * @param options The send TOTP options. + * @param options The SendTOTPOptions options. */ export interface SendTOTP { (options: SendTOTPOptions): Promise @@ -139,7 +128,7 @@ export interface SendTOTP { /** * The validate email method. - * This can be useful to ensure it's not a disposable email address. + * Useful to ensure it's not a disposable email address. * * @param email The email address to validate. */ @@ -166,6 +155,11 @@ export interface CustomErrorsOptions { */ invalidTotp?: string + /** + * The rate limit exceeded error message. + */ + rateLimitExceeded?: string + /** * The expired TOTP error message. */ @@ -175,6 +169,11 @@ export interface CustomErrorsOptions { * The missing session email error message. */ missingSessionEmail?: string + + /** + * The missing session totp error message. + */ + missingSessionTotp?: string } /** @@ -188,10 +187,10 @@ export interface TOTPStrategyOptions { secret: string /** - * The maximum age the session can live. + * The optional cookie options. * @default undefined */ - maxAge?: number + cookieOptions?: Omit /** * The TOTP generation configuration. @@ -222,31 +221,34 @@ export interface TOTPStrategyOptions { codeFieldKey?: string /** - * The session key that stores the email address. - * @default "auth:email" + * The send TOTP method. */ - sessionEmailKey?: string + sendTOTP: SendTOTP /** - * The session key that stores the signed TOTP. - * @default "auth:totp" + * The validate email method. */ - sessionTotpKey?: string + validateEmail?: ValidateEmail /** - * The send TOTP method. + * The redirect URL thrown after sending email. */ - sendTOTP: SendTOTP + emailSentRedirect: string /** - * The validate email method. + * The redirect URL thrown after verification success. */ - validateEmail?: ValidateEmail + successRedirect: string + + /** + * The redirect URL thrown after verification failure. + */ + failureRedirect: string } /** * The verify method callback. - * Returns the user for the email to be stored in the session. + * Returns the email user to be stored in the session. */ export interface TOTPVerifyParams { /** @@ -263,33 +265,180 @@ export interface TOTPVerifyParams { * The Request object. */ request: Request +} + +/** + * The magic link parameters. + */ +interface MagicLinkParams { + /** + * The TOTP code. + */ + code: string + + /** + * The TOTP expiry date. + */ + expires: number +} + +/** + * A store class that manages TOTP-related state in a cookie. + * Handles email, TOTP session data, and error messages. + */ +class TOTPStore { + private email?: string + private totp?: TOTPCookieData + private error?: { message: string } + + /** The name of the cookie used to store TOTP data. */ + static COOKIE_NAME = '_totp' + + /** + * Creates a new TOTPStore instance. + * @param cookie - The Cookie instance used to manage cookie data. + */ + constructor(private cookie: Cookie) { + const raw = this.cookie.get(TOTPStore.COOKIE_NAME) + if (raw) { + const params = new URLSearchParams(raw) + this.email = params.get('email') || undefined + + const totpRaw = params.get('totp') + if (totpRaw) { + try { + this.totp = JSON.parse(totpRaw) + } catch { + // Silently handle invalid JSON in the TOTP data. + } + } + + const err = params.get('error') + if (err) { + this.error = { message: err } + } + } + } + + /** + * Creates a TOTPStore instance from a Request object. + * @param request - The incoming request object. + * @returns A new TOTPStore instance. + */ + static fromRequest(request: Request): TOTPStore { + return new TOTPStore(new Cookie(request.headers.get('cookie') ?? '')) + } /** - * The context object received by the loader or action. - * Defaults to undefined. - * Explicitly include it in the options to authenticate if you need it. + * Gets the stored email address. + * @returns The email address or undefined if not set. */ - context?: AppLoadContext + getEmail(): string | undefined { + return this.email + } + + /** + * Gets the stored TOTP session data. + * @returns The TOTP session data or undefined if not set. + */ + getTOTP(): TOTPCookieData | undefined { + return this.totp + } + + /** + * Gets the stored error message. + * @returns The error object or undefined if no error exists. + */ + getError(): { message: string } | undefined { + return this.error + } + + /** + * Sets the email address in the store. + * @param email - The email address to store or undefined to clear it. + */ + setEmail(email: string | undefined): void { + this.email = email + } + + /** + * Sets the TOTP session data in the store. + * @param totp - The TOTP session data to store or undefined to clear it. + */ + setTOTP(totp: TOTPCookieData | undefined): void { + this.totp = totp + } + + /** + * Sets an error message in the store. + * @param message - The error message to store or undefined to clear it. + */ + setError(message: string | undefined): void { + if (message) { + this.error = { message } + } else { + this.error = undefined + } + } + + /** + * Commits the current store state to a cookie string. + * + * @param options - Optional SetCookie configuration options. + * @returns A string representation of the cookie with its current values. + */ + commit(options: Omit = {}): string { + const params = new URLSearchParams() + + if (this.email) { + params.set('email', this.email) + } + + if (this.totp) { + params.set('totp', JSON.stringify(this.totp)) + } + + if (this.error) { + params.set('error', this.error.message) + } + + const setCookie = new SetCookie({ + name: TOTPStore.COOKIE_NAME, + value: params.toString(), + httpOnly: true, + path: '/', + sameSite: 'Lax', + maxAge: options.maxAge || 60 * 5, // 5 minutes in seconds. + // `secure` may be passed in options, depending on environment. + ...options, + }) + + return setCookie.toString() + } } +/** + * The TOTP Strategy. + */ export class TOTPStrategy extends Strategy { public name = STRATEGY_NAME private readonly secret: string - private readonly maxAge: number | undefined + private readonly cookieOptions: Omit | undefined private readonly totpGeneration: Pick & Required> private readonly magicLinkPath: string private readonly customErrors: Required private readonly emailFieldKey: string private readonly codeFieldKey: string - private readonly sessionEmailKey: string - private readonly sessionTotpKey: string private readonly sendTOTP: SendTOTP private readonly validateEmail: ValidateEmail + private _emailSentRedirect: string + private _successRedirect: string + private _failureRedirect: string private readonly _totpGenerationDefaults = { - algorithm: 'SHA-256', // More secure than SHA1 - charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // No O or 0 + algorithm: 'SHA-256', + charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // Does not include O or 0. digits: 6, period: 60, maxAttempts: 3, @@ -299,24 +448,26 @@ export class TOTPStrategy extends Strategy { invalidEmail: ERRORS.INVALID_EMAIL, invalidTotp: ERRORS.INVALID_TOTP, expiredTotp: ERRORS.EXPIRED_TOTP, + rateLimitExceeded: ERRORS.RATE_LIMIT_EXCEEDED, missingSessionEmail: ERRORS.MISSING_SESSION_EMAIL, + missingSessionTotp: ERRORS.MISSING_SESSION_TOTP, } constructor( options: TOTPStrategyOptions, - verify: StrategyVerifyCallback, + verify: Strategy.VerifyFunction, ) { super(verify) this.secret = options.secret - this.maxAge = options.maxAge + this.cookieOptions = options.cookieOptions || {} this.magicLinkPath = options.magicLinkPath ?? '/magic-link' this.emailFieldKey = options.emailFieldKey ?? FORM_FIELDS.EMAIL this.codeFieldKey = options.codeFieldKey ?? FORM_FIELDS.CODE - this.sessionEmailKey = options.sessionEmailKey ?? SESSION_KEYS.EMAIL - this.sessionTotpKey = options.sessionTotpKey ?? SESSION_KEYS.TOTP this.sendTOTP = options.sendTOTP this.validateEmail = options.validateEmail ?? this._validateEmailDefault - + this._emailSentRedirect = options.emailSentRedirect + this._successRedirect = options.successRedirect + this._failureRedirect = options.failureRedirect this.totpGeneration = { ...this._totpGenerationDefaults, ...options.totpGeneration, @@ -327,113 +478,263 @@ export class TOTPStrategy extends Strategy { } } + /** Gets the email sent redirect URL. */ + get emailSentRedirect(): string { + return this._emailSentRedirect + } + + /** Sets the email sent redirect URL. */ + set emailSentRedirect(url: string) { + if (!url) { + throw new Error(ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL) + } + this._emailSentRedirect = url + } + + /** Gets the success redirect URL. */ + get successRedirect(): string { + return this._successRedirect + } + + /** Sets the success redirect URL. */ + set successRedirect(url: string) { + if (!url) { + throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) + } + this._successRedirect = url + } + + /** Gets the failure redirect URL. */ + get failureRedirect(): string { + return this._failureRedirect + } + + /** Sets the failure redirect URL. */ + set failureRedirect(url: string) { + if (!url) { + throw new Error(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) + } + this._failureRedirect = url + } + /** * Authenticates a user using TOTP. - * * If the user is already authenticated, simply returns the user. * * | Method | Email | Code | Sess. Email | Sess. TOTP | Action/Logic | * |--------|-------|------|-------------|------------|------------------------------------------| - * | POST | ✓ | - | - | - | Generate/send TOTP using form email. | - * | POST | ✗ | ✗ | ✓ | - | Generate/send TOTP using session email. | + * | POST | ✓ | - | - | - | Generate/Send TOTP using form email. | + * | POST | ✗ | ✗ | ✓ | - | Generate/Send TOTP using session email. | * | POST | ✗ | ✓ | ✓ | ✓ | Validate form TOTP code. | - * | GET | - | - | ✓ | ✓ | Validate magic link TOTP. | + * | GET | - | - | ✓ | ✓ | Validate magic-link TOTP. | * * @param {Request} request - The request object. - * @param {SessionStorage} sessionStorage - The session storage instance. - * @param {AuthenticateOptions} options - The authentication options. successRedirect is required. * @returns {Promise} The authenticated user. */ - async authenticate( - request: Request, - sessionStorage: SessionStorage, - options: AuthenticateOptions, - ): Promise { + async authenticate(request: Request): Promise { if (!this.secret) throw new Error(ERRORS.REQUIRED_ENV_SECRET) - assertIsRequiredAuthenticateOptions(options) + if (!this._emailSentRedirect) throw new Error(ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL) + if (!this._successRedirect) throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) + if (!this._failureRedirect) throw new Error(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) - const session = await sessionStorage.getSession(request.headers.get('cookie')) - const user: User | null = session.get(options.sessionKey) ?? null - if (user) return this.success(user, request, sessionStorage, options) + // Retrieve the TOTP store from request. + const store = TOTPStore.fromRequest(request) - const formData = await this._readFormData(request, options) + const formData = await this._readFormData(request) const formDataEmail = coerceToOptionalNonEmptyString(formData.get(this.emailFieldKey)) const formDataCode = coerceToOptionalNonEmptyString(formData.get(this.codeFieldKey)) - const sessionEmail = coerceToOptionalString(session.get(this.sessionEmailKey)) - const sessionTotp = coerceToOptionalTotpSessionData(session.get(this.sessionTotpKey)) - const email = - request.method === 'POST' - ? formDataEmail ?? (!formDataCode ? sessionEmail : null) - : null + const sessionEmail = coerceToOptionalString(store.getEmail()) + const sessionTotp = coerceToOptionalTotpSessionData(store.getTOTP()) + + let email = null + + if (request.method === 'POST') { + if (formDataEmail) { + email = formDataEmail + } else if (sessionEmail && !formDataCode) { + email = sessionEmail + } + } try { if (email) { + // Generate the TOTP. const { code, jwe, magicLink } = await this._generateTOTP({ email, request }) + + // Send the TOTP to the user. await this.sendTOTP({ email, code, magicLink, formData, request, - context: options.context, }) - const totpData: TOTPSessionData = { jwe, attempts: 0 } - session.set(this.sessionEmailKey, email) - session.set(this.sessionTotpKey, totpData) - session.unset(options.sessionErrorKey) + // Set the TOTP data in the store. + const totpData: TOTPCookieData = { jwe, attempts: 0 } + store.setEmail(email) + store.setTOTP(totpData) + store.setError(undefined) - throw redirect(options.successRedirect, { + // Redirect to the email sent URL. + throw redirect(this._emailSentRedirect, { headers: { - 'set-cookie': await sessionStorage.commitSession(session, { - maxAge: this.maxAge, - }), + 'Set-Cookie': store.commit(this.cookieOptions), }, }) } - const code = formDataCode ?? this._getMagicLinkCode(request) + // Try to get the TOTP code either from the form data or the magic link. + const { code: linkCode, expires: linkExpires } = await this._getMagicLinkCode( + request, + sessionTotp, + ) + const code = formDataCode ?? linkCode + if (code) { if (!sessionEmail) throw new Error(this.customErrors.missingSessionEmail) - if (!sessionTotp) throw new Error(this.customErrors.expiredTotp) - await this._validateTOTP({ code, sessionTotp, session, sessionStorage, options }) + if (!sessionTotp) throw new Error(this.customErrors.missingSessionTotp) - const user = await this.verify({ - email: sessionEmail, - formData: request.method === 'POST' ? formData : undefined, - request, - context: options.context, - }) + // Validate the TOTP. + await this._validateTOTP({ code, sessionTotp, store, urlExpires: linkExpires }) + + // Clear TOTP data since user verified successfully. + store.setEmail(undefined) + store.setTOTP(undefined) + store.setError(undefined) - session.set(options.sessionKey, user) - session.unset(this.sessionEmailKey) - session.unset(this.sessionTotpKey) - session.unset(options.sessionErrorKey) + // Call the verify method, allowing developers to handle the user. + await this.verify({ email: sessionEmail, formData, request }) - throw redirect(options.successRedirect, { + // Redirect to the success URL. + throw redirect(this._successRedirect, { headers: { - 'set-cookie': await sessionStorage.commitSession(session, { - maxAge: this.maxAge, - }), + 'Set-Cookie': store.commit(this.cookieOptions), }, }) } + + // If no email was provided, throw an error. throw new Error(this.customErrors.requiredEmail) - } catch (throwable) { - if (throwable instanceof Response) throw throwable - if (throwable instanceof Error) { - return await this.failure( - throwable.message, - request, - sessionStorage, - options, - throwable, + } catch (err: unknown) { + if (err instanceof Response) { + const headers = new Headers(err.headers) + headers.append('Set-Cookie', store.commit(this.cookieOptions)) + throw new Response(err.body, { + status: err.status, + headers: headers, + statusText: err.statusText, + }) + } + if (err instanceof Error) { + if ( + err.message === this.customErrors.rateLimitExceeded || + err.message === this.customErrors.expiredTotp + ) { + store.setTOTP(undefined) + } + store.setError(err.message) + throw redirect(this._failureRedirect, { + headers: { + 'Set-Cookie': store.commit(this.cookieOptions), + }, + }) + } + throw err + } + } + + /** + * Reads the form data from the request. + * @param request - The request object. + * @returns The form data. + */ + private async _readFormData(request: Request) { + if (request.method !== 'POST') { + return new FormData() + } + return await request.formData() + } + + /** + * Validates the TOTP. + * @param code - The TOTP code. + * @param sessionTotp - The TOTP session data. + * @param store - The TOTP store. + * @param urlExpires - The TOTP code expiry date in milliseconds. + */ + private async _validateTOTP({ + code, + sessionTotp, + store, + urlExpires, + }: { + code: string + sessionTotp: TOTPCookieData + store: TOTPStore + urlExpires?: number + }) { + try { + // Check if the TOTP is expired from the URL. + if (urlExpires) { + const dateNow = Date.now() + if (dateNow > urlExpires) { + throw new Error(this.customErrors.expiredTotp) + } + } + + // Decrypt the TOTP data from the Cookie. + // https://github.com/panva/jose/blob/main/docs/jwe/compact/decrypt/functions/compactDecrypt.md + const { plaintext } = await jose.compactDecrypt( + sessionTotp.jwe, + asJweKey(this.secret), + ) + const totpData = JSON.parse(new TextDecoder().decode(plaintext)) + assertTOTPData(totpData) + + // Check if the TOTP is expired from the Cookie. + const dateNow = Date.now() + const isExpired = dateNow - totpData.createdAt > this.totpGeneration.period * 1000 + + if (isExpired) { + throw new Error(this.customErrors.expiredTotp) + } + + // Check if the TOTP is valid. + const isValid = await verifyTOTP({ + ...this.totpGeneration, + secret: totpData.secret, + otp: code, + }) + + if (!isValid) { + throw new Error(this.customErrors.invalidTotp) + } + } catch (error) { + if (error instanceof Error && error.message === this.customErrors.expiredTotp) { + store.setTOTP(undefined) + store.setError(this.customErrors.expiredTotp) + } else { + store.setError( + error instanceof Error ? error.message : this.customErrors.invalidTotp, ) } - throw throwable + + // Redirect to the failure URL with the updated store. + throw redirect(this._failureRedirect, { + headers: { + 'Set-Cookie': store.commit(this.cookieOptions), + }, + }) } } + /** + * Generates the TOTP. + * @param email - The email address. + * @param request - The request object. + * @returns The TOTP data. + */ private async _generateTOTP({ email, request }: { email: string; request: Request }) { const isValidEmail = await this.validateEmail(email) if (!isValidEmail) throw new Error(this.customErrors.invalidEmail) @@ -444,99 +745,129 @@ export class TOTPStrategy extends Strategy { }) const totpData: TOTPData = { secret, createdAt: Date.now() } - // https://github.com/panva/jose/blob/main/docs/classes/jwe_compact_encrypt.CompactEncrypt.md const jwe = await new jose.CompactEncrypt( new TextEncoder().encode(JSON.stringify(totpData)), ) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) .encrypt(asJweKey(this.secret)) - const magicLink = generateMagicLink({ + const magicLink = await this._generateMagicLink({ code, request }) + + return { code, - magicLinkPath: this.magicLinkPath, - param: this.codeFieldKey, - request, - }) + jwe, + magicLink, + } + } - return { code, jwe, magicLink } + /** + * Encrypts magic link parameters. + * @param params - The parameters to encrypt. + * @returns The encrypted JWE token. + */ + private async _encryptUrlParams(params: MagicLinkParams): Promise { + const payload = new TextEncoder().encode(JSON.stringify(params)) + return await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(asJweKey(this.secret)) } - private _getMagicLinkCode(request: Request) { - if (request.method === 'GET') { - const url = new URL(request.url) - if (url.pathname !== this.magicLinkPath) { - throw new Error(ERRORS.INVALID_MAGIC_LINK_PATH) + /** + * Decrypts and validates magic link parameters. + * @param encrypted - The encrypted JWE token. + * @returns The decrypted and validated parameters. + */ + private async _decryptUrlParams( + encrypted: string, + sessionTotp?: TOTPCookieData, + ): Promise { + try { + const { plaintext } = await jose.compactDecrypt(encrypted, asJweKey(this.secret)) + const params = JSON.parse(new TextDecoder().decode(plaintext)) + + if (!params?.code || !params?.expires || typeof params.expires !== 'number') { + throw new Error('Invalid magic-link format.') } - if (url.searchParams.has(this.codeFieldKey)) { - return decodeURIComponent(url.searchParams.get(this.codeFieldKey) ?? '') + + return params + } catch (error) { + if (!sessionTotp || sessionTotp.attempts < this.totpGeneration.maxAttempts) { + if (sessionTotp) { + sessionTotp.attempts += 1 + } + throw new Error(this.customErrors.invalidTotp) } - } - return undefined - } - private async _validateEmailDefault(email: string) { - const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/gm - return regexEmail.test(email) + throw new Error(this.customErrors.rateLimitExceeded) + } } - private async _validateTOTP({ + /** + * Generates the magic link. + * @param code - The TOTP code. + * @param request - The request object. + * @returns The magic link. + */ + private async _generateMagicLink({ code, - sessionTotp, - session, - sessionStorage, - options, + request, }: { code: string - sessionTotp: TOTPSessionData - session: Session - sessionStorage: SessionStorage - options: RequiredAuthenticateOptions + request: Request }) { - try { - // https://github.com/panva/jose/blob/main/docs/functions/jwe_compact_decrypt.compactDecrypt.md - const { plaintext } = await jose.compactDecrypt( - sessionTotp.jwe, - asJweKey(this.secret), - ) - const totpData = JSON.parse(new TextDecoder().decode(plaintext)) - assertTOTPData(totpData) + const url = new URL(this.magicLinkPath ?? '/', new URL(request.url).origin) - if (Date.now() - totpData.createdAt > this.totpGeneration.period * 1000) { - throw new Error(this.customErrors.expiredTotp) + const params: MagicLinkParams = { + code, + expires: Date.now() + this.totpGeneration.period * 1000, + } + + const encrypted = await this._encryptUrlParams(params) + url.searchParams.set('t', encrypted) + + return url.toString() + } + + /** + * Gets the magic link code from the request. + * @param request - The request object. + * @returns The magic link code. + */ + private async _getMagicLinkCode( + request: Request, + sessionTotp?: TOTPCookieData, + ): Promise<{ code?: string; expires?: number }> { + if (request.method === 'GET') { + const url = new URL(request.url) + if (url.pathname !== this.magicLinkPath) { + throw new Error(ERRORS.INVALID_MAGIC_LINK_PATH) } - if (!await verifyTOTP({ ...this.totpGeneration, secret: totpData.secret, otp: code })) { - throw new Error(this.customErrors.invalidTotp) + + const token = url.searchParams.get('t') + if (!token) { + return {} } - } catch (error) { - if (error instanceof Error && error.message === this.customErrors.expiredTotp) { - session.unset(this.sessionTotpKey) - session.flash(options.sessionErrorKey, { message: this.customErrors.expiredTotp }) - } else { - sessionTotp.attempts += 1 - if (sessionTotp.attempts >= this.totpGeneration.maxAttempts) { - session.unset(this.sessionTotpKey) - } else { - session.set(this.sessionTotpKey, sessionTotp) + + try { + const params = await this._decryptUrlParams(token, sessionTotp) + return { + code: params.code, + expires: params.expires, } - session.flash(options.sessionErrorKey, { message: this.customErrors.invalidTotp }) + } catch (error) { + throw error } - throw redirect(options.failureRedirect, { - headers: { - 'set-cookie': await sessionStorage.commitSession(session, { - maxAge: this.maxAge, - }), - }, - }) } + return {} } - private async _readFormData(request: Request, options: AuthenticateOptions) { - if (request.method !== 'POST') { - return new FormData() - } - if (options.context?.formData instanceof FormData) { - return options.context.formData - } - return await request.formData() + /** + * Validates the email format. + * @param email - The email address. + * @returns Whether the email is valid. + */ + private async _validateEmailDefault(email: string) { + const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/gm + return regexEmail.test(email) } } diff --git a/src/utils.ts b/src/utils.ts index 0dd5a8f..632a77f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,29 +1,15 @@ -import type { TOTPData, TOTPSessionData } from './index.js' -import type { AuthenticateOptions } from 'remix-auth' -import { ERRORS } from './constants.js' +import type { TOTPData, TOTPCookieData } from './index.js' import base32Encode from 'base32-encode' /** * TOTP Generation. */ export function generateSecret() { - const randomBytes = new Uint8Array(32); - crypto.getRandomValues(randomBytes); + const randomBytes = new Uint8Array(32) + crypto.getRandomValues(randomBytes) return base32Encode(randomBytes, 'RFC4648').toString() as string } -export function generateMagicLink(options: { - code: string - magicLinkPath: string - param: string - request: Request -}) { - const url = new URL(options.magicLinkPath ?? '/', new URL(options.request.url).origin) - url.searchParams.set(options.param, options.code) - - return url.toString() -} - // https://github.com/sindresorhus/uint8array-extras/blob/main/index.js#L222 const hexToDecimalLookupTable = { 0: 0, @@ -48,27 +34,51 @@ const hexToDecimalLookupTable = { D: 13, E: 14, F: 15, -}; +} function hexToUint8Array(hexString: string) { if (hexString.length % 2 !== 0) { - throw new Error('Invalid Hex string length.'); + throw new Error('Invalid Hex string length.') } - const resultLength = hexString.length / 2; - const bytes = new Uint8Array(resultLength); + const resultLength = hexString.length / 2 + const bytes = new Uint8Array(resultLength) for (let index = 0; index < resultLength; index++) { - const highNibble = hexToDecimalLookupTable[hexString[index * 2] as keyof typeof hexToDecimalLookupTable]; - const lowNibble = hexToDecimalLookupTable[hexString[(index * 2) + 1] as keyof typeof hexToDecimalLookupTable]; + const highNibble = + hexToDecimalLookupTable[ + hexString[index * 2] as keyof typeof hexToDecimalLookupTable + ] + const lowNibble = + hexToDecimalLookupTable[ + hexString[index * 2 + 1] as keyof typeof hexToDecimalLookupTable + ] if (highNibble === undefined || lowNibble === undefined) { - throw new Error(`Invalid Hex character encountered at position ${index * 2}`); + throw new Error(`Invalid Hex character encountered at position ${index * 2}`) } - bytes[index] = (highNibble << 4) | lowNibble; + bytes[index] = (highNibble << 4) | lowNibble + } + + return bytes +} + +/** + * Redirect. + */ +export function redirect(url: string, init: ResponseInit | number = 302) { + let responseInit = init + + if (typeof responseInit === 'number') { + responseInit = { status: responseInit } + } else if (typeof responseInit.status === 'undefined') { + responseInit.status = 302 } - return bytes; + const headers = new Headers(responseInit.headers) + headers.set('Location', url) + + return new Response(null, { ...responseInit, headers }) } /** @@ -102,7 +112,7 @@ export function coerceToOptionalTotpSessionData(value: unknown) { 'attempts' in value && typeof (value as { attempts: unknown }).attempts === 'number' ) { - return value as TOTPSessionData + return value as TOTPCookieData } return undefined } @@ -118,20 +128,4 @@ export function assertTOTPData(obj: unknown): asserts obj is TOTPData { ) { throw new Error('Invalid totp data.') } -} - -export type RequiredAuthenticateOptions = Required< - Pick -> & - Omit - -export function assertIsRequiredAuthenticateOptions( - options: AuthenticateOptions, -): asserts options is RequiredAuthenticateOptions { - if (options.successRedirect === undefined) { - throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) - } - if (options.failureRedirect === undefined) { - throw new Error(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) - } -} +} \ No newline at end of file diff --git a/test/index.spec.ts b/test/index.spec.ts index e59fa62..3c3dcfa 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,20 +1,15 @@ -import type { AppLoadContext, Session } from '@remix-run/server-runtime' -import type { SendTOTPOptions, TOTPStrategyOptions, TOTPVerifyParams } from '../src/index' - +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { SendTOTPOptions, TOTPStrategyOptions } from '../src/index' import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' -import invariant from 'tiny-invariant' - +import { Cookie, SetCookie } from '@mjackson/headers' import { TOTPStrategy } from '../src/index' -import { asJweKey, generateMagicLink } from '../src/utils' -import { STRATEGY_NAME, FORM_FIELDS, SESSION_KEYS, ERRORS } from '../src/constants' - +import { asJweKey } from '../src/utils' +import { STRATEGY_NAME, FORM_FIELDS, ERRORS } from '../src/constants' import { SECRET_ENV, HOST_URL, - AUTH_OPTIONS, TOTP_GENERATION_DEFAULTS, DEFAULT_EMAIL, - sessionStorage, MAGIC_LINK_PATH, } from './utils' @@ -24,7 +19,10 @@ import { const verify = vi.fn() const sendTOTP = vi.fn() -const TOTP_STRATEGY_OPTIONS: TOTPStrategyOptions = { +const BASE_STRATEGY_OPTIONS: Omit< + TOTPStrategyOptions, + 'successRedirect' | 'failureRedirect' | 'emailSentRedirect' +> = { secret: SECRET_ENV, sendTOTP, magicLinkPath: MAGIC_LINK_PATH, @@ -41,179 +39,89 @@ afterEach(() => { describe('[ Basics ]', () => { test('Should contain the name of the Strategy.', async () => { - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + verify, + ) expect(strategy.name).toBe(STRATEGY_NAME) }) test('Should throw an Error on missing required secret option.', async () => { const strategy = new TOTPStrategy( // @ts-expect-error - Error is expected since missing secret option. - { sendTOTP }, + { + sendTOTP, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, verify, ) const request = new Request(`${HOST_URL}/login`, { method: 'POST', }) - await expect(() => - strategy.authenticate(request, sessionStorage, { ...AUTH_OPTIONS }), - ).rejects.toThrow(ERRORS.REQUIRED_ENV_SECRET) + await expect(() => strategy.authenticate(request)).rejects.toThrow( + ERRORS.REQUIRED_ENV_SECRET, + ) }) - test('Should throw an Error on missing required successRedirect option.', async () => { - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + test('Should throw an Error on missing required emailSentRedirect option.', async () => { + const strategy = new TOTPStrategy( + // @ts-expect-error - Error is expected since missing emailSentRedirect + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + }, + verify, + ) const request = new Request(`${HOST_URL}/login`, { method: 'POST', }) - await expect(() => - strategy.authenticate(request, sessionStorage, { ...AUTH_OPTIONS }), - ).rejects.toThrow(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL) + await expect(() => strategy.authenticate(request)).rejects.toThrow( + ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL, + ) }) - test('Should throw an Error on missing required failureRedirect option.', async () => { - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + test('Should throw an Error on missing required successRedirect option.', async () => { + const strategy = new TOTPStrategy( + // @ts-expect-error - Error is expected since missing successRedirect + { + ...BASE_STRATEGY_OPTIONS, + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + verify, + ) const request = new Request(`${HOST_URL}/login`, { method: 'POST', }) - await expect(() => - strategy.authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - }), - ).rejects.toThrow(ERRORS.REQUIRED_FAILURE_REDIRECT_URL) + await expect(() => strategy.authenticate(request)).rejects.toThrow( + ERRORS.REQUIRED_SUCCESS_REDIRECT_URL, + ) }) - test('Should set context in sendTOTP and verify when passed to authenticate.', async () => { - const context: AppLoadContext = { - cloudflare: { - env: { - SECRET_ENV: 'secret', - }, - }, - } - let sendTOTPOptions: SendTOTPOptions | undefined - sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { - sendTOTPOptions = options - expect(options.context).toEqual(context) - }) - verify.mockImplementation(async (options: TOTPVerifyParams) => { - expect(options.context).toEqual(context) - }) - - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) - const formData = new FormData() - formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) - let request = new Request(`${HOST_URL}/login`, { - method: 'POST', - body: formData, - }) - - let session: Session | undefined - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, + test('Should throw an Error on missing required failureRedirect option.', async () => { + const strategy = new TOTPStrategy( + // @ts-expect-error - Error is expected since missing failureRedirect + { + ...BASE_STRATEGY_OPTIONS, successRedirect: '/verify', - failureRedirect: '/login', - context, - }) - .catch(async (reason) => { - if (reason instanceof Response) { - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - } else throw reason - }) - - expect(sendTOTPOptions).not.toBeUndefined() - expect(session).not.toBeUndefined() - expect(sendTOTP).toHaveBeenCalledTimes(1) - expect(verify).toHaveBeenCalledTimes(0) - - formData.delete(FORM_FIELDS.EMAIL) - formData.append(FORM_FIELDS.CODE, sendTOTPOptions?.code || '') - request = new Request(`${HOST_URL}/verify`, { - method: 'POST', - headers: { - cookie: (session && (await sessionStorage.commitSession(session))) || '', + emailSentRedirect: '/check-email', }, - body: formData, - }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/', - failureRedirect: '/login', - context, - }) - .catch(async (reason) => { - if (reason instanceof Response) { - } else throw reason - }) - - expect(sendTOTP).toHaveBeenCalledTimes(1) - expect(verify).toHaveBeenCalledTimes(1) - }) - - test('Should use pre-read Form Data in context.', async () => { - let sendTOTPOptions: SendTOTPOptions | undefined - sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { - sendTOTPOptions = options - }) - - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) - let formData = new FormData() - formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) - formData.append('type', 'remix-auth-totp') - let request = new Request(`${HOST_URL}/login`, { - method: 'POST', - body: formData, - }) - let preReadFormData = await request.formData() - - let session: Session | undefined - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - context: { formData: preReadFormData }, - }) - .catch(async (reason) => { - if (reason instanceof Response) { - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - } else throw reason - }) - - expect(sendTOTPOptions).not.toBeUndefined() - expect(session).not.toBeUndefined() - expect(sendTOTP).toHaveBeenCalledTimes(1) - expect(verify).toHaveBeenCalledTimes(0) - - formData = new FormData() - formData.append(FORM_FIELDS.CODE, sendTOTPOptions?.code || '') - request = new Request(`${HOST_URL}/verify`, { + verify, + ) + const request = new Request(`${HOST_URL}/login`, { method: 'POST', - headers: { - cookie: (session && (await sessionStorage.commitSession(session))) || '', - }, - body: formData, }) - preReadFormData = await request.formData() - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/', - failureRedirect: '/login', - context: { formData: preReadFormData }, - }) - .catch(async (reason) => { - if (reason instanceof Response) { - } else throw reason - }) - - expect(sendTOTP).toHaveBeenCalledTimes(1) - expect(verify).toHaveBeenCalledTimes(1) + await expect(() => strategy.authenticate(request)).rejects.toThrow( + ERRORS.REQUIRED_FAILURE_REDIRECT_URL, + ) }) }) @@ -226,30 +134,35 @@ describe('[ TOTP ]', () => { expect(options.request).toBeInstanceOf(Request) expect(options.formData).toBeInstanceOf(FormData) }) - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) const request = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + const params = new URLSearchParams(raw!) + const email = params.get('email') + const totpRaw = params.get('totp') + expect(email).toBe(DEFAULT_EMAIL) + expect(totpRaw).toBeDefined() + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(1) }) @@ -264,7 +177,15 @@ describe('[ TOTP ]', () => { expect(options.formData).toBeInstanceOf(FormData) expect(options.formData.get(APP_FORM_FIELD)).toBe(APP_FORM_VALUE) }) - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) formData.append(APP_FORM_FIELD, APP_FORM_VALUE) @@ -272,23 +193,20 @@ describe('[ TOTP ]', () => { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + const params = new URLSearchParams(raw!) + const email = params.get('email') + const totpRaw = params.get('totp') + expect(email).toBe(DEFAULT_EMAIL) + expect(totpRaw).toBeDefined() + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(1) }) @@ -300,7 +218,15 @@ describe('[ TOTP ]', () => { expect(options.request).toBeInstanceOf(Request) expect(options.formData).toBeInstanceOf(FormData) }) - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) formData.append(FORM_FIELDS.CODE, '123456') @@ -308,87 +234,93 @@ describe('[ TOTP ]', () => { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toMatch('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toMatch('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + const params = new URLSearchParams(raw!) + const email = params.get('email') + const totpRaw = params.get('totp') + expect(email).toBe(DEFAULT_EMAIL) + expect(totpRaw).toBeDefined() + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(1) }) test('Should generate/send TOTP for form email ignoring session email.', async () => { - let session: Session | undefined - let sessionTotp: unknown - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formDataToPopulateSessionEmail = new FormData() formDataToPopulateSessionEmail.append(FORM_FIELDS.EMAIL, 'email@session.com') const requestToPopulateSessionEmail = new Request(`${HOST_URL}/login`, { method: 'POST', body: formDataToPopulateSessionEmail, }) - await strategy - .authenticate(requestToPopulateSessionEmail, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe('email@session.com') - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - sessionTotp = session.get(SESSION_KEYS.TOTP) - } else throw reason - }) + let firstTOTP: string | undefined + let firstResponseCookie: string | undefined + await strategy.authenticate(requestToPopulateSessionEmail).catch((reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe('email@session.com') + const totpRaw = params.get('totp') + expect(totpRaw).toBeDefined() + firstTOTP = totpRaw ?? undefined + firstResponseCookie = setCookieHeader + } else { + throw reason + } + }) + sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { expect(options.email).toBe(DEFAULT_EMAIL) - expect(options.code).to.not.equal('') + expect(options.code).not.toBe('') }) - if (!session) throw new Error('Undefined session.') + const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) const request = new Request(`${HOST_URL}/login`, { method: 'POST', - headers: { - cookie: await sessionStorage.commitSession(session), - }, + headers: { cookie: firstResponseCookie ?? '' }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - expect(session.get(SESSION_KEYS.TOTP)).not.toEqual(sessionTotp) - } else throw reason - }) + await strategy.authenticate(request).catch((reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + const newTOTP = params.get('totp') + expect(newTOTP).toBeDefined() + expect(newTOTP).not.toEqual(firstTOTP) + } else { + throw reason + } + }) + expect(sendTOTP).toHaveBeenCalledTimes(2) }) @@ -399,59 +331,61 @@ describe('[ TOTP ]', () => { expect(options.request).toBeInstanceOf(Request) expect(options.formData).toBeInstanceOf(FormData) }) - let session: Session | undefined - let sessionTotp: unknown - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) - const requestToPopulateSessionEmail = new Request(`${HOST_URL}/login`, { + const requestToPopulate = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(requestToPopulateSessionEmail, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - sessionTotp = session.get(SESSION_KEYS.TOTP) - } else throw reason - }) - if (!session) throw new Error('Undefined session.') + let firstTOTP: string | undefined + let firstCookieHeader: string | undefined + await strategy.authenticate(requestToPopulate).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + const totpRaw = params.get('totp') + expect(totpRaw).toBeDefined() + firstTOTP = totpRaw ?? undefined + firstCookieHeader = setCookieHeader + } else throw reason + }) + if (!firstTOTP) throw new Error('Undefined session.') const emptyFormRequest = new Request(`${HOST_URL}/login`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: firstCookieHeader ?? '', }, body: new FormData(), }) - await strategy - .authenticate(emptyFormRequest, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - expect(session.get(SESSION_KEYS.TOTP)).not.toEqual(sessionTotp) - } else throw reason - }) + await strategy.authenticate(emptyFormRequest).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + const newTOTP = params.get('totp') + expect(newTOTP).toBeDefined() + expect(newTOTP).not.toEqual(firstTOTP) + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(2) }) @@ -465,135 +399,146 @@ describe('[ TOTP ]', () => { expect(options.formData).toBeInstanceOf(FormData) expect(options.formData.get(APP_FORM_FIELD)).toBe(APP_FORM_VALUE) }) - let session: Session | undefined - let sessionTotp: unknown - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + let firstTOTP: string | undefined + let firstCookieHeader: string | undefined + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) formData.append(APP_FORM_FIELD, APP_FORM_VALUE) - const requestToPopulateSessionEmail = new Request(`${HOST_URL}/login`, { + const requestToPopulateEmail = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(requestToPopulateSessionEmail, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - sessionTotp = session.get(SESSION_KEYS.TOTP) - } else throw reason - }) - if (!session) throw new Error('Undefined session.') + await strategy.authenticate(requestToPopulateEmail).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + const totpRaw = params.get('totp') + expect(totpRaw).toBeDefined() + firstTOTP = totpRaw ?? undefined + firstCookieHeader = setCookieHeader + } else throw reason + }) const appFormData = new FormData() appFormData.append(APP_FORM_FIELD, APP_FORM_VALUE) const appFormRequest = new Request(`${HOST_URL}/login`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: firstCookieHeader ?? '', }, body: appFormData, }) - await strategy - .authenticate(appFormRequest, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - expect(session.get(SESSION_KEYS.TOTP)).not.toEqual(sessionTotp) - } else throw reason - }) + await strategy.authenticate(appFormRequest).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + const newTOTP = params.get('totp') + expect(newTOTP).toBeDefined() + expect(newTOTP).not.toEqual(firstTOTP) + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(2) }) test('Should failure redirect on invalid email.', async () => { - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + verify, + ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, '@invalid-email') const request = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/login') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.INVALID_EMAIL, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/login') + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.INVALID_EMAIL) + } else throw reason + }) }) test('Should failure redirect on invalid email with custom error.', async () => { const CUSTOM_ERROR = 'TEST: Invalid email.' const strategy = new TOTPStrategy( { - ...TOTP_STRATEGY_OPTIONS, - customErrors: { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + customErrors: { invalidEmail: CUSTOM_ERROR, }, }, verify, ) + const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, '@invalid-email') const request = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/login') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: CUSTOM_ERROR, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/login') + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(CUSTOM_ERROR) + } else throw reason + }) }) test('Should failure redirect when custom validateEmail returns false.', async () => { const ERROR_MESSAGE = 'TEST: Invalid email.' const strategy = new TOTPStrategy( { - ...TOTP_STRATEGY_OPTIONS, + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', customErrors: { invalidEmail: ERROR_MESSAGE, }, @@ -601,56 +546,54 @@ describe('[ TOTP ]', () => { }, verify, ) + const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, '@invalid-email') const request = new Request(`${HOST_URL}/login`, { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/login') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERROR_MESSAGE, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/login') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERROR_MESSAGE) + } else throw reason + }) }) test('Should failure redirect on missing email.', async () => { - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + verify, + ) const request = new Request(`${HOST_URL}/login`, { method: 'POST', body: new FormData(), }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/login') - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.REQUIRED_EMAIL, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/login') + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.REQUIRED_EMAIL) + } else throw reason + }) }) }) @@ -660,29 +603,33 @@ describe('[ TOTP ]', () => { ) { const user = { name: 'Joe Schmoe' } let sendTOTPOptions: SendTOTPOptions | undefined - let session: Session | undefined + let totpCookie: string | undefined sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { sendTOTPOptions = options expect(options.email).toBe(DEFAULT_EMAIL) expect(options.code).to.not.equal('') - expect(options.magicLink).toBe( - `${HOST_URL}${MAGIC_LINK_PATH}?code=${options.code}`, - ) + expect(options.magicLink).toMatch(new RegExp(`^${HOST_URL}${MAGIC_LINK_PATH}\\?t=`)) }) const strategy = new TOTPStrategy( - { ...TOTP_STRATEGY_OPTIONS, ...totpStrategyOptions }, + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/verify', + ...totpStrategyOptions, + }, async ({ email, formData, request }) => { - expect(email).toBe(DEFAULT_EMAIL) - expect(request).toBeInstanceOf(Request) - if (request.method === 'POST') { - expect(formData).toBeInstanceOf(FormData) + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); } else { - expect(formData).not.toBeDefined() + expect(request.method).toBe('GET'); } - return Promise.resolve(user) - }, + return Promise.resolve(user); + } ) const formData = new FormData() formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) @@ -690,168 +637,242 @@ describe('[ TOTP ]', () => { method: 'POST', body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/verify', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe('/verify') - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(SESSION_KEYS.EMAIL)).toBe(DEFAULT_EMAIL) - expect(session.get(SESSION_KEYS.TOTP)).toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe('/verify') + totpCookie = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(totpCookie) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBe(DEFAULT_EMAIL) + expect(params.get('totp')).toBeDefined() + } else throw reason + }) expect(sendTOTP).toHaveBeenCalledTimes(1) expect(sendTOTPOptions).toBeDefined() - invariant(sendTOTPOptions, 'Undefined sendTOTPOptions') - expect(session).toBeDefined() - invariant(session, 'Undefined session') - return { strategy, sendTOTPOptions, session, user } + if (!sendTOTPOptions) throw new Error('Undefined sendTOTPOptions.') + expect(totpCookie).toBeDefined() + if (!totpCookie) throw new Error('Undefined cookie.') + return { strategy, sendTOTPOptions, totpCookie, user } } test('Should successfully validate totp code.', async () => { - const { strategy, sendTOTPOptions, session, user } = await setupGenerateSendTOTP() + const { sendTOTPOptions, totpCookie, user } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) const formData = new FormData() formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code) const request = new Request(`${HOST_URL}/verify`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/account`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get('user')).toEqual(user) - expect(session.get(SESSION_KEYS.EMAIL)).not.toBeDefined() - expect(session.get(SESSION_KEYS.TOTP)).not.toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/account`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBeNull() + expect(params.get('totp')).toBeNull() + expect(params.get('error')).toBeNull() + } else throw reason + }) }) test('Should failure redirect on invalid totp code.', async () => { - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP() + const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) const formData = new FormData() formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code + 'INVALID') const request = new Request(`${HOST_URL}/verify`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/verify', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.INVALID_TOTP, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/verify`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.INVALID_TOTP) + } else throw reason + }) }) test('Should failure redirect on invalid totp code with custom error.', async () => { const CUSTOM_ERROR = 'TEST: invalid totp code' - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP({ - customErrors: { - invalidTotp: CUSTOM_ERROR, + const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + customErrors: { + invalidTotp: CUSTOM_ERROR, + }, }, - }) + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) const formData = new FormData() formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code + 'INVALID') const request = new Request(`${HOST_URL}/verify`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/verify`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(CUSTOM_ERROR) + } else throw reason + }) + }) + + test('Should failure redirect on invalid and max TOTP attempts.', async () => { + // eslint-disable-next-line prefer-const + let { user, totpCookie, sendTOTPOptions } = await setupGenerateSendTOTP() + + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, successRedirect: '/account', failureRedirect: '/verify', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) + + const url = new URL(sendTOTPOptions.magicLink) + const invalidToken = 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..invalid.token' + + for (let i = 0; i <= TOTP_GENERATION_DEFAULTS.maxAttempts; i++) { + url.searchParams.set('t', invalidToken) + + const request = new Request(url.toString(), { + method: 'GET', + headers: { + cookie: totpCookie, + }, }) - .catch(async (reason) => { + await strategy.authenticate(request).catch(async (reason) => { if (reason instanceof Response) { expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', + expect(reason.headers.get('Location')).toBe(`/verify`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + totpCookie = setCookieHeader + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe( + i < TOTP_GENERATION_DEFAULTS.maxAttempts + ? ERRORS.INVALID_TOTP + : ERRORS.RATE_LIMIT_EXCEEDED, ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: CUSTOM_ERROR, - }) + if (i >= TOTP_GENERATION_DEFAULTS.maxAttempts) { + expect(params.get('totp')).toBeNull() + } } else throw reason }) - }) - - test('Should failure redirect on invalid and max TOTP attempts.', async () => { - let { strategy, session, sendTOTPOptions } = await setupGenerateSendTOTP() - for (let i = 0; i <= TOTP_GENERATION_DEFAULTS.maxAttempts; i++) { - const formData = new FormData() - formData.append(FORM_FIELDS.CODE, sendTOTPOptions.code + 'INVALID') - const request = new Request(`${HOST_URL}/verify`, { - method: 'POST', - headers: { - cookie: await sessionStorage.commitSession(session), - }, - body: formData, - }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/verify', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: - i < TOTP_GENERATION_DEFAULTS.maxAttempts - ? ERRORS.INVALID_TOTP - : ERRORS.EXPIRED_TOTP, - }) - } else throw reason - }) } }) test('Should failure redirect on expired totp code.', async () => { - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP() + const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) vi.setSystemTime( new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), ) @@ -860,37 +881,49 @@ describe('[ TOTP ]', () => { const request = new Request(`${HOST_URL}/verify`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/verify', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.EXPIRED_TOTP, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/verify`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.EXPIRED_TOTP) + expect(params.get('totp')).toBeNull() + } else throw reason + }) }) test('Should failure redirect on expired totp code with custom error.', async () => { const CUSTOM_ERROR = 'TEST: expired totp code' - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP({ - customErrors: { - expiredTotp: CUSTOM_ERROR, + const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + customErrors: { + expiredTotp: CUSTOM_ERROR, + }, }, - }) + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) vi.setSystemTime( new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), ) @@ -899,250 +932,292 @@ describe('[ TOTP ]', () => { const request = new Request(`${HOST_URL}/verify`, { method: 'POST', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, body: formData, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/verify', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: CUSTOM_ERROR, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/verify`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(CUSTOM_ERROR) + expect(params.get('totp')).toBeNull() + } else throw reason + }) }) test('Should successfully validate magic-link.', async () => { - const { strategy, sendTOTPOptions, session, user } = await setupGenerateSendTOTP() + const { user, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) expect(sendTOTPOptions.magicLink).toBeDefined() - invariant(sendTOTPOptions.magicLink, 'Magic link is undefined.') + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') const request = new Request(sendTOTPOptions.magicLink, { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/account`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get('user')).toEqual(user) - expect(session.get('user')).toEqual(user) - expect(session.get(SESSION_KEYS.EMAIL)).not.toBeDefined() - expect(session.get(SESSION_KEYS.TOTP)).not.toBeDefined() - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/account`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('email')).toBeNull() + expect(params.get('totp')).toBeNull() + expect(params.get('error')).toBeNull() + } else throw reason + }) }) test('Should failure redirect on invalid magic-link code.', async () => { - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP() + const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() expect(sendTOTPOptions.magicLink).toBeDefined() - invariant(sendTOTPOptions.magicLink, 'Magic link is undefined.') + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') const request = new Request(sendTOTPOptions.magicLink + 'INVALID', { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/login`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.INVALID_TOTP, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/login`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.INVALID_TOTP) + } else throw reason + }) }) test('Should failure redirect on expired magic-link.', async () => { - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP() + const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() expect(sendTOTPOptions.magicLink).toBeDefined() - invariant(sendTOTPOptions.magicLink, 'Magic link is undefined.') + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') vi.setSystemTime( new Date(Date.now() + 1000 * 60 * (TOTP_GENERATION_DEFAULTS.period + 1)), ) const request = new Request(sendTOTPOptions.magicLink, { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/login`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.EXPIRED_TOTP, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/login`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.EXPIRED_TOTP) + } else throw reason + }) }) test('Should failure redirect on invalid magic-link path.', async () => { - const { strategy, sendTOTPOptions, session } = await setupGenerateSendTOTP() + const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() expect(sendTOTPOptions.magicLink).toBeDefined() - invariant(sendTOTPOptions.magicLink, 'Magic link is undefined.') + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') expect(sendTOTPOptions.magicLink).toMatch(MAGIC_LINK_PATH) const request = new Request( sendTOTPOptions.magicLink.replace(MAGIC_LINK_PATH, '/invalid-magic-link'), { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, }, ) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/login`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.INVALID_MAGIC_LINK_PATH, - }) - } else throw reason - }) + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/login`) + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.INVALID_MAGIC_LINK_PATH) + } else throw reason + }) }) - test('Should failure redirect on missing session email.', async () => { - let { session } = await setupGenerateSendTOTP() - session.unset(SESSION_KEYS.EMAIL) - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) - const request = new Request('https://prodserver.com/magic-link?code=KJJERI', { + test('Should failure redirect on missing email.', async () => { + const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + + expect(sendTOTPOptions.magicLink).toBeDefined() + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') + + const modifiedCookie = new Cookie(totpCookie) + const raw = modifiedCookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + params.delete('email') + const newCookie = new SetCookie({ + name: '_totp', + value: params.toString(), + httpOnly: true, + path: '/', + sameSite: 'Lax', + secure: true, + }) + + const request = new Request(sendTOTPOptions.magicLink, { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: newCookie.toString(), }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/login`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.MISSING_SESSION_EMAIL, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/login`) + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.MISSING_SESSION_EMAIL) + } else throw reason + }) }) test('Should failure redirect on stale magic-link.', async () => { - let { session } = await setupGenerateSendTOTP() - session.unset(SESSION_KEYS.TOTP) - const strategy = new TOTPStrategy(TOTP_STRATEGY_OPTIONS, verify) - const request = new Request('https://prodserver.com/magic-link?code=KJJERI', { + const { strategy, sendTOTPOptions, totpCookie } = await setupGenerateSendTOTP() + + expect(sendTOTPOptions.magicLink).toBeDefined(); + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.'); + + const modifiedCookie = new Cookie(totpCookie) + const raw = modifiedCookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + params.delete('totp') + const newCookie = new SetCookie({ + name: '_totp', + value: params.toString(), + httpOnly: true, + path: '/', + sameSite: 'Lax', + secure: true, + }) + + const request = new Request(sendTOTPOptions.magicLink, { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: newCookie.toString(), }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/login', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/login`) - const session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: ERRORS.EXPIRED_TOTP, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/login`) + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe(ERRORS.MISSING_SESSION_TOTP) + } else throw reason + }) }) test('Should failure redirect on magic-link invalid and max TOTP attempts.', async () => { - let { strategy, session, sendTOTPOptions } = await setupGenerateSendTOTP() + // eslint-disable-next-line prefer-const + let { user, totpCookie, sendTOTPOptions } = await setupGenerateSendTOTP() + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/account', + failureRedirect: '/verify', + emailSentRedirect: '/check-email', + }, + async ({ email, formData, request }) => { + expect(email).toBe(DEFAULT_EMAIL); + expect(request).toBeInstanceOf(Request); + if (formData) { + expect(formData).toBeInstanceOf(FormData); + } else { + expect(request.method).toBe('GET'); + } + return Promise.resolve(user); + }, + ) expect(sendTOTPOptions.magicLink).toBeDefined() - invariant(sendTOTPOptions.magicLink, 'Magic link is undefined.') + if (!sendTOTPOptions.magicLink) throw new Error('Magic link is undefined.') + for (let i = 0; i <= TOTP_GENERATION_DEFAULTS.maxAttempts; i++) { const request = new Request(sendTOTPOptions.magicLink + 'INVALID', { method: 'GET', headers: { - cookie: await sessionStorage.commitSession(session), + cookie: totpCookie, }, }) - await strategy - .authenticate(request, sessionStorage, { - ...AUTH_OPTIONS, - successRedirect: '/account', - failureRedirect: '/verify', - }) - .catch(async (reason) => { - if (reason instanceof Response) { - expect(reason.status).toBe(302) - expect(reason.headers.get('location')).toBe(`/verify`) - session = await sessionStorage.getSession( - reason.headers.get('set-cookie') ?? '', - ) - expect(session.get(AUTH_OPTIONS.sessionErrorKey)).toEqual({ - message: - i < TOTP_GENERATION_DEFAULTS.maxAttempts - ? ERRORS.INVALID_TOTP - : ERRORS.EXPIRED_TOTP, - }) - } else throw reason - }) + + await strategy.authenticate(request).catch(async (reason) => { + if (reason instanceof Response) { + expect(reason.status).toBe(302) + expect(reason.headers.get('Location')).toBe(`/verify`) + + const setCookieHeader = reason.headers.get('Set-Cookie') ?? '' + totpCookie = setCookieHeader + const cookie = new Cookie(setCookieHeader) + const raw = cookie.get('_totp') + expect(raw).toBeDefined() + + const params = new URLSearchParams(raw!) + expect(params.get('error')).toBe( + i < TOTP_GENERATION_DEFAULTS.maxAttempts + ? ERRORS.INVALID_TOTP + : ERRORS.RATE_LIMIT_EXCEEDED, + ) + + if (i >= TOTP_GENERATION_DEFAULTS.maxAttempts) { + expect(params.get('totp')).toBeNull() + } + } else throw reason + }) } }) }) @@ -1151,25 +1226,46 @@ describe('[ TOTP ]', () => { describe('[ Utils ]', () => { test('Should use the origin from the request for the magic-link.', async () => { const samples: Array<[string, string]> = [ - ['http://localhost/login', 'http://localhost/magic-link?code=U2N2EY'], - ['http://localhost:3000/login', 'http://localhost:3000/magic-link?code=U2N2EY'], - ['http://127.0.0.1/login', 'http://127.0.0.1/magic-link?code=U2N2EY'], - ['http://127.0.0.1:3000/login', 'http://127.0.0.1:3000/magic-link?code=U2N2EY'], - ['http://localhost:8788/signin', 'http://localhost:8788/magic-link?code=U2N2EY'], - ['https://host.com/login', 'https://host.com/magic-link?code=U2N2EY'], - ['https://host.com:3000/login', 'https://host.com:3000/magic-link?code=U2N2EY'], + ['http://localhost/login', 'http://localhost/magic-link\\?t='], + ['http://localhost:3000/login', 'http://localhost:3000/magic-link\\?t='], + ['http://127.0.0.1/login', 'http://127\\.0\\.0\\.1/magic-link\\?t='], + ['http://127.0.0.1:3000/login', 'http://127\\.0\\.0\\.1:3000/magic-link\\?t='], + ['http://localhost:8788/signin', 'http://localhost:8788/magic-link\\?t='], + ['https://host.com/login', 'https://host\\.com/magic-link\\?t='], + ['https://host.com:3000/login', 'https://host\\.com:3000/magic-link\\?t='], ] - for (const [requestUrl, magicLinkUrl] of samples) { - const request = new Request(requestUrl) - expect( - generateMagicLink({ - magicLinkPath: '/magic-link', - param: 'code', - code: 'U2N2EY', - request, - }), - ).toBe(magicLinkUrl) + for (const [requestUrl, magicLinkBase] of samples) { + let capturedMagicLink: string | undefined + + // Mock sendTOTP to capture the generated magic link. + sendTOTP.mockImplementation(async (options: SendTOTPOptions) => { + capturedMagicLink = options.magicLink + }) + + const strategy = new TOTPStrategy( + { + ...BASE_STRATEGY_OPTIONS, + successRedirect: '/verify', + failureRedirect: '/login', + emailSentRedirect: '/check-email', + }, + verify, + ) + + const formData = new FormData() + formData.append(FORM_FIELDS.EMAIL, DEFAULT_EMAIL) + const request = new Request(requestUrl, { + method: 'POST', + body: formData, + }) + + await strategy.authenticate(request).catch(() => { + // We expect this to throw since it redirects. + expect(capturedMagicLink).toBeDefined() + const regex = new RegExp(`^${magicLinkBase}[A-Za-z0-9_\\-\\.]+$`) + expect(capturedMagicLink).toMatch(regex) + }) } }) @@ -1184,4 +1280,4 @@ describe('[ Utils ]', () => { expect(() => asJweKey(secret)).toThrow() } }) -}) +}) \ No newline at end of file diff --git a/test/utils.ts b/test/utils.ts index 3cfb361..206b7f2 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,8 +1,5 @@ -import type { AuthenticateOptions } from 'remix-auth' import type { TOTPGenerationOptions } from '../src' -import { createCookieSessionStorage } from '@remix-run/node' - /** * Constants. */ @@ -20,7 +17,7 @@ export const AUTH_OPTIONS = { sessionKey: 'user', sessionErrorKey: 'error', sessionStrategyKey: 'strategy', -} satisfies AuthenticateOptions +} export const TOTP_GENERATION_DEFAULTS: Required< Pick @@ -28,10 +25,3 @@ export const TOTP_GENERATION_DEFAULTS: Required< period: 60, maxAttempts: 3, } - -/** - * Session Storage. - */ -export const sessionStorage = createCookieSessionStorage({ - cookie: { secrets: ['SESSION_SECRET'] }, -}) diff --git a/vitest-setup.ts b/vitest-setup.ts index 5c8be3a..c923c00 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -1,7 +1,7 @@ -import { installGlobals } from '@remix-run/node' + /** * Remix relies on browser API's such as fetch that are not natively available in Node.js, * you may find that unit tests fail without these globals, when running with tools such as Jest. */ -installGlobals() +// installGlobals()