mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-22 22:32:01 -05:00
Add allowedAttempts setting #1
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user