mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-21 17:36:23 -05:00
feat(last-login-method): beforeStoreCookie option for GDPR compliance (#5753)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
---
|
||||
|
||||
feat(last-login-method): beforeStoreCookie option for GDPR compliance
|
||||
@@ -46,4 +46,6 @@ encoredev
|
||||
korthout
|
||||
esac
|
||||
elif
|
||||
anthropics
|
||||
nextjs
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export const auth = betterAuth({
|
||||
```
|
||||
|
||||
<Callout>
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
## Ctx
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> | 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
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Custom Provider Tracking
|
||||
|
||||
@@ -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> | boolean)
|
||||
| undefined;
|
||||
/**
|
||||
* Custom schema for the plugin
|
||||
* @default undefined
|
||||
@@ -168,6 +182,28 @@ export const lastLoginMethod = <O extends LastLoginMethodOptions>(
|
||||
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,
|
||||
|
||||
@@ -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<true>", 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<false>", 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<string, boolean>();
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user