fix(expo): support async storage in client plugin

The Expo client plugin was not properly awaiting async storage operations,
which caused issues when users used async storage implementations like
AsyncStorage. This could result in sign-out not deleting sessions from the
database because the cookie wasn't being read correctly before the request.

Changes:
- Added Promise.resolve() wrapper to storage.getItem() calls to support
  both sync and async storage implementations
- Added tests for async storage scenarios
- Added tests to verify session deletion on sign-out

Fixes #5868

Co-authored-by: bekacru <bekacru@gmail.com>
This commit is contained in:
Cursor Agent
2026-02-02 19:36:23 +00:00
co-authored by bekacru
parent a605e70f4e
commit 7c2cee6ddf
2 changed files with 280 additions and 5 deletions
+16 -4
View File
@@ -324,7 +324,10 @@ export const expoClient = (opts: ExpoClientOptions) => {
// Only process and notify if the Set-Cookie header contains better-auth cookies
// This prevents infinite refetching when other cookies (like Cloudflare's __cf_bm) are present
if (hasBetterAuthCookies(setCookie, cookiePrefix)) {
const prevCookie = storage.getItem(cookieName);
// Support both sync and async storage implementations
const prevCookie = await Promise.resolve(
storage.getItem(cookieName),
);
const toSetCookie = getSetCookie(
setCookie || "",
prevCookie ?? undefined,
@@ -377,7 +380,10 @@ export const expoClient = (opts: ExpoClientOptions) => {
} catch {}
}
const storedCookieJson = storage.getItem(cookieName);
// Support both sync and async storage implementations
const storedCookieJson = await Promise.resolve(
storage.getItem(cookieName),
);
const oauthStateValue = getOAuthStateValue(
storedCookieJson,
cookiePrefix,
@@ -398,7 +404,10 @@ export const expoClient = (opts: ExpoClientOptions) => {
const url = new URL(result.url);
const cookie = url.searchParams.get("cookie");
if (!cookie) return;
const prevCookie = storage.getItem(cookieName);
// Support both sync and async storage implementations
const prevCookie = await Promise.resolve(
storage.getItem(cookieName),
);
const toSetCookie = getSetCookie(cookie, prevCookie ?? undefined);
storage.setItem(cookieName, toSetCookie);
store?.notify("$sessionSignal");
@@ -413,7 +422,10 @@ export const expoClient = (opts: ExpoClientOptions) => {
};
}
options = options || {};
const storedCookie = storage.getItem(cookieName);
// Support both sync and async storage implementations
const storedCookie = await Promise.resolve(
storage.getItem(cookieName),
);
const cookie = getCookie(storedCookie || "{}");
options.credentials = "omit";
options.headers = {
+264 -1
View File
@@ -1,6 +1,6 @@
import { createAuthMiddleware } from "better-auth/api";
import { magicLinkClient } from "better-auth/client/plugins";
import { magicLink, oAuthProxy } from "better-auth/plugins";
import { bearer, magicLink, oAuthProxy } from "better-auth/plugins";
import { getTestInstance } from "better-auth/test";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { expo } from "../src";
@@ -299,6 +299,87 @@ describe("expo", async () => {
});
});
it("should delete session from database on sign out", async () => {
// Sign in first
await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
// Get session and verify it exists
const sessionBefore = await client.getSession();
expect(sessionBefore.data?.session).toBeDefined();
const sessionId = sessionBefore.data?.session?.id!;
expect(sessionId).toBeDefined();
// Check session exists in database
const ctx = await auth.$context;
const sessionInDb = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionInDb).toBeDefined();
// Verify cookie is stored
const storedCookie = storage.get("better-auth_cookie");
expect(storedCookie).toBeDefined();
const parsedCookie = JSON.parse(storedCookie || "{}");
expect(parsedCookie["better-auth.session_token"]).toBeDefined();
expect(parsedCookie["better-auth.session_token"].value).toBeDefined();
// Sign out
const signOutRes = await client.signOut();
expect(signOutRes.data?.success).toBe(true);
// Check session is deleted from database
const sessionAfterSignOut = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionAfterSignOut).toBeNull();
});
it("should delete session from database on sign out even after multiple getSession calls", async () => {
// Sign in first
await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
// Make multiple getSession calls to simulate real app usage
for (let i = 0; i < 3; i++) {
const session = await client.getSession();
expect(session.data?.session).toBeDefined();
}
const sessionBefore = await client.getSession();
const sessionId = sessionBefore.data?.session?.id!;
expect(sessionId).toBeDefined();
// Check session exists in database
const ctx = await auth.$context;
const sessionInDb = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionInDb).toBeDefined();
// Sign out
const signOutRes = await client.signOut();
expect(signOutRes.data?.success).toBe(true);
// Check session is deleted from database
const sessionAfterSignOut = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionAfterSignOut).toBeNull();
// Verify getSession returns null after sign out
const sessionAfter = await client.getSession();
expect(sessionAfter.data).toBeNull();
});
it("should modify origin header to expo origin if origin is not set", async () => {
let originalOrigin = null;
let origin = null;
@@ -917,3 +998,185 @@ describe("ExpoOnlineManager duplicate notification prevention", () => {
expect(listener).toHaveBeenCalledTimes(2);
});
});
describe("expo with async storage", async () => {
const storage = new Map<string, string>();
// Simulate async storage like AsyncStorage
const asyncStorage = {
getItem: async (key: string): Promise<string | null> => {
// Add a small delay to simulate async operation
await new Promise((resolve) => setTimeout(resolve, 1));
return storage.get(key) || null;
},
setItem: async (key: string, value: string): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 1));
storage.set(key, value);
},
};
const { auth, client, testUser } = await getTestInstance(
{
emailAndPassword: {
enabled: true,
},
plugins: [expo()],
trustedOrigins: ["better-auth://"],
},
{
clientOptions: {
plugins: [
expoClient({
// Use async storage (simulating AsyncStorage)
storage: asyncStorage as any,
}),
],
},
},
);
it("should delete session from database on sign out with async storage", async () => {
// Sign in first
await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
// Get session and verify it exists
const sessionBefore = await client.getSession();
expect(sessionBefore.data?.session).toBeDefined();
const sessionId = sessionBefore.data?.session?.id!;
expect(sessionId).toBeDefined();
// Check session exists in database
const ctx = await auth.$context;
const sessionInDb = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionInDb).toBeDefined();
// Sign out
const signOutRes = await client.signOut();
expect(signOutRes.data?.success).toBe(true);
// Check session is deleted from database
const sessionAfterSignOut = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionAfterSignOut).toBeNull();
});
});
describe("expo with bearer plugin", async () => {
const storage = new Map<string, string>();
const { auth, client, testUser } = await getTestInstance(
{
emailAndPassword: {
enabled: true,
},
plugins: [expo(), bearer()],
trustedOrigins: ["better-auth://"],
},
{
clientOptions: {
plugins: [
expoClient({
storage: {
getItem: (key) => storage.get(key) || null,
setItem: async (key, value) => storage.set(key, value),
},
}),
],
},
},
);
it("should delete session from database on sign out with bearer plugin", async () => {
// Sign in first
await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
// Get session and verify it exists
const sessionBefore = await client.getSession();
expect(sessionBefore.data?.session).toBeDefined();
const sessionId = sessionBefore.data?.session?.id!;
expect(sessionId).toBeDefined();
// Check session exists in database
const ctx = await auth.$context;
const sessionInDb = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionInDb).toBeDefined();
// Sign out
const signOutRes = await client.signOut();
expect(signOutRes.data?.success).toBe(true);
// Check session is deleted from database
const sessionAfterSignOut = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionAfterSignOut).toBeNull();
});
it("should delete session when using bearer token for sign-out", async () => {
// Sign in and get bearer token
let bearerToken = "";
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: (ctx) => {
bearerToken = ctx.response.headers.get("set-auth-token") || "";
},
},
);
expect(bearerToken).toBeTruthy();
// Get session using bearer token
const sessionBefore = await client.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${bearerToken}`,
},
},
});
expect(sessionBefore.data?.session).toBeDefined();
const sessionId = sessionBefore.data?.session?.id!;
expect(sessionId).toBeDefined();
// Check session exists in database
const ctx = await auth.$context;
const sessionInDb = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionInDb).toBeDefined();
// Sign out using bearer token (not cookie)
const signOutRes = await client.$fetch("/sign-out", {
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
});
expect((signOutRes.data as { success: boolean })?.success).toBe(true);
// Check session is deleted from database
const sessionAfterSignOut = await ctx.adapter.findOne({
model: "session",
where: [{ field: "id", value: sessionId }],
});
expect(sessionAfterSignOut).toBeNull();
});
});