mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-06 12:27:44 +00:00
fix(two-factor): backup codes shouldn't be encrypted twice (#5202)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user