@payez/next-mvp 4.1.4 → 4.1.6

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.
@@ -326,7 +326,10 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
326
326
  return false;
327
327
  }
328
328
  // Build IDP token data
329
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
329
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
330
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
331
+ const hasRefreshToken = !!result.refresh_token;
332
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
330
333
  const idpTokenData = {
331
334
  idpAccessToken: result.access_token,
332
335
  idpRefreshToken: result.refresh_token,
@@ -38,7 +38,13 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
38
38
  const safetyWindowMs = options.safetyWindowMs ?? DEFAULT_SAFETY_WINDOW_MS;
39
39
  const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
40
40
  const requestId = options.requestId ?? `ensure_fresh_${Date.now()}`;
41
- const session = await (0, session_store_1.getSession)(sessionToken);
41
+ let session = await (0, session_store_1.getSession)(sessionToken);
42
+ if (!session || !session.idpRefreshToken) {
43
+ const baSession = await (0, session_store_1.getBetterAuthSession)(sessionToken);
44
+ if (baSession?.idpRefreshToken) {
45
+ session = baSession;
46
+ }
47
+ }
42
48
  if (!session) {
43
49
  return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
44
50
  }
@@ -76,7 +82,12 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
76
82
  await new Promise(r => setTimeout(r, 200));
77
83
  const stillLocked = await (0, session_store_1.checkRefreshLock)(sessionToken);
78
84
  if (!stillLocked) {
79
- const after = await (0, session_store_1.getSession)(sessionToken);
85
+ let after = await (0, session_store_1.getSession)(sessionToken);
86
+ if (!after || !after.idpRefreshToken) {
87
+ const baAfter = await (0, session_store_1.getBetterAuthSession)(sessionToken);
88
+ if (baAfter?.idpRefreshToken)
89
+ after = baAfter;
90
+ }
80
91
  if (after?.idpAccessToken && after.idpAccessTokenExpires) {
81
92
  const remaining = after.idpAccessTokenExpires - Date.now();
82
93
  if (remaining > safetyWindowMs) {
@@ -106,7 +117,12 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
106
117
  }
107
118
  try {
108
119
  // Re-check after lock — another caller may have already refreshed.
109
- const latest = await (0, session_store_1.getSession)(sessionToken);
120
+ let latest = await (0, session_store_1.getSession)(sessionToken);
121
+ if (!latest || !latest.idpRefreshToken) {
122
+ const baLatest = await (0, session_store_1.getBetterAuthSession)(sessionToken);
123
+ if (baLatest?.idpRefreshToken)
124
+ latest = baLatest;
125
+ }
110
126
  if (latest?.idpAccessToken && latest.idpAccessTokenExpires) {
111
127
  const latestRemaining = latest.idpAccessTokenExpires - Date.now();
112
128
  const latestJwtExp = decodeJwtExp(latest.idpAccessToken);
@@ -43,7 +43,7 @@ function makeAuthDecision(context) {
43
43
  const safeCallbackUrl2 = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
44
44
  return { type: 'redirect', location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl2)}`, reason: '2fa_required_login_redirect' };
45
45
  }
46
- return { type: 'allow' };
46
+ return { type: 'redirect', location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, reason: '2fa_required' };
47
47
  }
48
48
  if (sessionPointer.expired) {
49
49
  if (isLoginPage)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.4",
3
+ "version": "4.1.6",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -342,7 +342,10 @@ export async function exchangeOAuthForIdpTokens(
342
342
  }
343
343
 
344
344
  // Build IDP token data
345
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
345
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
346
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
347
+ const hasRefreshToken = !!result.refresh_token;
348
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
346
349
  const idpTokenData = {
347
350
  idpAccessToken: result.access_token,
348
351
  idpRefreshToken: result.refresh_token,
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import {
17
17
  getSession,
18
+ getBetterAuthSession,
18
19
  updateSession,
19
20
  acquireRefreshLock,
20
21
  releaseRefreshLock,
@@ -81,7 +82,13 @@ export async function ensureFreshAccessToken(
81
82
  const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
82
83
  const requestId = options.requestId ?? `ensure_fresh_${Date.now()}`;
83
84
 
84
- const session = await getSession(sessionToken);
85
+ let session = await getSession(sessionToken);
86
+ if (!session || !session.idpRefreshToken) {
87
+ const baSession = await getBetterAuthSession(sessionToken);
88
+ if (baSession?.idpRefreshToken) {
89
+ session = baSession;
90
+ }
91
+ }
85
92
  if (!session) {
86
93
  return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
87
94
  }
@@ -125,7 +132,11 @@ export async function ensureFreshAccessToken(
125
132
  await new Promise(r => setTimeout(r, 200));
126
133
  const stillLocked = await checkRefreshLock(sessionToken);
127
134
  if (!stillLocked) {
128
- const after = await getSession(sessionToken);
135
+ let after = await getSession(sessionToken);
136
+ if (!after || !after.idpRefreshToken) {
137
+ const baAfter = await getBetterAuthSession(sessionToken);
138
+ if (baAfter?.idpRefreshToken) after = baAfter;
139
+ }
129
140
  if (after?.idpAccessToken && after.idpAccessTokenExpires) {
130
141
  const remaining = after.idpAccessTokenExpires - Date.now();
131
142
  if (remaining > safetyWindowMs) {
@@ -155,7 +166,11 @@ export async function ensureFreshAccessToken(
155
166
 
156
167
  try {
157
168
  // Re-check after lock — another caller may have already refreshed.
158
- const latest = await getSession(sessionToken);
169
+ let latest = await getSession(sessionToken);
170
+ if (!latest || !latest.idpRefreshToken) {
171
+ const baLatest = await getBetterAuthSession(sessionToken);
172
+ if (baLatest?.idpRefreshToken) latest = baLatest;
173
+ }
159
174
  if (latest?.idpAccessToken && latest.idpAccessTokenExpires) {
160
175
  const latestRemaining = latest.idpAccessTokenExpires - Date.now();
161
176
  const latestJwtExp = decodeJwtExp(latest.idpAccessToken);
@@ -1,43 +1,43 @@
1
- /**
2
- * Auth Decision Engine (lifted)
3
- */
4
- import { NextRequest } from 'next/server';
5
-
6
- export interface AuthContext {
7
- pathname: string;
8
- isPublicRoute: boolean;
9
- isLoginPage: boolean;
10
- searchParams: URLSearchParams;
11
- sessionPointer: { exists: boolean; sessionToken?: string; expired?: boolean; hasRefreshToken?: boolean; };
12
- sessionStatus: { exists: boolean | null; forceInvalid: boolean | null; requires2FA: boolean; twoFactorComplete: boolean; };
13
- circuitBreakerOpen: boolean;
14
- }
15
-
16
- export type AuthAction =
17
- | { type: 'allow' }
18
- | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
19
- | { type: 'service_error'; reason: string };
20
-
21
- export function makeAuthDecision(context: AuthContext): AuthAction {
22
- const { pathname, isPublicRoute, isLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = context;
23
- // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
24
- const safeCallbackUrl = pathname.startsWith('/account-auth/') ? '/' : pathname;
25
- if (isPublicRoute && !isLoginPage) return { type: 'allow' };
26
- if (circuitBreakerOpen) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen', reason: 'CircuitBreakerOpen', clearCookies: true }; }
27
- if (!sessionPointer.exists) { if (isLoginPage) return { type: 'allow' }; if (pathname === '/') { const unauthUrl = process.env.UNAUTHENTICATED_REDIRECT_URL || '/account-auth/login'; return { type: 'redirect', location: unauthUrl, reason: 'unauthenticated_root_redirect' }; } return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, reason: 'no_session_pointer' }; }
28
- if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) return { type: 'service_error', reason: 'redis_unavailable' };
29
- if (sessionStatus.forceInvalid) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&reason=force_invalidated`, reason: 'force_invalidated' }; }
30
- if (!sessionStatus.exists) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&reason=stale_session`, reason: 'stale_session' }; }
31
- if (sessionStatus.requires2FA && !sessionStatus.twoFactorComplete) {
32
- if (pathname === '/account-auth/verify-code') return { type: 'allow' };
33
- if (isLoginPage) { const callbackUrl = searchParams.get('callbackUrl') || '/'; const safeCallbackUrl2 = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl; return { type: 'redirect', location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl2)}`, reason: '2fa_required_login_redirect' }; }
34
- return { type: 'allow' };
35
- }
36
- if (sessionPointer.expired) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&error=SessionExpired`, reason: 'token_expired' }; }
37
- if (pathname === '/') return { type: 'redirect', location: '/', reason: 'authenticated_root_redirect' };
38
- if (isLoginPage) { const callbackUrl = searchParams.get('callbackUrl') || '/'; const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl; return { type: 'redirect', location: safeCallbackUrl, reason: 'already_authenticated' }; }
39
- return { type: 'allow' };
40
- }
41
-
42
- export function isLoginPage(pathname: string): boolean { return pathname === '/account-auth/login'; }
43
- export function getCallbackUrl(pathname: string, searchParams: URLSearchParams): string { if (pathname.startsWith('/account-auth/')) return '/'; const existingCallback = searchParams.get('callbackUrl'); if (existingCallback && !existingCallback.startsWith('/account-auth/')) return existingCallback; return pathname; }
1
+ /**
2
+ * Auth Decision Engine (lifted)
3
+ */
4
+ import { NextRequest } from 'next/server';
5
+
6
+ export interface AuthContext {
7
+ pathname: string;
8
+ isPublicRoute: boolean;
9
+ isLoginPage: boolean;
10
+ searchParams: URLSearchParams;
11
+ sessionPointer: { exists: boolean; sessionToken?: string; expired?: boolean; hasRefreshToken?: boolean; };
12
+ sessionStatus: { exists: boolean | null; forceInvalid: boolean | null; requires2FA: boolean; twoFactorComplete: boolean; };
13
+ circuitBreakerOpen: boolean;
14
+ }
15
+
16
+ export type AuthAction =
17
+ | { type: 'allow' }
18
+ | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
19
+ | { type: 'service_error'; reason: string };
20
+
21
+ export function makeAuthDecision(context: AuthContext): AuthAction {
22
+ const { pathname, isPublicRoute, isLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = context;
23
+ // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
24
+ const safeCallbackUrl = pathname.startsWith('/account-auth/') ? '/' : pathname;
25
+ if (isPublicRoute && !isLoginPage) return { type: 'allow' };
26
+ if (circuitBreakerOpen) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen', reason: 'CircuitBreakerOpen', clearCookies: true }; }
27
+ if (!sessionPointer.exists) { if (isLoginPage) return { type: 'allow' }; if (pathname === '/') { const unauthUrl = process.env.UNAUTHENTICATED_REDIRECT_URL || '/account-auth/login'; return { type: 'redirect', location: unauthUrl, reason: 'unauthenticated_root_redirect' }; } return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, reason: 'no_session_pointer' }; }
28
+ if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) return { type: 'service_error', reason: 'redis_unavailable' };
29
+ if (sessionStatus.forceInvalid) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&reason=force_invalidated`, reason: 'force_invalidated' }; }
30
+ if (!sessionStatus.exists) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&reason=stale_session`, reason: 'stale_session' }; }
31
+ if (sessionStatus.requires2FA && !sessionStatus.twoFactorComplete) {
32
+ if (pathname === '/account-auth/verify-code') return { type: 'allow' };
33
+ if (isLoginPage) { const callbackUrl = searchParams.get('callbackUrl') || '/'; const safeCallbackUrl2 = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl; return { type: 'redirect', location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl2)}`, reason: '2fa_required_login_redirect' }; }
34
+ return { type: 'redirect', location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, reason: '2fa_required' };
35
+ }
36
+ if (sessionPointer.expired) { if (isLoginPage) return { type: 'allow' }; return { type: 'redirect', location: `/account-auth/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}&error=SessionExpired`, reason: 'token_expired' }; }
37
+ if (pathname === '/') return { type: 'redirect', location: '/', reason: 'authenticated_root_redirect' };
38
+ if (isLoginPage) { const callbackUrl = searchParams.get('callbackUrl') || '/'; const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl; return { type: 'redirect', location: safeCallbackUrl, reason: 'already_authenticated' }; }
39
+ return { type: 'allow' };
40
+ }
41
+
42
+ export function isLoginPage(pathname: string): boolean { return pathname === '/account-auth/login'; }
43
+ export function getCallbackUrl(pathname: string, searchParams: URLSearchParams): string { if (pathname.startsWith('/account-auth/')) return '/'; const existingCallback = searchParams.get('callbackUrl'); if (existingCallback && !existingCallback.startsWith('/account-auth/')) return existingCallback; return pathname; }