diff --git a/.changeset/long-brooms-trade.md b/.changeset/long-brooms-trade.md new file mode 100644 index 0000000000..8fbad1dfaf --- /dev/null +++ b/.changeset/long-brooms-trade.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +fix(last-login-method): include domain when clearing cross-subdomain cookies diff --git a/docs/content/docs/plugins/last-login-method.mdx b/docs/content/docs/plugins/last-login-method.mdx index 33a05588ea..2c97e3cbf4 100644 --- a/docs/content/docs/plugins/last-login-method.mdx +++ b/docs/content/docs/plugins/last-login-method.mdx @@ -303,7 +303,8 @@ import { lastLoginMethodClient } from "better-auth/client/plugins" export const authClient = createAuthClient({ plugins: [ lastLoginMethodClient({ - cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method" + cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method" + domain: ".example.com" // Required for cross-subdomain cookie clearing }) ] }) @@ -315,6 +316,12 @@ export const authClient = createAuthClient({ * Must match the server-side `cookieName` configuration * Default: `"better-auth.last_used_login_method"` +**domain**: `string` + +* The domain to use when clearing the cookie +* Required when using `crossSubDomainCookies` so the client can properly expire the cookie set by the server +* Should match the `domain` value in your server's `crossSubDomainCookies` configuration + ### Default Method Resolution By default, the plugin tracks these authentication methods: @@ -340,6 +347,23 @@ The plugin automatically inherits cookie settings from Better Auth's centralized 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. + + When using `crossSubDomainCookies`, you must pass the `domain` option to `lastLoginMethodClient()` so that `clearLastUsedLoginMethod()` can properly expire the cookie. Without it, browsers will silently ignore the clear request because the domain doesn't match. + + +```ts title="auth-client.ts" +import { createAuthClient } from "better-auth/client" +import { lastLoginMethodClient } from "better-auth/client/plugins" + +export const authClient = createAuthClient({ + plugins: [ + lastLoginMethodClient({ + domain: ".example.com" // Must match server crossSubDomainCookies domain // [!code highlight] + }) + ] +}) +``` + ## Advanced Examples ### Custom Provider Tracking diff --git a/packages/better-auth/src/plugins/last-login-method/client.ts b/packages/better-auth/src/plugins/last-login-method/client.ts index 7ccbb4769e..cb8ac02fe4 100644 --- a/packages/better-auth/src/plugins/last-login-method/client.ts +++ b/packages/better-auth/src/plugins/last-login-method/client.ts @@ -11,6 +11,16 @@ export interface LastLoginMethodClientConfig { * @default "better-auth.last_used_login_method" */ cookieName?: string | undefined; + /** + * Domain for the cookie. Required when using cross-subdomain cookies + * so the client can properly clear the cookie set by the server. + * + * Should match the `domain` value in your server's + * `crossSubDomainCookies` configuration. + * + * @example ".example.com" + */ + domain?: string | undefined; } function getCookieValue(name: string): string | null { @@ -46,7 +56,8 @@ export const lastLoginMethodClient = ( */ clearLastUsedLoginMethod: (): void => { if (typeof document !== "undefined") { - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + const domainPart = config.domain ? ` domain=${config.domain};` : ""; + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;${domainPart}`; } }, /** 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 d6cd95752d..97c2383deb 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,86 @@ describe("lastLoginMethod", async () => { ); }); + /** + * @see https://github.com/better-auth/better-auth/issues/9276 + */ + it("should clear cross-subdomain cookies by including the domain attribute", async () => { + const { client, testUser } = await getTestInstance( + { + baseURL: "https://auth.example.com", + advanced: { + crossSubDomainCookies: { + enabled: true, + domain: "example.com", + }, + }, + plugins: [lastLoginMethod()], + }, + { + clientOptions: { + plugins: [lastLoginMethodClient({ domain: "example.com" })], + }, + }, + ); + + // Step 1: Sign in to get the cookie set with domain attribute + let setCookieHeader = ""; + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onResponse(context) { + setCookieHeader = context.response.headers.get("set-cookie") || ""; + }, + }, + ); + + // Verify the server sets the cookie with Domain=example.com + expect(setCookieHeader).toContain( + "better-auth.last_used_login_method=email", + ); + expect(setCookieHeader).toContain("Domain=example.com"); + + // Step 2: Simulate clearing the cookie on the client side. + // Mock document.cookie to capture the string the client writes. + let writtenCookie = ""; + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "document", + ); + + Object.defineProperty(globalThis, "document", { + value: {}, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.document, "cookie", { + get() { + return "better-auth.last_used_login_method=email"; + }, + set(val: string) { + writtenCookie = val; + }, + configurable: true, + }); + + client.clearLastUsedLoginMethod(); + + // Restore + if (originalDescriptor) { + Object.defineProperty(globalThis, "document", originalDescriptor); + } else { + globalThis.document = undefined as any; + } + + // The written cookie MUST include the domain to properly expire + // a cross-subdomain cookie. + expect(writtenCookie).toContain("domain=example.com"); + }); + 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 = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 725cc240c2..d13173ca2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16977,14 +16977,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 25.6.0 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.5.0 + '@types/node': 25.6.0 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -17018,7 +17018,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -20244,7 +20244,7 @@ snapshots: '@types/readable-stream@4.0.23': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/resolve@1.20.2': {} @@ -20256,7 +20256,7 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/serve-static@2.2.0': dependencies: @@ -20289,7 +20289,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/yargs-parser@21.0.3': {} @@ -23319,7 +23319,7 @@ snapshots: happy-dom@20.8.9: dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -23903,7 +23903,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 25.6.0 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -23911,7 +23911,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 25.6.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11