From 8a0475ffd9294ea66153461d0fa73202a03efee9 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Sun, 1 Mar 2026 21:40:02 +0900 Subject: [PATCH] fix(db): support verification operations with secondary storage (#8247) --- .../better-auth/src/api/routes/password.ts | 2 +- .../better-auth/src/api/routes/update-user.ts | 4 +- .../src/db/internal-adapter.test.ts | 4 +- .../better-auth/src/db/internal-adapter.ts | 67 ++-- .../src/plugins/email-otp/routes.ts | 59 +-- .../src/plugins/magic-link/index.ts | 12 +- packages/better-auth/src/plugins/mcp/index.ts | 8 +- .../src/plugins/oidc-provider/index.ts | 16 +- .../src/plugins/one-time-token/index.ts | 4 +- .../src/plugins/phone-number/routes.ts | 34 +- .../better-auth/src/plugins/siwe/index.ts | 4 +- .../src/plugins/two-factor/index.ts | 4 +- .../src/plugins/two-factor/otp/index.ts | 12 +- .../plugins/two-factor/verify-two-factor.ts | 4 +- packages/better-auth/src/state.ts | 2 +- packages/core/src/types/context.ts | 6 +- packages/electron/src/routes.ts | 4 +- packages/electron/test/electron.test.ts | 2 +- packages/oauth-provider/src/authorize.ts | 13 +- packages/oauth-provider/src/token.ts | 6 +- packages/sso/src/index.ts | 4 +- packages/sso/src/routes/sso.ts | 10 +- .../unit/magic-link-secondary-storage.test.ts | 356 ++++++++++++++++++ 23 files changed, 513 insertions(+), 124 deletions(-) create mode 100644 test/unit/magic-link-secondary-storage.test.ts diff --git a/packages/better-auth/src/api/routes/password.ts b/packages/better-auth/src/api/routes/password.ts index 34aa8807f5..d6e247a7df 100644 --- a/packages/better-auth/src/api/routes/password.ts +++ b/packages/better-auth/src/api/routes/password.ts @@ -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); diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index 3a4e17a31e..e8275c41c3 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -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); diff --git a/packages/better-auth/src/db/internal-adapter.test.ts b/packages/better-auth/src/db/internal-adapter.test.ts index 8d782aa2f5..7c35d2a7b9 100644 --- a/packages/better-auth/src/db/internal-adapter.test.ts +++ b/packages/better-auth/src/db/internal-adapter.test.ts @@ -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(); }); diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 8e4d059ac1..0c806d2705 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -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, ) => { - const verification = await updateWithHooks( - 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(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( + data, + [{ field: "identifier", value: storedIdentifier }], + "verification", + undefined, + ); + return verification; + } + return data as Verification; }, }; }; diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts index e20e8438d8..1d6a72992a 100644 --- a/packages/better-auth/src/plugins/email-otp/routes.ts +++ b/packages/better-auth/src/plugins/email-otp/routes.ts @@ -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); diff --git a/packages/better-auth/src/plugins/magic-link/index.ts b/packages/better-auth/src/plugins/magic-link/index.ts index 7a950fbd0e..891f01463b 100644 --- a/packages/better-auth/src/plugins/magic-link/index.ts +++ b/packages/better-auth/src/plugins/magic-link/index.ts @@ -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, diff --git a/packages/better-auth/src/plugins/mcp/index.ts b/packages/better-auth/src/plugins/mcp/index.ts index 2b67c62d81..da9f77c25a 100644 --- a/packages/better-auth/src/plugins/mcp/index.ts +++ b/packages/better-auth/src/plugins/mcp/index.ts @@ -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"); diff --git a/packages/better-auth/src/plugins/oidc-provider/index.ts b/packages/better-auth/src/plugins/oidc-provider/index.ts index 0346960f4f..c81358c1c6 100644 --- a/packages/better-auth/src/plugins/oidc-provider/index.ts +++ b/packages/better-auth/src/plugins/oidc-provider/index.ts @@ -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"); diff --git a/packages/better-auth/src/plugins/one-time-token/index.ts b/packages/better-auth/src/plugins/one-time-token/index.ts index c03797ce0c..aa2b01d9be 100644 --- a/packages/better-auth/src/plugins/one-time-token/index.ts +++ b/packages/better-auth/src/plugins/one-time-token/index.ts @@ -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", { diff --git a/packages/better-auth/src/plugins/phone-number/routes.ts b/packages/better-auth/src/plugins/phone-number/routes.ts index 9d2f833db6..ed7139599f 100644 --- a/packages/better-auth/src/plugins/phone-number/routes.ts +++ b/packages/better-auth/src/plugins/phone-number/routes.ts @@ -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) { diff --git a/packages/better-auth/src/plugins/siwe/index.ts b/packages/better-auth/src/plugins/siwe/index.ts index fbf273c8f9..99f23eab32 100644 --- a/packages/better-auth/src/plugins/siwe/index.ts +++ b/packages/better-auth/src/plugins/siwe/index.ts @@ -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 diff --git a/packages/better-auth/src/plugins/two-factor/index.ts b/packages/better-auth/src/plugins/two-factor/index.ts index 076cc0b3f2..f77eb29412 100644 --- a/packages/better-auth/src/plugins/two-factor/index.ts +++ b/packages/better-auth/src/plugins/two-factor/index.ts @@ -373,8 +373,8 @@ export const twoFactor = (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( diff --git a/packages/better-auth/src/plugins/two-factor/otp/index.ts b/packages/better-auth/src/plugins/two-factor/otp/index.ts index 4e3a815071..332370f382 100644 --- a/packages/better-auth/src/plugins/two-factor/otp/index.ts +++ b/packages/better-auth/src/plugins/two-factor/otp/index.ts @@ -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}`, }, diff --git a/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts b/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts index 8ac113d685..71fd898d92 100644 --- a/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts +++ b/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts @@ -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, diff --git a/packages/better-auth/src/state.ts b/packages/better-auth/src/state.ts index afd90b3924..059ce93e24 100644 --- a/packages/better-auth/src/state.ts +++ b/packages/better-auth/src/state.ts @@ -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 diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index c434023e8c..bf5ee30dce 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -208,12 +208,10 @@ export interface InternalAdapter< findVerificationValue(identifier: string): Promise; - deleteVerificationValue(id: string): Promise; - deleteVerificationByIdentifier(identifier: string): Promise; - updateVerificationValue( - id: string, + updateVerificationByIdentifier( + identifier: string, data: Partial, ): Promise; } diff --git a/packages/electron/src/routes.ts b/packages/electron/src/routes.ts index 7f8639aeeb..e65be8d7f6 100644 --- a/packages/electron/src/routes.ts +++ b/packages/electron/src/routes.ts @@ -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, diff --git a/packages/electron/test/electron.test.ts b/packages/electron/test/electron.test.ts index a0add84c75..718b9079ba 100644 --- a/packages/electron/test/electron.test.ts +++ b/packages/electron/test/electron.test.ts @@ -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("/electron/token", { method: "POST", diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index ba6c61a9fb..33370609f9 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -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); diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index 40e50fb4b5..20e72b6cf8 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -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()) { diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts index de29fa18b5..8e91a2768e 100644 --- a/packages/sso/src/index.ts +++ b/packages/sso/src/index.ts @@ -219,10 +219,10 @@ export function sso( ); if (sessionLookup?.value) { await ctx.context.internalAdapter - .deleteVerificationValue(sessionLookup.value) + .deleteVerificationByIdentifier(sessionLookup.value) .catch(() => {}); await ctx.context.internalAdapter - .deleteVerificationValue(sessionLookupKey) + .deleteVerificationByIdentifier(sessionLookupKey) .catch(() => {}); } }), diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index ece7f32c08..4adae90609 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -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", diff --git a/test/unit/magic-link-secondary-storage.test.ts b/test/unit/magic-link-secondary-storage.test.ts new file mode 100644 index 0000000000..37c61d2909 --- /dev/null +++ b/test/unit/magic-link-secondary-storage.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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"); + }, + }, + ); + }); +});