Add allowedAttempts setting #1

This commit is contained in:
Vadim Ivanov
2026-02-04 21:03:03 +08:00
parent bd662ed50c
commit fb2ec774e5
3 changed files with 294 additions and 5 deletions

View File

@@ -134,6 +134,8 @@ and a `request` object as the second parameter.
**expiresIn**: specifies the time in seconds after which the magic link will expire. The default value is `300` seconds (5 minutes).
**allowedAttempts**: Specifies the number of allowed attempts for verifying the magic link. The default value is `1`. When the limit is exceeded, the token is deleted and the user is redirected with `?error=ATTEMPTS_EXCEEDED`. Set to `Infinity` to allow unlimited attempts.
**disableSignUp**: If set to `true`, the user will not be able to sign up using the magic link. The default value is `false`.
**generateToken**: The `generateToken` function is called to generate a token which is used to uniquely identify the user. The default value is a random string. There is one parameter:

View File

@@ -15,6 +15,12 @@ interface MagicLinkopts {
* @default (60 * 5) // 5 minutes
*/
expiresIn?: number;
/**
* Allowed attempts for verifying the magic link token.
* Note: Passing Infinity will allow unlimited attempts.
* @default 1
*/
allowedAttempts?: number;
/**
* Send magic link implementation.
*/
@@ -64,6 +70,7 @@ interface MagicLinkopts {
export const magicLink = (options: MagicLinkopts) => {
const opts = {
storeToken: "plain",
allowedAttempts: 1,
...options,
} satisfies MagicLinkopts;
@@ -182,7 +189,7 @@ export const magicLink = (options: MagicLinkopts) => {
await ctx.context.internalAdapter.createVerificationValue(
{
identifier: storedToken,
value: JSON.stringify({ email, name: ctx.body.name }),
value: JSON.stringify({ email, name: ctx.body.name, attempt: 0 }),
expiresAt: new Date(
Date.now() + (opts.expiresIn || 60 * 5) * 1000,
),
@@ -351,13 +358,33 @@ export const magicLink = (options: MagicLinkopts) => {
);
throw ctx.redirect(`${errorCallbackURL}?error=EXPIRED_TOKEN`);
}
await ctx.context.internalAdapter.deleteVerificationValue(
tokenValue.id,
);
const {
attempt = 0,
} = JSON.parse(tokenValue.value) as {
email: string;
name?: string | undefined;
attempt?: number | undefined;
};
if (attempt >= opts.allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationValue(
tokenValue.id,
);
throw ctx.redirect(`${errorCallbackURL}?error=ATTEMPTS_EXCEEDED`);
}
const { email, name } = JSON.parse(tokenValue.value) as {
email: string;
name?: string;
};
await ctx.context.internalAdapter.updateVerificationValue(
tokenValue.id,
{
value: JSON.stringify({
email,
name,
attempt: attempt + 1,
}),
},
);
let isNewUser = false;
let user = await ctx.context.internalAdapter
.findUserByEmail(email)

View File

@@ -73,7 +73,7 @@ describe("magic link", async () => {
onError(context) {
expect(context.response.status).toBe(302);
const location = context.response.headers.get("location");
expect(location).toContain("?error=INVALID_TOKEN");
expect(location).toContain("?error=ATTEMPTS_EXCEEDED");
},
},
);
@@ -303,3 +303,263 @@ describe("magic link storeToken", async () => {
expect(response2.status).toBe(true);
});
});
describe("magic link allowedAttempts", async () => {
it("should reject second verification attempt with default allowedAttempts (1)", async () => {
let verificationEmail: VerificationEmail = {
email: "",
token: "",
url: "",
};
const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({
plugins: [
magicLink({
async sendMagicLink(data) {
verificationEmail = data;
},
}),
],
});
const client = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000",
basePath: "/api/auth",
});
await client.signIn.magicLink({
email: testUser.email,
});
const token =
new URL(verificationEmail.url).searchParams.get("token") || "";
// First attempt should succeed
const headers = new Headers();
const response = await client.magicLink.verify({
query: {
token,
},
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
expect(response.data?.token).toBeDefined();
const betterAuthCookie = headers.get("set-cookie");
expect(betterAuthCookie).toBeDefined();
// Second attempt should be rejected with ATTEMPTS_EXCEEDED
await client.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");
},
onSuccess() {
throw new Error("Should not succeed");
},
},
);
});
it("should respect allowedAttempts value of 3", async () => {
let verificationEmail: VerificationEmail = {
email: "",
token: "",
url: "",
};
const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({
plugins: [
magicLink({
allowedAttempts: 3,
async sendMagicLink(data) {
verificationEmail = data;
},
}),
],
});
const client = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000",
basePath: "/api/auth",
});
await client.signIn.magicLink({
email: testUser.email,
});
const token =
new URL(verificationEmail.url).searchParams.get("token") || "";
// 3 attempts should succeed
for (let i = 0; i < 3; i++) {
const headers = new Headers();
const response = await client.magicLink.verify({
query: {
token,
},
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
expect(response.data?.token).toBeDefined();
const betterAuthCookie = headers.get("set-cookie");
expect(betterAuthCookie).toBeDefined();
}
// Fourth attempt should be rejected with ATTEMPTS_EXCEEDED
await client.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");
},
onSuccess() {
throw new Error("Should not succeed");
},
},
);
});
it("shouldn't verify magic link with an expired token on second attempt", async () => {
let verificationEmail: VerificationEmail = {
email: "",
token: "",
url: "",
};
const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({
plugins: [
magicLink({
allowedAttempts: 3,
async sendMagicLink(data) {
verificationEmail = data;
},
}),
],
});
const client = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000",
basePath: "/api/auth",
});
await client.signIn.magicLink({
email: testUser.email,
});
const token =
new URL(verificationEmail.url).searchParams.get("token") || "";
// 2 attempts should succeed
for (let i = 0; i < 2; i++) {
const headers = new Headers();
const response = await client.magicLink.verify({
query: {
token,
},
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
expect(response.data?.token).toBeDefined();
const betterAuthCookie = headers.get("set-cookie");
expect(betterAuthCookie).toBeDefined();
}
// Third attempt after expiration should be rejected with EXPIRED_TOKEN
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 5 + 1);
const _response = await client.magicLink.verify(
{
query: {
token,
callbackURL: "/callback",
},
},
{
onError(context) {
expect(context.response.status).toBe(302);
const location = context.response.headers.get("location");
expect(location).toContain("?error=EXPIRED_TOKEN");
},
onSuccess() {
throw new Error("Should not succeed");
},
},
);
});
it("should respect allowedAttempts value of Infinity", async () => {
let verificationEmail: VerificationEmail = {
email: "",
token: "",
url: "",
};
const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({
plugins: [
magicLink({
allowedAttempts: Infinity,
async sendMagicLink(data) {
verificationEmail = data;
},
}),
],
});
const client = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000",
basePath: "/api/auth",
});
await client.signIn.magicLink({
email: testUser.email,
});
const token =
new URL(verificationEmail.url).searchParams.get("token") || "";
// verify that at least 10 attempts succeed
for (let i = 0; i < 10; i++) {
const headers = new Headers();
const response = await client.magicLink.verify({
query: {
token,
},
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
expect(response.data?.token).toBeDefined();
const betterAuthCookie = headers.get("set-cookie");
expect(betterAuthCookie).toBeDefined();
}
});
});