@payez/next-mvp 4.1.8 → 4.1.10

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.
@@ -371,7 +371,7 @@ function createRefreshHandler(config) {
371
371
  const errorMessage = idpError.message || 'Token refresh failed';
372
372
  // CRITICAL: 401 means token is definitively invalid - always discard
373
373
  // This prevents redirect loops where viability check keeps returning canRefresh:true
374
- const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
374
+ let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
375
375
  const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
376
376
  const resolution = idpError.resolution || null;
377
377
  // ============================================================================
@@ -408,20 +408,43 @@ function createRefreshHandler(config) {
408
408
  console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
409
409
  // If IDP signals to discard token, clear it from Redis
410
410
  if (discardToken) {
411
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
412
- reason: 'IDP_DISCARD_TOKEN',
413
- errorCode,
414
- userId,
415
- sessionToken: sessionToken.substring(0, 8) + '...',
416
- hadRefreshToken: !!currentSession.idpRefreshToken,
417
- stack: new Error().stack
418
- });
419
- await (0, session_store_1.updateSession)(sessionToken, {
420
- idpRefreshToken: '',
421
- idpRefreshTokenExpires: undefined,
422
- refreshTokenClearedAt: Date.now(),
423
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
424
- });
411
+ // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
412
+ // the current one. A concurrent refresh may have already rotated it — our 401
413
+ // is then stale, and wiping here would silently log out a healthy session
414
+ // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
415
+ const postRefresh = await (0, session_store_1.getSession)(sessionToken);
416
+ const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
417
+ if (tokenUnchanged) {
418
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
419
+ reason: 'IDP_DISCARD_TOKEN',
420
+ errorCode,
421
+ userId,
422
+ sessionToken: sessionToken.substring(0, 8) + '...',
423
+ hadRefreshToken: !!currentSession.idpRefreshToken,
424
+ stack: new Error().stack
425
+ });
426
+ await (0, session_store_1.updateSession)(sessionToken, {
427
+ idpRefreshToken: '',
428
+ idpRefreshTokenExpires: undefined,
429
+ refreshTokenClearedAt: Date.now(),
430
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
431
+ });
432
+ }
433
+ else {
434
+ // Concurrent refresh already rotated the token — our 401 is stale. Return the
435
+ // freshly-rotated token directly so the loser succeeds without a retry
436
+ // (race-fix follow-up #1).
437
+ discardToken = false;
438
+ console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
439
+ if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
440
+ return server_1.NextResponse.json({
441
+ refreshed: true,
442
+ reason: 'completed_by_concurrent_request',
443
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
444
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
445
+ });
446
+ }
447
+ }
425
448
  }
426
449
  return server_1.NextResponse.json({
427
450
  error: errorMessage,
@@ -60,6 +60,7 @@ const magic_link_1 = require("better-auth/plugins/magic-link");
60
60
  const idp_client_config_1 = require("../lib/idp-client-config");
61
61
  const app_slug_1 = require("../lib/app-slug");
62
62
  const redis_1 = require("../lib/redis");
63
+ const session_store_1 = require("../lib/session-store");
63
64
  /**
64
65
  * Build Better Auth social providers from IDP config.
65
66
  */
@@ -139,7 +140,7 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
139
140
  await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
140
141
  }
141
142
  else {
142
- await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
143
+ await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
143
144
  }
144
145
  }
145
146
  catch { /* Redis unavailable — cookie cache still works */ }
@@ -347,13 +348,13 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
347
348
  };
348
349
  // Store in BA Redis session (for decodeSession)
349
350
  baData.idpTokens = idpTokenData;
350
- await (0, redis_1.getRedis)().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
351
+ await (0, redis_1.getRedis)().setex(baKey, session_store_1.SESSION_TTL, JSON.stringify(baData));
351
352
  // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
352
353
  // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
353
354
  try {
354
355
  const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
355
356
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
356
- await (0, redis_1.getRedis)().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
357
+ await (0, redis_1.getRedis)().setex(canonicalKey, session_store_1.SESSION_TTL, JSON.stringify({
357
358
  ...idpTokenData,
358
359
  oauthProvider: provider,
359
360
  }));
@@ -200,15 +200,38 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
200
200
  if (!idpResponse.ok) {
201
201
  const idpError = responseData?.error || {};
202
202
  const code = idpError.code || 'UNKNOWN_ERROR';
203
- const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
203
+ let discardToken = idpResponse.status === 401 || idpError.discard_token === true;
204
204
  const retryable = idpResponse.status !== 401 && idpError.retryable === true;
205
205
  if (discardToken) {
206
- await (0, session_store_1.updateSession)(sessionToken, {
207
- idpRefreshToken: '',
208
- idpRefreshTokenExpires: undefined,
209
- refreshTokenClearedAt: Date.now(),
210
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
211
- });
206
+ // Race-safe discard (CAS): only clear if the refresh token we used is STILL
207
+ // current. A concurrent refresh may have already rotated it — our 401 is then
208
+ // stale/expected, and wiping here would silently log out a healthy session
209
+ // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
210
+ // a concurrent rotation is NOT a terminal condition.
211
+ const postRefresh = await (0, session_store_1.getSession)(sessionToken);
212
+ const tokenUnchanged = !!session.idpRefreshToken && postRefresh?.idpRefreshToken === session.idpRefreshToken;
213
+ if (tokenUnchanged) {
214
+ await (0, session_store_1.updateSession)(sessionToken, {
215
+ idpRefreshToken: '',
216
+ idpRefreshTokenExpires: undefined,
217
+ refreshTokenClearedAt: Date.now(),
218
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
219
+ });
220
+ }
221
+ else {
222
+ // Concurrent refresh already rotated the token — our 401 is stale. Return the
223
+ // freshly-rotated token directly so the loser succeeds without a retry round-trip
224
+ // (race-fix follow-up #1).
225
+ discardToken = false;
226
+ if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
227
+ return {
228
+ ok: true,
229
+ accessToken: postRefresh.idpAccessToken,
230
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
231
+ refreshed: true,
232
+ };
233
+ }
234
+ }
212
235
  }
213
236
  return {
214
237
  ok: false,
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { SessionData } from '../models/SessionModel';
11
11
  export type { SessionData } from '../models/SessionModel';
12
+ export declare const SESSION_TTL: number;
12
13
  export interface RefreshLockInfo {
13
14
  sessionToken: string;
14
15
  acquiredAt: number;
@@ -12,6 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.SESSION_TTL = void 0;
15
16
  exports.generateSessionToken = generateSessionToken;
16
17
  exports.createSession = createSession;
17
18
  exports.getSession = getSession;
@@ -46,7 +47,10 @@ const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ve
46
47
  // Better Auth uses a different key format: ba:{appSlug}:{token}
47
48
  const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
48
49
  const REFRESH_LOCK_TTL = 60; // 60 seconds
49
- const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
50
+ // 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
51
+ // of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
52
+ // two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
53
+ exports.SESSION_TTL = 3 * 24 * 60 * 60;
50
54
  /**
51
55
  * Generates a new session token.
52
56
  * @returns A new session token string.
@@ -66,8 +70,8 @@ async function createSession(data) {
66
70
  const versionKey = getSessionVersionKey(sessionToken);
67
71
  try {
68
72
  await redis_1.default.multi()
69
- .setex(key, SESSION_TTL, JSON.stringify(data))
70
- .setex(versionKey, SESSION_TTL, '1')
73
+ .setex(key, exports.SESSION_TTL, JSON.stringify(data))
74
+ .setex(versionKey, exports.SESSION_TTL, '1')
71
75
  .exec();
72
76
  }
73
77
  catch (error) {
@@ -148,7 +152,7 @@ async function getBetterAuthSession(sessionToken, appSlug) {
148
152
  */
149
153
  async function touchSession(token) {
150
154
  const key = getSessionKey(token);
151
- await redis_1.default.expire(key, SESSION_TTL);
155
+ await redis_1.default.expire(key, exports.SESSION_TTL);
152
156
  }
153
157
  /**
154
158
  * Retrieves a session along with a version identifier for optimistic locking.
@@ -214,9 +218,9 @@ async function setSession(sessionToken, data) {
214
218
  const key = getSessionKey(sessionToken);
215
219
  const versionKey = getSessionVersionKey(sessionToken);
216
220
  await redis_1.default.multi()
217
- .setex(key, SESSION_TTL, JSON.stringify(data))
221
+ .setex(key, exports.SESSION_TTL, JSON.stringify(data))
218
222
  .incr(versionKey)
219
- .expire(versionKey, SESSION_TTL)
223
+ .expire(versionKey, exports.SESSION_TTL)
220
224
  .exec();
221
225
  }
222
226
  /**
@@ -254,9 +258,9 @@ async function updateSession(sessionToken, updates) {
254
258
  const updatedSession = { ...currentSession, ...updates };
255
259
  // Write the entire updated session back to Redis with version increment
256
260
  await redis_1.default.multi()
257
- .setex(key, SESSION_TTL, JSON.stringify(updatedSession))
261
+ .setex(key, exports.SESSION_TTL, JSON.stringify(updatedSession))
258
262
  .incr(versionKey)
259
- .expire(versionKey, SESSION_TTL)
263
+ .expire(versionKey, exports.SESSION_TTL)
260
264
  .exec();
261
265
  return updatedSession;
262
266
  }