@payez/next-mvp 4.0.20 → 4.0.21

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.
@@ -45,7 +45,15 @@ async function GET(req) {
45
45
  }
46
46
  const sessionToken = betterAuthSession?.session?.token;
47
47
  if (betterAuthSession && sessionToken) {
48
- const sessionData = await (0, session_store_1.getSession)(sessionToken);
48
+ // Try legacy session store first, then Better Auth format
49
+ let sessionData = await (0, session_store_1.getSession)(sessionToken);
50
+ if (!sessionData) {
51
+ // Better Auth stores sessions with ba:{appSlug}:{token} prefix
52
+ sessionData = await (0, session_store_1.getBetterAuthSession)(sessionToken);
53
+ if (sessionData) {
54
+ console.log('[VIABILITY] Found session in Better Auth store');
55
+ }
56
+ }
49
57
  if (sessionData) {
50
58
  // The session exists in Redis
51
59
  // Check if access token is expired (for middleware decision-making)
@@ -34,6 +34,15 @@ export declare function createSession(data: SessionData): Promise<string>;
34
34
  * @returns The session data, or null if not found.
35
35
  */
36
36
  export declare function getSession(sessionToken: string): Promise<SessionData | null>;
37
+ /**
38
+ * Retrieves a Better Auth session from Redis.
39
+ * Better Auth uses key format: ba:{appSlug}:{token}
40
+ *
41
+ * @param sessionToken The session token to look up.
42
+ * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
43
+ * @returns The session data, or null if not found.
44
+ */
45
+ export declare function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null>;
37
46
  /**
38
47
  * Refresh session TTL without reading/writing data (sliding window expiry).
39
48
  */
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.generateSessionToken = generateSessionToken;
16
16
  exports.createSession = createSession;
17
17
  exports.getSession = getSession;
18
+ exports.getBetterAuthSession = getBetterAuthSession;
18
19
  exports.touchSession = touchSession;
19
20
  exports.getSessionWithVersion = getSessionWithVersion;
20
21
  exports.isAccessTokenFresh = isAccessTokenFresh;
@@ -42,6 +43,8 @@ const token_utils_1 = require("../auth/utils/token-utils");
42
43
  const getSessionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}${token}`;
43
44
  const getRefreshLockKey = (token) => `${(0, app_slug_1.getRefreshLockPrefix)()}${token}`;
44
45
  const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ver:${token}`;
46
+ // Better Auth uses a different key format: ba:{appSlug}:{token}
47
+ const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
45
48
  const REFRESH_LOCK_TTL = 60; // 60 seconds
46
49
  const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
47
50
  /**
@@ -96,6 +99,47 @@ async function getSession(sessionToken) {
96
99
  return null;
97
100
  }
98
101
  }
102
+ /**
103
+ * Retrieves a Better Auth session from Redis.
104
+ * Better Auth uses key format: ba:{appSlug}:{token}
105
+ *
106
+ * @param sessionToken The session token to look up.
107
+ * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
108
+ * @returns The session data, or null if not found.
109
+ */
110
+ async function getBetterAuthSession(sessionToken, appSlug) {
111
+ if (!sessionToken) {
112
+ return null;
113
+ }
114
+ // Try to get appSlug from env if not provided
115
+ const slug = appSlug || process.env.CLIENT_ID || 'idealvibe_online';
116
+ const key = getBetterAuthSessionKey(sessionToken, slug);
117
+ const json = await redis_1.default.get(key);
118
+ if (!json) {
119
+ return null;
120
+ }
121
+ try {
122
+ const data = JSON.parse(json);
123
+ // Better Auth stores the session differently - extract user data
124
+ if (data.user) {
125
+ return {
126
+ userId: data.user.id || data.user.email,
127
+ email: data.user.email,
128
+ name: data.user.name,
129
+ idpAccessToken: data.idpTokens?.idpAccessToken,
130
+ idpRefreshToken: data.idpTokens?.idpRefreshToken,
131
+ idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
132
+ mfaVerified: data.idpTokens?.mfaVerified ?? false,
133
+ roles: data.idpTokens?.roles || [],
134
+ };
135
+ }
136
+ return data;
137
+ }
138
+ catch {
139
+ console.error('[SESSION-STORE] Failed to parse Better Auth session data');
140
+ return null;
141
+ }
142
+ }
99
143
  /**
100
144
  * Refresh session TTL without reading/writing data (sliding window expiry).
101
145
  */
@@ -438,27 +482,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
438
482
  const lockKey = getRefreshLockKey(sessionToken);
439
483
  try {
440
484
  // Lua script for atomic lock validation and release
441
- const luaScript = `
442
- local lockKey = KEYS[1]
443
- local expectedRequestId = ARGV[1]
444
- local expectedVersion = ARGV[2]
445
-
446
- local lockData = redis.call('GET', lockKey)
447
- if not lockData then
448
- return 0 -- Lock doesn't exist
449
- end
450
-
451
- local lockInfo = cjson.decode(lockData)
452
- if lockInfo.acquiredBy == expectedRequestId then
453
- if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
454
- redis.call('DEL', lockKey)
455
- return 1 -- Successfully released
456
- else
457
- return -2 -- Version mismatch
458
- end
459
- else
460
- return -1 -- Wrong owner
461
- end
485
+ const luaScript = `
486
+ local lockKey = KEYS[1]
487
+ local expectedRequestId = ARGV[1]
488
+ local expectedVersion = ARGV[2]
489
+
490
+ local lockData = redis.call('GET', lockKey)
491
+ if not lockData then
492
+ return 0 -- Lock doesn't exist
493
+ end
494
+
495
+ local lockInfo = cjson.decode(lockData)
496
+ if lockInfo.acquiredBy == expectedRequestId then
497
+ if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
498
+ redis.call('DEL', lockKey)
499
+ return 1 -- Successfully released
500
+ else
501
+ return -2 -- Version mismatch
502
+ end
503
+ else
504
+ return -1 -- Wrong owner
505
+ end
462
506
  `;
463
507
  const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
464
508
  if (result === 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.0.20",
3
+ "version": "4.0.21",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,129 +1,137 @@
1
- /**
2
- * Session Viability Check API Handler for `@payez/next-mvp`
3
- *
4
- * This API route is called by the middleware to securely check if a session is valid.
5
- */
6
-
7
- import { NextRequest, NextResponse } from 'next/server';
8
- import { getSession as getRedisSession } from '../../lib/session-store';
9
- import { getSession } from '../../server/auth';
10
- import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
11
- import { getIDPClientConfig } from '../../lib/idp-client-config';
12
-
13
- export async function GET(req: NextRequest) {
14
- try {
15
- // Ensure initialization is complete
16
- if (!process.env.NEXTAUTH_SECRET) {
17
- try {
18
- await ensureInitialized();
19
- } catch (error) {
20
- // Initialization failed - return 503
21
- console.error('[API Viability] Initialization failed - returning 503');
22
- return NextResponse.json(
23
- {
24
- error: 'Service Unavailable',
25
- message: 'Authentication service is not properly configured',
26
- code: 'AUTH_NOT_INITIALIZED'
27
- },
28
- { status: 503 }
29
- );
30
- }
31
- }
32
-
33
- // Double-check after initialization attempt
34
- if (isInitializationFailed()) {
35
- console.error('[API Viability] Initialization failed - returning 503');
36
- return NextResponse.json(
37
- {
38
- error: 'Service Unavailable',
39
- message: 'Authentication service is not properly configured',
40
- code: 'AUTH_NOT_INITIALIZED'
41
- },
42
- { status: 503 }
43
- );
44
- }
45
-
46
- // Get session from Better Auth
47
- const betterAuthSession = await getSession(req);
48
-
49
- // Debug logging
50
- if (!betterAuthSession) {
51
- console.warn('[VIABILITY] getSession returned null');
52
- }
53
-
54
- const sessionToken = betterAuthSession?.session?.token as string | undefined;
55
- if (betterAuthSession && sessionToken) {
56
- const sessionData = await getRedisSession(sessionToken);
57
- if (sessionData) {
58
- // The session exists in Redis
59
-
60
- // Check if access token is expired (for middleware decision-making)
61
- const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
62
- const accessTokenExpired = accessTokenExpires < Date.now();
63
-
64
- // Get requires2FA from cached client config (not session)
65
- // This is a client-wide setting from the broker handshake
66
- let requires2FA = true; // Default to true for security
67
- try {
68
- const cachedConfig = await getIDPClientConfig();
69
- requires2FA = cachedConfig.authSettings?.require2FA ?? true;
70
- } catch (e) {
71
- console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
72
- }
73
-
74
- // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
75
- // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
76
- // has passed, we must treat 2FA as incomplete to force re-verification.
77
- const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
78
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
79
- // Check both field names for compatibility (mfaVerified is the normalized name)
80
- const sessionMfaComplete = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
81
- const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
82
-
83
- console.log('[VIABILITY] Session 2FA check:', {
84
- sessionToken: sessionToken.substring(0, 8) + '...',
85
- mfaVerified: sessionData.mfaVerified,
86
- twoFactorComplete: (sessionData as any).twoFactorComplete,
87
- sessionMfaComplete,
88
- mfaExpired,
89
- effectiveTwoFactorComplete,
90
- });
91
-
92
- if (mfaExpired && sessionMfaComplete) {
93
- console.warn('[API Viability] MFA expired - forcing 2FA re-verification', {
94
- mfaExpiresAt: new Date(mfaExpiresAt).toISOString(),
95
- now: new Date().toISOString(),
96
- hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
97
- });
98
- }
99
-
100
- const response = {
101
- authenticated: true,
102
- sessionToken, // Include token for middleware tracking
103
- // 2FA fields - critical for middleware redirect logic
104
- requires2FA, // From cached client config (client-wide setting)
105
- twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
106
- // Token status for refresh decisions
107
- accessTokenExpired,
108
- hasRefreshToken: !!sessionData.idpRefreshToken
109
- };
110
- return NextResponse.json(response);
111
- }
112
-
113
- // CRITICAL: Cookie exists but Redis session is missing (stale cookie state)
114
- // Return sessionToken so middleware can detect this and clear the stale cookie
115
- console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
116
- return NextResponse.json({
117
- authenticated: false,
118
- sessionToken // Include token to enable stale cookie detection
119
- });
120
- }
121
-
122
- // If there's no token at all, it's not authenticated
123
- return NextResponse.json({ authenticated: false });
124
-
125
- } catch (error) {
126
- console.error('[API Viability] Error checking session viability:', error);
127
- return NextResponse.json({ authenticated: false }, { status: 500 });
128
- }
1
+ /**
2
+ * Session Viability Check API Handler for `@payez/next-mvp`
3
+ *
4
+ * This API route is called by the middleware to securely check if a session is valid.
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from 'next/server';
8
+ import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
9
+ import { getSession } from '../../server/auth';
10
+ import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
11
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
12
+
13
+ export async function GET(req: NextRequest) {
14
+ try {
15
+ // Ensure initialization is complete
16
+ if (!process.env.NEXTAUTH_SECRET) {
17
+ try {
18
+ await ensureInitialized();
19
+ } catch (error) {
20
+ // Initialization failed - return 503
21
+ console.error('[API Viability] Initialization failed - returning 503');
22
+ return NextResponse.json(
23
+ {
24
+ error: 'Service Unavailable',
25
+ message: 'Authentication service is not properly configured',
26
+ code: 'AUTH_NOT_INITIALIZED'
27
+ },
28
+ { status: 503 }
29
+ );
30
+ }
31
+ }
32
+
33
+ // Double-check after initialization attempt
34
+ if (isInitializationFailed()) {
35
+ console.error('[API Viability] Initialization failed - returning 503');
36
+ return NextResponse.json(
37
+ {
38
+ error: 'Service Unavailable',
39
+ message: 'Authentication service is not properly configured',
40
+ code: 'AUTH_NOT_INITIALIZED'
41
+ },
42
+ { status: 503 }
43
+ );
44
+ }
45
+
46
+ // Get session from Better Auth
47
+ const betterAuthSession = await getSession(req);
48
+
49
+ // Debug logging
50
+ if (!betterAuthSession) {
51
+ console.warn('[VIABILITY] getSession returned null');
52
+ }
53
+
54
+ const sessionToken = betterAuthSession?.session?.token as string | undefined;
55
+ if (betterAuthSession && sessionToken) {
56
+ // Try legacy session store first, then Better Auth format
57
+ let sessionData = await getRedisSession(sessionToken);
58
+ if (!sessionData) {
59
+ // Better Auth stores sessions with ba:{appSlug}:{token} prefix
60
+ sessionData = await getBetterAuthSession(sessionToken);
61
+ if (sessionData) {
62
+ console.log('[VIABILITY] Found session in Better Auth store');
63
+ }
64
+ }
65
+ if (sessionData) {
66
+ // The session exists in Redis
67
+
68
+ // Check if access token is expired (for middleware decision-making)
69
+ const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
70
+ const accessTokenExpired = accessTokenExpires < Date.now();
71
+
72
+ // Get requires2FA from cached client config (not session)
73
+ // This is a client-wide setting from the broker handshake
74
+ let requires2FA = true; // Default to true for security
75
+ try {
76
+ const cachedConfig = await getIDPClientConfig();
77
+ requires2FA = cachedConfig.authSettings?.require2FA ?? true;
78
+ } catch (e) {
79
+ console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
80
+ }
81
+
82
+ // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
83
+ // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
84
+ // has passed, we must treat 2FA as incomplete to force re-verification.
85
+ const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
86
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
87
+ // Check both field names for compatibility (mfaVerified is the normalized name)
88
+ const sessionMfaComplete = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
89
+ const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
90
+
91
+ console.log('[VIABILITY] Session 2FA check:', {
92
+ sessionToken: sessionToken.substring(0, 8) + '...',
93
+ mfaVerified: sessionData.mfaVerified,
94
+ twoFactorComplete: (sessionData as any).twoFactorComplete,
95
+ sessionMfaComplete,
96
+ mfaExpired,
97
+ effectiveTwoFactorComplete,
98
+ });
99
+
100
+ if (mfaExpired && sessionMfaComplete) {
101
+ console.warn('[API Viability] MFA expired - forcing 2FA re-verification', {
102
+ mfaExpiresAt: new Date(mfaExpiresAt).toISOString(),
103
+ now: new Date().toISOString(),
104
+ hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
105
+ });
106
+ }
107
+
108
+ const response = {
109
+ authenticated: true,
110
+ sessionToken, // Include token for middleware tracking
111
+ // 2FA fields - critical for middleware redirect logic
112
+ requires2FA, // From cached client config (client-wide setting)
113
+ twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
114
+ // Token status for refresh decisions
115
+ accessTokenExpired,
116
+ hasRefreshToken: !!sessionData.idpRefreshToken
117
+ };
118
+ return NextResponse.json(response);
119
+ }
120
+
121
+ // CRITICAL: Cookie exists but Redis session is missing (stale cookie state)
122
+ // Return sessionToken so middleware can detect this and clear the stale cookie
123
+ console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
124
+ return NextResponse.json({
125
+ authenticated: false,
126
+ sessionToken // Include token to enable stale cookie detection
127
+ });
128
+ }
129
+
130
+ // If there's no token at all, it's not authenticated
131
+ return NextResponse.json({ authenticated: false });
132
+
133
+ } catch (error) {
134
+ console.error('[API Viability] Error checking session viability:', error);
135
+ return NextResponse.json({ authenticated: false }, { status: 500 });
136
+ }
129
137
  }