Stabilize mobile auth session handling

This commit is contained in:
2026-06-29 17:58:50 -04:00
parent 477459edd4
commit 57e1aa658a
25 changed files with 1126 additions and 192 deletions
+13 -4
View File
@@ -1,24 +1,33 @@
import { router } from "expo-router";
import { Alert } from "react-native";
import type { createAuthClient } from "better-auth/react";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
import { performAuthReset } from "@/lib/auth-session";
type AuthClient = ReturnType<typeof createAuthClient>;
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
authClient: AuthClient;
clearActiveAccount: () => Promise<void>;
signOut: () => Promise<unknown>;
activeAccountId: string | null;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
authClient,
clearActiveAccount,
signOut,
activeAccountId,
}: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return;
await signOut();
await clearActiveAccount();
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in");
}
+4 -10
View File
@@ -1,11 +1,5 @@
import { getApiUrl } from "@/lib/config";
type ApiError = { error?: string; message?: string };
async function parseError(res: Response) {
const data = (await res.json().catch(() => ({}))) as ApiError;
return data.error ?? data.message ?? "Something went wrong";
}
import { readHttpErrorMessage } from "@/lib/trpc-errors";
export async function registerAccount(input: {
firstName: string;
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
});
if (!res.ok) {
throw new Error(await parseError(res));
throw new Error(await readHttpErrorMessage(res));
}
}
@@ -32,7 +26,7 @@ export async function requestPasswordReset(email: string) {
});
if (!res.ok) {
throw new Error(await parseError(res));
throw new Error(await readHttpErrorMessage(res));
}
const data = (await res.json()) as { message?: string };
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
});
if (!res.ok) {
throw new Error(await parseError(res));
throw new Error(await readHttpErrorMessage(res));
}
}
+55
View File
@@ -0,0 +1,55 @@
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import type { createAuthClient } from "better-auth/react";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
type AuthClient = ReturnType<typeof createAuthClient>;
const CHUNK_MARKER = "\u0001ba-chunks:";
function readSecureStoreValueSync(key: string): string | null {
const value = SecureStore.getItem(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
let assembled = "";
for (let index = 0; index < count; index += 1) {
const chunk = SecureStore.getItem(`${key}.${index}`);
if (chunk == null) return null;
assembled += chunk;
}
return assembled;
}
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
export function getAuthCookie(
authClient: AuthClient,
storagePrefix: string,
): string | null {
const fromClient = (
authClient as AuthClient & { getCookie?: () => string }
).getCookie?.();
if (fromClient?.trim()) return fromClient.trim();
const raw = readSecureStoreValueSync(
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
);
if (!raw || raw === "{}") return null;
const cookie = serializeStoredCookies(raw);
return cookie.trim() || null;
}
export function getAuthCookieHeaders(
authClient: AuthClient,
storagePrefix: string,
): Record<string, string> {
const cookie = getAuthCookie(authClient, storagePrefix);
return cookie
? { cookie, Cookie: cookie, "x-beenvoice-auth-cookie": cookie }
: {};
}
+60
View File
@@ -0,0 +1,60 @@
import type { createAuthClient } from "better-auth/react";
import { authStoragePrefix } from "@/lib/accounts";
import {
clearAuthStorage,
GUEST_AUTH_STORAGE_PREFIX,
prepareForAdditionalSignIn,
} from "@/lib/auth-storage";
type AuthClient = ReturnType<typeof createAuthClient>;
type PerformAuthResetInput = {
authClient: AuthClient;
clearActiveAccount: () => Promise<void>;
activeAccountId?: string | null;
refetchSession?: () => Promise<unknown>;
};
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
export async function performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession,
}: PerformAuthResetInput): Promise<void> {
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
try {
await authClient.signOut();
} catch {
// Continue clearing local state even when the server session is already gone.
}
if (accountPrefix) {
await clearAuthStorage(accountPrefix);
}
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
await clearActiveAccount();
await refetchSession?.();
}
/**
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
*/
export async function prepareAuthScreenSession(
authClient: AuthClient,
activeAccountId: string | null,
clearActiveAccount: () => Promise<void>,
): Promise<void> {
const session = await authClient.getSession();
if (session.data?.user) return;
if (activeAccountId) {
await clearAuthStorage(authStoragePrefix(activeAccountId));
await clearActiveAccount();
}
await prepareForAdditionalSignIn();
}
+2
View File
@@ -74,6 +74,8 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
),
);
await clearAuthStorage(fromPrefix);
}
export async function clearAuthStorage(prefix: string): Promise<void> {
+8 -2
View File
@@ -1,6 +1,6 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { isUnauthorizedError } from "@/lib/trpc-errors";
import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
export function createAppQueryClient(onUnauthorized: () => void) {
const handleError = (error: unknown) => {
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
queries: {
staleTime: 30_000,
retry: (failureCount, error) => {
if (isUnauthorizedError(error)) return false;
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
return failureCount < 1;
},
},
mutations: {
retry: (failureCount, error) => {
if (isRateLimitError(error)) return false;
return failureCount < 1;
},
},
+116 -3
View File
@@ -1,8 +1,121 @@
import { TRPCClientError } from "@trpc/client";
export function isUnauthorizedError(error: unknown): boolean {
function errorMessage(error: unknown): string {
if (error instanceof TRPCClientError) return error.message;
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message: unknown }).message;
if (typeof message === "string") return message;
}
return "";
}
function errorStatus(error: unknown): number | undefined {
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
const status = (error as { status: unknown }).status;
return typeof status === "number" ? status : undefined;
}
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
if (value == null || value === "") return null;
const seconds = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.ceil(seconds);
}
export function isRateLimitError(error: unknown): boolean {
if (errorStatus(error) === 429) return true;
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
return true;
}
const message = errorMessage(error).toLowerCase();
return (
error instanceof TRPCClientError &&
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
message.includes("too many") ||
message.includes("rate limit") ||
message.includes("try again later")
);
}
export function isUnauthorizedError(error: unknown): boolean {
if (isRateLimitError(error)) return false;
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") return true;
}
if (errorStatus(error) === 401) return true;
const message = errorMessage(error).toLowerCase();
return message === "unauthorized" || message.includes("not authenticated");
}
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
if (retryAfter != null) {
if (retryAfter < 60) {
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
}
const minutes = Math.ceil(retryAfter / 60);
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
}
return "Too many attempts. Please wait a moment and try again.";
}
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
if (isUnauthorizedError(error)) {
return "Your session expired. Sign in again to continue.";
}
if (error instanceof TRPCClientError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message || fallback;
}
return fallback;
}
type AuthLikeError = {
message?: string;
status?: number;
};
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
if (!error) return "Something went wrong";
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
const message = error.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
}
return message || "Invalid email or password";
}
export async function readHttpErrorMessage(response: Response): Promise<string> {
if (response.status === 429) {
const retryAfter = parseRetryAfterSeconds(
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
);
return formatRateLimitMessage(retryAfter);
}
const data = (await response.json().catch(() => ({}))) as {
error?: string;
message?: string;
};
const message = data.error ?? data.message;
if (message && isRateLimitError({ message, status: response.status })) {
return formatRateLimitMessage();
}
return message ?? "Something went wrong";
}
+50 -10
View File
@@ -1,25 +1,65 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useCallback, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import SuperJSON from "superjson";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
import { performAuthReset } from "@/lib/auth-session";
import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>();
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
export function TRPCProvider({
apiUrl,
children,
}: {
apiUrl: string;
children: ReactNode;
}) {
const authClient = useAuthClient();
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
useAccounts();
const { refetch } = useSession();
const authStoragePrefixRef = useRef(authStoragePrefix);
authStoragePrefixRef.current = authStoragePrefix;
const mountedRef = useRef(true);
const resettingRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handleUnauthorized = useCallback(async () => {
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
const session = await authClient.getSession();
if (!session.data?.user) {
await authClient.signOut();
await refetch();
if (session.data?.user || !mountedRef.current) return;
resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
}
}, [authClient, refetch]);
}, [authClient, clearActiveAccount, activeAccountId, refetch]);
const onUnauthorizedRef = useRef(handleUnauthorized);
onUnauthorizedRef.current = handleUnauthorized;
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers() {
const cookie = (
authClient as { getCookie?: () => string | null | undefined }
).getCookie?.();
return cookie ? { cookie } : {};
return getAuthCookieHeaders(
authClient,
authStoragePrefixRef.current,
);
},
}),
],