remix-auth-totp-dev
Version:
A Time-Based One-Time Password (TOTP) Authentication Strategy for Remix-Auth.
509 lines (508 loc) • 19 kB
JavaScript
import { Strategy } from 'remix-auth/strategy';
import { generateTOTP, verifyTOTP } from '@epic-web/totp';
import { Cookie, SetCookie } from '@mjackson/headers';
import * as jose from 'jose';
import { redirect } from './utils.js';
import { generateSecret, coerceToOptionalString, coerceToOptionalTotpSessionData, coerceToOptionalNonEmptyString, assertTOTPData, asJweKey, } from './utils.js';
import { STRATEGY_NAME, FORM_FIELDS, ERRORS } from './constants.js';
/**
* A store class that manages TOTP-related state in a cookie.
* Handles email, TOTP session data, and error messages.
*/
class TOTPStore {
cookie;
email;
totp;
error;
/** 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(cookie) {
this.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) {
return new TOTPStore(new Cookie(request.headers.get('cookie') ?? ''));
}
/**
* Gets the stored email address.
* @returns The email address or undefined if not set.
*/
getEmail() {
return this.email;
}
/**
* Gets the stored TOTP session data.
* @returns The TOTP session data or undefined if not set.
*/
getTOTP() {
return this.totp;
}
/**
* Gets the stored error message.
* @returns The error object or undefined if no error exists.
*/
getError() {
return this.error;
}
/**
* Sets the email address in the store.
* @param email - The email address to store or undefined to clear it.
*/
setEmail(email) {
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) {
this.totp = totp;
}
/**
* Sets an error message in the store.
* @param message - The error message to store or undefined to clear it.
*/
setError(message) {
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 = {}) {
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 {
name = STRATEGY_NAME;
secret;
cookieOptions;
totpGeneration;
magicLinkPath;
customErrors;
emailFieldKey;
codeFieldKey;
sendTOTP;
validateEmail;
_emailSentRedirect;
_successRedirect;
_failureRedirect;
_totpGenerationDefaults = {
algorithm: 'SHA-256',
charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // Does not include O or 0.
digits: 6,
period: 60,
maxAttempts: 3,
};
_customErrorsDefaults = {
requiredEmail: ERRORS.REQUIRED_EMAIL,
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, verify) {
super(verify);
this.secret = options.secret;
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.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,
};
this.customErrors = {
...this._customErrorsDefaults,
...options.customErrors,
};
}
/** Gets the email sent redirect URL. */
get emailSentRedirect() {
return this._emailSentRedirect;
}
/** Sets the email sent redirect URL. */
set emailSentRedirect(url) {
if (!url) {
throw new Error(ERRORS.REQUIRED_EMAIL_SENT_REDIRECT_URL);
}
this._emailSentRedirect = url;
}
/** Gets the success redirect URL. */
get successRedirect() {
return this._successRedirect;
}
/** Sets the success redirect URL. */
set successRedirect(url) {
if (!url) {
throw new Error(ERRORS.REQUIRED_SUCCESS_REDIRECT_URL);
}
this._successRedirect = url;
}
/** Gets the failure redirect URL. */
get failureRedirect() {
return this._failureRedirect;
}
/** Sets the failure redirect URL. */
set failureRedirect(url) {
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 | ✗ | ✓ | ✓ | ✓ | Validate form TOTP code. |
* | GET | - | - | ✓ | ✓ | Validate magic-link TOTP. |
*
* @param {Request} request - The request object.
* @param {Context} context - Optional context passed by the authenticator.
* @returns {Promise<User>} The authenticated user.
*/
async authenticate(request, context) {
if (!this.secret)
throw new Error(ERRORS.REQUIRED_ENV_SECRET);
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);
// Retrieve the TOTP store from request.
const store = TOTPStore.fromRequest(request);
const formData = await this._readFormData(request);
const formDataEmail = coerceToOptionalNonEmptyString(formData.get(this.emailFieldKey));
const formDataCode = coerceToOptionalNonEmptyString(formData.get(this.codeFieldKey));
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,
});
// Set the TOTP data in the store.
const totpData = { jwe, attempts: 0 };
store.setEmail(email);
store.setTOTP(totpData);
store.setError(undefined);
// Redirect to the email sent URL.
throw redirect(this._emailSentRedirect, {
headers: {
'Set-Cookie': store.commit(this.cookieOptions),
},
});
}
// 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.missingSessionTotp);
// 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);
// Call the verify method, allowing developers to handle the user.
await this.verify({ email: sessionEmail, formData, request, context });
// Redirect to the success URL.
throw redirect(this._successRedirect, {
headers: {
'Set-Cookie': store.commit(this.cookieOptions),
},
});
}
// If no email was provided, throw an error.
throw new Error(this.customErrors.requiredEmail);
}
catch (err) {
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.
*/
async _readFormData(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.
*/
async _validateTOTP({ code, sessionTotp, store, urlExpires, }) {
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);
}
// 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.
*/
async _generateTOTP({ email, request }) {
const isValidEmail = await this.validateEmail(email);
if (!isValidEmail)
throw new Error(this.customErrors.invalidEmail);
const { otp: code, secret } = await generateTOTP({
...this.totpGeneration,
secret: this.totpGeneration.secret ?? generateSecret(),
});
const totpData = { secret, createdAt: Date.now() };
const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(JSON.stringify(totpData)))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(asJweKey(this.secret));
const magicLink = await this._generateMagicLink({ code, request });
return {
code,
jwe,
magicLink,
};
}
/**
* Encrypts magic link parameters.
* @param params - The parameters to encrypt.
* @returns The encrypted JWE token.
*/
async _encryptUrlParams(params) {
const payload = new TextEncoder().encode(JSON.stringify(params));
return await new jose.CompactEncrypt(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(asJweKey(this.secret));
}
/**
* Decrypts and validates magic link parameters.
* @param encrypted - The encrypted JWE token.
* @returns The decrypted and validated parameters.
*/
async _decryptUrlParams(encrypted, sessionTotp) {
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.');
}
return params;
}
catch (error) {
if (!sessionTotp || sessionTotp.attempts < this.totpGeneration.maxAttempts) {
if (sessionTotp) {
sessionTotp.attempts += 1;
}
throw new Error(this.customErrors.invalidTotp);
}
throw new Error(this.customErrors.rateLimitExceeded);
}
}
/**
* Generates the magic link.
* @param code - The TOTP code.
* @param request - The request object.
* @returns The magic link.
*/
async _generateMagicLink({ code, request, }) {
const url = new URL(this.magicLinkPath ?? '/', new URL(request.url).origin);
const params = {
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.
*/
async _getMagicLinkCode(request, sessionTotp) {
if (request.method === 'GET') {
const url = new URL(request.url);
if (url.pathname !== this.magicLinkPath) {
throw new Error(ERRORS.INVALID_MAGIC_LINK_PATH);
}
const token = url.searchParams.get('t');
if (!token) {
return {};
}
try {
const params = await this._decryptUrlParams(token, sessionTotp);
return {
code: params.code,
expires: params.expires,
};
}
catch (error) {
throw error;
}
}
return {};
}
/**
* Validates the email format.
* @param email - The email address.
* @returns Whether the email is valid.
*/
async _validateEmailDefault(email) {
const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/gm;
return regexEmail.test(email);
}
}