@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.
@@ -400,7 +400,7 @@ export function createRefreshHandler(config: RefreshConfig) {
400
400
  const errorMessage = idpError.message || 'Token refresh failed';
401
401
  // CRITICAL: 401 means token is definitively invalid - always discard
402
402
  // This prevents redirect loops where viability check keeps returning canRefresh:true
403
- const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
403
+ let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
404
404
  const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
405
405
  const resolution = idpError.resolution || null;
406
406
 
@@ -442,20 +442,43 @@ export function createRefreshHandler(config: RefreshConfig) {
442
442
 
443
443
  // If IDP signals to discard token, clear it from Redis
444
444
  if (discardToken) {
445
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
446
- reason: 'IDP_DISCARD_TOKEN',
447
- errorCode,
448
- userId,
449
- sessionToken: sessionToken.substring(0, 8) + '...',
450
- hadRefreshToken: !!currentSession.idpRefreshToken,
451
- stack: new Error().stack
452
- });
453
- await updateSession(sessionToken, {
454
- idpRefreshToken: '',
455
- idpRefreshTokenExpires: undefined,
456
- refreshTokenClearedAt: Date.now(),
457
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
458
- });
445
+ // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
446
+ // the current one. A concurrent refresh may have already rotated it — our 401
447
+ // is then stale, and wiping here would silently log out a healthy session
448
+ // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
449
+ const postRefresh = await getSession(sessionToken);
450
+ const tokenUnchanged =
451
+ !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
452
+ if (tokenUnchanged) {
453
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
454
+ reason: 'IDP_DISCARD_TOKEN',
455
+ errorCode,
456
+ userId,
457
+ sessionToken: sessionToken.substring(0, 8) + '...',
458
+ hadRefreshToken: !!currentSession.idpRefreshToken,
459
+ stack: new Error().stack
460
+ });
461
+ await updateSession(sessionToken, {
462
+ idpRefreshToken: '',
463
+ idpRefreshTokenExpires: undefined,
464
+ refreshTokenClearedAt: Date.now(),
465
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
466
+ });
467
+ } else {
468
+ // Concurrent refresh already rotated the token — our 401 is stale. Return the
469
+ // freshly-rotated token directly so the loser succeeds without a retry
470
+ // (race-fix follow-up #1).
471
+ discardToken = false;
472
+ console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
473
+ if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
474
+ return NextResponse.json({
475
+ refreshed: true,
476
+ reason: 'completed_by_concurrent_request',
477
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
478
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
479
+ });
480
+ }
481
+ }
459
482
  }
460
483
 
461
484
  return NextResponse.json({
@@ -18,6 +18,7 @@ import type { IDPClientConfig } from '../lib/idp-client-config';
18
18
  import { getIDPClientConfig } from '../lib/idp-client-config';
19
19
  import { getAppSlug } from '../lib/app-slug';
20
20
  import { getRedis } from '../lib/redis';
21
+ import { SESSION_TTL } from '../lib/session-store';
21
22
 
22
23
  /**
23
24
  * Better Auth social provider config shape.
@@ -133,7 +134,7 @@ export function createBetterAuthInstance(
133
134
  if (ttl) {
134
135
  await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
135
136
  } else {
136
- await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
137
+ await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
137
138
  }
138
139
  } catch { /* Redis unavailable — cookie cache still works */ }
139
140
  },
@@ -364,14 +365,14 @@ export async function exchangeOAuthForIdpTokens(
364
365
 
365
366
  // Store in BA Redis session (for decodeSession)
366
367
  baData.idpTokens = idpTokenData;
367
- await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
368
+ await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
368
369
 
369
370
  // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
370
371
  // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
371
372
  try {
372
373
  const { getSessionPrefix } = await import('../lib/app-slug');
373
374
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
374
- await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
375
+ await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
375
376
  ...idpTokenData,
376
377
  oauthProvider: provider,
377
378
  }));