diff --git a/docs/content/docs/plugins/magic-link.mdx b/docs/content/docs/plugins/magic-link.mdx index ae191364ed..3cbbb716a4 100644 --- a/docs/content/docs/plugins/magic-link.mdx +++ b/docs/content/docs/plugins/magic-link.mdx @@ -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: diff --git a/packages/better-auth/src/plugins/magic-link/index.ts b/packages/better-auth/src/plugins/magic-link/index.ts index 3623c0261e..4a5a499324 100644 --- a/packages/better-auth/src/plugins/magic-link/index.ts +++ b/packages/better-auth/src/plugins/magic-link/index.ts @@ -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) diff --git a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts index b8fdcdbcbc..4531bde01c 100644 --- a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts +++ b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts @@ -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(); + } + }); +}); \ No newline at end of file