@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.
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +4 -3
- package/dist/lib/ensure-fresh-access-token.js +30 -7
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/package.json +893 -893
- package/src/api-handlers/auth/refresh.ts +38 -15
- package/src/auth/better-auth.ts +4 -3
- package/src/lib/ensure-fresh-access-token.ts +358 -335
- package/src/lib/session-store.ts +4 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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,
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -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}`,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
}
|