Skip to content

Commit 78424b4

Browse files
authored
fix(lastLoginMethod): inherit cross-subdomain cookie settings in lastLoginMethod plugin (#4572)
1 parent acb28e2 commit 78424b4

File tree

3 files changed

+249
-6
lines changed

3 files changed

+249
-6
lines changed

docs/content/docs/plugins/last-login-method.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export const auth = betterAuth({
260260
**cookieName**: `string`
261261
- The name of the cookie used to store the last login method
262262
- Default: `"better-auth.last_used_login_method"`
263+
- **Note**: This cookie is `httpOnly: false` to allow client-side JavaScript access for UI features
263264

264265
**maxAge**: `number`
265266
- Cookie expiration time in seconds
@@ -314,6 +315,15 @@ The plugin automatically detects the method from these endpoints:
314315
- `/sign-in/email` - Email sign in
315316
- `/sign-up/email` - Email sign up
316317

318+
## Cross-Domain Support
319+
320+
The plugin automatically inherits cookie settings from Better Auth's centralized cookie system. This solves the problem where the last login method wouldn't persist across:
321+
322+
- **Cross-subdomain setups**: `auth.example.com``app.example.com`
323+
- **Cross-origin setups**: `api.company.com``app.different.com`
324+
325+
When you enable `crossSubDomainCookies` or `crossOriginCookies` in your Better Auth config, the plugin will automatically use the same domain, secure, and sameSite settings as your session cookies, ensuring consistent behavior across your application.
326+
317327
## Advanced Examples
318328

319329
### Custom Provider Tracking
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getTestInstance } from "../../test-utils/test-instance";
3+
import { lastLoginMethod } from ".";
4+
import { lastLoginMethodClient } from "./client";
5+
import { parseCookies } from "../../cookies";
6+
7+
describe("lastLoginMethod custom cookie prefix", async () => {
8+
it("should work with default cookie name regardless of custom prefix", async () => {
9+
const { client, cookieSetter, testUser } = await getTestInstance(
10+
{
11+
advanced: {
12+
cookiePrefix: "custom-auth",
13+
},
14+
plugins: [lastLoginMethod()],
15+
},
16+
{
17+
clientOptions: {
18+
plugins: [lastLoginMethodClient()],
19+
},
20+
},
21+
);
22+
23+
const headers = new Headers();
24+
await client.signIn.email(
25+
{
26+
email: testUser.email,
27+
password: testUser.password,
28+
},
29+
{
30+
onSuccess(context) {
31+
cookieSetter(headers)(context);
32+
},
33+
},
34+
);
35+
const cookies = parseCookies(headers.get("cookie") || "");
36+
// Uses exact cookie name from config, not affected by cookiePrefix
37+
expect(cookies.get("better-auth.last_used_login_method")).toBe("email");
38+
});
39+
40+
it("should work with custom cookie name and prefix", async () => {
41+
const { client, cookieSetter, testUser } = await getTestInstance(
42+
{
43+
advanced: {
44+
cookiePrefix: "my-app",
45+
},
46+
plugins: [lastLoginMethod({ cookieName: "my-app.last_method" })],
47+
},
48+
{
49+
clientOptions: {
50+
plugins: [lastLoginMethodClient()],
51+
},
52+
},
53+
);
54+
55+
const headers = new Headers();
56+
await client.signIn.email(
57+
{
58+
email: testUser.email,
59+
password: testUser.password,
60+
},
61+
{
62+
onSuccess(context) {
63+
cookieSetter(headers)(context);
64+
},
65+
},
66+
);
67+
const cookies = parseCookies(headers.get("cookie") || "");
68+
expect(cookies.get("my-app.last_method")).toBe("email");
69+
});
70+
71+
it("should work with custom cookie name regardless of prefix", async () => {
72+
const { client, cookieSetter, testUser } = await getTestInstance(
73+
{
74+
advanced: {
75+
cookiePrefix: "my-app",
76+
},
77+
plugins: [lastLoginMethod({ cookieName: "last_login_method" })],
78+
},
79+
{
80+
clientOptions: {
81+
plugins: [lastLoginMethodClient()],
82+
},
83+
},
84+
);
85+
86+
const headers = new Headers();
87+
await client.signIn.email(
88+
{
89+
email: testUser.email,
90+
password: testUser.password,
91+
},
92+
{
93+
onSuccess(context) {
94+
cookieSetter(headers)(context);
95+
},
96+
},
97+
);
98+
const cookies = parseCookies(headers.get("cookie") || "");
99+
// Uses exact cookie name from config, not affected by cookiePrefix
100+
expect(cookies.get("last_login_method")).toBe("email");
101+
});
102+
103+
it("should work with cross-subdomain and custom prefix", async () => {
104+
const { client, testUser } = await getTestInstance(
105+
{
106+
baseURL: "https://auth.example.com",
107+
advanced: {
108+
cookiePrefix: "custom-auth",
109+
crossSubDomainCookies: {
110+
enabled: true,
111+
domain: "example.com",
112+
},
113+
},
114+
plugins: [lastLoginMethod()],
115+
},
116+
{
117+
clientOptions: {
118+
plugins: [lastLoginMethodClient()],
119+
},
120+
},
121+
);
122+
123+
await client.signIn.email(
124+
{
125+
email: testUser.email,
126+
password: testUser.password,
127+
},
128+
{
129+
onResponse(context) {
130+
const setCookie = context.response.headers.get("set-cookie");
131+
expect(setCookie).toContain("Domain=example.com");
132+
expect(setCookie).toContain("SameSite=Lax");
133+
// Uses exact cookie name from config, not affected by cookiePrefix
134+
expect(setCookie).toContain(
135+
"better-auth.last_used_login_method=email",
136+
);
137+
},
138+
},
139+
);
140+
});
141+
142+
it("should work with cross-origin cookies", async () => {
143+
const { client, testUser } = await getTestInstance(
144+
{
145+
baseURL: "https://api.example.com",
146+
advanced: {
147+
crossOriginCookies: {
148+
enabled: true,
149+
},
150+
defaultCookieAttributes: {
151+
sameSite: "none",
152+
secure: true,
153+
},
154+
},
155+
plugins: [lastLoginMethod()],
156+
},
157+
{
158+
clientOptions: {
159+
plugins: [lastLoginMethodClient()],
160+
},
161+
},
162+
);
163+
164+
await client.signIn.email(
165+
{
166+
email: testUser.email,
167+
password: testUser.password,
168+
},
169+
{
170+
onResponse(context) {
171+
const setCookie = context.response.headers.get("set-cookie");
172+
expect(setCookie).toContain("SameSite=None");
173+
expect(setCookie).toContain("Secure");
174+
// Should not contain Domain attribute for cross-origin
175+
expect(setCookie).not.toContain("Domain=");
176+
expect(setCookie).toContain(
177+
"better-auth.last_used_login_method=email",
178+
);
179+
},
180+
},
181+
);
182+
});
183+
184+
it("should handle cross-origin on localhost for development", async () => {
185+
const { client, testUser } = await getTestInstance(
186+
{
187+
baseURL: "http://localhost:3000",
188+
advanced: {
189+
crossOriginCookies: {
190+
enabled: true,
191+
allowLocalhostUnsecure: true,
192+
},
193+
defaultCookieAttributes: {
194+
sameSite: "none",
195+
secure: false,
196+
},
197+
},
198+
plugins: [lastLoginMethod()],
199+
},
200+
{
201+
clientOptions: {
202+
plugins: [lastLoginMethodClient()],
203+
},
204+
},
205+
);
206+
207+
await client.signIn.email(
208+
{
209+
email: testUser.email,
210+
password: testUser.password,
211+
},
212+
{
213+
onResponse(context) {
214+
const setCookie = context.response.headers.get("set-cookie");
215+
expect(setCookie).toContain("SameSite=None");
216+
// Should not contain Secure on localhost when allowLocalhostUnsecure is true
217+
expect(setCookie).not.toContain("Secure");
218+
expect(setCookie).toContain(
219+
"better-auth.last_used_login_method=email",
220+
);
221+
},
222+
},
223+
);
224+
});
225+
});

packages/better-auth/src/plugins/last-login-method/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,21 @@ export const lastLoginMethod = <O extends LastLoginMethodOptions>(
9797
},
9898
handler: createAuthMiddleware(async (ctx) => {
9999
const lastUsedLoginMethod = config.customResolveMethod(ctx);
100-
lastUsedLoginMethod &&
101-
ctx.setCookie(config.cookieName, lastUsedLoginMethod, {
100+
if (lastUsedLoginMethod) {
101+
// Inherit cookie attributes from Better Auth's centralized cookie system
102+
// This ensures consistency with cross-origin, cross-subdomain, and security settings
103+
const cookieAttributes = {
104+
...ctx.context.authCookies.sessionToken.options,
102105
maxAge: config.maxAge,
103-
secure: false,
104-
httpOnly: false,
105-
path: "/",
106-
});
106+
httpOnly: false, // Override: plugin cookies are not httpOnly
107+
};
108+
109+
ctx.setCookie(
110+
config.cookieName,
111+
lastUsedLoginMethod,
112+
cookieAttributes,
113+
);
114+
}
107115
}),
108116
},
109117
],

0 commit comments

Comments
 (0)