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:
@@ -47,49 +47,70 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
const accessTokenRef = useRef<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const generation = useRef(0);
|
||||
const userRef = useRef<User | null>(null);
|
||||
const authQueue = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const refreshPromise = useRef<Promise<boolean> | null>(null);
|
||||
const applyAuthentication = useCallback((authentication: Authentication) => {
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
||||
const serialize = useCallback(<T,>(run: () => Promise<T>): Promise<T> => {
|
||||
const next = authQueue.current.then(async () =>
|
||||
"locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run(),
|
||||
);
|
||||
authQueue.current = next.catch(() => undefined);
|
||||
return next;
|
||||
}, []);
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity =
|
||||
userRef.current && userRef.current.id !== authentication.user.id;
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
if (changedIdentity) {
|
||||
generation.current += 1;
|
||||
queryClient.removeQueries({ type: "inactive" });
|
||||
queryClient.getMutationCache().clear();
|
||||
void queryClient.resetQueries();
|
||||
}
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const clearSession = useCallback(() => {
|
||||
generation.current += 1;
|
||||
userRef.current = null;
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
accessTokenRef.current = null;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
|
||||
const refresh = useCallback(async (): Promise<boolean> => {
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromise.current) return refreshPromise.current;
|
||||
refreshPromise.current = (async () => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (!data) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
applyAuthentication(data as Authentication);
|
||||
return true;
|
||||
} catch {
|
||||
const started = generation.current;
|
||||
const pending = serialize(async () => {
|
||||
if (started !== generation.current) return false;
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generation.current) return false;
|
||||
if (!data) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
return "locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run();
|
||||
applyAuthentication(data);
|
||||
return true;
|
||||
} catch {
|
||||
clearSession();
|
||||
if (started === generation.current) clearSession();
|
||||
return false;
|
||||
}
|
||||
})().finally(() => {
|
||||
refreshPromise.current = null;
|
||||
}).finally(() => {
|
||||
if (refreshPromise.current === pending) refreshPromise.current = null;
|
||||
});
|
||||
return refreshPromise.current;
|
||||
}, [applyAuthentication, clearSession]);
|
||||
refreshPromise.current = pending;
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
@@ -100,21 +121,25 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
const authentication = await request(path, email, password);
|
||||
applyAuthentication(authentication);
|
||||
clearSession();
|
||||
const started = generation.current;
|
||||
await serialize(async () => {
|
||||
const authentication = await request(path, email, password);
|
||||
if (started === generation.current) applyAuthentication(authentication);
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
clearSession();
|
||||
await serialize(async () => {
|
||||
await apiClient.POST("/api/v1/auth/logout");
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
configureAuthClient({
|
||||
accessToken: () => accessTokenRef.current,
|
||||
generation: () => generation.current,
|
||||
refresh,
|
||||
clear: clearSession,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user