From f24ed2f511968a07e27dfb5476a3f3b68e9d98d4 Mon Sep 17 00:00:00 2001 From: Dion Low Date: Fri, 17 Oct 2025 12:24:50 -0700 Subject: [PATCH 1/2] refactor: use react query to manage jwt token expiration --- .../AmpersandContextProvider.tsx | 2 +- .../JwtTokenContextProvider.reactquery.tsx | 180 ++++++++++++++++++ src/services/api.ts | 2 +- 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/context/JwtTokenContextProvider.reactquery.tsx diff --git a/src/context/AmpersandContextProvider/AmpersandContextProvider.tsx b/src/context/AmpersandContextProvider/AmpersandContextProvider.tsx index 0cf3d471..4bfa0bf6 100644 --- a/src/context/AmpersandContextProvider/AmpersandContextProvider.tsx +++ b/src/context/AmpersandContextProvider/AmpersandContextProvider.tsx @@ -10,7 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ApiKeyProvider } from "../ApiKeyContextProvider"; import { ErrorStateProvider } from "../ErrorContextProvider"; -import { JwtTokenProvider } from "../JwtTokenContextProvider"; +import { JwtTokenProvider } from "../JwtTokenContextProvider.reactquery"; interface AmpersandProviderProps { options: { diff --git a/src/context/JwtTokenContextProvider.reactquery.tsx b/src/context/JwtTokenContextProvider.reactquery.tsx new file mode 100644 index 00000000..4e1da7f2 --- /dev/null +++ b/src/context/JwtTokenContextProvider.reactquery.tsx @@ -0,0 +1,180 @@ +/** + * JWT Token Provider using React Query + * + * React Query handles all caching, expiration, and refetching automatically. + * No manual cache management needed! + */ + +import { createContext, useCallback, useContext } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { decodeJwt } from "jose"; + +interface JwtTokenContextValue { + getToken?: ({ + consumerRef, + groupRef, + }: { + consumerRef: string; + groupRef: string; + }) => Promise; +} + +const JwtTokenContext = createContext(null); + +interface JwtTokenProviderProps { + getTokenCallback: + | (({ + consumerRef, + groupRef, + }: { + consumerRef: string; + groupRef: string; + }) => Promise) + | null; + children: React.ReactNode; +} + +interface TokenData { + token: string; + expiresAt: number; +} + +// const DEFAULT_TOKEN_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes + +/** + * Extract JWT token expiration time + */ +const getTokenExpirationTime = (token: string): number | null => { + try { + const payload = decodeJwt(token); + console.log("[JWT Debug] Decoded JWT payload", { payload }); + + if (payload.exp && typeof payload.exp === "number") { + const expirationTime = payload.exp * 1000; // Convert seconds to milliseconds + console.log("[JWT Debug] Token expiration extracted", { + exp: payload.exp, + expirationTime, + expiresAt: new Date(expirationTime).toISOString(), + }); + return expirationTime; + } + + console.warn("[JWT Debug] No exp field in JWT payload"); + return null; + } catch (error) { + console.warn("[JWT Debug] Failed to decode JWT token:", error); + return null; + } +}; + +/** + * Create a query key for the token + */ +const createTokenQueryKey = (consumerRef: string, groupRef: string) => [ + "jwt-token", + consumerRef, + groupRef, +]; + +/** + * JWT Token Provider using React Query + * React Query handles all caching automatically! + */ +export function JwtTokenProvider({ + getTokenCallback, + children, +}: JwtTokenProviderProps) { + const queryClient = useQueryClient(); + + const getToken = useCallback( + async ({ + consumerRef, + groupRef, + }: { + consumerRef: string; + groupRef: string; + }): Promise => { + if (!getTokenCallback) { + throw new Error("JWT token callback not provided"); + } + + const queryKey = createTokenQueryKey(consumerRef, groupRef); + + console.log("[JWT Debug] getToken called", { consumerRef, groupRef }); + + // Let React Query handle caching and expiration + const data = await queryClient.fetchQuery({ + queryKey, + queryFn: async (): Promise => { + console.log( + "[JWT Debug] Calling getTokenCallback (via React Query)", + { consumerRef, groupRef }, + ); + + const token = await getTokenCallback({ consumerRef, groupRef }); + const expirationTime = await getTokenExpirationTime(token); + if (!expirationTime) { + console.warn( + "Failed to get token expiration time, using default expiration", + ); + } + console.log("[JWT Debug] Token expiration time", { expirationTime }); + const expiresAt = expirationTime || Date.now(); + + console.log("[JWT Debug] Token fetched and cached", { + consumerRef, + groupRef, + expiresAt: new Date(expiresAt).toISOString(), + expiresInMs: expiresAt - Date.now(), + expiresInMinutes: Math.floor((expiresAt - Date.now()) / 60000), + }); + + return { token, expiresAt }; + }, + // React Query will use existing cache if available and not stale + staleTime: (query) => { + const data = query.state.data as TokenData | undefined; + if (!data) return 0; + + const timeUntilExpiry = data.expiresAt - Date.now(); + + // Token is stale when it expires (with 1 min buffer) + const staleTime = Math.max(0, timeUntilExpiry - 60000); + + console.log("[JWT Debug] Token staleTime calculated", { + consumerRef, + groupRef, + staleTime, + staleInMinutes: Math.floor(staleTime / 60000), + }); + + return staleTime; + }, + gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + }); + + return data.token; + }, + [getTokenCallback, queryClient], + ); + + const contextValue: JwtTokenContextValue = { + getToken: getTokenCallback ? getToken : undefined, + }; + + return ( + + {children} + + ); +} + +export const useJwtToken = () => { + const context = useContext(JwtTokenContext); + if (!context) { + throw new Error("useJwtToken must be used within a JwtTokenProvider"); + } + return context; +}; diff --git a/src/services/api.ts b/src/services/api.ts index a220df52..2815892c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -27,7 +27,7 @@ import { UpdateInstallationRequestInstallationConfig, } from "@generated/api/src"; import { useApiKey } from "src/context/ApiKeyContextProvider"; -import { useJwtToken } from "src/context/JwtTokenContextProvider"; +import { useJwtToken } from "src/context/JwtTokenContextProvider.reactquery"; import { useInstallationProps } from "src/headless/InstallationProvider"; import { ApiService } from "./ApiService"; From 783c5e66742bc0c196a86111d5a736fe305ff2e3 Mon Sep 17 00:00:00 2001 From: Dion Low Date: Fri, 17 Oct 2025 12:33:54 -0700 Subject: [PATCH 2/2] remove loggings --- .../JwtTokenContextProvider.reactquery.tsx | 54 ++++--------------- src/context/JwtTokenContextProvider.tsx | 20 ++++--- 2 files changed, 18 insertions(+), 56 deletions(-) diff --git a/src/context/JwtTokenContextProvider.reactquery.tsx b/src/context/JwtTokenContextProvider.reactquery.tsx index 4e1da7f2..51fb35bd 100644 --- a/src/context/JwtTokenContextProvider.reactquery.tsx +++ b/src/context/JwtTokenContextProvider.reactquery.tsx @@ -39,7 +39,7 @@ interface TokenData { expiresAt: number; } -// const DEFAULT_TOKEN_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes +const DEFAULT_TOKEN_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes /** * Extract JWT token expiration time @@ -47,22 +47,14 @@ interface TokenData { const getTokenExpirationTime = (token: string): number | null => { try { const payload = decodeJwt(token); - console.log("[JWT Debug] Decoded JWT payload", { payload }); if (payload.exp && typeof payload.exp === "number") { - const expirationTime = payload.exp * 1000; // Convert seconds to milliseconds - console.log("[JWT Debug] Token expiration extracted", { - exp: payload.exp, - expirationTime, - expiresAt: new Date(expirationTime).toISOString(), - }); - return expirationTime; + return payload.exp * 1000; // Convert seconds to milliseconds } - console.warn("[JWT Debug] No exp field in JWT payload"); return null; } catch (error) { - console.warn("[JWT Debug] Failed to decode JWT token:", error); + console.warn("Failed to decode JWT token:", error); return null; } }; @@ -100,34 +92,15 @@ export function JwtTokenProvider({ const queryKey = createTokenQueryKey(consumerRef, groupRef); - console.log("[JWT Debug] getToken called", { consumerRef, groupRef }); - // Let React Query handle caching and expiration const data = await queryClient.fetchQuery({ queryKey, queryFn: async (): Promise => { - console.log( - "[JWT Debug] Calling getTokenCallback (via React Query)", - { consumerRef, groupRef }, - ); - const token = await getTokenCallback({ consumerRef, groupRef }); - const expirationTime = await getTokenExpirationTime(token); - if (!expirationTime) { - console.warn( - "Failed to get token expiration time, using default expiration", - ); - } - console.log("[JWT Debug] Token expiration time", { expirationTime }); - const expiresAt = expirationTime || Date.now(); - - console.log("[JWT Debug] Token fetched and cached", { - consumerRef, - groupRef, - expiresAt: new Date(expiresAt).toISOString(), - expiresInMs: expiresAt - Date.now(), - expiresInMinutes: Math.floor((expiresAt - Date.now()) / 60000), - }); + const expirationTime = getTokenExpirationTime(token); + + const expiresAt = + expirationTime || Date.now() + DEFAULT_TOKEN_EXPIRATION_TIME; return { token, expiresAt }; }, @@ -138,17 +111,8 @@ export function JwtTokenProvider({ const timeUntilExpiry = data.expiresAt - Date.now(); - // Token is stale when it expires (with 1 min buffer) - const staleTime = Math.max(0, timeUntilExpiry - 60000); - - console.log("[JWT Debug] Token staleTime calculated", { - consumerRef, - groupRef, - staleTime, - staleInMinutes: Math.floor(staleTime / 60000), - }); - - return staleTime; + // Token is stale when it expires (with 30 seconds buffer) + return Math.max(0, timeUntilExpiry - 30000); // 30 seconds buffer }, gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes retry: 2, diff --git a/src/context/JwtTokenContextProvider.tsx b/src/context/JwtTokenContextProvider.tsx index a22e6c6b..b9e83bd1 100644 --- a/src/context/JwtTokenContextProvider.tsx +++ b/src/context/JwtTokenContextProvider.tsx @@ -5,7 +5,7 @@ import { useEffect, useState, } from "react"; -import { jwtVerify } from "jose"; +import { decodeJwt } from "jose"; interface TokenCacheEntry { token: string; @@ -50,17 +50,15 @@ interface JwtTokenProviderProps { /** * Extract JWT token expiration time */ -const getTokenExpirationTime = async ( - token: string, -): Promise => { +const getTokenExpirationTime = (token: string): number | null => { try { - const decoded = await jwtVerify(token, new Uint8Array(0), { - algorithms: [], // Skip signature verification - }); - const payload = decoded.payload; - return payload.exp && typeof payload.exp === "number" - ? payload.exp * 1000 // jwt expiration is in seconds, convert to milliseconds - : null; + const payload = decodeJwt(token); + + if (payload.exp && typeof payload.exp === "number") { + return payload.exp * 1000; // Convert seconds to milliseconds + } + + return null; } catch (error) { console.warn("Failed to decode JWT token:", error); return null;