Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
144 changes: 144 additions & 0 deletions src/context/JwtTokenContextProvider.reactquery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* JWT Token Provider using React Query
*
* React Query handles all caching, expiration, and refetching automatically.
* No manual cache management needed!
Comment on lines +4 to +5
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims automatic handling of expiration/refetching, but the implementation still embeds custom expiration logic and (incorrect) dynamic stale handling. Update the comment to clearly describe the actual strategy: React Query caches tokens; custom logic determines expiry and triggers refresh, since dynamic per-item staleTime is manually enforced.

Suggested change
* React Query handles all caching, expiration, and refetching automatically.
* No manual cache management needed!
* React Query caches tokens. Custom logic determines token expiry and triggers refresh,
* since dynamic per-item staleTime is manually enforced. Manual cache management is required
* for correct expiration and refresh behavior.

Copilot uses AI. Check for mistakes.
*/

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<string>;
}

const JwtTokenContext = createContext<JwtTokenContextValue | null>(null);

interface JwtTokenProviderProps {
getTokenCallback:
| (({
consumerRef,
groupRef,
}: {
consumerRef: string;
groupRef: string;
}) => Promise<string>)
| null;
children: React.ReactNode;
}

interface TokenData {
token: string;
expiresAt: number;
}

const DEFAULT_TOKEN_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Repeated magic numbers (10 * 60 * 1000 and 30000) reduce clarity and make future adjustments harder. Recommend extracting constants, e.g., TOKEN_DEFAULT_TTL_MS, TOKEN_EXPIRY_BUFFER_MS, and reusing DEFAULT_TOKEN_EXPIRATION_TIME for gcTime where appropriate (or computing gcTime relative to expiresAt to avoid premature garbage collection).

Copilot uses AI. Check for mistakes.

/**
* Extract JWT token expiration time
*/
const getTokenExpirationTime = (token: string): number | null => {
try {
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;
}
};

/**
* 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<string> => {
if (!getTokenCallback) {
throw new Error("JWT token callback not provided");
}

const queryKey = createTokenQueryKey(consumerRef, groupRef);

// Let React Query handle caching and expiration
const data = await queryClient.fetchQuery({
queryKey,
queryFn: async (): Promise<TokenData> => {
const token = await getTokenCallback({ consumerRef, groupRef });
const expirationTime = getTokenExpirationTime(token);

const expiresAt =
expirationTime || Date.now() + DEFAULT_TOKEN_EXPIRATION_TIME;

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 30 seconds buffer)
return Math.max(0, timeUntilExpiry - 30000); // 30 seconds buffer
},
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
Comment on lines +115 to +117
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Repeated magic numbers (10 * 60 * 1000 and 30000) reduce clarity and make future adjustments harder. Recommend extracting constants, e.g., TOKEN_DEFAULT_TTL_MS, TOKEN_EXPIRY_BUFFER_MS, and reusing DEFAULT_TOKEN_EXPIRATION_TIME for gcTime where appropriate (or computing gcTime relative to expiresAt to avoid premature garbage collection).

Copilot uses AI. Check for mistakes.
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});

return data.token;
Comment on lines +95 to +122
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

staleTime expects a number (or Infinity) in TanStack Query; providing a function here is not a supported API and will not yield the intended dynamic behavior. To implement per-token dynamic staleness, compute the token first (with its expiration), then decide whether to reuse or refetch instead of relying on staleTime. For example, replace fetchQuery usage with a manual check: get existing = queryClient.getQueryData(queryKey); if existing && existing.expiresAt - Date.now() > 30000 return existing.token; else fetch new token, compute expiresAt, and setQueryData.

Suggested change
// Let React Query handle caching and expiration
const data = await queryClient.fetchQuery({
queryKey,
queryFn: async (): Promise<TokenData> => {
const token = await getTokenCallback({ consumerRef, groupRef });
const expirationTime = getTokenExpirationTime(token);
const expiresAt =
expirationTime || Date.now() + DEFAULT_TOKEN_EXPIRATION_TIME;
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 30 seconds buffer)
return Math.max(0, timeUntilExpiry - 30000); // 30 seconds buffer
},
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
return data.token;
// Check if a valid token exists in cache
const existing = queryClient.getQueryData<TokenData>(queryKey);
if (
existing &&
typeof existing.expiresAt === "number" &&
existing.expiresAt - Date.now() > 30000 // 30 seconds buffer
) {
return existing.token;
}
// Fetch new token and cache it
const token = await getTokenCallback({ consumerRef, groupRef });
const expirationTime = getTokenExpirationTime(token);
const expiresAt =
expirationTime || Date.now() + DEFAULT_TOKEN_EXPIRATION_TIME;
const data: TokenData = { token, expiresAt };
queryClient.setQueryData(queryKey, data);
return token;

Copilot uses AI. Check for mistakes.
},
[getTokenCallback, queryClient],
);

const contextValue: JwtTokenContextValue = {
getToken: getTokenCallback ? getToken : undefined,
};

return (
<JwtTokenContext.Provider value={contextValue}>
{children}
</JwtTokenContext.Provider>
);
}

export const useJwtToken = () => {
const context = useContext(JwtTokenContext);
if (!context) {
throw new Error("useJwtToken must be used within a JwtTokenProvider");
}
return context;
};
20 changes: 9 additions & 11 deletions src/context/JwtTokenContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
useEffect,
useState,
} from "react";
import { jwtVerify } from "jose";
import { decodeJwt } from "jose";

interface TokenCacheEntry {
token: string;
Expand Down Expand Up @@ -50,17 +50,15 @@ interface JwtTokenProviderProps {
/**
* Extract JWT token expiration time
*/
const getTokenExpirationTime = async (
token: string,
): Promise<number | null> => {
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;
Expand Down
2 changes: 1 addition & 1 deletion src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down