@payez/next-mvp 4.1.11 β 4.1.12
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 +15 -38
- package/dist/auth/better-auth.js +12 -33
- package/dist/client/better-auth-client.d.ts +14 -2
- package/dist/lib/ensure-fresh-access-token.js +8 -42
- package/dist/lib/session-store.d.ts +0 -1
- package/dist/lib/session-store.js +29 -33
- package/dist/lib/startup-init.js +19 -19
- package/dist/lib/token-lifecycle.d.ts +0 -2
- package/dist/lib/token-lifecycle.js +3 -21
- package/package.json +4 -3
- package/scripts/check-internal-url-usage.sh +73 -73
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/masked-info.ts +45 -45
- package/src/api-handlers/account/profile.ts +80 -80
- package/src/api-handlers/account/recovery/initiate.ts +23 -23
- package/src/api-handlers/account/recovery/send-code.ts +25 -25
- package/src/api-handlers/account/recovery/verify-code.ts +25 -25
- package/src/api-handlers/account/reset-password.ts +23 -23
- package/src/api-handlers/account/send-code.ts +76 -76
- package/src/api-handlers/account/validate-password.ts +118 -118
- package/src/api-handlers/admin/stats.ts +249 -249
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/jwks.ts +20 -20
- package/src/api-handlers/auth/login.ts +240 -240
- package/src/api-handlers/auth/refresh.ts +15 -38
- package/src/api-handlers/auth/validate.ts +43 -43
- package/src/api-handlers/session/viability.ts +142 -142
- package/src/auth/better-auth.ts +450 -467
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useProfile.ts +75 -75
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/auth-secret.ts +137 -137
- package/src/lib/demo-mode.ts +17 -17
- package/src/lib/ensure-fresh-access-token.ts +8 -42
- package/src/lib/idp-client-config.ts +559 -559
- package/src/lib/idp-fetch.ts +126 -126
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +26 -26
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-store.ts +692 -695
- package/src/lib/session.ts +34 -34
- package/src/lib/startup-init.ts +251 -251
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +470 -497
- package/src/lib/types/api-responses.ts +336 -336
- package/src/middleware/auth-decision.ts +43 -43
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +264 -264
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/refresh.ts +48 -48
- package/src/routes/auth/session.ts +166 -166
- package/src/routes/index.ts +17 -17
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth.ts +373 -373
- package/src/server/decode-session.ts +202 -202
- package/src/services/signalrActivityService.ts +258 -258
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +22 -22
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +353 -353
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
|
@@ -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
|
+
const 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,43 +408,20 @@ 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
|
-
|
|
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
|
-
}
|
|
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
|
+
});
|
|
448
425
|
}
|
|
449
426
|
return server_1.NextResponse.json({
|
|
450
427
|
error: errorMessage,
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -60,7 +60,6 @@ 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");
|
|
64
63
|
/**
|
|
65
64
|
* Build Better Auth social providers from IDP config.
|
|
66
65
|
*/
|
|
@@ -126,24 +125,16 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
|
126
125
|
// Redis-backed session storage via secondaryStorage
|
|
127
126
|
secondaryStorage: {
|
|
128
127
|
get: async (key) => {
|
|
129
|
-
|
|
130
|
-
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
128
|
+
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
135
129
|
},
|
|
136
130
|
set: async (key, value, ttl) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
|
|
144
|
-
}
|
|
131
|
+
const redis = (0, redis_1.getRedis)();
|
|
132
|
+
if (ttl) {
|
|
133
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
|
|
145
137
|
}
|
|
146
|
-
catch { /* Redis unavailable β cookie cache still works */ }
|
|
147
138
|
},
|
|
148
139
|
delete: async (key) => {
|
|
149
140
|
try {
|
|
@@ -327,20 +318,8 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
327
318
|
return false;
|
|
328
319
|
}
|
|
329
320
|
// Build IDP token data
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
|
|
334
|
-
// oauth-callback even for require_2fa tenants that haven't done 2FA yet.
|
|
335
|
-
// This made !hasRefreshToken=false β requiresTwoFactor=false β mfaVerified=true,
|
|
336
|
-
// bypassing the 2FA gate entirely.
|
|
337
|
-
//
|
|
338
|
-
// FIX: Always set mfaVerified=false on the initial OAuth exchange.
|
|
339
|
-
// The tenant's require_2fa setting is enforced by the viability endpoint
|
|
340
|
-
// (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
|
|
341
|
-
// mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
|
|
342
|
-
// Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
|
|
343
|
-
// IDP correctly withholds refresh_token until post-2FA.
|
|
321
|
+
// CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
|
|
322
|
+
// treat missing refresh token as "2FA required" so middleware redirects to verify-code.
|
|
344
323
|
const hasRefreshToken = !!result.refresh_token;
|
|
345
324
|
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
|
|
346
325
|
const idpTokenData = {
|
|
@@ -354,19 +333,19 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
354
333
|
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
355
334
|
image: image,
|
|
356
335
|
roles: result.user?.roles || result.roles || [],
|
|
357
|
-
mfaVerified:
|
|
336
|
+
mfaVerified: !requiresTwoFactor,
|
|
358
337
|
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
359
338
|
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
360
339
|
};
|
|
361
340
|
// Store in BA Redis session (for decodeSession)
|
|
362
341
|
baData.idpTokens = idpTokenData;
|
|
363
|
-
await (0, redis_1.getRedis)().setex(baKey,
|
|
342
|
+
await (0, redis_1.getRedis)().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
|
|
364
343
|
// Write to canonical session store so refresh handler and token lifecycle can find the tokens.
|
|
365
344
|
// Key format: {sessionPrefix}{token} β same key that getSession() reads from.
|
|
366
345
|
try {
|
|
367
346
|
const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
|
|
368
347
|
const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
|
|
369
|
-
await (0, redis_1.getRedis)().setex(canonicalKey,
|
|
348
|
+
await (0, redis_1.getRedis)().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
|
|
370
349
|
...idpTokenData,
|
|
371
350
|
oauthProvider: provider,
|
|
372
351
|
}));
|
|
@@ -814,7 +814,13 @@ export declare const authClient: {
|
|
|
814
814
|
isPending: boolean;
|
|
815
815
|
isRefetching: boolean;
|
|
816
816
|
error: import("@better-fetch/fetch").BetterFetchError | null;
|
|
817
|
-
refetch: (queryParams
|
|
817
|
+
refetch: (queryParams
|
|
818
|
+
/**
|
|
819
|
+
* NextAuth-compatible signOut wrapper.
|
|
820
|
+
*
|
|
821
|
+
* Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
|
|
822
|
+
*/
|
|
823
|
+
?: {
|
|
818
824
|
query?: import("better-auth/types").SessionQueryParams;
|
|
819
825
|
} | undefined) => Promise<void>;
|
|
820
826
|
};
|
|
@@ -988,7 +994,13 @@ export declare const useSession: () => {
|
|
|
988
994
|
isPending: boolean;
|
|
989
995
|
isRefetching: boolean;
|
|
990
996
|
error: import("@better-fetch/fetch").BetterFetchError | null;
|
|
991
|
-
refetch: (queryParams
|
|
997
|
+
refetch: (queryParams
|
|
998
|
+
/**
|
|
999
|
+
* NextAuth-compatible signOut wrapper.
|
|
1000
|
+
*
|
|
1001
|
+
* Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
|
|
1002
|
+
*/
|
|
1003
|
+
?: {
|
|
992
1004
|
query?: import("better-auth/types").SessionQueryParams;
|
|
993
1005
|
} | undefined) => Promise<void>;
|
|
994
1006
|
}, signIn: {
|
|
@@ -158,16 +158,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
158
158
|
if (isOAuthSession && session.mfaVerified && acrValue === '1') {
|
|
159
159
|
acrValue = '2';
|
|
160
160
|
}
|
|
161
|
-
// WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
|
|
162
|
-
// `session` was read before we acquired the lock; a concurrent refresh that
|
|
163
|
-
// completed while we waited rotates the token, so the pre-lock value is stale
|
|
164
|
-
// and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
|
|
165
|
-
// the round-trip is wasted). `latest` is the authoritative post-lock session.
|
|
166
|
-
// Auth-context (amr/acr/2FA) intentionally still derives from `session` β it is
|
|
167
|
-
// the identity context of THIS request, unchanged by a token rotation.
|
|
168
|
-
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
169
161
|
const body = {
|
|
170
|
-
refresh_token:
|
|
162
|
+
refresh_token: session.idpRefreshToken,
|
|
171
163
|
amr: authMethods,
|
|
172
164
|
acr: acrValue,
|
|
173
165
|
};
|
|
@@ -208,41 +200,15 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
208
200
|
if (!idpResponse.ok) {
|
|
209
201
|
const idpError = responseData?.error || {};
|
|
210
202
|
const code = idpError.code || 'UNKNOWN_ERROR';
|
|
211
|
-
|
|
203
|
+
const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
|
|
212
204
|
const retryable = idpResponse.status !== 401 && idpError.retryable === true;
|
|
213
205
|
if (discardToken) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
|
|
221
|
-
// the post-lock value), not the pre-lock snapshot β otherwise a token rotated
|
|
222
|
-
// between pre-lock read and our send would be misjudged.
|
|
223
|
-
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
224
|
-
if (tokenUnchanged) {
|
|
225
|
-
await (0, session_store_1.updateSession)(sessionToken, {
|
|
226
|
-
idpRefreshToken: '',
|
|
227
|
-
idpRefreshTokenExpires: undefined,
|
|
228
|
-
refreshTokenClearedAt: Date.now(),
|
|
229
|
-
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
// Concurrent refresh already rotated the token β our 401 is stale. Return the
|
|
234
|
-
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
235
|
-
// (race-fix follow-up #1).
|
|
236
|
-
discardToken = false;
|
|
237
|
-
if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
|
|
238
|
-
return {
|
|
239
|
-
ok: true,
|
|
240
|
-
accessToken: postRefresh.idpAccessToken,
|
|
241
|
-
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
242
|
-
refreshed: true,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
}
|
|
206
|
+
await (0, session_store_1.updateSession)(sessionToken, {
|
|
207
|
+
idpRefreshToken: '',
|
|
208
|
+
idpRefreshTokenExpires: undefined,
|
|
209
|
+
refreshTokenClearedAt: Date.now(),
|
|
210
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
211
|
+
});
|
|
246
212
|
}
|
|
247
213
|
return {
|
|
248
214
|
ok: false,
|
|
@@ -12,7 +12,6 @@ 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;
|
|
16
15
|
exports.generateSessionToken = generateSessionToken;
|
|
17
16
|
exports.createSession = createSession;
|
|
18
17
|
exports.getSession = getSession;
|
|
@@ -47,10 +46,7 @@ const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ve
|
|
|
47
46
|
// Better Auth uses a different key format: ba:{appSlug}:{token}
|
|
48
47
|
const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
|
|
49
48
|
const REFRESH_LOCK_TTL = 60; // 60 seconds
|
|
50
|
-
// 3 days in seconds (matches refresh token lifetime)
|
|
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;
|
|
49
|
+
const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
|
|
54
50
|
/**
|
|
55
51
|
* Generates a new session token.
|
|
56
52
|
* @returns A new session token string.
|
|
@@ -70,8 +66,8 @@ async function createSession(data) {
|
|
|
70
66
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
71
67
|
try {
|
|
72
68
|
await redis_1.default.multi()
|
|
73
|
-
.setex(key,
|
|
74
|
-
.setex(versionKey,
|
|
69
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
70
|
+
.setex(versionKey, SESSION_TTL, '1')
|
|
75
71
|
.exec();
|
|
76
72
|
}
|
|
77
73
|
catch (error) {
|
|
@@ -152,7 +148,7 @@ async function getBetterAuthSession(sessionToken, appSlug) {
|
|
|
152
148
|
*/
|
|
153
149
|
async function touchSession(token) {
|
|
154
150
|
const key = getSessionKey(token);
|
|
155
|
-
await redis_1.default.expire(key,
|
|
151
|
+
await redis_1.default.expire(key, SESSION_TTL);
|
|
156
152
|
}
|
|
157
153
|
/**
|
|
158
154
|
* Retrieves a session along with a version identifier for optimistic locking.
|
|
@@ -218,9 +214,9 @@ async function setSession(sessionToken, data) {
|
|
|
218
214
|
const key = getSessionKey(sessionToken);
|
|
219
215
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
220
216
|
await redis_1.default.multi()
|
|
221
|
-
.setex(key,
|
|
217
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
222
218
|
.incr(versionKey)
|
|
223
|
-
.expire(versionKey,
|
|
219
|
+
.expire(versionKey, SESSION_TTL)
|
|
224
220
|
.exec();
|
|
225
221
|
}
|
|
226
222
|
/**
|
|
@@ -258,9 +254,9 @@ async function updateSession(sessionToken, updates) {
|
|
|
258
254
|
const updatedSession = { ...currentSession, ...updates };
|
|
259
255
|
// Write the entire updated session back to Redis with version increment
|
|
260
256
|
await redis_1.default.multi()
|
|
261
|
-
.setex(key,
|
|
257
|
+
.setex(key, SESSION_TTL, JSON.stringify(updatedSession))
|
|
262
258
|
.incr(versionKey)
|
|
263
|
-
.expire(versionKey,
|
|
259
|
+
.expire(versionKey, SESSION_TTL)
|
|
264
260
|
.exec();
|
|
265
261
|
return updatedSession;
|
|
266
262
|
}
|
|
@@ -489,27 +485,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
|
|
|
489
485
|
const lockKey = getRefreshLockKey(sessionToken);
|
|
490
486
|
try {
|
|
491
487
|
// Lua script for atomic lock validation and release
|
|
492
|
-
const luaScript = `
|
|
493
|
-
local lockKey = KEYS[1]
|
|
494
|
-
local expectedRequestId = ARGV[1]
|
|
495
|
-
local expectedVersion = ARGV[2]
|
|
496
|
-
|
|
497
|
-
local lockData = redis.call('GET', lockKey)
|
|
498
|
-
if not lockData then
|
|
499
|
-
return 0 -- Lock doesn't exist
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
local lockInfo = cjson.decode(lockData)
|
|
503
|
-
if lockInfo.acquiredBy == expectedRequestId then
|
|
504
|
-
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
505
|
-
redis.call('DEL', lockKey)
|
|
506
|
-
return 1 -- Successfully released
|
|
507
|
-
else
|
|
508
|
-
return -2 -- Version mismatch
|
|
509
|
-
end
|
|
510
|
-
else
|
|
511
|
-
return -1 -- Wrong owner
|
|
512
|
-
end
|
|
488
|
+
const luaScript = `
|
|
489
|
+
local lockKey = KEYS[1]
|
|
490
|
+
local expectedRequestId = ARGV[1]
|
|
491
|
+
local expectedVersion = ARGV[2]
|
|
492
|
+
|
|
493
|
+
local lockData = redis.call('GET', lockKey)
|
|
494
|
+
if not lockData then
|
|
495
|
+
return 0 -- Lock doesn't exist
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
local lockInfo = cjson.decode(lockData)
|
|
499
|
+
if lockInfo.acquiredBy == expectedRequestId then
|
|
500
|
+
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
501
|
+
redis.call('DEL', lockKey)
|
|
502
|
+
return 1 -- Successfully released
|
|
503
|
+
else
|
|
504
|
+
return -2 -- Version mismatch
|
|
505
|
+
end
|
|
506
|
+
else
|
|
507
|
+
return -1 -- Wrong owner
|
|
508
|
+
end
|
|
513
509
|
`;
|
|
514
510
|
const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
|
|
515
511
|
if (result === 1) {
|
package/dist/lib/startup-init.js
CHANGED
|
@@ -213,25 +213,25 @@ async function performInitialization() {
|
|
|
213
213
|
const connectionLine = isConnectionError
|
|
214
214
|
? 'β π CONNECTION REFUSED - IDP appears to be down β\nβ β\n'
|
|
215
215
|
: '';
|
|
216
|
-
console.error(`
|
|
217
|
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
218
|
-
β β FATAL: BETTER_AUTH_SECRET NOT AVAILABLE β
|
|
219
|
-
β β
|
|
220
|
-
β The app cannot start without a valid auth signing secret. β
|
|
221
|
-
β This should be fetched from IDP at startup. β
|
|
222
|
-
β β
|
|
223
|
-
${connectionLine}β Possible causes: β
|
|
224
|
-
β β’ IDP is not running or unreachable β
|
|
225
|
-
β β’ CLIENT_ID is not registered in IDP β
|
|
226
|
-
β β’ IDP_URL is incorrect β
|
|
227
|
-
β β’ Network connectivity issue β
|
|
228
|
-
β β
|
|
229
|
-
β Current config: β
|
|
230
|
-
β β’ IDP_URL: ${idpUrl}β
|
|
231
|
-
β β’ CLIENT_ID: ${clientId}β
|
|
232
|
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
233
|
-
|
|
234
|
-
[STARTUP] Error: ${errorMsg}
|
|
216
|
+
console.error(`
|
|
217
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
218
|
+
β β FATAL: BETTER_AUTH_SECRET NOT AVAILABLE β
|
|
219
|
+
β β
|
|
220
|
+
β The app cannot start without a valid auth signing secret. β
|
|
221
|
+
β This should be fetched from IDP at startup. β
|
|
222
|
+
β β
|
|
223
|
+
${connectionLine}β Possible causes: β
|
|
224
|
+
β β’ IDP is not running or unreachable β
|
|
225
|
+
β β’ CLIENT_ID is not registered in IDP β
|
|
226
|
+
β β’ IDP_URL is incorrect β
|
|
227
|
+
β β’ Network connectivity issue β
|
|
228
|
+
β β
|
|
229
|
+
β Current config: β
|
|
230
|
+
β β’ IDP_URL: ${idpUrl}β
|
|
231
|
+
β β’ CLIENT_ID: ${clientId}β
|
|
232
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
233
|
+
|
|
234
|
+
[STARTUP] Error: ${errorMsg}
|
|
235
235
|
`);
|
|
236
236
|
// Re-throw so callers know initialization failed
|
|
237
237
|
throw lastInitError;
|
|
@@ -31,8 +31,6 @@ export interface TokenError {
|
|
|
31
31
|
error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
|
|
32
32
|
message: string;
|
|
33
33
|
terminal?: boolean;
|
|
34
|
-
code?: string;
|
|
35
|
-
resolution?: string;
|
|
36
34
|
}
|
|
37
35
|
export type EnsureFreshTokenResult = TokenResult | TokenError;
|
|
38
36
|
/**
|
|
@@ -150,16 +150,7 @@ async function triggerRefresh(request, sessionToken, retryCount = 0) {
|
|
|
150
150
|
// Don't retry - user must re-authenticate
|
|
151
151
|
if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
|
|
152
152
|
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
|
|
153
|
-
|
|
154
|
-
// message in `error` and the code in `code`; carry both up so the consumer
|
|
155
|
-
// can tell the user WHY (not just "session expired") and stop a futile loop.
|
|
156
|
-
return {
|
|
157
|
-
success: false,
|
|
158
|
-
terminal: true,
|
|
159
|
-
code: responseData.code,
|
|
160
|
-
message: responseData.error,
|
|
161
|
-
resolution: responseData.resolution,
|
|
162
|
-
};
|
|
153
|
+
return { success: false, terminal: true, code: responseData.code };
|
|
163
154
|
}
|
|
164
155
|
// For other 401s, check if maybe session was refreshed by another request
|
|
165
156
|
if (response.status === 401 && retryCount < maxRetries) {
|
|
@@ -327,21 +318,12 @@ async function ensureFreshToken(request) {
|
|
|
327
318
|
if (!refreshResult.success) {
|
|
328
319
|
// Check for terminal state - session cannot be recovered
|
|
329
320
|
if (refreshResult.terminal) {
|
|
330
|
-
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login'
|
|
331
|
-
code: refreshResult.code,
|
|
332
|
-
message: refreshResult.message,
|
|
333
|
-
});
|
|
334
|
-
// WO-1: keep `error` as the stable terminal category (existing consumers
|
|
335
|
-
// branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
|
|
336
|
-
// reason via code/message/resolution so the user learns WHY and a genuinely
|
|
337
|
-
// dead session (e.g. unapproved account) doesn't loop loginβbounceβlogin.
|
|
321
|
+
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
|
|
338
322
|
return {
|
|
339
323
|
success: false,
|
|
340
324
|
error: 'SESSION_EXPIRED_NO_REFRESH',
|
|
341
|
-
message:
|
|
325
|
+
message: 'Session expired. Please sign in again.',
|
|
342
326
|
terminal: true,
|
|
343
|
-
code: refreshResult.code,
|
|
344
|
-
resolution: refreshResult.resolution,
|
|
345
327
|
};
|
|
346
328
|
}
|
|
347
329
|
console.warn('[TOKEN_LIFECYCLE] Refresh failed');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.12",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
"src"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "npx tsc --project tsconfig.json",
|
|
13
|
+
"build": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\" && npx tsc --project tsconfig.json",
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
15
|
"dev": "next dev",
|
|
16
16
|
"dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
|
|
17
|
-
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1"
|
|
17
|
+
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
|
|
18
|
+
"prepack": "npm run build"
|
|
18
19
|
},
|
|
19
20
|
"exports": {
|
|
20
21
|
".": {
|