mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(db): support verification operations with secondary storage (#8247)
This commit is contained in:
@@ -308,7 +308,7 @@ export const resetPassword = createAuthEndpoint(
|
||||
} else {
|
||||
await ctx.context.internalAdapter.updatePassword(userId, hashedPassword);
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(verification.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(id);
|
||||
|
||||
if (ctx.context.options.emailAndPassword?.onPasswordReset) {
|
||||
const user = await ctx.context.internalAdapter.findUserById(userId);
|
||||
|
||||
@@ -643,7 +643,9 @@ export const deleteUserCallback = createAuthEndpoint(
|
||||
await ctx.context.internalAdapter.deleteUser(session.user.id);
|
||||
await ctx.context.internalAdapter.deleteSessions(session.user.id);
|
||||
await ctx.context.internalAdapter.deleteAccounts(session.user.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(token.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`delete-account-${ctx.query.token}`,
|
||||
);
|
||||
|
||||
deleteSessionCookie(ctx);
|
||||
|
||||
|
||||
@@ -233,13 +233,13 @@ describe("internal adapter test", async () => {
|
||||
});
|
||||
|
||||
it("should delete verification by value with hooks", async () => {
|
||||
const verification = await internalAdapter.createVerificationValue({
|
||||
await internalAdapter.createVerificationValue({
|
||||
identifier: `test-id-1`,
|
||||
value: "test-id-1",
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
await internalAdapter.deleteVerificationValue(verification.id);
|
||||
await internalAdapter.deleteVerificationByIdentifier("test-id-1");
|
||||
expect(hookVerificationDeleteBefore).toHaveBeenCalledOnce();
|
||||
expect(hookVerificationDeleteAfter).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -1139,19 +1139,6 @@ export const createInternalAdapter = (
|
||||
|
||||
return (verification[0] as Verification) || null;
|
||||
},
|
||||
/**
|
||||
* Note: In secondary-only mode, this is a no-op since secondary storage
|
||||
* is keyed by identifier, not id. Use deleteVerificationByIdentifier instead.
|
||||
*/
|
||||
deleteVerificationValue: async (id: string) => {
|
||||
if (!secondaryStorage || options.verification?.storeInDatabase) {
|
||||
await deleteWithHooks(
|
||||
[{ field: "id", value: id }],
|
||||
"verification",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
},
|
||||
deleteVerificationByIdentifier: async (identifier: string) => {
|
||||
const storageOption = getStorageOption(
|
||||
identifier,
|
||||
@@ -1174,17 +1161,55 @@ export const createInternalAdapter = (
|
||||
);
|
||||
}
|
||||
},
|
||||
updateVerificationValue: async (
|
||||
id: string,
|
||||
updateVerificationByIdentifier: async (
|
||||
identifier: string,
|
||||
data: Partial<Verification>,
|
||||
) => {
|
||||
const verification = await updateWithHooks<Verification>(
|
||||
data,
|
||||
[{ field: "id", value: id }],
|
||||
"verification",
|
||||
undefined,
|
||||
const storageOption = getStorageOption(
|
||||
identifier,
|
||||
options.verification?.storeIdentifier,
|
||||
);
|
||||
return verification;
|
||||
const storedIdentifier = await processIdentifier(
|
||||
identifier,
|
||||
storageOption,
|
||||
);
|
||||
|
||||
if (secondaryStorage) {
|
||||
const cached = await secondaryStorage.get(
|
||||
`verification:${storedIdentifier}`,
|
||||
);
|
||||
if (cached) {
|
||||
const parsed = safeJSONParse<Verification>(cached);
|
||||
if (parsed) {
|
||||
const updated = { ...parsed, ...data };
|
||||
const expiresAt = updated.expiresAt ?? parsed.expiresAt;
|
||||
const ttl = getTTLSeconds(
|
||||
expiresAt instanceof Date ? expiresAt : new Date(expiresAt),
|
||||
);
|
||||
if (ttl > 0) {
|
||||
await secondaryStorage.set(
|
||||
`verification:${storedIdentifier}`,
|
||||
JSON.stringify(updated),
|
||||
ttl,
|
||||
);
|
||||
}
|
||||
if (!options.verification?.storeInDatabase) {
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!secondaryStorage || options.verification?.storeInDatabase) {
|
||||
const verification = await updateWithHooks<Verification>(
|
||||
data,
|
||||
[{ field: "identifier", value: storedIdentifier }],
|
||||
"verification",
|
||||
undefined,
|
||||
);
|
||||
return verification;
|
||||
}
|
||||
return data as Verification;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -378,9 +378,10 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
if (!verificationValue) {
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
|
||||
}
|
||||
const otpIdentifier = `${ctx.body.type}-otp-${email}`;
|
||||
if (verificationValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
otpIdentifier,
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED);
|
||||
}
|
||||
@@ -388,15 +389,15 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
const [otpValue, attempts] = splitAtLastColon(verificationValue.value);
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
otpIdentifier,
|
||||
);
|
||||
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
|
||||
}
|
||||
const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp);
|
||||
if (!verified) {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
otpIdentifier,
|
||||
{
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
},
|
||||
@@ -1097,9 +1098,10 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
if (!currentEmailVerificationValue) {
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
|
||||
}
|
||||
const currentEmailIdentifier = `email-verification-otp-${email}`;
|
||||
if (currentEmailVerificationValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
currentEmailVerificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
currentEmailIdentifier,
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED);
|
||||
}
|
||||
@@ -1109,8 +1111,8 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
);
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
currentEmailVerificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
currentEmailIdentifier,
|
||||
);
|
||||
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
|
||||
}
|
||||
@@ -1122,16 +1124,16 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
ctx.body.otp,
|
||||
);
|
||||
if (!verified) {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
currentEmailVerificationValue.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
currentEmailIdentifier,
|
||||
{
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
},
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
currentEmailVerificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
currentEmailIdentifier,
|
||||
);
|
||||
} else {
|
||||
if (ctx.body.otp) {
|
||||
@@ -1265,9 +1267,10 @@ export const changeEmailEmailOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
if (!verificationValue) {
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
|
||||
}
|
||||
const changeEmailIdentifier = `change-email-otp-${email}-${newEmail}`;
|
||||
if (verificationValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
changeEmailIdentifier,
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED);
|
||||
}
|
||||
@@ -1275,24 +1278,24 @@ export const changeEmailEmailOTP = (opts: RequiredEmailOTPOptions) =>
|
||||
const [otpValue, attempts] = splitAtLastColon(verificationValue.value);
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
changeEmailIdentifier,
|
||||
);
|
||||
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
|
||||
}
|
||||
|
||||
const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp);
|
||||
if (!verified) {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
changeEmailIdentifier,
|
||||
{
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
},
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP);
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
changeEmailIdentifier,
|
||||
);
|
||||
|
||||
const currentUser =
|
||||
@@ -1371,8 +1374,8 @@ async function atomicVerifyOTP(
|
||||
}
|
||||
|
||||
if (verificationValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
identifier,
|
||||
);
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED);
|
||||
}
|
||||
@@ -1381,16 +1384,14 @@ async function atomicVerifyOTP(
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
identifier,
|
||||
);
|
||||
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
|
||||
}
|
||||
|
||||
// Atomically delete token before verification to prevent race condition
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
|
||||
|
||||
const verified = await verifyStoredOTP(ctx, opts, otpValue, providedOTP);
|
||||
|
||||
|
||||
@@ -354,8 +354,8 @@ export const magicLink = (options: MagicLinkOptions) => {
|
||||
redirectWithError("INVALID_TOKEN");
|
||||
}
|
||||
if (tokenValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
tokenValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
storedToken,
|
||||
);
|
||||
redirectWithError("EXPIRED_TOKEN");
|
||||
}
|
||||
@@ -369,13 +369,13 @@ export const magicLink = (options: MagicLinkOptions) => {
|
||||
attempt?: number | undefined;
|
||||
};
|
||||
if (attempt >= opts.allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
tokenValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
storedToken,
|
||||
);
|
||||
redirectWithError("ATTEMPTS_EXCEEDED");
|
||||
}
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
tokenValue.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
storedToken,
|
||||
{
|
||||
value: JSON.stringify({
|
||||
email,
|
||||
|
||||
@@ -489,8 +489,8 @@ export const mcp = (options: MCPOptions) => {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
code.toString(),
|
||||
);
|
||||
|
||||
if (!client_id) {
|
||||
@@ -612,8 +612,8 @@ export const mcp = (options: MCPOptions) => {
|
||||
}
|
||||
|
||||
const requestedScopes = value.scope;
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
code.toString(),
|
||||
);
|
||||
const accessToken = generateRandomString(32, "a-z", "A-Z");
|
||||
const refreshToken = generateRandomString(32, "A-Z", "a-z");
|
||||
|
||||
@@ -594,8 +594,8 @@ export const oidcProvider = (options: OIDCOptions) => {
|
||||
}
|
||||
|
||||
if (!ctx.body.accept) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
consentCode,
|
||||
);
|
||||
return ctx.json({
|
||||
redirectURI: `${value.redirectURI}?error=access_denied&error_description=User denied access`,
|
||||
@@ -605,8 +605,8 @@ export const oidcProvider = (options: OIDCOptions) => {
|
||||
const codeExpiresInMs =
|
||||
(opts?.codeExpiresIn ?? DEFAULT_CODE_EXPIRES_IN) * 1000;
|
||||
const expiresAt = new Date(Date.now() + codeExpiresInMs);
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
consentCode,
|
||||
{
|
||||
value: JSON.stringify({
|
||||
...value,
|
||||
@@ -812,8 +812,8 @@ export const oidcProvider = (options: OIDCOptions) => {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
code.toString(),
|
||||
);
|
||||
if (!client_id) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
@@ -921,8 +921,8 @@ export const oidcProvider = (options: OIDCOptions) => {
|
||||
}
|
||||
|
||||
const requestedScopes = value.scope;
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
code.toString(),
|
||||
);
|
||||
const accessToken = generateRandomString(32, "a-z", "A-Z");
|
||||
const refreshToken = generateRandomString(32, "A-Z", "a-z");
|
||||
|
||||
@@ -183,8 +183,8 @@ export const oneTimeToken = (options?: OneTimeTokenOptions | undefined) => {
|
||||
message: "Invalid token",
|
||||
});
|
||||
}
|
||||
await c.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
await c.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`one-time-token:${storedToken}`,
|
||||
);
|
||||
if (verificationValue.expiresAt < new Date()) {
|
||||
throw c.error("BAD_REQUEST", {
|
||||
|
||||
@@ -473,7 +473,9 @@ export const verifyPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
|
||||
ctx.body.phoneNumber,
|
||||
);
|
||||
if (otp) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(otp.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
ctx.body.phoneNumber,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Default internal verification logic
|
||||
@@ -496,23 +498,30 @@ export const verifyPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
|
||||
const [otpValue, attempts] = otp.value.split(":");
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(otp.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
ctx.body.phoneNumber,
|
||||
);
|
||||
throw APIError.from(
|
||||
"FORBIDDEN",
|
||||
PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS,
|
||||
);
|
||||
}
|
||||
if (otpValue !== ctx.body.code) {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(otp.id, {
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
});
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
ctx.body.phoneNumber,
|
||||
{
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
},
|
||||
);
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
PHONE_NUMBER_ERROR_CODES.INVALID_OTP,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(otp.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
ctx.body.phoneNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.body.updatePhoneNumber) {
|
||||
@@ -789,9 +798,10 @@ export const resetPasswordPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
|
||||
}
|
||||
const [otpValue, attempts] = verification.value.split(":");
|
||||
const allowedAttempts = opts?.allowedAttempts || 3;
|
||||
const phoneResetIdentifier = `${ctx.body.phoneNumber}-request-password-reset`;
|
||||
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
phoneResetIdentifier,
|
||||
);
|
||||
throw APIError.from(
|
||||
"FORBIDDEN",
|
||||
@@ -799,8 +809,8 @@ export const resetPasswordPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
|
||||
);
|
||||
}
|
||||
if (ctx.body.otp !== otpValue) {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
phoneResetIdentifier,
|
||||
{
|
||||
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
||||
},
|
||||
@@ -858,8 +868,8 @@ export const resetPasswordPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
|
||||
hashedPassword,
|
||||
);
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
phoneResetIdentifier,
|
||||
);
|
||||
|
||||
if (ctx.context.options.emailAndPassword?.onPasswordReset) {
|
||||
|
||||
@@ -153,8 +153,8 @@ export const siwe = (options: SIWEPluginOptions) =>
|
||||
}
|
||||
|
||||
// Clean up used nonce
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`siwe:${walletAddress}:${chainId}`,
|
||||
);
|
||||
|
||||
// Look for existing user by their wallet addresses
|
||||
|
||||
@@ -373,8 +373,8 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
|
||||
verificationRecord.value === data.user.id &&
|
||||
verificationRecord.expiresAt > new Date()
|
||||
) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationRecord.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
trustIdentifier,
|
||||
);
|
||||
const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
|
||||
const newToken = await createHMAC(
|
||||
|
||||
@@ -312,8 +312,8 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
|
||||
const [otp, counter] = toCheckOtp?.value?.split(":") ?? [];
|
||||
if (!toCheckOtp || toCheckOtp.expiresAt < new Date()) {
|
||||
if (toCheckOtp) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
toCheckOtp.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`2fa-otp-${key}`,
|
||||
);
|
||||
}
|
||||
throw APIError.from(
|
||||
@@ -323,8 +323,8 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
|
||||
}
|
||||
const allowedAttempts = options?.allowedAttempts || 5;
|
||||
if (parseInt(counter!) >= allowedAttempts) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
toCheckOtp.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`2fa-otp-${key}`,
|
||||
);
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
@@ -373,8 +373,8 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
|
||||
}
|
||||
return valid(ctx);
|
||||
} else {
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
toCheckOtp.id,
|
||||
await ctx.context.internalAdapter.updateVerificationByIdentifier(
|
||||
`2fa-otp-${key}`,
|
||||
{
|
||||
value: `${otp}:${(parseInt(counter!, 10) || 0) + 1}`,
|
||||
},
|
||||
|
||||
@@ -69,8 +69,8 @@ export async function verifyTwoFactor(ctx: GenericEndpointContext) {
|
||||
});
|
||||
}
|
||||
// Delete the verification token from the database after successful verification
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationToken.id,
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
signedTwoFactorCookie,
|
||||
);
|
||||
await setSessionCookie(ctx, {
|
||||
session,
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function parseGenericState(
|
||||
expireCookie(c, stateCookie);
|
||||
|
||||
// Delete verification value after retrieval
|
||||
await c.context.internalAdapter.deleteVerificationValue(data.id);
|
||||
await c.context.internalAdapter.deleteVerificationByIdentifier(state);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
|
||||
@@ -208,12 +208,10 @@ export interface InternalAdapter<
|
||||
|
||||
findVerificationValue(identifier: string): Promise<Verification | null>;
|
||||
|
||||
deleteVerificationValue(id: string): Promise<void>;
|
||||
|
||||
deleteVerificationByIdentifier(identifier: string): Promise<void>;
|
||||
|
||||
updateVerificationValue(
|
||||
id: string,
|
||||
updateVerificationByIdentifier(
|
||||
identifier: string,
|
||||
data: Partial<Verification>,
|
||||
): Promise<Verification>;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ export const electronToken = (_opts: ElectronOptions) =>
|
||||
);
|
||||
}
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(token.id);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`electron:${ctx.body.token}`,
|
||||
);
|
||||
|
||||
const user = await ctx.context.internalAdapter.findUserById(
|
||||
tokenRecord.userId,
|
||||
|
||||
@@ -638,7 +638,7 @@ describe("Electron", () => {
|
||||
});
|
||||
|
||||
const ctx = await auth.$context;
|
||||
const spy = vi.spyOn(ctx.internalAdapter, "deleteVerificationValue");
|
||||
const spy = vi.spyOn(ctx.internalAdapter, "deleteVerificationByIdentifier");
|
||||
|
||||
const { data } = await client.$fetch<any>("/electron/token", {
|
||||
method: "POST",
|
||||
|
||||
@@ -424,15 +424,10 @@ async function redirectWithAuthorizationCode(
|
||||
authTime: verificationValue.authTime,
|
||||
} satisfies VerificationValue),
|
||||
};
|
||||
ctx.context.verification_id
|
||||
? await ctx.context.internalAdapter.updateVerificationValue(
|
||||
ctx.context.verification_id,
|
||||
data,
|
||||
)
|
||||
: await ctx.context.internalAdapter.createVerificationValue({
|
||||
...data,
|
||||
createdAt: new Date(iat * 1000),
|
||||
});
|
||||
await ctx.context.internalAdapter.createVerificationValue({
|
||||
...data,
|
||||
createdAt: new Date(iat * 1000),
|
||||
});
|
||||
|
||||
const redirectUriWithCode = new URL(verificationValue.query.redirect_uri);
|
||||
redirectUriWithCode.searchParams.set("code", code);
|
||||
|
||||
@@ -522,9 +522,9 @@ async function checkVerificationValue(
|
||||
}
|
||||
|
||||
// Delete used code
|
||||
if (verification?.id) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(verification.id);
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
await storeToken(opts.storeTokens, code, "authorization_code"),
|
||||
);
|
||||
|
||||
// Check verification
|
||||
if (!verification.expiresAt || verification.expiresAt < new Date()) {
|
||||
|
||||
@@ -219,10 +219,10 @@ export function sso<O extends SSOOptions>(
|
||||
);
|
||||
if (sessionLookup?.value) {
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(sessionLookup.value)
|
||||
.deleteVerificationByIdentifier(sessionLookup.value)
|
||||
.catch(() => {});
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(sessionLookupKey)
|
||||
.deleteVerificationByIdentifier(sessionLookupKey)
|
||||
.catch(() => {});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -3116,7 +3116,7 @@ async function handleLogoutResponse(
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(key)
|
||||
.deleteVerificationByIdentifier(key)
|
||||
.catch((e: unknown) =>
|
||||
ctx.context.logger.warn(
|
||||
"Failed to delete logout request verification value",
|
||||
@@ -3183,7 +3183,7 @@ async function handleLogoutRequest(
|
||||
}),
|
||||
);
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(
|
||||
.deleteVerificationByIdentifier(
|
||||
`${constants.SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`,
|
||||
)
|
||||
.catch((e: unknown) =>
|
||||
@@ -3204,7 +3204,7 @@ async function handleLogoutRequest(
|
||||
}
|
||||
}
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(key)
|
||||
.deleteVerificationByIdentifier(key)
|
||||
.catch((e: unknown) =>
|
||||
ctx.context.logger.warn(
|
||||
"Failed to delete SAML session key during SLO",
|
||||
@@ -3339,7 +3339,7 @@ export const initiateSLO = (options?: SSOOptions) => {
|
||||
|
||||
if (samlSessionKey) {
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(samlSessionKey)
|
||||
.deleteVerificationByIdentifier(samlSessionKey)
|
||||
.catch((e) =>
|
||||
ctx.context.logger.warn(
|
||||
"Failed to delete SAML session key during logout",
|
||||
@@ -3348,7 +3348,7 @@ export const initiateSLO = (options?: SSOOptions) => {
|
||||
);
|
||||
}
|
||||
await ctx.context.internalAdapter
|
||||
.deleteVerificationValue(sessionLookupKey)
|
||||
.deleteVerificationByIdentifier(sessionLookupKey)
|
||||
.catch((e) =>
|
||||
ctx.context.logger.warn(
|
||||
"Failed to delete session lookup key during logout",
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { safeJSONParse } from "@better-auth/core/utils/json";
|
||||
import { magicLinkClient } from "better-auth/client/plugins";
|
||||
import { magicLink } from "better-auth/plugins";
|
||||
import { getTestInstance } from "better-auth/test";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface VerificationEmail {
|
||||
email: string;
|
||||
token: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/8228
|
||||
*/
|
||||
describe("magic link with secondary storage (string return)", async () => {
|
||||
const store = new Map<string, string>();
|
||||
let verificationEmail: VerificationEmail = {
|
||||
email: "",
|
||||
token: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
const { testUser, sessionSetter, client } = await getTestInstance(
|
||||
{
|
||||
secondaryStorage: {
|
||||
set(key, value, ttl) {
|
||||
store.set(key, value);
|
||||
},
|
||||
get(key) {
|
||||
return store.get(key) || null;
|
||||
},
|
||||
delete(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
async sendMagicLink(data) {
|
||||
verificationEmail = data;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [magicLinkClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
it("should send and verify magic link", async () => {
|
||||
await client.signIn.magicLink({
|
||||
email: testUser.email,
|
||||
});
|
||||
expect(verificationEmail.email).toBe(testUser.email);
|
||||
expect(verificationEmail.url).toContain(
|
||||
"http://localhost:3000/api/auth/magic-link/verify",
|
||||
);
|
||||
|
||||
// Verify a verification entry exists in store
|
||||
const verificationKeys = [...store.keys()].filter((k) =>
|
||||
k.startsWith("verification:"),
|
||||
);
|
||||
expect(verificationKeys.length).toBeGreaterThan(0);
|
||||
|
||||
const headers = new Headers();
|
||||
const response = await client.magicLink.verify({
|
||||
query: {
|
||||
token: new URL(verificationEmail.url).searchParams.get("token")!,
|
||||
},
|
||||
fetchOptions: {
|
||||
onSuccess: sessionSetter(headers),
|
||||
},
|
||||
});
|
||||
expect(response.data?.token).toBeDefined();
|
||||
const betterAuthCookie = headers.get("set-cookie");
|
||||
expect(betterAuthCookie).toBeDefined();
|
||||
});
|
||||
|
||||
it("should sign up new user via magic link", async () => {
|
||||
const email = "new-secondary-user@test.com";
|
||||
await client.signIn.magicLink({
|
||||
email,
|
||||
name: "New User",
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
await client.magicLink.verify({
|
||||
query: {
|
||||
token: new URL(verificationEmail.url).searchParams.get("token")!,
|
||||
},
|
||||
fetchOptions: {
|
||||
onSuccess: sessionSetter(headers),
|
||||
},
|
||||
});
|
||||
const session = await client.getSession({
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
expect(session.data?.user).toMatchObject({
|
||||
name: "New User",
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should track attempts and reject when exceeded", async () => {
|
||||
const attemptStore = new Map<string, string>();
|
||||
let attemptEmail: VerificationEmail = { email: "", token: "", url: "" };
|
||||
|
||||
const {
|
||||
testUser: tu,
|
||||
sessionSetter: ss,
|
||||
client: c,
|
||||
} = await getTestInstance(
|
||||
{
|
||||
secondaryStorage: {
|
||||
set(key, value, ttl) {
|
||||
attemptStore.set(key, value);
|
||||
},
|
||||
get(key) {
|
||||
return attemptStore.get(key) || null;
|
||||
},
|
||||
delete(key) {
|
||||
attemptStore.delete(key);
|
||||
},
|
||||
},
|
||||
rateLimit: { enabled: false },
|
||||
plugins: [
|
||||
magicLink({
|
||||
allowedAttempts: 3,
|
||||
async sendMagicLink(data) {
|
||||
attemptEmail = data;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [magicLinkClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await c.signIn.magicLink({ email: tu.email });
|
||||
const token = new URL(attemptEmail.url).searchParams.get("token")!;
|
||||
|
||||
// 3 attempts should succeed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const headers = new Headers();
|
||||
const response = await c.magicLink.verify({
|
||||
query: { token },
|
||||
fetchOptions: { onSuccess: ss(headers) },
|
||||
});
|
||||
expect(response.data?.token).toBeDefined();
|
||||
}
|
||||
|
||||
// 4th attempt should be rejected
|
||||
await c.magicLink.verify(
|
||||
{ query: { token } },
|
||||
{
|
||||
onError(context) {
|
||||
expect(context.response.status).toBe(302);
|
||||
const location = context.response.headers.get("location");
|
||||
expect(location).toContain("?error=ATTEMPTS_EXCEEDED");
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should delete expired verification on verify", async () => {
|
||||
const expiredStore = new Map<string, string>();
|
||||
let expiredEmail: VerificationEmail = { email: "", token: "", url: "" };
|
||||
|
||||
const { testUser: tu, client: c } = await getTestInstance(
|
||||
{
|
||||
secondaryStorage: {
|
||||
set(key, value, ttl) {
|
||||
expiredStore.set(key, value);
|
||||
},
|
||||
get(key) {
|
||||
return expiredStore.get(key) || null;
|
||||
},
|
||||
delete(key) {
|
||||
expiredStore.delete(key);
|
||||
},
|
||||
},
|
||||
rateLimit: { enabled: false },
|
||||
plugins: [
|
||||
magicLink({
|
||||
expiresIn: 1, // 1 second
|
||||
async sendMagicLink(data) {
|
||||
expiredEmail = data;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [magicLinkClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await c.signIn.magicLink({ email: tu.email });
|
||||
const token = new URL(expiredEmail.url).searchParams.get("token")!;
|
||||
|
||||
// Wait for token to expire
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
await c.magicLink.verify(
|
||||
{ query: { token } },
|
||||
{
|
||||
onError(context) {
|
||||
expect(context.response.status).toBe(302);
|
||||
const location = context.response.headers.get("location");
|
||||
expect(location).toContain("?error=EXPIRED_TOKEN");
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Same tests but secondary storage returns already-parsed objects (like some
|
||||
* Redis client wrappers do). This exercises the `new Date()` defensive path
|
||||
* in updateVerificationByIdentifier.
|
||||
*
|
||||
* @see https://github.com/better-auth/better-auth/issues/8228
|
||||
*/
|
||||
describe("magic link with secondary storage (pre-parsed object return)", async () => {
|
||||
const store = new Map<string, any>();
|
||||
let verificationEmail: VerificationEmail = {
|
||||
email: "",
|
||||
token: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
const { testUser, sessionSetter, client } = await getTestInstance(
|
||||
{
|
||||
secondaryStorage: {
|
||||
set(key, value, ttl) {
|
||||
store.set(key, safeJSONParse(value));
|
||||
},
|
||||
get(key) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
delete(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
async sendMagicLink(data) {
|
||||
verificationEmail = data;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [magicLinkClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
it("should send and verify magic link", async () => {
|
||||
await client.signIn.magicLink({
|
||||
email: testUser.email,
|
||||
});
|
||||
expect(verificationEmail.email).toBe(testUser.email);
|
||||
|
||||
const headers = new Headers();
|
||||
const response = await client.magicLink.verify({
|
||||
query: {
|
||||
token: new URL(verificationEmail.url).searchParams.get("token")!,
|
||||
},
|
||||
fetchOptions: {
|
||||
onSuccess: sessionSetter(headers),
|
||||
},
|
||||
});
|
||||
expect(response.data?.token).toBeDefined();
|
||||
const betterAuthCookie = headers.get("set-cookie");
|
||||
expect(betterAuthCookie).toBeDefined();
|
||||
});
|
||||
|
||||
it("should track attempts with pre-parsed storage", async () => {
|
||||
const attemptStore = new Map<string, any>();
|
||||
let attemptEmail: VerificationEmail = { email: "", token: "", url: "" };
|
||||
|
||||
const {
|
||||
testUser: tu,
|
||||
sessionSetter: ss,
|
||||
client: c,
|
||||
} = await getTestInstance(
|
||||
{
|
||||
secondaryStorage: {
|
||||
set(key, value, ttl) {
|
||||
attemptStore.set(key, safeJSONParse(value));
|
||||
},
|
||||
get(key) {
|
||||
return attemptStore.get(key) ?? null;
|
||||
},
|
||||
delete(key) {
|
||||
attemptStore.delete(key);
|
||||
},
|
||||
},
|
||||
rateLimit: { enabled: false },
|
||||
plugins: [
|
||||
magicLink({
|
||||
allowedAttempts: 2,
|
||||
async sendMagicLink(data) {
|
||||
attemptEmail = data;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [magicLinkClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await c.signIn.magicLink({ email: tu.email });
|
||||
const token = new URL(attemptEmail.url).searchParams.get("token")!;
|
||||
|
||||
// 2 attempts should succeed
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const headers = new Headers();
|
||||
const response = await c.magicLink.verify({
|
||||
query: { token },
|
||||
fetchOptions: { onSuccess: ss(headers) },
|
||||
});
|
||||
expect(response.data?.token).toBeDefined();
|
||||
}
|
||||
|
||||
// 3rd attempt should be rejected
|
||||
await c.magicLink.verify(
|
||||
{ query: { token } },
|
||||
{
|
||||
onError(context) {
|
||||
expect(context.response.status).toBe(302);
|
||||
const location = context.response.headers.get("location");
|
||||
expect(location).toContain("?error=ATTEMPTS_EXCEEDED");
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user