fix(two-factor): backup codes shouldn't be encrypted twice (#5202)

This commit is contained in:
Bereket Engida
2025-10-09 17:55:22 -07:00
committed by GitHub
parent 3e013eea62
commit 130184f05d
3 changed files with 66 additions and 85 deletions

View File

@@ -11,7 +11,7 @@ import type {
import { APIError } from "better-call";
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
import { verifyTwoFactor } from "../verify-two-factor";
import type { GenericEndpointContext } from "@better-auth/core";
import { safeJSONParse } from "../../../utils/json";
export interface BackupCodeOptions {
/**
@@ -53,19 +53,35 @@ export async function generateBackupCodes(
secret: string,
options?: BackupCodeOptions,
) {
const key = secret;
const backupCodes = options?.customBackupCodesGenerate
? options.customBackupCodesGenerate()
: generateBackupCodesFn(options);
if (options?.storeBackupCodes === "encrypted") {
const encCodes = await symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
key: secret,
});
return {
backupCodes,
encryptedBackupCodes: encCodes,
};
}
if (
typeof options?.storeBackupCodes === "object" &&
"encrypt" in options?.storeBackupCodes
) {
return {
backupCodes,
encryptedBackupCodes: await options?.storeBackupCodes.encrypt(
JSON.stringify(backupCodes),
),
};
}
return {
backupCodes,
encryptedBackupCodes: JSON.stringify(backupCodes),
};
}
export async function verifyBackupCode(
data: {
@@ -73,8 +89,9 @@ export async function verifyBackupCode(
code: string;
},
key: string,
options?: BackupCodeOptions,
) {
const codes = await getBackupCodes(data.backupCodes, key);
const codes = await getBackupCodes(data.backupCodes, key, options);
if (!codes) {
return {
status: false,
@@ -87,61 +104,29 @@ export async function verifyBackupCode(
};
}
export async function getBackupCodes(backupCodes: string, key: string) {
const secret = new TextDecoder("utf-8").decode(
new TextEncoder().encode(
await symmetricDecrypt({ key, data: backupCodes }),
),
);
const data = JSON.parse(secret);
const result = z.array(z.string()).safeParse(data);
if (result.success) {
return result.data;
}
return null;
}
export const backupCode2fa = (options?: BackupCodeOptions) => {
const twoFactorTable = "twoFactor";
async function storeBackupCodes(
ctx: GenericEndpointContext,
export async function getBackupCodes(
backupCodes: string,
key: string,
options?: BackupCodeOptions,
) {
if (options?.storeBackupCodes === "encrypted") {
return await symmetricEncrypt({
key: ctx.context.secret,
data: backupCodes,
});
}
if (
typeof options?.storeBackupCodes === "object" &&
"encrypt" in options?.storeBackupCodes
) {
return await options?.storeBackupCodes.encrypt(backupCodes);
}
return backupCodes;
}
async function decryptBackupCodes(
ctx: GenericEndpointContext,
backupCodes: string,
) {
if (options?.storeBackupCodes === "encrypted") {
return await symmetricDecrypt({
key: ctx.context.secret,
data: backupCodes,
});
const decrypted = await symmetricDecrypt({ key, data: backupCodes });
return safeJSONParse<string[]>(decrypted);
}
if (
typeof options?.storeBackupCodes === "object" &&
"decrypt" in options?.storeBackupCodes
) {
return await options?.storeBackupCodes.decrypt(backupCodes);
const decrypted = await options?.storeBackupCodes.decrypt(backupCodes);
return safeJSONParse<string[]>(decrypted);
}
return backupCodes;
return safeJSONParse<string[]>(backupCodes);
}
export const backupCode2fa = (opts: BackupCodeOptions) => {
const twoFactorTable = "twoFactor";
return {
id: "backup_code",
endpoints: {
@@ -319,16 +304,13 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
});
}
const decryptedBackupCodes = await decryptBackupCodes(
ctx,
twoFactor.backupCodes,
);
const validate = await verifyBackupCode(
{
backupCodes: decryptedBackupCodes,
backupCodes: twoFactor.backupCodes,
code: ctx.body.code,
},
ctx.context.secret,
opts,
);
if (!validate.status) {
throw new APIError("UNAUTHORIZED", {
@@ -439,16 +421,13 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
await ctx.context.password.checkPassword(user.id, ctx);
const backupCodes = await generateBackupCodes(
ctx.context.secret,
options,
);
const storedBackupCodes = await storeBackupCodes(
ctx,
backupCodes.encryptedBackupCodes,
opts,
);
await ctx.context.adapter.update({
model: twoFactorTable,
update: {
backupCodes: storedBackupCodes,
backupCodes: backupCodes.encryptedBackupCodes,
},
where: [
{
@@ -503,25 +482,23 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
});
if (!twoFactor) {
throw new APIError("BAD_REQUEST", {
message: "Backup codes aren't enabled",
message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
});
}
const decryptedBackupCodes = await decryptBackupCodes(
ctx,
const decryptedBackupCodes = await getBackupCodes(
twoFactor.backupCodes,
);
const backupCodes = await getBackupCodes(
decryptedBackupCodes,
ctx.context.secret,
opts,
);
if (!backupCodes) {
if (!decryptedBackupCodes) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE,
});
}
return ctx.json({
status: true,
backupCodes,
backupCodes: decryptedBackupCodes,
});
},
),

View File

@@ -7,7 +7,11 @@ import {
import { sessionMiddleware } from "../../api";
import { symmetricEncrypt } from "../../crypto";
import type { BetterAuthPlugin } from "@better-auth/core";
import { backupCode2fa, generateBackupCodes } from "./backup-codes";
import {
backupCode2fa,
generateBackupCodes,
type BackupCodeOptions,
} from "./backup-codes";
import { otp2fa } from "./otp";
import { totp2fa } from "./totp";
import type { TwoFactorOptions, UserWithTwoFactor } from "./types";
@@ -27,8 +31,12 @@ export const twoFactor = (options?: TwoFactorOptions) => {
const opts = {
twoFactorTable: "twoFactor",
};
const backupCodeOptions = {
storeBackupCodes: "encrypted",
...options?.backupCodeOptions,
} satisfies BackupCodeOptions;
const totp = totp2fa(options?.totpOptions);
const backupCode = backupCode2fa(options?.backupCodeOptions);
const backupCode = backupCode2fa(backupCodeOptions);
const otp = otp2fa(options?.otpOptions);
return {
@@ -120,7 +128,7 @@ export const twoFactor = (options?: TwoFactorOptions) => {
});
const backupCodes = await generateBackupCodes(
ctx.context.secret,
options?.backupCodeOptions,
backupCodeOptions,
);
if (options?.skipVerificationOnEnable) {
const updatedUser = await ctx.context.internalAdapter.updateUser(

View File

@@ -539,9 +539,6 @@ describe("two factor auth API", async () => {
});
});
// Regression tests for PR #5174 - viewBackupCodes endpoint
// Bug: viewBackupCodes was returning encrypted JSON string instead of parsed array
// This caused "SyntaxError: Unexpected number in JSON" errors
describe("view backup codes", async () => {
const sendOTP = vi.fn();
const { auth, signInWithTestUser, testUser, db } = await getTestInstance({
@@ -580,7 +577,6 @@ describe("view backup codes", async () => {
body: { userId },
});
// Critical: Verify it returns an array, NOT a JSON string (the bug that was fixed)
expect(typeof viewResult.backupCodes).not.toBe("string");
expect(Array.isArray(viewResult.backupCodes)).toBe(true);
expect(viewResult.backupCodes.length).toBe(10);