@payez/next-mvp 4.9.0 → 4.11.0

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 (50) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/api-handlers/auth/refresh.d.ts +17 -56
  3. package/dist/api-handlers/auth/refresh.js +71 -696
  4. package/dist/auth/utils/token-utils.d.ts +1 -1
  5. package/dist/auth/utils/token-utils.js +3 -2
  6. package/dist/lib/api-handler.js +4 -3
  7. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  8. package/dist/lib/ensure-fresh-access-token.js +255 -29
  9. package/dist/lib/idp-fetch.js +3 -2
  10. package/dist/lib/idp-logout.d.ts +4 -2
  11. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  12. package/dist/lib/refresh-in-doubt.js +81 -0
  13. package/dist/lib/refresh-rotation.d.ts +19 -0
  14. package/dist/lib/refresh-rotation.js +18 -0
  15. package/dist/lib/refresh-token-validator.js +2 -1
  16. package/dist/lib/refresh-window.d.ts +16 -0
  17. package/dist/lib/refresh-window.js +19 -0
  18. package/dist/lib/safe-trace-id.d.ts +11 -0
  19. package/dist/lib/safe-trace-id.js +23 -0
  20. package/dist/lib/session-store.d.ts +0 -5
  21. package/dist/lib/session-store.js +28 -1
  22. package/dist/lib/token-lifecycle.js +3 -2
  23. package/dist/models/SessionModel.d.ts +7 -0
  24. package/dist/routes/auth/refresh.d.ts +1 -14
  25. package/dist/server/auth.d.ts +1 -1
  26. package/dist/stores/authStore.js +3 -2
  27. package/package.json +3 -12
  28. package/scripts/verify-release-provenance.mjs +1 -0
  29. package/src/api-handlers/auth/refresh.ts +72 -736
  30. package/src/auth/utils/token-utils.ts +3 -2
  31. package/src/lib/api-handler.ts +4 -3
  32. package/src/lib/ensure-fresh-access-token.ts +275 -30
  33. package/src/lib/idp-fetch.ts +3 -2
  34. package/src/lib/idp-logout.ts +4 -2
  35. package/src/lib/refresh-in-doubt.ts +79 -0
  36. package/src/lib/refresh-rotation.ts +18 -0
  37. package/src/lib/refresh-token-validator.ts +2 -1
  38. package/src/lib/refresh-window.ts +16 -0
  39. package/src/lib/safe-trace-id.ts +21 -0
  40. package/src/lib/session-store.ts +31 -1
  41. package/src/lib/token-lifecycle.ts +3 -2
  42. package/src/models/SessionModel.ts +7 -0
  43. package/src/server/auth.ts +1 -1
  44. package/src/stores/authStore.ts +3 -2
  45. package/dist/api/auth-handler.d.ts +0 -64
  46. package/dist/api/auth-handler.js +0 -402
  47. package/dist/api/index.d.ts +0 -10
  48. package/dist/api/index.js +0 -19
  49. package/src/api/auth-handler.ts +0 -551
  50. package/src/api/index.ts +0 -18
@@ -1,402 +0,0 @@
1
- "use strict";
2
- /**
3
- * Enhanced Auth Handler with Coordinated Token Refresh
4
- *
5
- * Provides a middleware wrapper that automatically handles token lifecycle:
6
- * - Checks token expiry before each request
7
- * - Automatically refreshes expired or near-expired tokens
8
- * - Uses Redis locks for coordinated refresh (prevents race conditions)
9
- * - Retries requests on 401 responses with fresh tokens
10
- *
11
- * Pattern ported from website-membership simple-api-handler.ts
12
- *
13
- * @version 2.1.0
14
- * @since auth-ready-v2
15
- */
16
- Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.createAuthHandler = createAuthHandler;
18
- const server_1 = require("next/server");
19
- const auth_1 = require("../server/auth");
20
- const nanoid_1 = require("nanoid");
21
- const session_store_1 = require("../lib/session-store");
22
- const token_log_id_1 = require("../lib/token-log-id");
23
- /**
24
- * Creates an auth-aware handler with automatic token refresh
25
- *
26
- * @example
27
- * ```typescript
28
- * import { createAuthHandler } from '@payez/next-mvp/api';
29
- *
30
- * const handler = createAuthHandler({ requireAuth: true });
31
- *
32
- * export const GET = handler.handle(async (req, context, auth) => {
33
- * // auth.accessToken is guaranteed to be fresh
34
- * const response = await fetch('https://api.example.com/data', {
35
- * headers: { 'Authorization': `Bearer ${auth.accessToken}` }
36
- * });
37
- * return NextResponse.json(await response.json());
38
- * });
39
- * ```
40
- */
41
- function createAuthHandler(options = {}) {
42
- const { requireAuth = true, autoRefresh = true, refreshBuffer = 60, // 60 seconds - matches website-membership proven threshold
43
- retryOn401 = true, maxRetries = 1, idpBaseUrl = process.env.IDP_URL, clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID } = options;
44
- /**
45
- * Performs coordinated token refresh with Redis locking
46
- * This prevents multiple concurrent requests from all trying to refresh simultaneously
47
- */
48
- async function performCoordinatedRefresh(sessionToken, requestId) {
49
- // Check if refresh is already in progress
50
- const existingLock = await (0, session_store_1.checkRefreshLock)(sessionToken);
51
- if (existingLock) {
52
- console.info('[AUTH_HANDLER] Refresh already in progress, waiting...', {
53
- requestId,
54
- lockOwner: existingLock.acquiredBy,
55
- lockAge: Date.now() - existingLock.acquiredAt
56
- });
57
- // Wait for the refresh to complete
58
- const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
59
- if (waitResult.success) {
60
- // Get the fresh token from session
61
- const freshSession = await (0, session_store_1.getSession)(sessionToken);
62
- if (freshSession?.accessToken) {
63
- return {
64
- success: true,
65
- accessToken: freshSession.accessToken,
66
- refreshToken: freshSession.refreshToken,
67
- expiresIn: freshSession.accessTokenExpires
68
- ? Math.floor((freshSession.accessTokenExpires - Date.now()) / 1000)
69
- : undefined
70
- };
71
- }
72
- }
73
- return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
74
- }
75
- // Try to acquire the refresh lock
76
- const lockAcquired = await (0, session_store_1.acquireRefreshLock)(sessionToken, requestId, 5000);
77
- if (!lockAcquired) {
78
- // Another request grabbed the lock, wait for it
79
- console.info('[AUTH_HANDLER] Failed to acquire lock, waiting for other request', { requestId });
80
- const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
81
- if (waitResult.success) {
82
- const freshSession = await (0, session_store_1.getSession)(sessionToken);
83
- if (freshSession?.accessToken) {
84
- return {
85
- success: true,
86
- accessToken: freshSession.accessToken,
87
- refreshToken: freshSession.refreshToken
88
- };
89
- }
90
- }
91
- return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
92
- }
93
- try {
94
- // Double-check if tokens are still stale after acquiring lock
95
- const latestSession = await (0, session_store_1.getSession)(sessionToken);
96
- if (latestSession && !tokenNeedsRefresh(latestSession, refreshBuffer)) {
97
- console.info('[AUTH_HANDLER] Tokens already fresh after acquiring lock, skipping refresh', { requestId });
98
- return {
99
- success: true,
100
- accessToken: latestSession.accessToken,
101
- refreshToken: latestSession.refreshToken
102
- };
103
- }
104
- // Actually perform the refresh
105
- return await executeRefresh(sessionToken, latestSession);
106
- }
107
- finally {
108
- // Always release the lock
109
- await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId);
110
- }
111
- }
112
- /**
113
- * Wait for an in-progress refresh to complete
114
- */
115
- async function waitForRefreshCompletion(sessionToken, requestId, maxWaitMs) {
116
- const startTime = Date.now();
117
- const pollInterval = 100;
118
- while (Date.now() - startTime < maxWaitMs) {
119
- const lockExists = await (0, session_store_1.checkRefreshLock)(sessionToken);
120
- if (!lockExists) {
121
- // Lock released, check if tokens are fresh
122
- const session = await (0, session_store_1.getSession)(sessionToken);
123
- if (session?.accessToken && !tokenNeedsRefresh(session, refreshBuffer)) {
124
- return { success: true };
125
- }
126
- else {
127
- return { success: false, reason: 'Lock released but tokens not fresh' };
128
- }
129
- }
130
- await new Promise(resolve => setTimeout(resolve, pollInterval));
131
- }
132
- return { success: false, reason: `Timeout waiting for refresh (${maxWaitMs}ms)` };
133
- }
134
- /**
135
- * Check if token needs refresh based on expiry and buffer
136
- */
137
- function tokenNeedsRefresh(session, bufferSeconds) {
138
- if (!session.accessToken)
139
- return true;
140
- const expires = session.accessTokenExpires || 0;
141
- const bufferMs = bufferSeconds * 1000;
142
- const timeUntilExpiry = expires - Date.now();
143
- return timeUntilExpiry <= bufferMs;
144
- }
145
- /**
146
- * Execute the actual token refresh against IDP
147
- */
148
- async function executeRefresh(sessionToken, currentSession) {
149
- try {
150
- if (!idpBaseUrl || !clientId) {
151
- console.error('[AUTH_HANDLER] Missing IDP configuration for refresh');
152
- return { success: false, error: 'Missing IDP configuration' };
153
- }
154
- if (!currentSession) {
155
- console.error('[AUTH_HANDLER] No session found for refresh');
156
- return { success: false, error: 'No session found' };
157
- }
158
- if (!currentSession.refreshToken) {
159
- console.error('[AUTH_HANDLER] No refresh token available');
160
- return { success: false, error: 'No refresh token' };
161
- }
162
- // Extract authentication methods from session
163
- const authMethods = currentSession.authMethods ||
164
- (currentSession.token?.amr ? JSON.parse(currentSession.token.amr) : ['pwd', 'mfa']);
165
- const authLevel = String(currentSession.authenticationLevel || currentSession.token?.acr || '2');
166
- const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
167
- // Build refresh request body
168
- const refreshRequestBody = {
169
- refresh_token: currentSession.refreshToken,
170
- amr: authMethods,
171
- acr: authLevel
172
- };
173
- if (currentSession.twoFactorComplete) {
174
- refreshRequestBody.two_factor_verified = true;
175
- }
176
- if (twoFactorMethod) {
177
- refreshRequestBody.two_factor_method = twoFactorMethod;
178
- }
179
- if (currentSession.mfaCompletedAt) {
180
- refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
181
- }
182
- console.info('[AUTH_HANDLER] Executing refresh against IDP', {
183
- sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
184
- hasRefreshToken: true
185
- });
186
- const response = await fetch(`${idpBaseUrl}/api/ExternalAuth/refresh`, {
187
- method: 'POST',
188
- headers: {
189
- 'Content-Type': 'application/json',
190
- 'X-Client-Id': clientId,
191
- },
192
- body: JSON.stringify(refreshRequestBody),
193
- });
194
- if (!response.ok) {
195
- const errorText = await response.text().catch(() => 'Unknown error');
196
- // PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
197
- console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
198
- return { success: false, error: `Refresh failed: ${response.status}` };
199
- }
200
- const data = await response.json();
201
- if (data.success === false) {
202
- return { success: false, error: data.error?.message || data.message || 'Refresh failed' };
203
- }
204
- const tokenData = data.data || data;
205
- if (!tokenData.access_token) {
206
- console.error('[AUTH_HANDLER] No access token in refresh response');
207
- return { success: false, error: 'No access token received' };
208
- }
209
- // Update session with new tokens
210
- const updatedSession = {
211
- ...currentSession,
212
- accessToken: tokenData.access_token,
213
- refreshToken: tokenData.refresh_token || currentSession.refreshToken,
214
- accessTokenExpires: tokenData.expires_in
215
- ? Date.now() + (tokenData.expires_in * 1000)
216
- : Date.now() + (3600 * 1000),
217
- };
218
- await (0, session_store_1.updateSession)(sessionToken, updatedSession);
219
- console.info('[AUTH_HANDLER] Token refresh successful', {
220
- newExpiry: new Date(updatedSession.accessTokenExpires).toISOString()
221
- });
222
- return {
223
- success: true,
224
- accessToken: tokenData.access_token,
225
- refreshToken: tokenData.refresh_token,
226
- expiresIn: tokenData.expires_in,
227
- };
228
- }
229
- catch (error) {
230
- console.error('[AUTH_HANDLER] Refresh exception:', error);
231
- return { success: false, error: error instanceof Error ? error.message : 'Refresh failed' };
232
- }
233
- }
234
- /**
235
- * Checks if auth context token needs refresh based on expiry and buffer
236
- */
237
- function needsRefresh(auth) {
238
- if (!autoRefresh)
239
- return false;
240
- // No refresh token = nothing to refresh with, skip entirely
241
- if (!auth.refreshToken)
242
- return false;
243
- // Check if we have token expiry information
244
- const token = auth.token;
245
- const expiresAt = token.accessTokenExpires || token.exp;
246
- if (!expiresAt) {
247
- // No expiry info, can't determine if refresh needed
248
- return false;
249
- }
250
- const now = Math.floor(Date.now() / 1000);
251
- const expiryTime = typeof expiresAt === 'number' && expiresAt > 1000000000000
252
- ? Math.floor(expiresAt / 1000) // Convert milliseconds to seconds
253
- : expiresAt;
254
- // Check if token expires within the buffer period
255
- return (expiryTime - now) <= refreshBuffer;
256
- }
257
- /**
258
- * Generate a unique request ID for coordinated refresh
259
- */
260
- function generateRequestId() {
261
- return (0, nanoid_1.nanoid)();
262
- }
263
- /**
264
- * Main handler wrapper
265
- */
266
- return {
267
- handle: (handler) => {
268
- return async (req, context = {}) => {
269
- // Extract session from Better Auth
270
- const betterAuthSession = await (0, auth_1.getSession)(req);
271
- const token = betterAuthSession ? { ...betterAuthSession.user, ...betterAuthSession.session } : null;
272
- // Check if auth is required
273
- if (requireAuth && !betterAuthSession) {
274
- return server_1.NextResponse.json({ error: 'Authentication required', code: 'UNAUTHORIZED' }, { status: 401 });
275
- }
276
- // If no session and auth not required, call handler without auth context
277
- if (!betterAuthSession) {
278
- return handler(req, context, null);
279
- }
280
- // Validate client_slug (token confusion attack prevention)
281
- // SECURITY: Fail closed - require configuration to be explicitly set
282
- const expectedClientSlug = process.env.NEXT_PUBLIC_EXPECTED_CLIENT_SLUG;
283
- if (!expectedClientSlug) {
284
- console.error('[AUTH_HANDLER] SECURITY MISCONFIGURATION: NEXT_PUBLIC_EXPECTED_CLIENT_SLUG not set');
285
- return server_1.NextResponse.json({
286
- error: 'Server configuration error',
287
- code: 'SECURITY_CONFIGURATION_MISSING'
288
- }, { status: 500 });
289
- }
290
- // Extract client_slug from token (normalize property name)
291
- const tokenClientSlug = token.client_slug || token.clientSlug;
292
- // SECURITY: Require client_slug claim in all tokens (no backward compat)
293
- if (!tokenClientSlug) {
294
- console.warn('[AUTH_HANDLER] Token missing required client_slug claim');
295
- return server_1.NextResponse.json({
296
- error: 'Token missing required claim',
297
- code: 'TOKEN_MISSING_CLIENT_SLUG'
298
- }, { status: 401 });
299
- }
300
- // SECURITY: Case-insensitive comparison to avoid casing attacks
301
- if (tokenClientSlug.toLowerCase() !== expectedClientSlug.toLowerCase()) {
302
- // Log without exposing sensitive details
303
- console.warn('[AUTH_HANDLER] Token client mismatch detected');
304
- return server_1.NextResponse.json({
305
- error: 'Token issued for different client',
306
- code: 'TOKEN_CLIENT_MISMATCH'
307
- }, { status: 401 });
308
- }
309
- // Build initial auth context
310
- let authContext = {
311
- token,
312
- accessToken: token.accessToken || '',
313
- userId: betterAuthSession.user?.id || token.userId || '',
314
- sessionToken: betterAuthSession.session?.token || '',
315
- refreshToken: token.refreshToken,
316
- };
317
- // Check if token needs refresh
318
- if (needsRefresh(authContext) && authContext.refreshToken) {
319
- const requestId = generateRequestId();
320
- console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
321
- requestId,
322
- sessionToken: (0, token_log_id_1.tokenLogId)(authContext.sessionToken)
323
- });
324
- const refreshResult = await performCoordinatedRefresh(authContext.sessionToken, requestId);
325
- if (refreshResult.success && refreshResult.accessToken) {
326
- // Update auth context with fresh token
327
- authContext.accessToken = refreshResult.accessToken;
328
- if (refreshResult.refreshToken) {
329
- authContext.refreshToken = refreshResult.refreshToken;
330
- }
331
- // Update token object for future checks
332
- authContext.token.accessToken = refreshResult.accessToken;
333
- if (refreshResult.expiresIn) {
334
- authContext.token.accessTokenExpires = Date.now() + (refreshResult.expiresIn * 1000);
335
- }
336
- console.info('[AUTH_HANDLER] Coordinated refresh successful', { requestId });
337
- }
338
- else {
339
- console.warn('[AUTH_HANDLER] Failed to refresh token:', refreshResult.error);
340
- // Continue with potentially expired token - handler may still succeed
341
- }
342
- }
343
- // Attach auth context to request for downstream use (following existing pattern)
344
- // IMPORTANT: Set this ONCE before the retry loop to avoid overwriting with stale data
345
- req.__authContext = {
346
- accessToken: authContext.accessToken,
347
- userId: authContext.userId,
348
- sessionToken: authContext.sessionToken,
349
- };
350
- // Call the actual handler
351
- let response;
352
- let retryCount = 0;
353
- while (retryCount <= maxRetries) {
354
- try {
355
- response = await handler(req, context, authContext);
356
- // Check if we got a 401 and should retry
357
- if (response.status === 401 &&
358
- retryOn401 &&
359
- retryCount < maxRetries &&
360
- authContext.refreshToken) {
361
- const retryRequestId = generateRequestId();
362
- console.info('[AUTH_HANDLER] Got 401, attempting coordinated refresh and retry', { retryRequestId });
363
- const refreshResult = await performCoordinatedRefresh(authContext.sessionToken, retryRequestId);
364
- if (refreshResult.success && refreshResult.accessToken) {
365
- console.info('[AUTH_HANDLER] Refresh succeeded, updating tokens', { retryRequestId });
366
- // Update auth context with fresh token
367
- authContext.accessToken = refreshResult.accessToken;
368
- if (refreshResult.refreshToken) {
369
- authContext.refreshToken = refreshResult.refreshToken;
370
- }
371
- // Update request context
372
- req.__authContext.accessToken = refreshResult.accessToken;
373
- console.info('[AUTH_HANDLER] Updated req.__authContext with new token, retrying request', { retryRequestId });
374
- retryCount++;
375
- continue; // Retry the request
376
- }
377
- else {
378
- console.warn('[AUTH_HANDLER] Refresh failed on 401 retry:', refreshResult.error);
379
- break; // Don't retry if refresh failed
380
- }
381
- }
382
- // Success or non-401 error - return response
383
- break;
384
- }
385
- catch (error) {
386
- // Handler threw an error
387
- console.error('[AUTH_HANDLER] Handler error:', error);
388
- return server_1.NextResponse.json({
389
- error: 'Internal server error',
390
- details: error instanceof Error ? error.message : 'Unknown error'
391
- }, { status: 500 });
392
- }
393
- }
394
- return response;
395
- };
396
- }
397
- };
398
- }
399
- /**
400
- * Default export for convenience
401
- */
402
- exports.default = createAuthHandler;
@@ -1,10 +0,0 @@
1
- /**
2
- * @payez/next-mvp API Module Exports
3
- *
4
- * Provides enhanced API route handlers with automatic token management
5
- *
6
- * @version 2.0.0
7
- * @since auth-ready-v2
8
- */
9
- export { createAuthHandler, type AuthContext, type AuthHandlerOptions, type HandlerFunction } from './auth-handler';
10
- export { default } from './auth-handler';
package/dist/api/index.js DELETED
@@ -1,19 +0,0 @@
1
- "use strict";
2
- /**
3
- * @payez/next-mvp API Module Exports
4
- *
5
- * Provides enhanced API route handlers with automatic token management
6
- *
7
- * @version 2.0.0
8
- * @since auth-ready-v2
9
- */
10
- var __importDefault = (this && this.__importDefault) || function (mod) {
11
- return (mod && mod.__esModule) ? mod : { "default": mod };
12
- };
13
- Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.default = exports.createAuthHandler = void 0;
15
- var auth_handler_1 = require("./auth-handler");
16
- Object.defineProperty(exports, "createAuthHandler", { enumerable: true, get: function () { return auth_handler_1.createAuthHandler; } });
17
- // Default export for convenience
18
- var auth_handler_2 = require("./auth-handler");
19
- Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(auth_handler_2).default; } });