fix(auth): isolate session races and financial caches

TD-001 and TD-002: serialize cookie writes, ignore stale responses and reset financial
queries on identity changes. Backend cookie lifecycle regression: ec5780d.
This commit is contained in:
2026-09-21 21:09:23 +03:00
parent 3c99debe88
commit a209d71589
4 changed files with 261 additions and 38 deletions
+29
View File
@@ -64,4 +64,33 @@ describe("apiClient authentication", () => {
expect(authorization).toBeNull();
});
it("discards a retried request response after another session takes over", async () => {
let generation = 0;
let calls = 0;
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
if (++calls === 1)
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
await pending;
return HttpResponse.json([{ id: "previous-user-account" }]);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
generation: () => generation,
refresh: async () => true,
clear: vi.fn(),
});
const response = expect(
apiClient.GET("/api/v1/brokerage-accounts"),
).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(calls).toBe(2));
generation += 1;
release();
await response;
});
});
+14 -5
View File
@@ -8,6 +8,7 @@ const HTTP_UNAUTHORIZED = 401;
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
type AuthHandlers = {
generation?: () => number;
accessToken: () => string | null;
refresh: () => Promise<boolean>;
clear: () => void;
@@ -41,7 +42,10 @@ async function authenticatedFetch(request: Request): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const token = auth?.accessToken();
const session = auth;
const generation = session?.generation?.();
const isCurrent = () => generation === auth?.generation?.();
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
@@ -51,6 +55,8 @@ async function authenticatedFetch(request: Request): Promise<Response> {
token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
response.status !== HTTP_UNAUTHORIZED ||
isAuthRequest ||
@@ -63,14 +69,17 @@ async function authenticatedFetch(request: Request): Promise<Response> {
)?.code !== "UNAUTHORIZED"
)
return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await auth.refresh())) {
auth.clear();
if (isCurrent()) auth.clear();
return response;
}
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = auth.accessToken();
return refreshedToken
? fetch(withToken(retryRequest, refreshedToken))
: response;
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
const apiClient = createClient<paths>({