test auth stuff

This commit is contained in:
yohlo
2026-03-02 09:43:46 -06:00
parent 74d83da466
commit 3909fbc966
12 changed files with 255 additions and 152 deletions

View File

@@ -125,12 +125,13 @@ export const Route = createRootRouteWithContext<{
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session'); const { doesSessionExist } = await import('supertokens-web-js/recipe/session');
const { refreshManager } = await import('@/lib/supertokens/refresh-manager');
const sessionExists = await doesSessionExist(); const sessionExists = await doesSessionExist();
if (sessionExists) { if (sessionExists) {
try { try {
await attemptRefreshingSession(); await refreshManager.refresh();
const auth = await ensureServerQueryData( const auth = await ensureServerQueryData(
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()

View File

@@ -1,14 +1,26 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader' import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' import { refreshManager } from '@/lib/supertokens/refresh-manager'
import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
import { logger } from '@/lib/supertokens' import { logger } from '@/lib/supertokens'
export const Route = createFileRoute('/refresh-session')({ export const Route = createFileRoute('/refresh-session')({
component: RouteComponent, component: RouteComponent,
}) })
function clearSuperTokensCookies() {
const cookieNames = ['sAccessToken', 'sRefreshToken', 'sIdRefreshToken', 'sFrontToken'];
const cookieDomain = (window as any).__COOKIE_DOMAIN__ || undefined;
cookieNames.forEach(name => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
if (cookieDomain) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${cookieDomain}`;
}
});
}
function RouteComponent() { function RouteComponent() {
const hasAttemptedRef = useRef(false); const hasAttemptedRef = useRef(false);
@@ -20,9 +32,17 @@ function RouteComponent() {
try { try {
logger.info("Refresh session route: starting refresh"); logger.info("Refresh session route: starting refresh");
const refreshed = await getOrCreateRefreshPromise(async () => { const cookies = document.cookie.split(';');
return await attemptRefreshingSession(); const accessTokenCookies = cookies.filter(c => c.trim().startsWith('sAccessToken='));
});
if (accessTokenCookies.length > 1) {
logger.warn(`Found ${accessTokenCookies.length} access tokens, clearing all before refresh`);
clearSuperTokensCookies();
await new Promise(resolve => setTimeout(resolve, 100));
}
const refreshed = await refreshManager.refresh();
if (refreshed) { if (refreshed) {
logger.info("Refresh session route: refresh successful"); logger.info("Refresh session route: refresh successful");

View File

@@ -1,14 +1,11 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session'; import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client'; import { refreshManager } from '@/lib/supertokens/refresh-manager';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens'; import { logger } from '@/lib/supertokens';
export function SessionMonitor() { export function SessionMonitor() {
const navigate = useNavigate();
const lastRefreshTimeRef = useRef<number>(0); const lastRefreshTimeRef = useRef<number>(0);
const REFRESH_COOLDOWN = 30 * 1000; const REFRESH_COOLDOWN = 5 * 1000;
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -22,32 +19,35 @@ export function SessionMonitor() {
} }
const now = Date.now(); const now = Date.now();
if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) { const timeSinceLastRefresh = now - lastRefreshTimeRef.current;
logger.info('Session monitor: skipping refresh (cooldown)');
if (timeSinceLastRefresh < REFRESH_COOLDOWN) {
logger.info(`Session monitor: skipping refresh (refreshed ${timeSinceLastRefresh}ms ago)`);
return; return;
} }
try { try {
const sessionExists = await doesSessionExist(); const sessionExists = await doesSessionExist();
if (!sessionExists) { if (!sessionExists) {
logger.info('Session monitor: no session exists, skipping refresh'); logger.info('Session monitor: no session exists, redirecting to login');
window.location.href = '/login';
return; return;
} }
logger.info('Session monitor: tab became visible, refreshing session'); logger.info('Session monitor: tab became visible, checking session freshness');
const refreshed = await getOrCreateRefreshPromise(async () => { const refreshed = await refreshManager.refresh();
return await attemptRefreshingSession();
});
if (refreshed) { if (refreshed) {
lastRefreshTimeRef.current = Date.now(); lastRefreshTimeRef.current = Date.now();
logger.info('Session monitor: session refreshed successfully'); logger.info('Session monitor: session refreshed successfully');
} else { } else {
logger.warn('Session monitor: refresh returned false'); logger.warn('Session monitor: refresh returned false, redirecting to login');
window.location.href = '/login';
} }
} catch (error) { } catch (error) {
logger.error('Session monitor: error refreshing session', error); logger.error('Session monitor: error refreshing session', error);
window.location.href = '/login';
} }
}; };
@@ -58,7 +58,7 @@ export function SessionMonitor() {
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [navigate]); }, []);
return null; return null;
} }

View File

@@ -1,8 +1,5 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
import { logger } from '@/lib/supertokens';
let queryRefreshRedirect: Promise<void> | null = null;
export const playerKeys = { export const playerKeys = {
auth: ['auth'], auth: ['auth'],
@@ -64,37 +61,7 @@ export const useMe = () => {
staleTime: 30 * 1000, staleTime: 30 * 1000,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
retry: (failureCount, error: any) => { retry: 3,
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
logger.warn("Query detected SESSION_REFRESH_REQUIRED");
if (!queryRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Query initiating refresh redirect to:", currentUrl);
queryRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
queryRefreshRedirect.finally(() => {
setTimeout(() => {
queryRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Query: refresh redirect already in progress");
}
return false;
}
}
return failureCount < 3;
},
}, },
}); });
}; };

View File

@@ -4,34 +4,15 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config"; import { appInfo } from "./config";
import { logger } from "./"; import { logger } from "./";
let refreshPromise: Promise<boolean> | null = null;
export const resetRefreshFlag = () => { export const resetRefreshFlag = () => {
refreshPromise = null; logger.warn("resetRefreshFlag is deprecated. Use refreshManager.reset() instead.");
}; };
export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => { export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
if (refreshPromise) { logger.warn("getOrCreateRefreshPromise is deprecated. Use refreshManager.refresh() instead.");
logger.info("Reusing existing refresh promise"); return refreshFn();
return refreshPromise;
}
logger.info("Creating new refresh promise");
refreshPromise = refreshFn()
.then((result) => {
logger.info("Refresh completed successfully:", result);
setTimeout(() => {
refreshPromise = null;
}, 500);
return result;
})
.catch((error) => {
logger.error("Refresh failed:", error);
refreshPromise = null;
throw error;
});
return refreshPromise;
}; };
export const frontendConfig = () => { export const frontendConfig = () => {

View File

@@ -0,0 +1,77 @@
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from './index';
class SessionRefreshManager {
private refreshPromise: Promise<boolean> | null = null;
private redirectPromise: Promise<void> | null = null;
private lastRefreshTime: number = 0;
private readonly MIN_REFRESH_INTERVAL = 1000;
async refresh(): Promise<boolean> {
if (this.refreshPromise) {
logger.info('RefreshManager: Reusing existing refresh promise');
return this.refreshPromise;
}
const timeSinceLastRefresh = Date.now() - this.lastRefreshTime;
if (timeSinceLastRefresh < this.MIN_REFRESH_INTERVAL) {
logger.info(`RefreshManager: Skipping refresh (last refresh ${timeSinceLastRefresh}ms ago)`);
return true;
}
logger.info('RefreshManager: Starting new session refresh');
this.refreshPromise = attemptRefreshingSession()
.then((result) => {
logger.info('RefreshManager: Refresh completed successfully:', result);
this.lastRefreshTime = Date.now();
return result;
})
.catch((error) => {
logger.error('RefreshManager: Refresh failed:', error);
throw error;
})
.finally(() => {
setTimeout(() => {
this.refreshPromise = null;
}, 500);
});
return this.refreshPromise;
}
async redirectToRefresh(currentPath: string): Promise<void> {
if (this.redirectPromise) {
logger.info('RefreshManager: Redirect already in progress, waiting...');
return this.redirectPromise;
}
logger.info('RefreshManager: Initiating refresh redirect to:', currentPath);
this.redirectPromise = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentPath)}`;
resolve();
}, 100);
});
this.redirectPromise.finally(() => {
setTimeout(() => {
this.redirectPromise = null;
}, 1000);
});
return this.redirectPromise;
}
reset(): void {
logger.info('RefreshManager: Resetting state');
this.refreshPromise = null;
this.redirectPromise = null;
this.lastRefreshTime = 0;
}
getTimeSinceLastRefresh(): number {
return Date.now() - this.lastRefreshTime;
}
}
export const refreshManager = new SessionRefreshManager();

View File

@@ -28,6 +28,26 @@ export const backendConfig = (): TypeInput => {
olderCookieDomain: undefined, olderCookieDomain: undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
sessionExpiredStatusCode: 440,
invalidClaimStatusCode: 403,
override: {
functions: (originalImplementation) => ({
...originalImplementation,
refreshSession: async (input) => {
logger.info('Backend: Refresh session attempt');
try {
const result = await originalImplementation.refreshSession(input);
logger.info('Backend: Refresh session successful');
return result;
} catch (error) {
logger.error('Backend: Refresh session failed:', error);
throw error;
}
},
}),
},
// Debug only // Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production", exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
}), }),

View File

@@ -1,10 +1,7 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/supertokens' import { handleQueryError } from '../utils/global-error-handler';
let sessionRefreshRedirect: Promise<void> | null = null;
export function useServerMutation<TData, TVariables = unknown>( export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & { options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
@@ -39,41 +36,7 @@ export function useServerMutation<TData, TVariables = unknown>(
return result.data; return result.data;
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 401) { await handleQueryError(error);
try {
const errorData = typeof error.response.data === 'string'
? JSON.parse(error.response.data)
: error.response.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
if (!sessionRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Mutation initiating refresh redirect to:", currentUrl);
sessionRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
sessionRefreshRedirect.finally(() => {
setTimeout(() => {
sessionRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Mutation: refresh redirect already in progress, waiting...");
await sessionRefreshRedirect;
}
throw new Error("SESSION_REFRESH_REQUIRED");
}
} catch (parseError) {}
}
throw error; throw error;
} }
}, },

View File

@@ -1,6 +1,7 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { handleQueryError } from '../utils/global-error-handler';
export function useServerQuery<TData>( export function useServerQuery<TData>(
options: { options: {
@@ -17,6 +18,7 @@ export function useServerQuery<TData>(
...queryOptions, ...queryOptions,
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
try {
const result = await queryFn(); const result = await queryFn();
if (!result.success) { if (!result.success) {
@@ -27,6 +29,10 @@ export function useServerQuery<TData>(
} }
return result.data; return result.data;
} catch (error: any) {
await handleQueryError(error);
throw error;
}
} }
}); });
} }

View File

@@ -1,6 +1,7 @@
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { handleQueryError } from '../utils/global-error-handler';
export function useServerSuspenseQuery<TData>( export function useServerSuspenseQuery<TData>(
options: { options: {
@@ -16,6 +17,7 @@ export function useServerSuspenseQuery<TData>(
...queryOptions, ...queryOptions,
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
try {
const result = await queryFn(); const result = await queryFn();
if (!result.success) { if (!result.success) {
@@ -26,6 +28,10 @@ export function useServerSuspenseQuery<TData>(
} }
return result.data; return result.data;
} catch (error: any) {
await handleQueryError(error);
throw error;
}
} }
}); });

View File

@@ -0,0 +1,54 @@
import { refreshManager } from '@/lib/supertokens/refresh-manager';
import { logger } from '@/lib/supertokens';
export async function handleQueryError(error: any): Promise<void> {
if (typeof window === 'undefined') {
throw error;
}
const isSessionExpired =
error?.response?.status === 440 ||
error?.response?.headers?.get?.('X-Session-Expired') === 'true';
if (isSessionExpired) {
try {
const errorData = await error.response.json().catch(() => ({}));
if (errorData.error === 'SESSION_REFRESH_REQUIRED' && errorData.shouldRetry) {
logger.warn('Query detected SESSION_REFRESH_REQUIRED, initiating redirect');
const currentUrl = window.location.pathname + window.location.search;
await refreshManager.redirectToRefresh(currentUrl);
throw new Error('Redirecting to refresh session');
}
} catch (parseError) {
if (error?.response?.status === 440) {
logger.warn('Session expired (440), redirecting to refresh');
const currentUrl = window.location.pathname + window.location.search;
await refreshManager.redirectToRefresh(currentUrl);
throw new Error('Redirecting to refresh session');
}
}
}
if (error?.response?.status === 401) {
try {
const errorData = typeof error.response.data === 'string'
? JSON.parse(error.response.data)
: error.response.data;
if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
logger.warn('Query detected legacy SESSION_REFRESH_REQUIRED (401), initiating redirect');
const currentUrl = window.location.pathname + window.location.search;
await refreshManager.redirectToRefresh(currentUrl);
throw new Error('Redirecting to refresh session');
}
} catch (parseError) {
}
}
throw error;
}

View File

@@ -110,11 +110,15 @@ export const superTokensFunctionMiddleware = createMiddleware({
throw new Response( throw new Response(
JSON.stringify({ JSON.stringify({
error: "SESSION_REFRESH_REQUIRED", error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed" message: "Session needs to be refreshed",
shouldRetry: true
}), }),
{ {
status: 401, status: 440,
headers: { "Content-Type": "application/json" } headers: {
"Content-Type": "application/json",
"X-Session-Expired": "true"
}
} }
); );
} }
@@ -141,11 +145,15 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
throw new Response( throw new Response(
JSON.stringify({ JSON.stringify({
error: "SESSION_REFRESH_REQUIRED", error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed" message: "Session needs to be refreshed",
shouldRetry: true
}), }),
{ {
status: 401, status: 440,
headers: { "Content-Type": "application/json" } headers: {
"Content-Type": "application/json",
"X-Session-Expired": "true"
}
} }
); );
} }