@payez/next-mvp 4.1.11 → 4.1.20

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.
Files changed (178) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  12. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  13. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  14. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  15. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  16. package/dist/api-handlers/mfa/methods.js +41 -0
  17. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  18. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  19. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  20. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  21. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  22. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  23. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  24. package/dist/api-handlers/passkeys/list.js +29 -0
  25. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  26. package/dist/api-handlers/passkeys/manage.js +54 -0
  27. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  28. package/dist/api-handlers/passkeys/register-options.js +41 -0
  29. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  30. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  31. package/dist/api-handlers/session/viability.js +19 -0
  32. package/dist/auth/better-auth.d.ts +68 -5
  33. package/dist/auth/better-auth.js +256 -42
  34. package/dist/auth/utils/idp-client.d.ts +13 -22
  35. package/dist/auth/utils/idp-client.js +12 -82
  36. package/dist/client/better-auth-client.d.ts +14 -2
  37. package/dist/lib/session-store.js +28 -21
  38. package/dist/lib/startup-init.js +19 -19
  39. package/dist/lib/token-lifecycle.js +40 -4
  40. package/dist/models/SessionModel.d.ts +17 -0
  41. package/dist/pages/login/page.d.ts +10 -1
  42. package/dist/pages/login/page.js +69 -80
  43. package/dist/pages/verify-code/page.d.ts +10 -1
  44. package/dist/pages/verify-code/page.js +72 -4
  45. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  46. package/dist/routes/auth/passkey-signin.js +18 -0
  47. package/dist/routes/auth/viability.d.ts +2 -0
  48. package/dist/routes/auth/viability.js +88 -0
  49. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  50. package/dist/routes/mfa/backup-code-verify.js +5 -0
  51. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  52. package/dist/routes/mfa/backup-codes.js +14 -0
  53. package/dist/routes/mfa/methods.d.ts +21 -0
  54. package/dist/routes/mfa/methods.js +10 -0
  55. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  56. package/dist/routes/mfa/totp-confirm.js +14 -0
  57. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  58. package/dist/routes/mfa/totp-enroll.js +14 -0
  59. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  60. package/dist/routes/mfa/totp-verify.js +18 -0
  61. package/dist/routes/passkeys/list.d.ts +10 -0
  62. package/dist/routes/passkeys/list.js +14 -0
  63. package/dist/routes/passkeys/manage.d.ts +18 -0
  64. package/dist/routes/passkeys/manage.js +13 -0
  65. package/dist/routes/passkeys/register-options.d.ts +10 -0
  66. package/dist/routes/passkeys/register-options.js +14 -0
  67. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  68. package/dist/routes/passkeys/register-verify.js +14 -0
  69. package/dist/server/auth.d.ts +2 -2
  70. package/dist/server/decode-session.js +37 -3
  71. package/package.json +958 -893
  72. package/scripts/check-internal-url-usage.sh +73 -73
  73. package/src/api/index.ts +17 -17
  74. package/src/api-handlers/account/masked-info.ts +45 -45
  75. package/src/api-handlers/account/profile.ts +99 -80
  76. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  77. package/src/api-handlers/account/recovery/send-code.ts +73 -25
  78. package/src/api-handlers/account/recovery/verify-code.ts +73 -25
  79. package/src/api-handlers/account/reset-password.ts +23 -23
  80. package/src/api-handlers/account/send-code.ts +76 -76
  81. package/src/api-handlers/account/validate-password.ts +118 -118
  82. package/src/api-handlers/account/verify-email.ts +15 -1
  83. package/src/api-handlers/account/verify-sms.ts +15 -1
  84. package/src/api-handlers/admin/stats.ts +249 -249
  85. package/src/api-handlers/anon/preferences.ts +123 -123
  86. package/src/api-handlers/auth/jwks.ts +20 -20
  87. package/src/api-handlers/auth/login.ts +240 -240
  88. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  89. package/src/api-handlers/auth/validate.ts +43 -43
  90. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  91. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  92. package/src/api-handlers/mfa/methods.ts +46 -0
  93. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  94. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  95. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  96. package/src/api-handlers/passkeys/list.ts +31 -0
  97. package/src/api-handlers/passkeys/manage.ts +54 -0
  98. package/src/api-handlers/passkeys/register-options.ts +42 -0
  99. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  100. package/src/api-handlers/session/viability.ts +161 -142
  101. package/src/auth/better-auth.ts +247 -6
  102. package/src/auth/unauthenticated-routes.ts +2 -2
  103. package/src/auth/utils/idp-client.ts +13 -95
  104. package/src/client/better-auth-client.ts +70 -70
  105. package/src/client/fetchWithSession.ts +21 -21
  106. package/src/client/index.ts +13 -13
  107. package/src/client/useAnonSession.ts +131 -131
  108. package/src/components/admin/VibeAdminContext.tsx +87 -87
  109. package/src/config/logging-config.ts +82 -82
  110. package/src/config/unauthenticated-routes.ts +19 -19
  111. package/src/edge/internal-api-url.ts +65 -65
  112. package/src/edge/middleware.ts +42 -42
  113. package/src/hooks/useProfile.ts +75 -75
  114. package/src/lib/anon-session.ts +213 -213
  115. package/src/lib/auth-secret.ts +137 -137
  116. package/src/lib/demo-mode.ts +17 -17
  117. package/src/lib/idp-client-config.ts +559 -559
  118. package/src/lib/idp-fetch.ts +126 -126
  119. package/src/lib/internal-api.ts +171 -171
  120. package/src/lib/jwt-decode-client.ts +45 -45
  121. package/src/lib/rate-limit-service.ts +9 -9
  122. package/src/lib/redis.ts +26 -26
  123. package/src/lib/refresh-token-validator.ts +64 -64
  124. package/src/lib/secret-validation.ts +8 -8
  125. package/src/lib/session-store.ts +702 -695
  126. package/src/lib/session.ts +34 -34
  127. package/src/lib/startup-init.ts +251 -251
  128. package/src/lib/token-expiry.ts +40 -40
  129. package/src/lib/token-lifecycle.ts +533 -497
  130. package/src/lib/types/api-responses.ts +336 -336
  131. package/src/middleware/auth-decision.ts +43 -43
  132. package/src/models/DecodedAccessToken.ts +16 -16
  133. package/src/models/SessionModel.ts +282 -264
  134. package/src/pages/admin-roles/index.ts +9 -9
  135. package/src/pages/login/page.tsx +123 -228
  136. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  137. package/src/pages/verify-code/page.tsx +71 -5
  138. package/src/routes/account/index.ts +31 -31
  139. package/src/routes/account/masked-info.ts +36 -36
  140. package/src/routes/account/send-code.ts +39 -39
  141. package/src/routes/account/verify-email.ts +40 -40
  142. package/src/routes/account/verify-sms.ts +40 -40
  143. package/src/routes/auth/index.ts +22 -22
  144. package/src/routes/auth/passkey-signin.ts +13 -0
  145. package/src/routes/auth/refresh.ts +48 -48
  146. package/src/routes/auth/session.ts +166 -166
  147. package/src/routes/auth/viability.ts +89 -0
  148. package/src/routes/index.ts +17 -17
  149. package/src/routes/mfa/backup-code-verify.ts +1 -0
  150. package/src/routes/mfa/backup-codes.ts +10 -0
  151. package/src/routes/mfa/methods.ts +22 -0
  152. package/src/routes/mfa/totp-confirm.ts +10 -0
  153. package/src/routes/mfa/totp-enroll.ts +10 -0
  154. package/src/routes/mfa/totp-verify.ts +14 -0
  155. package/src/routes/passkeys/list.ts +10 -0
  156. package/src/routes/passkeys/manage.ts +23 -0
  157. package/src/routes/passkeys/register-options.ts +10 -0
  158. package/src/routes/passkeys/register-verify.ts +10 -0
  159. package/src/routes/session/index.ts +7 -7
  160. package/src/routes/session/refresh-viability.ts +17 -17
  161. package/src/server/auth.ts +373 -373
  162. package/src/server/decode-session.ts +235 -202
  163. package/src/services/signalrActivityService.ts +258 -258
  164. package/src/theme/ThemeProvider.tsx +39 -39
  165. package/src/theme/default.ts +33 -33
  166. package/src/theme/index.ts +31 -31
  167. package/src/theme/types.ts +69 -69
  168. package/src/theme/useTheme.ts +57 -57
  169. package/src/theme/utils.ts +40 -40
  170. package/src/types/api.ts +12 -12
  171. package/src/types/auth.d.ts +23 -23
  172. package/src/types/auth.ts +22 -22
  173. package/src/types/logging.ts +11 -11
  174. package/src/types/recovery.ts +54 -54
  175. package/src/utils/api.ts +353 -353
  176. package/src/utils/circuitBreaker.ts +40 -40
  177. package/src/utils/error-message.ts +108 -108
  178. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,497 +1,533 @@
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
- // WO-1 (genuine-dead refresh): the VERBATIM upstream reason from the IDP, preserved
50
- // end-to-end so the consumer can surface an honest error + halt a futile re-auth loop
51
- // (e.g. an unapproved user whose login mints a bearer but whose refresh terminally
52
- // fails). `error` stays the stable category for existing consumers; `code`/`resolution`
53
- // carry the real cause. Never soften — surface it.
54
- code?: string; // Upstream IDP error code (e.g. UNAUTHORIZED, USER_NOT_APPROVED)
55
- resolution?: string; // Upstream human-readable resolution hint, if any
56
- }
57
-
58
- export type EnsureFreshTokenResult = TokenResult | TokenError;
59
-
60
- /**
61
- * Check if token needs refresh based on expiration time
62
- */
63
- function needsRefresh(accessTokenExpires: number | undefined): boolean {
64
- if (!accessTokenExpires) return true;
65
- const timeUntilExpiry = accessTokenExpires - Date.now();
66
- return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
67
- }
68
-
69
- /**
70
- * Helper to delay execution
71
- */
72
- function delay(ms: number): Promise<void> {
73
- return new Promise(resolve => setTimeout(resolve, ms));
74
- }
75
-
76
- /**
77
- * Wait for a concurrent refresh to complete by polling the session.
78
- * Returns true if session becomes fresh, false if timeout reached.
79
- */
80
- async function waitForConcurrentRefresh(
81
- sessionToken: string,
82
- maxWaitMs: number = CONCURRENT_REFRESH_MAX_WAIT_MS
83
- ): Promise<{ success: boolean; sessionData?: SessionData }> {
84
- const startTime = Date.now();
85
-
86
- while (Date.now() - startTime < maxWaitMs) {
87
- await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
88
-
89
- const sessionData = await getRedisSession(sessionToken);
90
-
91
- if (!sessionData) {
92
- return { success: false };
93
- }
94
-
95
- // Check if token is now fresh
96
- if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
97
- return { success: true, sessionData };
98
- }
99
-
100
- // Check if session has a new access token (even if still within threshold)
101
- if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
102
- sessionData.idpAccessTokenExpires > Date.now()) {
103
- return { success: true, sessionData };
104
- }
105
- }
106
-
107
- return { success: false };
108
- }
109
-
110
- /**
111
- * Get the internal API URL for making internal service calls.
112
- * INTERNAL_API_URL is REQUIRED - no fallbacks.
113
- */
114
- function getInternalApiUrl(request: NextRequest): string {
115
- const internalUrl = process.env.INTERNAL_API_URL;
116
- if (!internalUrl) {
117
- throw new Error(
118
- '[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
119
- 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
120
- 'or http://localhost:3000 for local development.'
121
- );
122
- }
123
- return internalUrl;
124
- }
125
-
126
- /**
127
- * Result of triggerRefresh - includes terminal flag for unrecoverable errors
128
- */
129
- interface RefreshResult {
130
- success: boolean;
131
- terminal?: boolean; // If true, session is dead - don't retry, redirect to login
132
- code?: string; // Error code from refresh endpoint
133
- message?: string; // WO-1: verbatim human message from the refresh endpoint
134
- resolution?: string; // WO-1: verbatim resolution hint from the refresh endpoint
135
- }
136
-
137
- /**
138
- * Trigger a token refresh via the refresh API endpoint.
139
- *
140
- * HANDLES CONCURRENT REFRESH (409):
141
- * When another request is already refreshing the token, this function
142
- * waits for that refresh to complete instead of failing immediately.
143
- * This prevents race conditions where multiple parallel API calls
144
- * could cause unnecessary refresh failures.
145
- *
146
- * TERMINAL STATES:
147
- * Returns { success: false, terminal: true } when the session cannot be
148
- * recovered (e.g., no refresh token). Callers should redirect to login.
149
- */
150
- async function triggerRefresh(
151
- request: NextRequest,
152
- sessionToken: string,
153
- retryCount: number = 0
154
- ): Promise<RefreshResult> {
155
- const maxRetries = 2;
156
-
157
- try {
158
- const baseUrl = getInternalApiUrl(request);
159
- const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
160
-
161
- const response = await fetch(`${baseUrl}/api/auth/refresh`, {
162
- method: 'POST',
163
- headers: {
164
- 'Content-Type': 'application/json',
165
- 'Cookie': request.headers.get('cookie') || '',
166
- 'X-Session-Token': sessionToken,
167
- 'X-Request-Id': requestId,
168
- },
169
- });
170
-
171
- // Handle 409 Conflict - another refresh is in progress
172
- if (response.status === 409) {
173
- // Wait for the concurrent refresh to complete
174
- const waitResult = await waitForConcurrentRefresh(sessionToken);
175
-
176
- if (waitResult.success) {
177
- return { success: true };
178
- }
179
-
180
- // Concurrent refresh didn't produce a fresh token - try again if we have retries left
181
- if (retryCount < maxRetries) {
182
- await delay(REFRESH_RETRY_DELAY_MS);
183
- return triggerRefresh(request, sessionToken, retryCount + 1);
184
- }
185
-
186
- return { success: false };
187
- }
188
-
189
- // Handle other non-OK responses
190
- if (!response.ok) {
191
- // Parse response body to check for terminal errors
192
- let responseData: any = {};
193
- try {
194
- responseData = await response.json();
195
- } catch {
196
- // Ignore parse errors
197
- }
198
-
199
- // Log the failure for debugging
200
- console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
201
- status: response.status,
202
- statusText: response.statusText,
203
- baseUrl,
204
- retryCount,
205
- code: responseData.code,
206
- terminal: responseData.terminal
207
- });
208
-
209
- // CHECK FOR TERMINAL STATE: No refresh token = session is dead
210
- // Don't retry - user must re-authenticate
211
- if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
212
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
213
- // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
214
- // message in `error` and the code in `code`; carry both up so the consumer
215
- // can tell the user WHY (not just "session expired") and stop a futile loop.
216
- return {
217
- success: false,
218
- terminal: true,
219
- code: responseData.code,
220
- message: responseData.error,
221
- resolution: responseData.resolution,
222
- };
223
- }
224
-
225
- // For other 401s, check if maybe session was refreshed by another request
226
- if (response.status === 401 && retryCount < maxRetries) {
227
- await delay(REFRESH_RETRY_DELAY_MS);
228
-
229
- const sessionData = await getRedisSession(sessionToken);
230
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
231
- return { success: true };
232
- }
233
- }
234
-
235
- return { success: false, code: responseData.code };
236
- }
237
-
238
- const result = await response.json();
239
- const success = result.refreshed === true || result.reason === 'already_fresh';
240
- return { success };
241
- } catch (error) {
242
- // Log network errors for debugging
243
- console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
244
- error: error instanceof Error ? error.message : String(error),
245
- retryCount
246
- });
247
-
248
- // On network error, check if maybe another request refreshed the token
249
- if (retryCount < maxRetries) {
250
- await delay(REFRESH_RETRY_DELAY_MS);
251
- const sessionData = await getRedisSession(sessionToken);
252
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
253
- return { success: true };
254
- }
255
- }
256
-
257
- return { success: false };
258
- }
259
- }
260
-
261
- /**
262
- * Ensures we have a fresh access token before making API calls.
263
- *
264
- * This utility checks token expiration and triggers a refresh if needed,
265
- * preventing 401 errors from expired tokens being sent to downstream APIs.
266
- *
267
- * @param request - The incoming NextRequest
268
- * @returns TokenResult with accessToken and sessionData, or TokenError
269
- *
270
- * @example
271
- * ```typescript
272
- * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
273
- *
274
- * export async function GET(request: NextRequest) {
275
- * const tokenResult = await ensureFreshToken(request);
276
- * if (!tokenResult.success) {
277
- * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
278
- * }
279
- *
280
- * // Use tokenResult.accessToken for downstream API calls
281
- * const response = await fetch('https://api.example.com/data', {
282
- * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
283
- * });
284
- * }
285
- * ```
286
- */
287
- export async function ensureFreshToken(
288
- request: NextRequest
289
- ): Promise<EnsureFreshTokenResult> {
290
- try {
291
- // 1. Get Better Auth session to extract sessionToken
292
- const betterAuthSession = await getBetterAuthSession(request);
293
-
294
- if (!betterAuthSession?.session?.token) {
295
- console.warn('[TOKEN_LIFECYCLE] NO_SESSION - Better Auth session not found');
296
- return {
297
- success: false,
298
- error: 'NO_SESSION',
299
- message: 'No session available',
300
- };
301
- }
302
-
303
- const sessionToken = betterAuthSession.session.token;
304
-
305
- // 2. Get session data from Redis (legacy prefix), or Better Auth's secondary storage
306
- let sessionData = await getRedisSession(sessionToken);
307
-
308
- if (!sessionData) {
309
- // Try Better Auth's secondaryStorage key (ba:{slug}:{token})
310
- try {
311
- const baKey = `ba:${getAppSlug()}:${sessionToken}`;
312
- const baRaw = await getRedis().get(baKey);
313
- if (baRaw) {
314
- const baSession = JSON.parse(baRaw);
315
- const idpTokens = baSession.idpTokens;
316
-
317
- sessionData = {
318
- userId: idpTokens?.userId || baSession.user?.id || betterAuthSession.user?.id || '',
319
- email: idpTokens?.email || baSession.user?.email || betterAuthSession.user?.email || '',
320
- name: idpTokens?.name || baSession.user?.name || betterAuthSession.user?.name,
321
- roles: idpTokens?.roles || [],
322
- idpAccessToken: idpTokens?.idpAccessToken,
323
- idpRefreshToken: idpTokens?.idpRefreshToken,
324
- idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
325
- || (baSession.session?.expiresAt ? new Date(baSession.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
326
- mfaVerified: true,
327
- oauthProvider: 'google',
328
- idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
329
- merchantId: idpTokens?.merchantId ?? baSession.merchantId,
330
- } as SessionData;
331
- }
332
- } catch { /* Redis unavailable */ }
333
- }
334
-
335
- if (!sessionData && betterAuthSession.user) {
336
- // Last resort: build from Better Auth in-memory session (no IDP tokens)
337
- sessionData = {
338
- userId: betterAuthSession.user.id || '',
339
- email: betterAuthSession.user.email || '',
340
- name: betterAuthSession.user.name,
341
- roles: [],
342
- idpAccessTokenExpires: Date.now() + 24 * 60 * 60 * 1000,
343
- mfaVerified: true,
344
- oauthProvider: 'google',
345
- } as SessionData;
346
- }
347
-
348
- if (!sessionData) {
349
- return {
350
- success: false,
351
- error: 'NO_SESSION',
352
- message: 'Session expired or not found',
353
- };
354
- }
355
-
356
- // DEBUG: Log session data before refresh check
357
- const tokenExpiresStr = sessionData.idpAccessTokenExpires
358
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
359
- : 'undefined';
360
- let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
361
-
362
- // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
363
- // This catches cases where accessTokenExpires was updated but accessToken wasn't
364
- let tokenMismatch = false;
365
- if (sessionData.idpAccessToken && !needsRefreshNow) {
366
- try {
367
- const tokenParts = sessionData.idpAccessToken.split('.');
368
- if (tokenParts.length === 3) {
369
- const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
370
- const jwtExpMs = (payload.exp || 0) * 1000;
371
- const now = Date.now();
372
-
373
- // If the JWT is actually expired, force a refresh regardless of what Redis says
374
- if (jwtExpMs < now) {
375
- console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
376
- jwtExp: new Date(jwtExpMs).toISOString(),
377
- redisAccessTokenExpires: tokenExpiresStr,
378
- now: new Date(now).toISOString(),
379
- mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
380
- });
381
- needsRefreshNow = true;
382
- tokenMismatch = true;
383
- }
384
- }
385
- } catch (e) {
386
- // If we can't decode, proceed with normal logic
387
- console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
388
- }
389
- }
390
-
391
- console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
392
- sessionToken: sessionToken.substring(0, 8) + '...',
393
- accessTokenExpires: tokenExpiresStr,
394
- now: new Date().toISOString(),
395
- needsRefresh: needsRefreshNow,
396
- tokenMismatch,
397
- hasRefreshToken: !!sessionData.idpRefreshToken
398
- });
399
-
400
- // 3. Check if token needs refresh
401
- if (needsRefreshNow) {
402
- // 4. Trigger refresh
403
- console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
404
- const refreshResult = await triggerRefresh(request, sessionToken);
405
-
406
- if (!refreshResult.success) {
407
- // Check for terminal state - session cannot be recovered
408
- if (refreshResult.terminal) {
409
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
410
- code: refreshResult.code,
411
- message: refreshResult.message,
412
- });
413
- // WO-1: keep `error` as the stable terminal category (existing consumers
414
- // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
415
- // reason via code/message/resolution so the user learns WHY and a genuinely
416
- // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
417
- return {
418
- success: false,
419
- error: 'SESSION_EXPIRED_NO_REFRESH',
420
- message: refreshResult.message || 'Session expired. Please sign in again.',
421
- terminal: true,
422
- code: refreshResult.code,
423
- resolution: refreshResult.resolution,
424
- };
425
- }
426
-
427
- console.warn('[TOKEN_LIFECYCLE] Refresh failed');
428
- return {
429
- success: false,
430
- error: 'REFRESH_FAILED',
431
- message: 'Token refresh failed',
432
- };
433
- }
434
-
435
- // 5. Re-fetch session data after refresh
436
- sessionData = await getRedisSession(sessionToken);
437
- console.log('[TOKEN_LIFECYCLE] After refresh:', {
438
- hasAccessToken: !!sessionData?.idpAccessToken,
439
- newAccessTokenExpires: sessionData?.idpAccessTokenExpires
440
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
441
- : 'undefined'
442
- });
443
-
444
- if (!sessionData?.idpAccessToken) {
445
- return {
446
- success: false,
447
- error: 'REFRESH_FAILED',
448
- message: 'No access token after refresh',
449
- };
450
- }
451
-
452
- // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
453
- // This is critical when IDP rotates signing keys - the new token's kid may not be
454
- // immediately available in Vibe's JWKS cache
455
- await delay(KEY_PROPAGATION_DELAY_MS);
456
- }
457
-
458
- // 6. Return session — accessToken may be empty for social-only OAuth sessions
459
- return {
460
- success: true,
461
- accessToken: sessionData.idpAccessToken || '',
462
- sessionData,
463
- };
464
- } catch (error) {
465
- console.error('[TOKEN_LIFECYCLE] Error:', error);
466
- return {
467
- success: false,
468
- error: 'NO_SESSION',
469
- message: error instanceof Error ? error.message : 'Unknown error',
470
- };
471
- }
472
- }
473
-
474
- /**
475
- * Get authorization header from fresh token.
476
- * Convenience wrapper for API routes.
477
- *
478
- * @param request - The incoming NextRequest
479
- * @returns Authorization header string or null if token unavailable
480
- *
481
- * @example
482
- * ```typescript
483
- * const authHeader = await getFreshAuthHeader(request);
484
- * if (!authHeader) {
485
- * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
486
- * }
487
- * ```
488
- */
489
- export async function getFreshAuthHeader(
490
- request: NextRequest
491
- ): Promise<string | null> {
492
- const result = await ensureFreshToken(request);
493
- if (!result.success) {
494
- return null;
495
- }
496
- return `Bearer ${result.accessToken}`;
497
- }
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
+ // WO-1 (genuine-dead refresh): the VERBATIM upstream reason from the IDP, preserved
50
+ // end-to-end so the consumer can surface an honest error + halt a futile re-auth loop
51
+ // (e.g. an unapproved user whose login mints a bearer but whose refresh terminally
52
+ // fails). `error` stays the stable category for existing consumers; `code`/`resolution`
53
+ // carry the real cause. Never soften — surface it.
54
+ code?: string; // Upstream IDP error code (e.g. UNAUTHORIZED, USER_NOT_APPROVED)
55
+ resolution?: string; // Upstream human-readable resolution hint, if any
56
+ }
57
+
58
+ export type EnsureFreshTokenResult = TokenResult | TokenError;
59
+
60
+ /**
61
+ * Check if token needs refresh based on expiration time
62
+ */
63
+ function needsRefresh(accessTokenExpires: number | undefined): boolean {
64
+ if (!accessTokenExpires) return true;
65
+ const timeUntilExpiry = accessTokenExpires - Date.now();
66
+ return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
67
+ }
68
+
69
+ /**
70
+ * Helper to delay execution
71
+ */
72
+ function delay(ms: number): Promise<void> {
73
+ return new Promise(resolve => setTimeout(resolve, ms));
74
+ }
75
+
76
+ /**
77
+ * Wait for a concurrent refresh to complete by polling the session.
78
+ * Returns true if session becomes fresh, false if timeout reached.
79
+ */
80
+ async function waitForConcurrentRefresh(
81
+ sessionToken: string,
82
+ maxWaitMs: number = CONCURRENT_REFRESH_MAX_WAIT_MS
83
+ ): Promise<{ success: boolean; sessionData?: SessionData }> {
84
+ const startTime = Date.now();
85
+
86
+ while (Date.now() - startTime < maxWaitMs) {
87
+ await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
88
+
89
+ const sessionData = await getRedisSession(sessionToken);
90
+
91
+ if (!sessionData) {
92
+ return { success: false };
93
+ }
94
+
95
+ // Check if token is now fresh
96
+ if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
97
+ return { success: true, sessionData };
98
+ }
99
+
100
+ // Check if session has a new access token (even if still within threshold)
101
+ if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
102
+ sessionData.idpAccessTokenExpires > Date.now()) {
103
+ return { success: true, sessionData };
104
+ }
105
+ }
106
+
107
+ return { success: false };
108
+ }
109
+
110
+ /**
111
+ * Get the internal API URL for making internal service calls.
112
+ * INTERNAL_API_URL is REQUIRED - no fallbacks.
113
+ */
114
+ function getInternalApiUrl(request: NextRequest): string {
115
+ const internalUrl = process.env.INTERNAL_API_URL;
116
+ if (!internalUrl) {
117
+ throw new Error(
118
+ '[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
119
+ 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
120
+ 'or http://localhost:3000 for local development.'
121
+ );
122
+ }
123
+ return internalUrl;
124
+ }
125
+
126
+ /**
127
+ * Result of triggerRefresh - includes terminal flag for unrecoverable errors
128
+ */
129
+ interface RefreshResult {
130
+ success: boolean;
131
+ terminal?: boolean; // If true, session is dead - don't retry, redirect to login
132
+ code?: string; // Error code from refresh endpoint
133
+ message?: string; // WO-1: verbatim human message from the refresh endpoint
134
+ resolution?: string; // WO-1: verbatim resolution hint from the refresh endpoint
135
+ }
136
+
137
+ /**
138
+ * Trigger a token refresh via the refresh API endpoint.
139
+ *
140
+ * HANDLES CONCURRENT REFRESH (409):
141
+ * When another request is already refreshing the token, this function
142
+ * waits for that refresh to complete instead of failing immediately.
143
+ * This prevents race conditions where multiple parallel API calls
144
+ * could cause unnecessary refresh failures.
145
+ *
146
+ * TERMINAL STATES:
147
+ * Returns { success: false, terminal: true } when the session cannot be
148
+ * recovered (e.g., no refresh token). Callers should redirect to login.
149
+ */
150
+ async function triggerRefresh(
151
+ request: NextRequest,
152
+ sessionToken: string,
153
+ retryCount: number = 0
154
+ ): Promise<RefreshResult> {
155
+ const maxRetries = 2;
156
+
157
+ try {
158
+ const baseUrl = getInternalApiUrl(request);
159
+ const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
160
+
161
+ const response = await fetch(`${baseUrl}/api/auth/refresh`, {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ 'Cookie': request.headers.get('cookie') || '',
166
+ 'X-Session-Token': sessionToken,
167
+ 'X-Request-Id': requestId,
168
+ },
169
+ });
170
+
171
+ // Handle 409 Conflict - another refresh is in progress
172
+ if (response.status === 409) {
173
+ // Wait for the concurrent refresh to complete
174
+ const waitResult = await waitForConcurrentRefresh(sessionToken);
175
+
176
+ if (waitResult.success) {
177
+ return { success: true };
178
+ }
179
+
180
+ // Concurrent refresh didn't produce a fresh token - try again if we have retries left
181
+ if (retryCount < maxRetries) {
182
+ await delay(REFRESH_RETRY_DELAY_MS);
183
+ return triggerRefresh(request, sessionToken, retryCount + 1);
184
+ }
185
+
186
+ return { success: false };
187
+ }
188
+
189
+ // Handle other non-OK responses
190
+ if (!response.ok) {
191
+ // Parse response body to check for terminal errors
192
+ let responseData: any = {};
193
+ try {
194
+ responseData = await response.json();
195
+ } catch {
196
+ // Ignore parse errors
197
+ }
198
+
199
+ // Log the failure for debugging
200
+ console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
201
+ status: response.status,
202
+ statusText: response.statusText,
203
+ baseUrl,
204
+ retryCount,
205
+ code: responseData.code,
206
+ terminal: responseData.terminal
207
+ });
208
+
209
+ // CHECK FOR TERMINAL STATE: No refresh token = session is dead
210
+ // Don't retry - user must re-authenticate
211
+ if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
212
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
213
+ // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
214
+ // message in `error` and the code in `code`; carry both up so the consumer
215
+ // can tell the user WHY (not just "session expired") and stop a futile loop.
216
+ return {
217
+ success: false,
218
+ terminal: true,
219
+ code: responseData.code,
220
+ message: responseData.error,
221
+ resolution: responseData.resolution,
222
+ };
223
+ }
224
+
225
+ // For other 401s, check if maybe session was refreshed by another request
226
+ if (response.status === 401 && retryCount < maxRetries) {
227
+ await delay(REFRESH_RETRY_DELAY_MS);
228
+
229
+ const sessionData = await getRedisSession(sessionToken);
230
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
231
+ return { success: true };
232
+ }
233
+ }
234
+
235
+ return { success: false, code: responseData.code };
236
+ }
237
+
238
+ const result = await response.json();
239
+ const success = result.refreshed === true || result.reason === 'already_fresh';
240
+ return { success };
241
+ } catch (error) {
242
+ // Log network errors for debugging
243
+ console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
244
+ error: error instanceof Error ? error.message : String(error),
245
+ retryCount
246
+ });
247
+
248
+ // On network error, check if maybe another request refreshed the token
249
+ if (retryCount < maxRetries) {
250
+ await delay(REFRESH_RETRY_DELAY_MS);
251
+ const sessionData = await getRedisSession(sessionToken);
252
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
253
+ return { success: true };
254
+ }
255
+ }
256
+
257
+ return { success: false };
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Ensures we have a fresh access token before making API calls.
263
+ *
264
+ * This utility checks token expiration and triggers a refresh if needed,
265
+ * preventing 401 errors from expired tokens being sent to downstream APIs.
266
+ *
267
+ * @param request - The incoming NextRequest
268
+ * @returns TokenResult with accessToken and sessionData, or TokenError
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
273
+ *
274
+ * export async function GET(request: NextRequest) {
275
+ * const tokenResult = await ensureFreshToken(request);
276
+ * if (!tokenResult.success) {
277
+ * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
278
+ * }
279
+ *
280
+ * // Use tokenResult.accessToken for downstream API calls
281
+ * const response = await fetch('https://api.example.com/data', {
282
+ * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
283
+ * });
284
+ * }
285
+ * ```
286
+ */
287
+ export async function ensureFreshToken(
288
+ request: NextRequest
289
+ ): Promise<EnsureFreshTokenResult> {
290
+ try {
291
+ // 1. Get Better Auth session to extract sessionToken
292
+ const betterAuthSession = await getBetterAuthSession(request);
293
+
294
+ if (!betterAuthSession?.session?.token) {
295
+ console.warn('[TOKEN_LIFECYCLE] NO_SESSION - Better Auth session not found');
296
+ return {
297
+ success: false,
298
+ error: 'NO_SESSION',
299
+ message: 'No session available',
300
+ };
301
+ }
302
+
303
+ const sessionToken = betterAuthSession.session.token;
304
+
305
+ // 2. Get session data from Redis (legacy prefix), or Better Auth's secondary storage
306
+ let sessionData = await getRedisSession(sessionToken);
307
+
308
+ if (!sessionData) {
309
+ // Try Better Auth's secondaryStorage key (ba:{slug}:{token})
310
+ try {
311
+ const baKey = `ba:${getAppSlug()}:${sessionToken}`;
312
+ const baRaw = await getRedis().get(baKey);
313
+ if (baRaw) {
314
+ const baSession = JSON.parse(baRaw);
315
+ const idpTokens = baSession.idpTokens;
316
+
317
+ sessionData = {
318
+ userId: idpTokens?.userId || baSession.user?.id || betterAuthSession.user?.id || '',
319
+ email: idpTokens?.email || baSession.user?.email || betterAuthSession.user?.email || '',
320
+ name: idpTokens?.name || baSession.user?.name || betterAuthSession.user?.name,
321
+ roles: idpTokens?.roles || [],
322
+ idpAccessToken: idpTokens?.idpAccessToken,
323
+ idpRefreshToken: idpTokens?.idpRefreshToken,
324
+ idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
325
+ || (baSession.session?.expiresAt ? new Date(baSession.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
326
+ /**
327
+ * ⚠ THESE WERE HARDCODED `true` / `'google'`. (QAPert finding #3,
328
+ * BAPert ruling, card 167892.)
329
+ *
330
+ * This rebuild path assumed EVERY session is a Google OAuth sign-in
331
+ * that completed MFA. It reads `idpTokens` for everything else —
332
+ * including roles, two lines up — and then IGNORED the stored
333
+ * mfaVerified sitting in that same object and asserted `true`.
334
+ *
335
+ * WHY IT MATTERS MORE THAN A WRONG FIELD: a passkey sign-in stores
336
+ * an HONEST mfaVerified derived from the token's `amr`. On any
337
+ * Redis miss this path OVERWROTE that false with true — so the
338
+ * session claimed an MFA the user never performed, and the honest
339
+ * derivation upstream was not weakened but VOIDED. Same defect
340
+ * class as the mints that assert completion they never did,
341
+ * arriving through a cache-miss rather than a mint.
342
+ *
343
+ * `oauthProvider: 'google'` was the second lie on the same line:
344
+ * a passkey session rebuilt here was relabelled a Google sign-in,
345
+ * destroying the provenance the sign-in path deliberately records.
346
+ * The IDP emits NO provider claim (DotNetPert), so the honest
347
+ * answer when it was not stored is UNSET — not a guess.
348
+ *
349
+ * Both now default to the SAFE direction: absent/false is
350
+ * fail-closed at every consumer we measured (`!!mfaVerified`,
351
+ * `mfaVerified || ... || false`).
352
+ */
353
+ mfaVerified: idpTokens?.mfaVerified ?? false,
354
+ oauthProvider: idpTokens?.oauthProvider ?? baSession.oauthProvider ?? undefined,
355
+ idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
356
+ merchantId: idpTokens?.merchantId ?? baSession.merchantId,
357
+ } as SessionData;
358
+ }
359
+ } catch { /* Redis unavailable */ }
360
+ }
361
+
362
+ if (!sessionData && betterAuthSession.user) {
363
+ // Last resort: build from Better Auth in-memory session (no IDP tokens)
364
+ sessionData = {
365
+ userId: betterAuthSession.user.id || '',
366
+ email: betterAuthSession.user.email || '',
367
+ name: betterAuthSession.user.name,
368
+ roles: [],
369
+ idpAccessTokenExpires: Date.now() + 24 * 60 * 60 * 1000,
370
+ /**
371
+ * ⚠ ALSO HARDCODED `true` / `'google'`, and this path is the worse of
372
+ * the two: it runs with NO IDP TOKENS AT ALL — the comment above says
373
+ * so — and still asserted that MFA was completed. It fabricated an
374
+ * assurance claim from nothing, for every consumer, not just passkey.
375
+ *
376
+ * With nothing to derive from, FALSE is the only honest value and
377
+ * UNSET is the only honest provider. Both are fail-closed.
378
+ */
379
+ mfaVerified: false,
380
+ oauthProvider: undefined,
381
+ } as SessionData;
382
+ }
383
+
384
+ if (!sessionData) {
385
+ return {
386
+ success: false,
387
+ error: 'NO_SESSION',
388
+ message: 'Session expired or not found',
389
+ };
390
+ }
391
+
392
+ // DEBUG: Log session data before refresh check
393
+ const tokenExpiresStr = sessionData.idpAccessTokenExpires
394
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
395
+ : 'undefined';
396
+ let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
397
+
398
+ // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
399
+ // This catches cases where accessTokenExpires was updated but accessToken wasn't
400
+ let tokenMismatch = false;
401
+ if (sessionData.idpAccessToken && !needsRefreshNow) {
402
+ try {
403
+ const tokenParts = sessionData.idpAccessToken.split('.');
404
+ if (tokenParts.length === 3) {
405
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
406
+ const jwtExpMs = (payload.exp || 0) * 1000;
407
+ const now = Date.now();
408
+
409
+ // If the JWT is actually expired, force a refresh regardless of what Redis says
410
+ if (jwtExpMs < now) {
411
+ console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
412
+ jwtExp: new Date(jwtExpMs).toISOString(),
413
+ redisAccessTokenExpires: tokenExpiresStr,
414
+ now: new Date(now).toISOString(),
415
+ mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
416
+ });
417
+ needsRefreshNow = true;
418
+ tokenMismatch = true;
419
+ }
420
+ }
421
+ } catch (e) {
422
+ // If we can't decode, proceed with normal logic
423
+ console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
424
+ }
425
+ }
426
+
427
+ console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
428
+ sessionToken: sessionToken.substring(0, 8) + '...',
429
+ accessTokenExpires: tokenExpiresStr,
430
+ now: new Date().toISOString(),
431
+ needsRefresh: needsRefreshNow,
432
+ tokenMismatch,
433
+ hasRefreshToken: !!sessionData.idpRefreshToken
434
+ });
435
+
436
+ // 3. Check if token needs refresh
437
+ if (needsRefreshNow) {
438
+ // 4. Trigger refresh
439
+ console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
440
+ const refreshResult = await triggerRefresh(request, sessionToken);
441
+
442
+ if (!refreshResult.success) {
443
+ // Check for terminal state - session cannot be recovered
444
+ if (refreshResult.terminal) {
445
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
446
+ code: refreshResult.code,
447
+ message: refreshResult.message,
448
+ });
449
+ // WO-1: keep `error` as the stable terminal category (existing consumers
450
+ // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
451
+ // reason via code/message/resolution so the user learns WHY and a genuinely
452
+ // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
453
+ return {
454
+ success: false,
455
+ error: 'SESSION_EXPIRED_NO_REFRESH',
456
+ message: refreshResult.message || 'Session expired. Please sign in again.',
457
+ terminal: true,
458
+ code: refreshResult.code,
459
+ resolution: refreshResult.resolution,
460
+ };
461
+ }
462
+
463
+ console.warn('[TOKEN_LIFECYCLE] Refresh failed');
464
+ return {
465
+ success: false,
466
+ error: 'REFRESH_FAILED',
467
+ message: 'Token refresh failed',
468
+ };
469
+ }
470
+
471
+ // 5. Re-fetch session data after refresh
472
+ sessionData = await getRedisSession(sessionToken);
473
+ console.log('[TOKEN_LIFECYCLE] After refresh:', {
474
+ hasAccessToken: !!sessionData?.idpAccessToken,
475
+ newAccessTokenExpires: sessionData?.idpAccessTokenExpires
476
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
477
+ : 'undefined'
478
+ });
479
+
480
+ if (!sessionData?.idpAccessToken) {
481
+ return {
482
+ success: false,
483
+ error: 'REFRESH_FAILED',
484
+ message: 'No access token after refresh',
485
+ };
486
+ }
487
+
488
+ // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
489
+ // This is critical when IDP rotates signing keys - the new token's kid may not be
490
+ // immediately available in Vibe's JWKS cache
491
+ await delay(KEY_PROPAGATION_DELAY_MS);
492
+ }
493
+
494
+ // 6. Return session — accessToken may be empty for social-only OAuth sessions
495
+ return {
496
+ success: true,
497
+ accessToken: sessionData.idpAccessToken || '',
498
+ sessionData,
499
+ };
500
+ } catch (error) {
501
+ console.error('[TOKEN_LIFECYCLE] Error:', error);
502
+ return {
503
+ success: false,
504
+ error: 'NO_SESSION',
505
+ message: error instanceof Error ? error.message : 'Unknown error',
506
+ };
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Get authorization header from fresh token.
512
+ * Convenience wrapper for API routes.
513
+ *
514
+ * @param request - The incoming NextRequest
515
+ * @returns Authorization header string or null if token unavailable
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * const authHeader = await getFreshAuthHeader(request);
520
+ * if (!authHeader) {
521
+ * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
522
+ * }
523
+ * ```
524
+ */
525
+ export async function getFreshAuthHeader(
526
+ request: NextRequest
527
+ ): Promise<string | null> {
528
+ const result = await ensureFreshToken(request);
529
+ if (!result.success) {
530
+ return null;
531
+ }
532
+ return `Bearer ${result.accessToken}`;
533
+ }