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(