mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-22 16:42:53 -05:00
fix(account): resolve stateless account cookies across instances
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user