fix(account): resolve stateless account cookies across instances

This commit is contained in:
Gustavo Valverde
2026-06-11 15:50:20 -07:00
parent 7faddd4a1d
commit a7100087e5
7 changed files with 292 additions and 12 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Stateless OAuth deployments can now read account info, access tokens, and refresh tokens after different server instances handle sign-in and later requests. Session refresh also keeps the OAuth account cookie instead of clearing it in that case.
@@ -8,13 +8,16 @@ import {
afterEach,
assert,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { betterAuth } from "../../auth/minimal";
import { parseSetCookieHeader } from "../../cookies";
import { signJWT, symmetricDecodeJWT } from "../../crypto";
import { genericOAuth } from "../../plugins/generic-oauth";
import { getTestInstance } from "../../test-utils/test-instance";
import type { Account } from "../../types";
import { DEFAULT_SECRET } from "../../utils/constants";
@@ -2039,3 +2042,214 @@ describe("token routes cookie cache revocation", async () => {
expect(bypass.error?.status).toBe(401);
});
});
describe("account resolution in stateless mode", async () => {
const IDP = "https://idp.stateless.test";
const STATELESS_SECRET = "stateless-test-secret-stateless-test-secret";
const idpHandlers = [
http.get(`${IDP}/.well-known/openid-configuration`, () =>
HttpResponse.json({
issuer: IDP,
authorization_endpoint: `${IDP}/authorize`,
token_endpoint: `${IDP}/token`,
userinfo_endpoint: `${IDP}/userinfo`,
jwks_uri: `${IDP}/jwks`,
}),
),
http.post(`${IDP}/token`, async ({ request }) => {
const params = new URLSearchParams(await request.text());
if (params.get("grant_type") === "refresh_token") {
return HttpResponse.json({
token_type: "Bearer",
access_token: "idp-refreshed-access-token",
refresh_token: "idp-rotated-refresh-token",
expires_in: 3600,
scope: "openid profile email",
});
}
return HttpResponse.json({
token_type: "Bearer",
access_token: "idp-access-token",
refresh_token: "idp-refresh-token",
expires_in: 3600,
scope: "openid profile email",
});
}),
];
beforeEach(() => server.use(...idpHandlers));
const makeStatelessAuth = () =>
betterAuth({
secret: STATELESS_SECRET,
baseURL: "http://localhost:3000",
trustedOrigins: ["http://localhost:3000"],
session: {
cookieCache: {
enabled: true,
strategy: "jwe",
maxAge: 60,
refreshCache: { updateAge: 60 * 60 },
},
},
account: { storeStateStrategy: "cookie", storeAccountCookie: true },
plugins: [
genericOAuth({
config: [
{
providerId: "idp",
clientId: "client-id",
clientSecret: "client-secret",
scopes: ["openid", "profile", "email"],
discoveryUrl: `${IDP}/.well-known/openid-configuration`,
getUserInfo: async (tokens) => ({
id: "shared-idp-user",
email: "user@stateless.test",
name: "Stateless User",
emailVerified: true,
accessTokenSeen: tokens.accessToken,
}),
},
],
}),
],
});
type Jar = Map<string, string>;
const collectCookies = (res: Response, jar: Jar) => {
for (const cookie of res.headers.getSetCookie()) {
const [pair = ""] = cookie.split(";");
const idx = pair.indexOf("=");
if (idx > 0) {
jar.set(pair.slice(0, idx), pair.slice(idx + 1));
}
}
};
const cookieHeader = (jar: Jar) =>
[...jar].map(([name, value]) => `${name}=${value}`).join("; ");
const requestHeaders = (jar: Jar) =>
new Headers({
cookie: cookieHeader(jar),
host: "localhost:3000",
});
const signIn = async (auth: ReturnType<typeof makeStatelessAuth>) => {
const jar: Jar = new Map();
let res = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-in/oauth2", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId: "idp", callbackURL: "/" }),
}),
);
collectCookies(res, jar);
const { url } = (await res.json()) as { url: string };
const state = new URL(url).searchParams.get("state");
assert(state, "expected an OAuth state to be issued");
res = await auth.handler(
new Request(
`http://localhost:3000/api/auth/oauth2/callback/idp?code=test-code&state=${state}`,
{ headers: requestHeaders(jar), redirect: "manual" },
),
);
collectCookies(res, jar);
const session = (await auth.api.getSession({
headers: requestHeaders(jar),
})) as { user: { id: string } } | null;
assert(session?.user.id, "expected OAuth sign-in to create a session");
return { jar, userId: session.user.id };
};
const signInOnTwoInstances = async () => {
const authA = makeStatelessAuth();
const a = await signIn(authA);
const b = await signIn(makeStatelessAuth());
expect(a.userId).not.toBe(b.userId);
const accountCookieName = (await authA.$context).authCookies.accountData
.name;
const mixed: Jar = new Map(a.jar);
for (const key of [...mixed.keys()]) {
if (key.startsWith(accountCookieName)) mixed.delete(key);
}
for (const [key, value] of b.jar) {
if (key.startsWith(accountCookieName)) mixed.set(key, value);
}
return {
accountCookieName,
mixed,
sessionUserId: a.userId,
};
};
/**
* @see https://github.com/better-auth/better-auth/issues/9978
*/
it("resolves getAccessToken with a valid account cookie whose userId differs from the session user", async () => {
const { mixed, sessionUserId } = await signInOnTwoInstances();
const result = await makeStatelessAuth().api.getAccessToken({
body: { providerId: "idp", userId: sessionUserId },
headers: requestHeaders(mixed),
});
expect(result.accessToken).toBe("idp-access-token");
});
/**
* @see https://github.com/better-auth/better-auth/issues/9978
*/
it("resolves accountInfo with a valid account cookie whose userId differs from the session user", async () => {
const { mixed, sessionUserId } = await signInOnTwoInstances();
const info = await makeStatelessAuth().api.accountInfo({
query: { providerId: "idp", userId: sessionUserId },
headers: requestHeaders(mixed),
});
assert(info, "expected accountInfo to resolve from the account cookie");
expect(info.user.id).toBe("shared-idp-user");
expect(info.data.accessTokenSeen).toBe("idp-access-token");
});
/**
* @see https://github.com/better-auth/better-auth/issues/9978
*/
it("refreshes a valid account cookie whose userId differs from the session user", async () => {
const { mixed, sessionUserId } = await signInOnTwoInstances();
const result = await makeStatelessAuth().api.refreshToken({
body: { providerId: "idp", userId: sessionUserId },
headers: requestHeaders(mixed),
});
expect(result.accessToken).toBe("idp-refreshed-access-token");
expect(result.refreshToken).toBe("idp-rotated-refresh-token");
});
/**
* @see https://github.com/better-auth/better-auth/issues/9978
*/
it("preserves a valid mismatched account cookie during stateless session refresh", async () => {
const { accountCookieName, mixed } = await signInOnTwoInstances();
const res = await makeStatelessAuth().handler(
new Request("http://localhost:3000/api/auth/get-session", {
headers: requestHeaders(mixed),
}),
);
expect(res.status).toBe(200);
const cookies = parseSetCookieHeader(res.headers.get("set-cookie") || "");
const accountCookie = cookies.get(accountCookieName);
expect(accountCookie?.value).toBeTruthy();
expect(accountCookie?.maxAge).not.toBe(0);
});
});
+47 -8
View File
@@ -7,6 +7,7 @@ import { SocialProviderListEnum } from "@better-auth/core/social-providers";
import * as z from "zod";
import { getAwaitableValue } from "../../context/helpers";
import { shouldBindAccountCookieToSessionUser } from "../../context/store-capabilities";
import {
getAccountCookie,
setAccountCookie,
@@ -473,6 +474,29 @@ async function resolveUserId(
return resolvedUserId;
}
function matchesAccountSelection(
ctx: GenericEndpointContext,
account: Account,
{
resolvedUserId,
providerId,
accountId,
}: {
resolvedUserId: string;
providerId?: string;
accountId?: string;
},
) {
const matchesSessionUser =
!shouldBindAccountCookieToSessionUser(ctx.context.options) ||
account.userId === resolvedUserId;
return (
matchesSessionUser &&
(!providerId || providerId === account.providerId) &&
(!accountId || account.accountId === accountId)
);
}
/**
* Fetches a currently-valid access token for a user's provider account,
* refreshing and persisting it when it is within five seconds of expiry.
@@ -512,9 +536,11 @@ async function getValidAccessToken(
const accountData = await getAccountCookie(ctx);
if (
accountData &&
accountData.userId === resolvedUserId &&
providerId === accountData.providerId &&
(!accountId || accountData.accountId === accountId)
matchesAccountSelection(ctx, accountData, {
resolvedUserId,
providerId,
accountId,
})
) {
account = accountData;
} else {
@@ -761,9 +787,11 @@ export const refreshToken = createAuthEndpoint(
const accountData = await getAccountCookie(ctx);
const usedAccountCookie =
!!accountData &&
accountData.userId === resolvedUserId &&
providerId === accountData.providerId &&
(!accountId || accountData.accountId === accountId);
matchesAccountSelection(ctx, accountData, {
resolvedUserId,
providerId,
accountId,
});
if (usedAccountCookie) {
account = accountData;
} else {
@@ -940,7 +968,13 @@ export const accountInfo = createAuthEndpoint(
if (!providedAccountId) {
if (ctx.context.options.account?.storeAccountCookie) {
const accountData = await getAccountCookie(ctx);
if (accountData) {
if (
accountData &&
matchesAccountSelection(ctx, accountData, {
resolvedUserId,
providerId: providedProviderId,
})
) {
account = accountData;
}
}
@@ -962,7 +996,12 @@ export const accountInfo = createAuthEndpoint(
account = matchingAccounts[0];
}
if (!account || account.userId !== resolvedUserId) {
if (
!account ||
!matchesAccountSelection(ctx, account, {
resolvedUserId,
})
) {
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
}
@@ -13,6 +13,7 @@ import { binary } from "@better-auth/utils/binary";
import { createHMAC } from "@better-auth/utils/hmac";
import * as z from "zod";
import { hasServerSessionStore } from "../../context/store-capabilities";
import {
deleteSessionCookie,
expireCookie,
@@ -529,7 +530,7 @@ export const getSession = <Option extends BetterAuthOptions>() =>
* revoked-but-cached session cannot authorize a sensitive action.
*/
export const isStateful = (ctx: GenericEndpointContext): boolean =>
!!ctx.context.options.database || !!ctx.context.options.secondaryStorage;
hasServerSessionStore(ctx.context.options);
export const getSessionFromCtx = async <
U extends Record<string, any> = Record<string, any>,
@@ -35,6 +35,7 @@ import {
parseSecretsEnv,
validateSecretsArray,
} from "./secret-utils";
import { hasServerSessionStore } from "./store-capabilities";
/**
* Estimates the entropy of a string in bits.
@@ -94,8 +95,9 @@ export async function createAuthContext<Options extends BetterAuthOptions>(
options: Options,
getDatabaseType: (database: Options["database"]) => string,
): Promise<AuthContext<Options>> {
// secondaryStorage is a durable server-side store, so treat it like a database.
const isStateful = !!options.database || !!options.secondaryStorage;
// secondaryStorage is a durable server-side session store, so treat it like
// a database for session cache defaults.
const isStateful = hasServerSessionStore(options);
// Cookie-cached sessions stand in for a durable store; only default them on
// when there is no durable store at all.
@@ -0,0 +1,15 @@
import type { BetterAuthOptions } from "@better-auth/core";
export function hasServerSessionStore(options: BetterAuthOptions): boolean {
return !!options.database || !!options.secondaryStorage;
}
export function hasServerAccountStore(options: BetterAuthOptions): boolean {
return !!options.database;
}
export function shouldBindAccountCookieToSessionUser(
options: BetterAuthOptions,
): boolean {
return hasServerAccountStore(options);
}
+5 -1
View File
@@ -12,6 +12,7 @@ import { base64Url } from "@better-auth/utils/base64";
import { binary } from "@better-auth/utils/binary";
import { createHMAC } from "@better-auth/utils/hmac";
import type { CookieOptions } from "better-call";
import { shouldBindAccountCookieToSessionUser } from "../context/store-capabilities";
import {
signJWT,
symmetricDecodeJWT,
@@ -264,7 +265,10 @@ export async function setCookieCache(
) {
const accountData = await getAccountCookie(ctx);
if (accountData) {
if (accountData.userId === session.user.id) {
if (
!shouldBindAccountCookieToSessionUser(ctx.context.options) ||
accountData.userId === session.user.id
) {
await setAccountCookie(ctx, accountData);
} else {
expireCookie(ctx, ctx.context.authCookies.accountData);