From f23ce5012ea47fac1a69b1dad203dfdef3830fd0 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:02:04 +1000 Subject: [PATCH] feat(last-login-method): `beforeStoreCookie` option for GDPR compliance (#5753) --- .changeset/swift-cows-join.md | 5 + .cspell/third-party.txt | 2 + docs/content/docs/concepts/hooks.mdx | 2 +- docs/content/docs/integrations/nuxt.mdx | 1 + .../docs/plugins/last-login-method.mdx | 80 ++ .../src/plugins/last-login-method/index.ts | 36 + .../last-login-method.test.ts | 827 +++++++++++++++++- 7 files changed, 948 insertions(+), 5 deletions(-) create mode 100644 .changeset/swift-cows-join.md diff --git a/.changeset/swift-cows-join.md b/.changeset/swift-cows-join.md new file mode 100644 index 0000000000..ca2d3f486d --- /dev/null +++ b/.changeset/swift-cows-join.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +feat(last-login-method): beforeStoreCookie option for GDPR compliance diff --git a/.cspell/third-party.txt b/.cspell/third-party.txt index 891060a757..e9ab2943e2 100644 --- a/.cspell/third-party.txt +++ b/.cspell/third-party.txt @@ -46,4 +46,6 @@ encoredev korthout esac elif +anthropics +nextjs diff --git a/docs/content/docs/concepts/hooks.mdx b/docs/content/docs/concepts/hooks.mdx index 43b20e373e..488e1d81bb 100644 --- a/docs/content/docs/concepts/hooks.mdx +++ b/docs/content/docs/concepts/hooks.mdx @@ -118,7 +118,7 @@ export const auth = betterAuth({ ``` -Each hook (`before` / `after`) takes a single middleware function, not an array. To run logic for different endpoints, branch on `ctx.path` inside that single function. + Each hook (`before` / `after`) takes a single middleware function, not an array. To run logic for different endpoints, branch on `ctx.path` inside that single function. ## Ctx diff --git a/docs/content/docs/integrations/nuxt.mdx b/docs/content/docs/integrations/nuxt.mdx index 08b151f2a7..d0468e0815 100644 --- a/docs/content/docs/integrations/nuxt.mdx +++ b/docs/content/docs/integrations/nuxt.mdx @@ -152,6 +152,7 @@ export default defineNuxtConfig({ ## Resources & examples {/* cspell:disable */} + * [`nuxt-modules/better-auth`](https://github.com/nuxt-modules/better-auth) * [`atinux/nuxthub-better-auth`](https://github.com/atinux/nuxthub-better-auth) * [`leamsigc/nuxt-better-auth-drizzle`](https://github.com/leamsigc/nuxt-better-auth-drizzle) diff --git a/docs/content/docs/plugins/last-login-method.mdx b/docs/content/docs/plugins/last-login-method.mdx index 2c97e3cbf4..a749f00b0a 100644 --- a/docs/content/docs/plugins/last-login-method.mdx +++ b/docs/content/docs/plugins/last-login-method.mdx @@ -255,6 +255,14 @@ export const auth = betterAuth({ return null }, + // GDPR compliance hook + beforeStoreCookie: async (ctx, lastUsedLoginMethod) => { + // Check if user has given consent for non-essential cookies + // Return false to prevent cookie storage + const hasConsent = await checkUserCookieConsent(ctx) + return hasConsent + }, + // Schema customization (when storeInDatabase is true) schema: { user: { @@ -282,6 +290,14 @@ export const auth = betterAuth({ * Whether to store the last login method in the database * Default: `false` * When enabled, adds a `lastLoginMethod` field to the user table +* To store the method only in the database (no cookie), combine with `beforeStoreCookie: () => false`: + +```ts title="auth.ts" +lastLoginMethod({ + storeInDatabase: true, + beforeStoreCookie: () => false, // never set the non-essential cookie +}) +``` **customResolveMethod**: `(ctx: GenericEndpointContext) => string | null` @@ -289,6 +305,32 @@ export const auth = betterAuth({ * Return `null` to use the default resolution logic * Useful for custom OAuth providers or authentication flows +**beforeStoreCookie**: `(ctx: GenericEndpointContext, lastUsedLoginMethod: string) => Promise | boolean` + +* Hook function that runs before storing the last login method cookie +* Return `true` to allow the cookie to be set, `false` to prevent it +* Useful for GDPR compliance or other regulations where cookie storage requires user consent +* If the function throws an error, the cookie will not be set (error is logged but doesn't break authentication) +* **Example**: Check user consent before storing the cookie + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { lastLoginMethod } from "better-auth/plugins" + +export const auth = betterAuth({ + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async (ctx, lastUsedLoginMethod) => { + // Check if user has given consent for non-essential cookies + // This is important for GDPR compliance + const hasConsent = await checkUserCookieConsent(ctx) + return hasConsent + } + }) + ] +}) +``` + **schema**: `object` * Customize database field names when `storeInDatabase` is enabled @@ -364,6 +406,44 @@ export const authClient = createAuthClient({ }) ``` +## GDPR Compliance + +The last login method cookie is considered a non-essential cookie under GDPR regulations. +To comply with GDPR and similar privacy laws, you should only store this cookie if the user has given explicit consent. + +The `beforeStoreCookie` hook allows you to implement consent checks before storing the cookie: + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { lastLoginMethod } from "better-auth/plugins" + +export const auth = betterAuth({ + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async (ctx, lastUsedLoginMethod) => { + // Example 1: Check consent from session or database + const session = await getSessionFromCtx(ctx) + if (session?.user) { + // custom function which hits your database to check if the user has given consent + const userConsent = await checkUserConsent(session.user.id) + return userConsent?.allowsNonEssentialCookies ?? false + } + + // Example 2: Check consent from request headers (cookie banner) + // parseConsentCookie should return false/null/undefined when no consent is present + const consentCookie = ctx.request?.headers?.get("cookie") + const hasConsent = parseConsentCookie(consentCookie) + return !!hasConsent + } + }) + ] +}) +``` + + + When `beforeStoreCookie` returns `false` or throws an error, the cookie will not be set. Authentication will still succeed normally - only the cookie storage is prevented. + + ## Advanced Examples ### Custom Provider Tracking diff --git a/packages/better-auth/src/plugins/last-login-method/index.ts b/packages/better-auth/src/plugins/last-login-method/index.ts index ae335d5fd1..9a8c5ea6f1 100644 --- a/packages/better-auth/src/plugins/last-login-method/index.ts +++ b/packages/better-auth/src/plugins/last-login-method/index.ts @@ -39,6 +39,20 @@ export interface LastLoginMethodOptions { * @default false */ storeInDatabase?: boolean | undefined; + /** + * A hook to run before the last login method is stored in the cookie. + * Useful if you are required to follow GDPR or other regulations to ensure that you're allowed to store the last login method in the cookie. + * + * @param ctx - The context from the hook + * @param lastUsedLoginMethod - The last login method + * @returns `true` to store the cookie, `false` to skip storing it (authentication continues either way) + */ + beforeStoreCookie?: + | (( + ctx: GenericEndpointContext, + lastUsedLoginMethod: string, + ) => Promise | boolean) + | undefined; /** * Custom schema for the plugin * @default undefined @@ -168,6 +182,28 @@ export const lastLoginMethod = ( httpOnly: false, // Override: plugin cookies are not httpOnly }; + let isPermitted = true; + if (config.beforeStoreCookie) { + try { + isPermitted = await config.beforeStoreCookie( + ctx, + lastUsedLoginMethod, + ); + } catch (error) { + // If beforeStoreCookie throws an error, don't set the cookie + // Log the error but don't break the authentication flow + if (ctx.context.logger) { + ctx.context.logger.error?.( + "[LastLoginMethod] Error in beforeStoreCookie hook", + error, + ); + } + isPermitted = false; + } + } + + if (!isPermitted) return; + ctx.setCookie( config.cookieName, lastUsedLoginMethod, diff --git a/packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts b/packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts index 97c2383deb..2aa807d45b 100644 --- a/packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts +++ b/packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts @@ -703,6 +703,829 @@ describe("lastLoginMethod", async () => { ); }); + describe("beforeStoreCookie hook", () => { + it("should set cookie when beforeStoreCookie returns true", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => true, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should NOT set cookie when beforeStoreCookie returns false", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => false, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + }); + + it("should set cookie when beforeStoreCookie returns Promise", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return true; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should NOT set cookie when beforeStoreCookie returns Promise", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return false; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + }); + + it("should set cookie when beforeStoreCookie is undefined (default behavior)", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [lastLoginMethod()], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should conditionally set cookie based on login method", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: (_ctx, lastUsedLoginMethod) => { + // Only allow storing for email logins + return lastUsedLoginMethod === "email"; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Email login should set cookie + const emailHeaders = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(emailHeaders)(context); + }, + }, + ); + + const emailCookies = parseCookies(emailHeaders.get("cookie") || ""); + expect(emailCookies.get("better-auth.last_used_login_method")).toBe( + "email", + ); + + // OAuth login should NOT set cookie + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + const state = + new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + const oauthHeaders = new Headers(); + await client.$fetch("/callback/google", { + query: { + state, + code: "test", + }, + headers: oAuthHeaders, + method: "GET", + onError(context) { + cookieSetter(oauthHeaders)(context as any); + }, + }); + + const oauthCookies = parseCookies(oauthHeaders.get("cookie") || ""); + expect( + oauthCookies.get("better-auth.last_used_login_method"), + ).toBeUndefined(); + }); + + it("should conditionally set cookie based on context properties", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: (ctx) => { + // Only allow storing if path contains "sign-in" + return ctx.path.includes("sign-in"); + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Sign-in should set cookie + const signInHeaders = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(signInHeaders)(context); + }, + }, + ); + + const signInCookies = parseCookies(signInHeaders.get("cookie") || ""); + expect(signInCookies.get("better-auth.last_used_login_method")).toBe( + "email", + ); + }); + + it("should allow different behavior for different login methods", async () => { + const allowedMethods = new Set(["email", "google"]); + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: (_ctx, lastUsedLoginMethod) => { + return allowedMethods.has(lastUsedLoginMethod); + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Email login should set cookie + const emailHeaders = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(emailHeaders)(context); + }, + }, + ); + + const emailCookies = parseCookies(emailHeaders.get("cookie") || ""); + expect(emailCookies.get("better-auth.last_used_login_method")).toBe( + "email", + ); + + // Google OAuth should set cookie + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + const state = + new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + const oauthHeaders = new Headers(); + await client.$fetch("/callback/google", { + query: { + state, + code: "test", + }, + headers: oAuthHeaders, + method: "GET", + onError(context) { + cookieSetter(oauthHeaders)(context as any); + const cookies = parseSetCookieHeader( + context.response.headers.get("set-cookie") || "", + ); + const lastLoginMethodCookie = cookies.get( + "better-auth.last_used_login_method", + )?.value; + if (lastLoginMethodCookie) { + expect(lastLoginMethodCookie).toBe("google"); + } + }, + }); + + const oauthCookies = parseCookies(oauthHeaders.get("cookie") || ""); + expect(oauthCookies.get("better-auth.last_used_login_method")).toBe( + "google", + ); + }); + + it("should handle dynamic consent changes between logins", async () => { + let consentGiven = false; + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => consentGiven, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // First login without consent - cookie should NOT be set + const headers1 = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers1)(context); + }, + }, + ); + + const cookies1 = parseCookies(headers1.get("cookie") || ""); + expect( + cookies1.get("better-auth.last_used_login_method"), + ).toBeUndefined(); + + // Simulate user giving consent + consentGiven = true; + + // Second login with consent - cookie should be set + await client.signOut(); + const headers2 = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers2)(context); + }, + }, + ); + + const cookies2 = parseCookies(headers2.get("cookie") || ""); + expect(cookies2.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should handle falsy return values correctly", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => { + // Test various falsy values + return 0 as any; // TypeScript allows this, but runtime will treat as false + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + // 0 is falsy, so cookie should NOT be set + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + }); + + it("should handle truthy return values correctly", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => { + // Test truthy value + return 1 as any; // TypeScript allows this, runtime will treat as true + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + // 1 is truthy, so cookie should be set + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should receive correct context and lastUsedLoginMethod parameters", async () => { + let receivedContext: any = null; + let receivedMethod: string | null = null; + + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: (ctx, lastUsedLoginMethod) => { + receivedContext = ctx; + receivedMethod = lastUsedLoginMethod; + return true; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + expect(receivedContext).toBeDefined(); + expect(receivedContext.path).toBe("/sign-in/email"); + expect(receivedMethod).toBe("email"); + }); + + it("should work correctly with sign-up flow", async () => { + const { client, cookieSetter } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: (_ctx, lastUsedLoginMethod) => { + // Only allow for sign-up + return lastUsedLoginMethod === "email"; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signUp.email( + { + email: "newuser@example.com", + password: "password123", + name: "New User", + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should handle async operations in beforeStoreCookie", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async (_ctx, lastUsedLoginMethod) => { + // Simulate async GDPR check + await new Promise((resolve) => setTimeout(resolve, 50)); + // Simulate checking user consent from database + return lastUsedLoginMethod === "email"; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should NOT set cookie when beforeStoreCookie throws an error", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: () => { + throw new Error("GDPR check failed"); + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Error should be caught gracefully, authentication should succeed, but cookie should not be set + const headers = new Headers(); + let responseHeaders: Headers | null = null; + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + responseHeaders = context.response.headers; + }, + }, + ); + + // Verify cookie is not set + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + + // Also check response headers + if (responseHeaders) { + const setCookieHeader = + (responseHeaders as Headers).get("set-cookie") || ""; + const setCookie = parseSetCookieHeader(setCookieHeader); + expect( + setCookie.get("better-auth.last_used_login_method"), + ).toBeUndefined(); + } + }); + + it("should NOT set cookie when beforeStoreCookie returns a rejected promise", async () => { + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + throw new Error("GDPR check failed"); + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Error should be caught gracefully, authentication should succeed, but cookie should not be set + const headers = new Headers(); + let responseHeaders: Headers | null = null; + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + responseHeaders = context.response.headers; + }, + }, + ); + + // Verify cookie is not set + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + + // Also check response headers + if (responseHeaders) { + const setCookieHeader = + (responseHeaders as Headers).get("set-cookie") || ""; + const setCookie = parseSetCookieHeader(setCookieHeader); + expect( + setCookie.get("better-auth.last_used_login_method"), + ).toBeUndefined(); + } + }); + + it("should handle complex GDPR consent logic", async () => { + const userConsents = new Map(); + userConsents.set("test-gdpr@example.com", true); + + const { client, cookieSetter } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + beforeStoreCookie: async (ctx, lastUsedLoginMethod) => { + // Simulate checking user consent from database + // Since ctx.body might not be available in the hook context, + // we'll use a simpler approach: check based on login method + // In a real scenario, you'd look up the user's consent from the database + // based on the session or user ID from ctx + return lastUsedLoginMethod === "email"; + }, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + // Create user with consent first + await client.signUp.email( + { + email: "test-gdpr@example.com", + password: "password123", + name: "Test GDPR User", + }, + { throw: true }, + ); + + // User with consent - email login should work + const headers1 = new Headers(); + await client.signIn.email( + { + email: "test-gdpr@example.com", + password: "password123", + }, + { + onSuccess(context) { + cookieSetter(headers1)(context); + }, + }, + ); + + const cookies1 = parseCookies(headers1.get("cookie") || ""); + expect(cookies1.get("better-auth.last_used_login_method")).toBe("email"); + }); + + it("should work correctly with custom cookie name", async () => { + const customCookieName = "custom.last_login_method"; + const { client, cookieSetter, testUser } = await getTestInstance( + { + plugins: [ + lastLoginMethod({ + cookieName: customCookieName, + beforeStoreCookie: () => true, + }), + ], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient()], + }, + }, + ); + + const headers = new Headers(); + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onSuccess(context) { + cookieSetter(headers)(context); + }, + }, + ); + + const cookies = parseCookies(headers.get("cookie") || ""); + expect(cookies.get(customCookieName)).toBe("email"); + expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); + }); + + it("should combine beforeStoreCookie with storeInDatabase correctly", async () => { + const { client, auth } = await getTestInstance({ + plugins: [ + lastLoginMethod({ + storeInDatabase: true, + beforeStoreCookie: (_ctx, lastUsedLoginMethod) => { + // Only store cookie for email, but database will store anyway + return lastUsedLoginMethod === "email"; + }, + }), + ], + }); + + const data = await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { throw: true }, + ); + + const session = await auth.api.getSession({ + headers: new Headers({ + authorization: `Bearer ${data.token}`, + }), + }); + + // Database should still store even if cookie is blocked + expect(session?.user.lastLoginMethod).toBe("email"); + }); + }); + /** * @see https://github.com/better-auth/better-auth/issues/9276 */ @@ -784,7 +1607,6 @@ describe("lastLoginMethod", async () => { }); it("should handle multiple set-cookie headers correctly", async () => { - // Create a custom plugin that sets an additional cookie to simulate multiple Set-Cookie headers const multiCookiePlugin = { id: "multi-cookie-test", hooks: { @@ -798,7 +1620,6 @@ describe("lastLoginMethod", async () => { ctx.context.responseHeaders?.getSetCookie?.() || []; const sessionTokenName = ctx.context.authCookies.sessionToken.name; - // If session token is being set, also set an additional cookie BEFORE checking const hasSessionToken = setCookieHeaders.some((cookie) => cookie.includes(sessionTokenName), ); @@ -835,12 +1656,10 @@ describe("lastLoginMethod", async () => { onSuccess(context) { cookieSetter(headers)(context); - // Verify that multiple cookies are present in the response const setCookieHeaders = context.response.headers.getSetCookie?.() || []; expect(setCookieHeaders.length).toBeGreaterThan(1); - // Verify both cookies are present const cookieStrings = setCookieHeaders.join(";"); expect(cookieStrings).toContain("additional-test-cookie=test-value"); expect(cookieStrings).toContain(