@payez/next-mvp 4.1.0 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,468 +1,470 @@
1
- /**
2
- * Token Lifecycle Management for @payez/next-mvp
3
- *
4
- * Ensures tokens are fresh before making API calls.
5
- * Checks expiration and triggers refresh if needed.
6
- *
7
- * Pattern: Check first, refresh if needed, fail gracefully if refresh fails.
8
- *
9
- * HANDLES CONCURRENT REFRESH: When multiple API calls arrive simultaneously
10
- * with expired tokens, only one will actually perform the refresh. Others
11
- * receive 409 (conflict) and wait for the refresh to complete, then use
12
- * the freshly refreshed tokens.
13
- *
14
- * REQUIRED: Your app must expose the refresh route:
15
- * ```typescript
16
- * // app/api/auth/refresh/route.ts
17
- * export { POST } from '@payez/next-mvp/routes/auth/refresh';
18
- * ```
19
- *
20
- * @version 2.0.0
21
- */
22
-
23
- import { NextRequest } from 'next/server';
24
- import { getSession as getRedisSession, SessionData } from './session-store';
25
- import { getSession as getBetterAuthSession } from '../server/auth';
26
- import { getRedis } from './redis';
27
- import { getAppSlug } from './app-slug';
28
-
29
- // 5 minute threshold for "needs refresh" - matches refresh handler pattern
30
- const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
31
-
32
- // Concurrent refresh handling configuration
33
- const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
34
- const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
35
- const REFRESH_RETRY_DELAY_MS = 500; // Delay before retrying after failed concurrent refresh
36
- const KEY_PROPAGATION_DELAY_MS = 150; // Delay after refresh to allow JWKS cache updates in downstream services
37
-
38
- export interface TokenResult {
39
- success: true;
40
- accessToken: string;
41
- sessionData: SessionData;
42
- }
43
-
44
- export interface TokenError {
45
- success: false;
46
- error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
47
- message: string;
48
- terminal?: boolean; // If true, don't retry - redirect to login
49
- }
50
-
51
- export type EnsureFreshTokenResult = TokenResult | TokenError;
52
-
53
- /**
54
- * Check if token needs refresh based on expiration time
55
- */
56
- function needsRefresh(accessTokenExpires: number | undefined): boolean {
57
- if (!accessTokenExpires) return true;
58
- const timeUntilExpiry = accessTokenExpires - Date.now();
59
- return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
60
- }
61
-
62
- /**
63
- * Helper to delay execution
64
- */
65
- function delay(ms: number): Promise<void> {
66
- return new Promise(resolve => setTimeout(resolve, ms));
67
- }
68
-
69
- /**
70
- * Wait for a concurrent refresh to complete by polling the session.
71
- * Returns true if session becomes fresh, false if timeout reached.
72
- */
73
- async function waitForConcurrentRefresh(
74
- sessionToken: string,
75
- maxWaitMs: number = CONCURRENT_REFRESH_MAX_WAIT_MS
76
- ): Promise<{ success: boolean; sessionData?: SessionData }> {
77
- const startTime = Date.now();
78
-
79
- while (Date.now() - startTime < maxWaitMs) {
80
- await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
81
-
82
- const sessionData = await getRedisSession(sessionToken);
83
-
84
- if (!sessionData) {
85
- return { success: false };
86
- }
87
-
88
- // Check if token is now fresh
89
- if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
90
- return { success: true, sessionData };
91
- }
92
-
93
- // Check if session has a new access token (even if still within threshold)
94
- if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
95
- sessionData.idpAccessTokenExpires > Date.now()) {
96
- return { success: true, sessionData };
97
- }
98
- }
99
-
100
- return { success: false };
101
- }
102
-
103
- /**
104
- * Get the internal API URL for making internal service calls.
105
- * INTERNAL_API_URL is REQUIRED - no fallbacks.
106
- */
107
- function getInternalApiUrl(request: NextRequest): string {
108
- const internalUrl = process.env.INTERNAL_API_URL;
109
- if (!internalUrl) {
110
- throw new Error(
111
- '[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
112
- 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
113
- 'or http://localhost:3000 for local development.'
114
- );
115
- }
116
- return internalUrl;
117
- }
118
-
119
- /**
120
- * Result of triggerRefresh - includes terminal flag for unrecoverable errors
121
- */
122
- interface RefreshResult {
123
- success: boolean;
124
- terminal?: boolean; // If true, session is dead - don't retry, redirect to login
125
- code?: string; // Error code from refresh endpoint
126
- }
127
-
128
- /**
129
- * Trigger a token refresh via the refresh API endpoint.
130
- *
131
- * HANDLES CONCURRENT REFRESH (409):
132
- * When another request is already refreshing the token, this function
133
- * waits for that refresh to complete instead of failing immediately.
134
- * This prevents race conditions where multiple parallel API calls
135
- * could cause unnecessary refresh failures.
136
- *
137
- * TERMINAL STATES:
138
- * Returns { success: false, terminal: true } when the session cannot be
139
- * recovered (e.g., no refresh token). Callers should redirect to login.
140
- */
141
- async function triggerRefresh(
142
- request: NextRequest,
143
- sessionToken: string,
144
- retryCount: number = 0
145
- ): Promise<RefreshResult> {
146
- const maxRetries = 2;
147
-
148
- try {
149
- const baseUrl = getInternalApiUrl(request);
150
- const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
151
-
152
- const response = await fetch(`${baseUrl}/api/auth/refresh`, {
153
- method: 'POST',
154
- headers: {
155
- 'Content-Type': 'application/json',
156
- 'Cookie': request.headers.get('cookie') || '',
157
- 'X-Session-Token': sessionToken,
158
- 'X-Request-Id': requestId,
159
- },
160
- });
161
-
162
- // Handle 409 Conflict - another refresh is in progress
163
- if (response.status === 409) {
164
- // Wait for the concurrent refresh to complete
165
- const waitResult = await waitForConcurrentRefresh(sessionToken);
166
-
167
- if (waitResult.success) {
168
- return { success: true };
169
- }
170
-
171
- // Concurrent refresh didn't produce a fresh token - try again if we have retries left
172
- if (retryCount < maxRetries) {
173
- await delay(REFRESH_RETRY_DELAY_MS);
174
- return triggerRefresh(request, sessionToken, retryCount + 1);
175
- }
176
-
177
- return { success: false };
178
- }
179
-
180
- // Handle other non-OK responses
181
- if (!response.ok) {
182
- // Parse response body to check for terminal errors
183
- let responseData: any = {};
184
- try {
185
- responseData = await response.json();
186
- } catch {
187
- // Ignore parse errors
188
- }
189
-
190
- // Log the failure for debugging
191
- console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
192
- status: response.status,
193
- statusText: response.statusText,
194
- baseUrl,
195
- retryCount,
196
- code: responseData.code,
197
- terminal: responseData.terminal
198
- });
199
-
200
- // CHECK FOR TERMINAL STATE: No refresh token = session is dead
201
- // Don't retry - user must re-authenticate
202
- if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
203
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
204
- return { success: false, terminal: true, code: responseData.code };
205
- }
206
-
207
- // For other 401s, check if maybe session was refreshed by another request
208
- if (response.status === 401 && retryCount < maxRetries) {
209
- await delay(REFRESH_RETRY_DELAY_MS);
210
-
211
- const sessionData = await getRedisSession(sessionToken);
212
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
213
- return { success: true };
214
- }
215
- }
216
-
217
- return { success: false, code: responseData.code };
218
- }
219
-
220
- const result = await response.json();
221
- const success = result.refreshed === true || result.reason === 'already_fresh';
222
- return { success };
223
- } catch (error) {
224
- // Log network errors for debugging
225
- console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
226
- error: error instanceof Error ? error.message : String(error),
227
- retryCount
228
- });
229
-
230
- // On network error, check if maybe another request refreshed the token
231
- if (retryCount < maxRetries) {
232
- await delay(REFRESH_RETRY_DELAY_MS);
233
- const sessionData = await getRedisSession(sessionToken);
234
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
235
- return { success: true };
236
- }
237
- }
238
-
239
- return { success: false };
240
- }
241
- }
242
-
243
- /**
244
- * Ensures we have a fresh access token before making API calls.
245
- *
246
- * This utility checks token expiration and triggers a refresh if needed,
247
- * preventing 401 errors from expired tokens being sent to downstream APIs.
248
- *
249
- * @param request - The incoming NextRequest
250
- * @returns TokenResult with accessToken and sessionData, or TokenError
251
- *
252
- * @example
253
- * ```typescript
254
- * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
255
- *
256
- * export async function GET(request: NextRequest) {
257
- * const tokenResult = await ensureFreshToken(request);
258
- * if (!tokenResult.success) {
259
- * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
260
- * }
261
- *
262
- * // Use tokenResult.accessToken for downstream API calls
263
- * const response = await fetch('https://api.example.com/data', {
264
- * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
265
- * });
266
- * }
267
- * ```
268
- */
269
- export async function ensureFreshToken(
270
- request: NextRequest
271
- ): Promise<EnsureFreshTokenResult> {
272
- try {
273
- // 1. Get Better Auth session to extract sessionToken
274
- const betterAuthSession = await getBetterAuthSession(request);
275
-
276
- if (!betterAuthSession?.session?.token) {
277
- console.warn('[TOKEN_LIFECYCLE] NO_SESSION - Better Auth session not found');
278
- return {
279
- success: false,
280
- error: 'NO_SESSION',
281
- message: 'No session available',
282
- };
283
- }
284
-
285
- const sessionToken = betterAuthSession.session.token;
286
-
287
- // 2. Get session data from Redis (legacy prefix), or Better Auth's secondary storage
288
- let sessionData = await getRedisSession(sessionToken);
289
-
290
- if (!sessionData) {
291
- // Try Better Auth's secondaryStorage key (ba:{slug}:{token})
292
- try {
293
- const baKey = `ba:${getAppSlug()}:${sessionToken}`;
294
- const baRaw = await getRedis().get(baKey);
295
- if (baRaw) {
296
- const baSession = JSON.parse(baRaw);
297
- const idpTokens = baSession.idpTokens;
298
-
299
- sessionData = {
300
- userId: idpTokens?.userId || baSession.user?.id || betterAuthSession.user?.id || '',
301
- email: idpTokens?.email || baSession.user?.email || betterAuthSession.user?.email || '',
302
- name: idpTokens?.name || baSession.user?.name || betterAuthSession.user?.name,
303
- roles: idpTokens?.roles || [],
304
- idpAccessToken: idpTokens?.idpAccessToken,
305
- idpRefreshToken: idpTokens?.idpRefreshToken,
306
- idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
307
- || (baSession.session?.expiresAt ? new Date(baSession.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
308
- mfaVerified: true,
309
- oauthProvider: 'google',
310
- } as SessionData;
311
- }
312
- } catch { /* Redis unavailable */ }
313
- }
314
-
315
- if (!sessionData && betterAuthSession.user) {
316
- // Last resort: build from Better Auth in-memory session (no IDP tokens)
317
- sessionData = {
318
- userId: betterAuthSession.user.id || '',
319
- email: betterAuthSession.user.email || '',
320
- name: betterAuthSession.user.name,
321
- roles: [],
322
- idpAccessTokenExpires: Date.now() + 24 * 60 * 60 * 1000,
323
- mfaVerified: true,
324
- oauthProvider: 'google',
325
- } as SessionData;
326
- }
327
-
328
- if (!sessionData) {
329
- return {
330
- success: false,
331
- error: 'NO_SESSION',
332
- message: 'Session expired or not found',
333
- };
334
- }
335
-
336
- // DEBUG: Log session data before refresh check
337
- const tokenExpiresStr = sessionData.idpAccessTokenExpires
338
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
339
- : 'undefined';
340
- let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
341
-
342
- // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
343
- // This catches cases where accessTokenExpires was updated but accessToken wasn't
344
- let tokenMismatch = false;
345
- if (sessionData.idpAccessToken && !needsRefreshNow) {
346
- try {
347
- const tokenParts = sessionData.idpAccessToken.split('.');
348
- if (tokenParts.length === 3) {
349
- const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
350
- const jwtExpMs = (payload.exp || 0) * 1000;
351
- const now = Date.now();
352
-
353
- // If the JWT is actually expired, force a refresh regardless of what Redis says
354
- if (jwtExpMs < now) {
355
- console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
356
- jwtExp: new Date(jwtExpMs).toISOString(),
357
- redisAccessTokenExpires: tokenExpiresStr,
358
- now: new Date(now).toISOString(),
359
- mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
360
- });
361
- needsRefreshNow = true;
362
- tokenMismatch = true;
363
- }
364
- }
365
- } catch (e) {
366
- // If we can't decode, proceed with normal logic
367
- console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
368
- }
369
- }
370
-
371
- console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
372
- sessionToken: sessionToken.substring(0, 8) + '...',
373
- accessTokenExpires: tokenExpiresStr,
374
- now: new Date().toISOString(),
375
- needsRefresh: needsRefreshNow,
376
- tokenMismatch,
377
- hasRefreshToken: !!sessionData.idpRefreshToken
378
- });
379
-
380
- // 3. Check if token needs refresh
381
- if (needsRefreshNow) {
382
- // 4. Trigger refresh
383
- console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
384
- const refreshResult = await triggerRefresh(request, sessionToken);
385
-
386
- if (!refreshResult.success) {
387
- // Check for terminal state - session cannot be recovered
388
- if (refreshResult.terminal) {
389
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
390
- return {
391
- success: false,
392
- error: 'SESSION_EXPIRED_NO_REFRESH',
393
- message: 'Session expired. Please sign in again.',
394
- terminal: true,
395
- };
396
- }
397
-
398
- console.warn('[TOKEN_LIFECYCLE] Refresh failed');
399
- return {
400
- success: false,
401
- error: 'REFRESH_FAILED',
402
- message: 'Token refresh failed',
403
- };
404
- }
405
-
406
- // 5. Re-fetch session data after refresh
407
- sessionData = await getRedisSession(sessionToken);
408
- console.log('[TOKEN_LIFECYCLE] After refresh:', {
409
- hasAccessToken: !!sessionData?.idpAccessToken,
410
- newAccessTokenExpires: sessionData?.idpAccessTokenExpires
411
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
412
- : 'undefined'
413
- });
414
-
415
- if (!sessionData?.idpAccessToken) {
416
- return {
417
- success: false,
418
- error: 'REFRESH_FAILED',
419
- message: 'No access token after refresh',
420
- };
421
- }
422
-
423
- // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
424
- // This is critical when IDP rotates signing keys - the new token's kid may not be
425
- // immediately available in Vibe's JWKS cache
426
- await delay(KEY_PROPAGATION_DELAY_MS);
427
- }
428
-
429
- // 6. Return session — accessToken may be empty for social-only OAuth sessions
430
- return {
431
- success: true,
432
- accessToken: sessionData.idpAccessToken || '',
433
- sessionData,
434
- };
435
- } catch (error) {
436
- console.error('[TOKEN_LIFECYCLE] Error:', error);
437
- return {
438
- success: false,
439
- error: 'NO_SESSION',
440
- message: error instanceof Error ? error.message : 'Unknown error',
441
- };
442
- }
443
- }
444
-
445
- /**
446
- * Get authorization header from fresh token.
447
- * Convenience wrapper for API routes.
448
- *
449
- * @param request - The incoming NextRequest
450
- * @returns Authorization header string or null if token unavailable
451
- *
452
- * @example
453
- * ```typescript
454
- * const authHeader = await getFreshAuthHeader(request);
455
- * if (!authHeader) {
456
- * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
457
- * }
458
- * ```
459
- */
460
- export async function getFreshAuthHeader(
461
- request: NextRequest
462
- ): Promise<string | null> {
463
- const result = await ensureFreshToken(request);
464
- if (!result.success) {
465
- return null;
466
- }
467
- return `Bearer ${result.accessToken}`;
468
- }
1
+ /**
2
+ * Token Lifecycle Management for @payez/next-mvp
3
+ *
4
+ * Ensures tokens are fresh before making API calls.
5
+ * Checks expiration and triggers refresh if needed.
6
+ *
7
+ * Pattern: Check first, refresh if needed, fail gracefully if refresh fails.
8
+ *
9
+ * HANDLES CONCURRENT REFRESH: When multiple API calls arrive simultaneously
10
+ * with expired tokens, only one will actually perform the refresh. Others
11
+ * receive 409 (conflict) and wait for the refresh to complete, then use
12
+ * the freshly refreshed tokens.
13
+ *
14
+ * REQUIRED: Your app must expose the refresh route:
15
+ * ```typescript
16
+ * // app/api/auth/refresh/route.ts
17
+ * export { POST } from '@payez/next-mvp/routes/auth/refresh';
18
+ * ```
19
+ *
20
+ * @version 2.0.0
21
+ */
22
+
23
+ import { NextRequest } from 'next/server';
24
+ import { getSession as getRedisSession, SessionData } from './session-store';
25
+ import { getSession as getBetterAuthSession } from '../server/auth';
26
+ import { getRedis } from './redis';
27
+ import { getAppSlug } from './app-slug';
28
+
29
+ // 5 minute threshold for "needs refresh" - matches refresh handler pattern
30
+ const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
31
+
32
+ // Concurrent refresh handling configuration
33
+ const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
34
+ const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
35
+ const REFRESH_RETRY_DELAY_MS = 500; // Delay before retrying after failed concurrent refresh
36
+ const KEY_PROPAGATION_DELAY_MS = 150; // Delay after refresh to allow JWKS cache updates in downstream services
37
+
38
+ export interface TokenResult {
39
+ success: true;
40
+ accessToken: string;
41
+ sessionData: SessionData;
42
+ }
43
+
44
+ export interface TokenError {
45
+ success: false;
46
+ error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
47
+ message: string;
48
+ terminal?: boolean; // If true, don't retry - redirect to login
49
+ }
50
+
51
+ export type EnsureFreshTokenResult = TokenResult | TokenError;
52
+
53
+ /**
54
+ * Check if token needs refresh based on expiration time
55
+ */
56
+ function needsRefresh(accessTokenExpires: number | undefined): boolean {
57
+ if (!accessTokenExpires) return true;
58
+ const timeUntilExpiry = accessTokenExpires - Date.now();
59
+ return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
60
+ }
61
+
62
+ /**
63
+ * Helper to delay execution
64
+ */
65
+ function delay(ms: number): Promise<void> {
66
+ return new Promise(resolve => setTimeout(resolve, ms));
67
+ }
68
+
69
+ /**
70
+ * Wait for a concurrent refresh to complete by polling the session.
71
+ * Returns true if session becomes fresh, false if timeout reached.
72
+ */
73
+ async function waitForConcurrentRefresh(
74
+ sessionToken: string,
75
+ maxWaitMs: number = CONCURRENT_REFRESH_MAX_WAIT_MS
76
+ ): Promise<{ success: boolean; sessionData?: SessionData }> {
77
+ const startTime = Date.now();
78
+
79
+ while (Date.now() - startTime < maxWaitMs) {
80
+ await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
81
+
82
+ const sessionData = await getRedisSession(sessionToken);
83
+
84
+ if (!sessionData) {
85
+ return { success: false };
86
+ }
87
+
88
+ // Check if token is now fresh
89
+ if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
90
+ return { success: true, sessionData };
91
+ }
92
+
93
+ // Check if session has a new access token (even if still within threshold)
94
+ if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
95
+ sessionData.idpAccessTokenExpires > Date.now()) {
96
+ return { success: true, sessionData };
97
+ }
98
+ }
99
+
100
+ return { success: false };
101
+ }
102
+
103
+ /**
104
+ * Get the internal API URL for making internal service calls.
105
+ * INTERNAL_API_URL is REQUIRED - no fallbacks.
106
+ */
107
+ function getInternalApiUrl(request: NextRequest): string {
108
+ const internalUrl = process.env.INTERNAL_API_URL;
109
+ if (!internalUrl) {
110
+ throw new Error(
111
+ '[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
112
+ 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
113
+ 'or http://localhost:3000 for local development.'
114
+ );
115
+ }
116
+ return internalUrl;
117
+ }
118
+
119
+ /**
120
+ * Result of triggerRefresh - includes terminal flag for unrecoverable errors
121
+ */
122
+ interface RefreshResult {
123
+ success: boolean;
124
+ terminal?: boolean; // If true, session is dead - don't retry, redirect to login
125
+ code?: string; // Error code from refresh endpoint
126
+ }
127
+
128
+ /**
129
+ * Trigger a token refresh via the refresh API endpoint.
130
+ *
131
+ * HANDLES CONCURRENT REFRESH (409):
132
+ * When another request is already refreshing the token, this function
133
+ * waits for that refresh to complete instead of failing immediately.
134
+ * This prevents race conditions where multiple parallel API calls
135
+ * could cause unnecessary refresh failures.
136
+ *
137
+ * TERMINAL STATES:
138
+ * Returns { success: false, terminal: true } when the session cannot be
139
+ * recovered (e.g., no refresh token). Callers should redirect to login.
140
+ */
141
+ async function triggerRefresh(
142
+ request: NextRequest,
143
+ sessionToken: string,
144
+ retryCount: number = 0
145
+ ): Promise<RefreshResult> {
146
+ const maxRetries = 2;
147
+
148
+ try {
149
+ const baseUrl = getInternalApiUrl(request);
150
+ const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
151
+
152
+ const response = await fetch(`${baseUrl}/api/auth/refresh`, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ 'Cookie': request.headers.get('cookie') || '',
157
+ 'X-Session-Token': sessionToken,
158
+ 'X-Request-Id': requestId,
159
+ },
160
+ });
161
+
162
+ // Handle 409 Conflict - another refresh is in progress
163
+ if (response.status === 409) {
164
+ // Wait for the concurrent refresh to complete
165
+ const waitResult = await waitForConcurrentRefresh(sessionToken);
166
+
167
+ if (waitResult.success) {
168
+ return { success: true };
169
+ }
170
+
171
+ // Concurrent refresh didn't produce a fresh token - try again if we have retries left
172
+ if (retryCount < maxRetries) {
173
+ await delay(REFRESH_RETRY_DELAY_MS);
174
+ return triggerRefresh(request, sessionToken, retryCount + 1);
175
+ }
176
+
177
+ return { success: false };
178
+ }
179
+
180
+ // Handle other non-OK responses
181
+ if (!response.ok) {
182
+ // Parse response body to check for terminal errors
183
+ let responseData: any = {};
184
+ try {
185
+ responseData = await response.json();
186
+ } catch {
187
+ // Ignore parse errors
188
+ }
189
+
190
+ // Log the failure for debugging
191
+ console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
192
+ status: response.status,
193
+ statusText: response.statusText,
194
+ baseUrl,
195
+ retryCount,
196
+ code: responseData.code,
197
+ terminal: responseData.terminal
198
+ });
199
+
200
+ // CHECK FOR TERMINAL STATE: No refresh token = session is dead
201
+ // Don't retry - user must re-authenticate
202
+ if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
203
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
204
+ return { success: false, terminal: true, code: responseData.code };
205
+ }
206
+
207
+ // For other 401s, check if maybe session was refreshed by another request
208
+ if (response.status === 401 && retryCount < maxRetries) {
209
+ await delay(REFRESH_RETRY_DELAY_MS);
210
+
211
+ const sessionData = await getRedisSession(sessionToken);
212
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
213
+ return { success: true };
214
+ }
215
+ }
216
+
217
+ return { success: false, code: responseData.code };
218
+ }
219
+
220
+ const result = await response.json();
221
+ const success = result.refreshed === true || result.reason === 'already_fresh';
222
+ return { success };
223
+ } catch (error) {
224
+ // Log network errors for debugging
225
+ console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
226
+ error: error instanceof Error ? error.message : String(error),
227
+ retryCount
228
+ });
229
+
230
+ // On network error, check if maybe another request refreshed the token
231
+ if (retryCount < maxRetries) {
232
+ await delay(REFRESH_RETRY_DELAY_MS);
233
+ const sessionData = await getRedisSession(sessionToken);
234
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
235
+ return { success: true };
236
+ }
237
+ }
238
+
239
+ return { success: false };
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Ensures we have a fresh access token before making API calls.
245
+ *
246
+ * This utility checks token expiration and triggers a refresh if needed,
247
+ * preventing 401 errors from expired tokens being sent to downstream APIs.
248
+ *
249
+ * @param request - The incoming NextRequest
250
+ * @returns TokenResult with accessToken and sessionData, or TokenError
251
+ *
252
+ * @example
253
+ * ```typescript
254
+ * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
255
+ *
256
+ * export async function GET(request: NextRequest) {
257
+ * const tokenResult = await ensureFreshToken(request);
258
+ * if (!tokenResult.success) {
259
+ * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
260
+ * }
261
+ *
262
+ * // Use tokenResult.accessToken for downstream API calls
263
+ * const response = await fetch('https://api.example.com/data', {
264
+ * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
265
+ * });
266
+ * }
267
+ * ```
268
+ */
269
+ export async function ensureFreshToken(
270
+ request: NextRequest
271
+ ): Promise<EnsureFreshTokenResult> {
272
+ try {
273
+ // 1. Get Better Auth session to extract sessionToken
274
+ const betterAuthSession = await getBetterAuthSession(request);
275
+
276
+ if (!betterAuthSession?.session?.token) {
277
+ console.warn('[TOKEN_LIFECYCLE] NO_SESSION - Better Auth session not found');
278
+ return {
279
+ success: false,
280
+ error: 'NO_SESSION',
281
+ message: 'No session available',
282
+ };
283
+ }
284
+
285
+ const sessionToken = betterAuthSession.session.token;
286
+
287
+ // 2. Get session data from Redis (legacy prefix), or Better Auth's secondary storage
288
+ let sessionData = await getRedisSession(sessionToken);
289
+
290
+ if (!sessionData) {
291
+ // Try Better Auth's secondaryStorage key (ba:{slug}:{token})
292
+ try {
293
+ const baKey = `ba:${getAppSlug()}:${sessionToken}`;
294
+ const baRaw = await getRedis().get(baKey);
295
+ if (baRaw) {
296
+ const baSession = JSON.parse(baRaw);
297
+ const idpTokens = baSession.idpTokens;
298
+
299
+ sessionData = {
300
+ userId: idpTokens?.userId || baSession.user?.id || betterAuthSession.user?.id || '',
301
+ email: idpTokens?.email || baSession.user?.email || betterAuthSession.user?.email || '',
302
+ name: idpTokens?.name || baSession.user?.name || betterAuthSession.user?.name,
303
+ roles: idpTokens?.roles || [],
304
+ idpAccessToken: idpTokens?.idpAccessToken,
305
+ idpRefreshToken: idpTokens?.idpRefreshToken,
306
+ idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
307
+ || (baSession.session?.expiresAt ? new Date(baSession.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
308
+ mfaVerified: true,
309
+ oauthProvider: 'google',
310
+ idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
311
+ merchantId: idpTokens?.merchantId ?? baSession.merchantId,
312
+ } as SessionData;
313
+ }
314
+ } catch { /* Redis unavailable */ }
315
+ }
316
+
317
+ if (!sessionData && betterAuthSession.user) {
318
+ // Last resort: build from Better Auth in-memory session (no IDP tokens)
319
+ sessionData = {
320
+ userId: betterAuthSession.user.id || '',
321
+ email: betterAuthSession.user.email || '',
322
+ name: betterAuthSession.user.name,
323
+ roles: [],
324
+ idpAccessTokenExpires: Date.now() + 24 * 60 * 60 * 1000,
325
+ mfaVerified: true,
326
+ oauthProvider: 'google',
327
+ } as SessionData;
328
+ }
329
+
330
+ if (!sessionData) {
331
+ return {
332
+ success: false,
333
+ error: 'NO_SESSION',
334
+ message: 'Session expired or not found',
335
+ };
336
+ }
337
+
338
+ // DEBUG: Log session data before refresh check
339
+ const tokenExpiresStr = sessionData.idpAccessTokenExpires
340
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
341
+ : 'undefined';
342
+ let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
343
+
344
+ // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
345
+ // This catches cases where accessTokenExpires was updated but accessToken wasn't
346
+ let tokenMismatch = false;
347
+ if (sessionData.idpAccessToken && !needsRefreshNow) {
348
+ try {
349
+ const tokenParts = sessionData.idpAccessToken.split('.');
350
+ if (tokenParts.length === 3) {
351
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
352
+ const jwtExpMs = (payload.exp || 0) * 1000;
353
+ const now = Date.now();
354
+
355
+ // If the JWT is actually expired, force a refresh regardless of what Redis says
356
+ if (jwtExpMs < now) {
357
+ console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
358
+ jwtExp: new Date(jwtExpMs).toISOString(),
359
+ redisAccessTokenExpires: tokenExpiresStr,
360
+ now: new Date(now).toISOString(),
361
+ mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
362
+ });
363
+ needsRefreshNow = true;
364
+ tokenMismatch = true;
365
+ }
366
+ }
367
+ } catch (e) {
368
+ // If we can't decode, proceed with normal logic
369
+ console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
370
+ }
371
+ }
372
+
373
+ console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
374
+ sessionToken: sessionToken.substring(0, 8) + '...',
375
+ accessTokenExpires: tokenExpiresStr,
376
+ now: new Date().toISOString(),
377
+ needsRefresh: needsRefreshNow,
378
+ tokenMismatch,
379
+ hasRefreshToken: !!sessionData.idpRefreshToken
380
+ });
381
+
382
+ // 3. Check if token needs refresh
383
+ if (needsRefreshNow) {
384
+ // 4. Trigger refresh
385
+ console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
386
+ const refreshResult = await triggerRefresh(request, sessionToken);
387
+
388
+ if (!refreshResult.success) {
389
+ // Check for terminal state - session cannot be recovered
390
+ if (refreshResult.terminal) {
391
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
392
+ return {
393
+ success: false,
394
+ error: 'SESSION_EXPIRED_NO_REFRESH',
395
+ message: 'Session expired. Please sign in again.',
396
+ terminal: true,
397
+ };
398
+ }
399
+
400
+ console.warn('[TOKEN_LIFECYCLE] Refresh failed');
401
+ return {
402
+ success: false,
403
+ error: 'REFRESH_FAILED',
404
+ message: 'Token refresh failed',
405
+ };
406
+ }
407
+
408
+ // 5. Re-fetch session data after refresh
409
+ sessionData = await getRedisSession(sessionToken);
410
+ console.log('[TOKEN_LIFECYCLE] After refresh:', {
411
+ hasAccessToken: !!sessionData?.idpAccessToken,
412
+ newAccessTokenExpires: sessionData?.idpAccessTokenExpires
413
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
414
+ : 'undefined'
415
+ });
416
+
417
+ if (!sessionData?.idpAccessToken) {
418
+ return {
419
+ success: false,
420
+ error: 'REFRESH_FAILED',
421
+ message: 'No access token after refresh',
422
+ };
423
+ }
424
+
425
+ // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
426
+ // This is critical when IDP rotates signing keys - the new token's kid may not be
427
+ // immediately available in Vibe's JWKS cache
428
+ await delay(KEY_PROPAGATION_DELAY_MS);
429
+ }
430
+
431
+ // 6. Return session — accessToken may be empty for social-only OAuth sessions
432
+ return {
433
+ success: true,
434
+ accessToken: sessionData.idpAccessToken || '',
435
+ sessionData,
436
+ };
437
+ } catch (error) {
438
+ console.error('[TOKEN_LIFECYCLE] Error:', error);
439
+ return {
440
+ success: false,
441
+ error: 'NO_SESSION',
442
+ message: error instanceof Error ? error.message : 'Unknown error',
443
+ };
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Get authorization header from fresh token.
449
+ * Convenience wrapper for API routes.
450
+ *
451
+ * @param request - The incoming NextRequest
452
+ * @returns Authorization header string or null if token unavailable
453
+ *
454
+ * @example
455
+ * ```typescript
456
+ * const authHeader = await getFreshAuthHeader(request);
457
+ * if (!authHeader) {
458
+ * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
459
+ * }
460
+ * ```
461
+ */
462
+ export async function getFreshAuthHeader(
463
+ request: NextRequest
464
+ ): Promise<string | null> {
465
+ const result = await ensureFreshToken(request);
466
+ if (!result.success) {
467
+ return null;
468
+ }
469
+ return `Bearer ${result.accessToken}`;
470
+ }