Skip to content

Commit 86359b0

Browse files
dionlowDion Low
andauthored
[ENG-2416] feat: add jwt provider session storage part 1 (#1219)
* feat: add jwt token provider to ampersand context - adds jose library to handle jwt token expiration / decoding - adds jwt token provider to ampersand context - refactor api service to use jwt token provider * refactor: use session storage for jwt token cache add installation provider to connect provider add feature flag for jwt auth * fix: api flow * refactor: jwt logic - sessionStorage is now used to cache the JWT token - default token expiration time is 10 minutes - jwt token is now cached in sessionStorage - jwt token is now cached in the in-memory cache - jwt token is now cached in the sessionStorage - jwt token is now cached in the in-memory cache --------- Co-authored-by: Dion Low <dion@withampersand.com>
1 parent 51f29d5 commit 86359b0

7 files changed

Lines changed: 339 additions & 43 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"eslint-plugin-react-refresh": "^0.4.20",
100100
"globals": "^16.0.0",
101101
"immer": "^10.0.3",
102+
"jose": "^6.0.12",
102103
"lodash.isequal": "^4.5.0",
103104
"react-tooltip": "^5.28.0"
104105
},

src/components/Connect/ConnectProvider.tsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback } from "react";
22
import { ConnectionsProvider } from "context/ConnectionsContextProvider";
3+
import { InstallationProvider } from "src/headless";
34
import { useForceUpdate } from "src/hooks/useForceUpdate";
45
import { Connection } from "src/services/api";
56

@@ -60,26 +61,35 @@ export function ConnectProvider({
6061

6162
return (
6263
<div className={resetStyles.resetContainer} key={seed}>
63-
<ConnectionsProvider groupRef={groupRef} provider={provider}>
64-
<ProtectedConnectionLayout
65-
resetComponent={reset}
66-
provider={provider}
67-
consumerRef={consumerRef}
68-
consumerName={consumerName}
69-
groupRef={groupRef}
70-
groupName={groupName}
71-
onSuccess={onSuccessFx}
72-
onDisconnectSuccess={onDisconnectSuccess}
73-
>
74-
<RedirectHandler redirectURL={redirectUrl}>
75-
<ConnectedSuccessBox
76-
resetComponent={reset}
77-
provider={provider}
78-
onDisconnectSuccess={onDisconnectSuccess}
79-
/>
80-
</RedirectHandler>
81-
</ProtectedConnectionLayout>
82-
</ConnectionsProvider>
64+
{/* InstallationProvider is nested in ConnectionsProvider and API service JWT auth */}
65+
<InstallationProvider
66+
integration={provider}
67+
consumerRef={consumerRef}
68+
consumerName={consumerName}
69+
groupRef={groupRef}
70+
groupName={groupName}
71+
>
72+
<ConnectionsProvider groupRef={groupRef} provider={provider}>
73+
<ProtectedConnectionLayout
74+
resetComponent={reset}
75+
provider={provider}
76+
consumerRef={consumerRef}
77+
consumerName={consumerName}
78+
groupRef={groupRef}
79+
groupName={groupName}
80+
onSuccess={onSuccessFx}
81+
onDisconnectSuccess={onDisconnectSuccess}
82+
>
83+
<RedirectHandler redirectURL={redirectUrl}>
84+
<ConnectedSuccessBox
85+
resetComponent={reset}
86+
provider={provider}
87+
onDisconnectSuccess={onDisconnectSuccess}
88+
/>
89+
</RedirectHandler>
90+
</ProtectedConnectionLayout>
91+
</ConnectionsProvider>
92+
</InstallationProvider>
8393
</div>
8494
);
8595
}

src/context/AmpersandContextProvider/AmpersandContextProvider.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1010

1111
import { ApiKeyProvider } from "../ApiKeyContextProvider";
1212
import { ErrorStateProvider } from "../ErrorContextProvider";
13+
import { JwtTokenProvider } from "../JwtTokenContextProvider";
1314

1415
interface AmpersandProviderProps {
1516
options: {
16-
apiKey: string;
17+
apiKey?: string;
1718
/**
1819
* Use `project` instead of `projectId`.
1920
* @deprecated
@@ -24,6 +25,11 @@ interface AmpersandProviderProps {
2425
*/
2526
project?: string;
2627
styles?: object;
28+
/**
29+
* Callback function to get a JWT token for authorization.
30+
* This function should return a Promise that resolves to a JWT token string.
31+
*/
32+
getToken?: (consumerRef: string, groupRef: string) => Promise<string>;
2733
};
2834
children: React.ReactNode;
2935
}
@@ -52,7 +58,7 @@ const queryClient = new QueryClient();
5258

5359
export function AmpersandProvider(props: AmpersandProviderProps) {
5460
const {
55-
options: { apiKey, projectId, project },
61+
options: { apiKey, projectId, project, getToken },
5662
children,
5763
} = props;
5864
const projectIdOrName = project || projectId;
@@ -67,8 +73,16 @@ export function AmpersandProvider(props: AmpersandProviderProps) {
6773
);
6874
}
6975

70-
if (!apiKey) {
71-
throw new Error("Cannot use AmpersandProvider without an apiKey.");
76+
if (!apiKey && !getToken) {
77+
throw new Error(
78+
"Cannot use AmpersandProvider without an apiKey or getToken.",
79+
);
80+
}
81+
82+
if (apiKey && getToken) {
83+
throw new Error(
84+
"Cannot use AmpersandProvider with both apiKey and getToken.",
85+
);
7286
}
7387

7488
const contextValue: AmpersandContextValue = {
@@ -80,7 +94,9 @@ export function AmpersandProvider(props: AmpersandProviderProps) {
8094
<QueryClientProvider client={queryClient}>
8195
<AmpersandContext.Provider value={contextValue}>
8296
<ErrorStateProvider>
83-
<ApiKeyProvider value={apiKey}>{children}</ApiKeyProvider>
97+
<JwtTokenProvider getTokenCallback={getToken || null}>
98+
<ApiKeyProvider value={apiKey || null}>{children}</ApiKeyProvider>
99+
</JwtTokenProvider>
84100
</ErrorStateProvider>
85101
</AmpersandContext.Provider>
86102
</QueryClientProvider>

src/context/ApiKeyContextProvider.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,5 @@ export const ApiKeyProvider = ApiKeyContext.Provider;
66

77
export const useApiKey = () => {
88
const apiKey = useContext(ApiKeyContext);
9-
10-
if (apiKey === null) {
11-
throw new Error("useApiKey must be used within an ApiKeyProvider");
12-
}
13-
149
return apiKey;
1510
};
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useState,
7+
} from "react";
8+
import { jwtVerify } from "jose";
9+
10+
interface TokenCacheEntry {
11+
token: string;
12+
expiresAt: number;
13+
}
14+
15+
const SESSION_STORAGE_PREFIX = "amp-labs_jwt_";
16+
17+
const DEFAULT_TOKEN_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes
18+
19+
const createCacheKey = (consumerRef: string, groupRef: string) =>
20+
`${consumerRef}:${groupRef}`;
21+
22+
const getSessionStorageKey = (cacheKey: string) =>
23+
`${SESSION_STORAGE_PREFIX}${cacheKey}`;
24+
25+
interface JwtTokenContextValue {
26+
getToken?: (consumerRef: string, groupRef: string) => Promise<string>;
27+
}
28+
29+
const JwtTokenContext = createContext<JwtTokenContextValue | null>(null);
30+
31+
interface JwtTokenProviderProps {
32+
getTokenCallback:
33+
| ((consumerRef: string, groupRef: string) => Promise<string>)
34+
| null;
35+
children: React.ReactNode;
36+
}
37+
38+
/**
39+
* Extract JWT token expiration time
40+
*/
41+
const getTokenExpirationTime = async (
42+
token: string,
43+
): Promise<number | null> => {
44+
try {
45+
const decoded = await jwtVerify(token, new Uint8Array(0), {
46+
algorithms: [], // Skip signature verification
47+
});
48+
const payload = decoded.payload;
49+
return payload.exp && typeof payload.exp === "number"
50+
? payload.exp * 1000 // jwt expiration is in seconds, convert to milliseconds
51+
: null;
52+
} catch (error) {
53+
console.warn("Failed to decode JWT token:", error);
54+
return null;
55+
}
56+
};
57+
58+
/**
59+
* Simplified JWT token provider with cleaner caching logic
60+
*/
61+
export function JwtTokenProvider({
62+
getTokenCallback,
63+
children,
64+
}: JwtTokenProviderProps) {
65+
const [tokenCache, setTokenCache] = useState<Map<string, TokenCacheEntry>>(
66+
new Map(),
67+
);
68+
69+
// Load cached tokens from sessionStorage on mount
70+
useEffect(() => {
71+
try {
72+
const newCache = new Map<string, TokenCacheEntry>();
73+
const now = Date.now(); // current time in milliseconds
74+
75+
Object.keys(sessionStorage).forEach((key) => {
76+
if (key.startsWith(SESSION_STORAGE_PREFIX)) {
77+
const cacheKey = key.replace(SESSION_STORAGE_PREFIX, "");
78+
const stored = sessionStorage.getItem(key);
79+
80+
if (stored) {
81+
try {
82+
const cacheEntry: TokenCacheEntry = JSON.parse(stored);
83+
if (cacheEntry.expiresAt > now) {
84+
newCache.set(cacheKey, cacheEntry);
85+
} else {
86+
sessionStorage.removeItem(key);
87+
}
88+
} catch {
89+
sessionStorage.removeItem(key);
90+
}
91+
}
92+
}
93+
});
94+
95+
if (newCache.size > 0) {
96+
setTokenCache(newCache);
97+
}
98+
} catch {
99+
console.warn("Failed to load JWT tokens from sessionStorage");
100+
}
101+
}, []);
102+
103+
/**
104+
* Get a cached token from the in-memory cache or sessionStorage
105+
* @param consumerRef - The consumer reference
106+
* @param groupRef - The group reference
107+
* @returns The cached token or null if not found
108+
*/
109+
const getCachedToken = useCallback(
110+
(consumerRef: string, groupRef: string): string | null => {
111+
const cacheKey = createCacheKey(consumerRef, groupRef);
112+
const now = Date.now();
113+
114+
// Check in-memory cache first
115+
const cached = tokenCache.get(cacheKey);
116+
if (cached && cached.expiresAt > now) {
117+
return cached.token;
118+
} else {
119+
tokenCache.delete(cacheKey);
120+
}
121+
122+
// Check sessionStorage
123+
const sessionKey = getSessionStorageKey(cacheKey);
124+
const stored = sessionStorage.getItem(sessionKey);
125+
126+
if (stored) {
127+
try {
128+
const cacheEntry: TokenCacheEntry = JSON.parse(stored);
129+
if (cacheEntry.expiresAt > now) {
130+
// Update in-memory cache
131+
setTokenCache((prev) => new Map(prev).set(cacheKey, cacheEntry));
132+
return cacheEntry.token;
133+
} else {
134+
sessionStorage.removeItem(sessionKey);
135+
}
136+
} catch {
137+
sessionStorage.removeItem(sessionKey);
138+
}
139+
}
140+
141+
return null;
142+
},
143+
[tokenCache],
144+
);
145+
146+
const setCachedToken = useCallback(
147+
async (consumerRef: string, groupRef: string, token: string) => {
148+
const cacheKey = createCacheKey(consumerRef, groupRef);
149+
const tokenExpiration = await getTokenExpirationTime(token);
150+
const expiresAt =
151+
tokenExpiration || Date.now() + DEFAULT_TOKEN_EXPIRATION_TIME;
152+
153+
const cacheEntry: TokenCacheEntry = { token, expiresAt };
154+
155+
// Update both in-memory cache
156+
setTokenCache((prev) => new Map(prev).set(cacheKey, cacheEntry));
157+
158+
// Update sessionStorage
159+
try {
160+
sessionStorage.setItem(
161+
getSessionStorageKey(cacheKey),
162+
JSON.stringify(cacheEntry),
163+
);
164+
} catch {
165+
console.warn("Failed to store JWT token in sessionStorage");
166+
}
167+
},
168+
[],
169+
);
170+
171+
const getToken = useCallback(
172+
async (consumerRef: string, groupRef: string): Promise<string> => {
173+
// Check all caches first
174+
const cachedToken = getCachedToken(consumerRef, groupRef);
175+
if (cachedToken) {
176+
return cachedToken;
177+
}
178+
179+
// Fetch new token if no callback provided
180+
if (!getTokenCallback) {
181+
throw new Error("JWT token callback not provided");
182+
}
183+
184+
try {
185+
const token = await getTokenCallback(consumerRef, groupRef);
186+
await setCachedToken(consumerRef, groupRef, token);
187+
return token;
188+
} catch (error) {
189+
console.error("Failed to get JWT token:", error);
190+
throw new Error("Failed to get JWT token");
191+
}
192+
},
193+
[getTokenCallback, getCachedToken, setCachedToken],
194+
);
195+
196+
const contextValue: JwtTokenContextValue = {
197+
getToken: getTokenCallback ? getToken : undefined,
198+
};
199+
200+
return (
201+
<JwtTokenContext.Provider value={contextValue}>
202+
{children}
203+
</JwtTokenContext.Provider>
204+
);
205+
}
206+
207+
export const useJwtToken = () => {
208+
const context = useContext(JwtTokenContext);
209+
if (!context) {
210+
throw new Error("useJwtToken must be used within a JwtTokenProvider");
211+
}
212+
return context;
213+
};

0 commit comments

Comments
 (0)