fix(db): support verification operations with secondary storage (#8247)

This commit is contained in:
Alex Yang
2026-03-01 23:13:50 +09:00
parent 01ae4a7238
commit 8a0475ffd9
23 changed files with 513 additions and 124 deletions
@@ -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();
});
+46 -21
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -4
View File
@@ -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>;
}
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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",
+4 -9
View File
@@ -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);
+3 -3
View File
@@ -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()) {
+2 -2
View File
@@ -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(() => {});
}
}),
+5 -5
View File
@@ -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");
},
},
);
});
});