mirror of
https://github.com/better-auth/better-auth.git
synced 2026-09-01 02:55:08 -05:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user