session auto refresh blink

This commit is contained in:
Igor Loskutov
2025-09-03 08:33:13 -04:00
parent cff662709d
commit 1b22eabb3f
4 changed files with 28 additions and 32 deletions

View File

@@ -8,9 +8,10 @@ export default function UserInfo() {
const status = auth.status; const status = auth.status;
const isLoading = status === "loading"; const isLoading = status === "loading";
const isAuthenticated = status === "authenticated"; const isAuthenticated = status === "authenticated";
const isRefreshing = status === "refreshing";
return isLoading ? ( return isLoading ? (
<Spinner size="xs" className="mx-3" /> <Spinner size="xs" className="mx-3" />
) : !isAuthenticated ? ( ) : !isAuthenticated && !isRefreshing ? (
<Link <Link
href="/" href="/"
className="font-light px-2" className="font-light px-2"

View File

@@ -10,6 +10,7 @@ import { SessionAutoRefresh } from "./SessionAutoRefresh";
type AuthContextType = ( type AuthContextType = (
| { status: "loading" } | { status: "loading" }
| { status: "refreshing" }
| { status: "unauthenticated"; error?: string } | { status: "unauthenticated"; error?: string }
| { | {
status: "authenticated"; status: "authenticated";
@@ -30,8 +31,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const customSession = session ? assertExtendedTokenAndUserId(session) : null; const customSession = session ? assertExtendedTokenAndUserId(session) : null;
const contextValue: AuthContextType = { const contextValue: AuthContextType = {
...(status === "loading" ...(status === "loading" && !customSession
? { status } ? { status }
: status === "loading" && customSession
? { status: "refreshing" as const }
: status === "authenticated" && customSession?.accessToken : status === "authenticated" && customSession?.accessToken
? { ? {
status, status,

View File

@@ -18,15 +18,17 @@ export function SessionAutoRefresh({
const accessTokenExpires = const accessTokenExpires =
auth.status === "authenticated" ? auth.accessTokenExpires : null; auth.status === "authenticated" ? auth.accessTokenExpires : null;
const refreshIntervalMs = refreshInterval * 1000;
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (accessTokenExpires) { if (accessTokenExpires !== null) {
const timeLeft = accessTokenExpires - Date.now(); const timeLeft = accessTokenExpires - Date.now();
if (timeLeft < refreshInterval * 1000) { if (timeLeft < refreshIntervalMs) {
auth.update(); auth.update();
} }
} }
}, refreshInterval * 1000); }, refreshIntervalMs);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [accessTokenExpires, refreshInterval, auth.update]); }, [accessTokenExpires, refreshInterval, auth.update]);

View File

@@ -8,16 +8,14 @@ import {
parseMaybeNonEmptyString, parseMaybeNonEmptyString,
} from "./utils"; } from "./utils";
const PRETIMEOUT = 60; // seconds before token expires to refresh it const PRETIMEOUT = 600;
// Simple in-memory cache for tokens (in production, consider using a proper cache solution)
const tokenCache = new Map< const tokenCache = new Map<
string, string,
{ token: JWTWithAccessToken; timestamp: number } { token: JWTWithAccessToken; timestamp: number }
>(); >();
const TOKEN_CACHE_TTL = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds const TOKEN_CACHE_TTL = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
// Simple lock mechanism to prevent concurrent token refreshes
const refreshLocks = new Map<string, Promise<JWTWithAccessToken>>(); const refreshLocks = new Map<string, Promise<JWTWithAccessToken>>();
const CLIENT_ID = assertExistsAndNonEmptyString( const CLIENT_ID = assertExistsAndNonEmptyString(
@@ -100,32 +98,25 @@ async function lockedRefreshAccessToken(
): Promise<JWTWithAccessToken> { ): Promise<JWTWithAccessToken> {
const lockKey = `${token.sub}-refresh`; const lockKey = `${token.sub}-refresh`;
// Check if there's already a refresh in progress
const existingRefresh = refreshLocks.get(lockKey); const existingRefresh = refreshLocks.get(lockKey);
if (existingRefresh) { if (existingRefresh) {
return existingRefresh; return await existingRefresh;
} }
// Create a new refresh promise
const refreshPromise = (async () => { const refreshPromise = (async () => {
try { try {
// Check cache for recent token
const cached = tokenCache.get(`token:${token.sub}`); const cached = tokenCache.get(`token:${token.sub}`);
if (cached) { if (cached) {
// Clean up old cache entries
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
tokenCache.delete(`token:${token.sub}`); tokenCache.delete(`token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) { } else if (Date.now() < cached.token.accessTokenExpires) {
// Token is still valid
return cached.token; return cached.token;
} }
} }
// Refresh the token
const currentToken = cached?.token || (token as JWTWithAccessToken); const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken); const newToken = await refreshAccessToken(currentToken);
// Update cache
tokenCache.set(`token:${token.sub}`, { tokenCache.set(`token:${token.sub}`, {
token: newToken, token: newToken,
timestamp: Date.now(), timestamp: Date.now(),
@@ -133,7 +124,6 @@ async function lockedRefreshAccessToken(
return newToken; return newToken;
} finally { } finally {
// Clean up the lock after a short delay
setTimeout(() => refreshLocks.delete(lockKey), 100); setTimeout(() => refreshLocks.delete(lockKey), 100);
} }
})(); })();