@payez/next-mvp 4.1.6 → 4.1.8

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.
@@ -59,8 +59,7 @@ async function GET(req) {
59
59
  // Check if access token is expired (for middleware decision-making)
60
60
  const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
61
61
  const accessTokenExpired = accessTokenExpires < Date.now();
62
- // Get requires2FA from cached client config (not session)
63
- // This is a client-wide setting from the broker handshake
62
+ // Get requires2FA from cached client config (client-wide setting)
64
63
  let requires2FA = true; // Default to true for security
65
64
  try {
66
65
  const cachedConfig = await (0, idp_client_config_1.getIDPClientConfig)();
@@ -69,6 +68,12 @@ async function GET(req) {
69
68
  catch (e) {
70
69
  console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
71
70
  }
71
+ // CRITICAL: OAuth sessions without refresh token always need 2FA completion
72
+ // to obtain a refresh token from the IDP. The OAuth callback sets mfaVerified=false
73
+ // when no refresh_token is present. This must override the client config.
74
+ if (sessionData.mfaVerified === false && !sessionData.idpRefreshToken) {
75
+ requires2FA = true;
76
+ }
72
77
  // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
73
78
  // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
74
79
  // has passed, we must treat 2FA as incomplete to force re-verification.
@@ -67,6 +67,14 @@ async function ensureFreshAccessToken(req) {
67
67
  return {};
68
68
  }
69
69
  let session = await (0, session_store_1.getSession)(sessionToken);
70
+ // Fall back to Better Auth Redis key when app session is missing/stale
71
+ if (!session || !session.idpAccessToken) {
72
+ const { getBetterAuthSession } = await Promise.resolve().then(() => __importStar(require('./session-store')));
73
+ const baSession = await getBetterAuthSession(sessionToken);
74
+ if (baSession?.idpAccessToken) {
75
+ session = baSession;
76
+ }
77
+ }
70
78
  // Fall back to Better Auth: decode session from cookies to get IDP tokens
71
79
  if (!session?.idpAccessToken) {
72
80
  try {
@@ -81,28 +89,34 @@ async function ensureFreshAccessToken(req) {
81
89
  console.log('[IDP_FETCH] Redis session lookup:', {
82
90
  hasSession: !!session,
83
91
  hasAccessToken: !!session?.idpAccessToken,
92
+ hasRefreshToken: !!session?.idpRefreshToken,
84
93
  bearerKeyId: session?.bearerKeyId || 'NOT_SET',
85
94
  idpClientId: session?.idpClientId || 'NOT_SET',
86
95
  });
87
96
  if (!session?.idpAccessToken)
88
97
  return { sessionToken };
98
+ const hasRefreshToken = !!session.idpRefreshToken;
89
99
  const now = Date.now();
90
100
  const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
91
101
  // Treat tokens as effectively expired if within 5 minutes of expiry
92
102
  if (timeLeft > 5 * 60 * 1000) {
93
- return { sessionToken, accessToken: session.idpAccessToken };
103
+ return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
94
104
  }
95
- // attempt refresh once via centralized internal API helper
96
- try {
97
- await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
105
+ // Only attempt refresh when we actually have a refresh token to use.
106
+ // Provisional tokens (issued before 2FA completion) have no refresh token.
107
+ if (hasRefreshToken) {
108
+ try {
109
+ await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
110
+ }
111
+ catch { }
112
+ session = await (0, session_store_1.getSession)(sessionToken);
113
+ return { sessionToken, accessToken: session?.idpAccessToken, hasRefreshToken: !!session?.idpRefreshToken };
98
114
  }
99
- catch { }
100
- session = await (0, session_store_1.getSession)(sessionToken);
101
- return { sessionToken, accessToken: session?.idpAccessToken };
115
+ return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
102
116
  }
103
117
  async function idpFetchJSON(req, targetUrl, init = {}) {
104
118
  let attemptedRefresh = false;
105
- let { accessToken, sessionToken } = await ensureFreshAccessToken(req);
119
+ let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
106
120
  const makeCall = async (bearer) => {
107
121
  const res = await fetch(targetUrl, {
108
122
  ...init,
@@ -119,7 +133,9 @@ async function idpFetchJSON(req, targetUrl, init = {}) {
119
133
  };
120
134
  // First attempt
121
135
  let { res, json } = await makeCall(accessToken);
122
- if ((res.status === 401 || res.status === 403) && sessionToken && !attemptedRefresh) {
136
+ // Only attempt refresh on 401/403 if we actually have a refresh token.
137
+ // Provisional tokens (pre-2FA) have no refresh token — refreshing is futile.
138
+ if ((res.status === 401 || res.status === 403) && sessionToken && hasRefreshToken && !attemptedRefresh) {
123
139
  attemptedRefresh = true;
124
140
  try {
125
141
  // Use centralized internal API helper for server-to-server calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -69,8 +69,7 @@ export async function GET(req: NextRequest) {
69
69
  const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
70
70
  const accessTokenExpired = accessTokenExpires < Date.now();
71
71
 
72
- // Get requires2FA from cached client config (not session)
73
- // This is a client-wide setting from the broker handshake
72
+ // Get requires2FA from cached client config (client-wide setting)
74
73
  let requires2FA = true; // Default to true for security
75
74
  try {
76
75
  const cachedConfig = await getIDPClientConfig();
@@ -79,6 +78,13 @@ export async function GET(req: NextRequest) {
79
78
  console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
80
79
  }
81
80
 
81
+ // CRITICAL: OAuth sessions without refresh token always need 2FA completion
82
+ // to obtain a refresh token from the IDP. The OAuth callback sets mfaVerified=false
83
+ // when no refresh_token is present. This must override the client config.
84
+ if (sessionData.mfaVerified === false && !sessionData.idpRefreshToken) {
85
+ requires2FA = true;
86
+ }
87
+
82
88
  // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
83
89
  // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
84
90
  // has passed, we must treat 2FA as incomplete to force re-verification.
@@ -1,111 +1,127 @@
1
- import { NextRequest } from 'next/server';
2
- import { getTokenTestAware } from './test-aware-get-token';
3
- import { getSession } from './session-store';
4
- import { internalRefresh } from './internal-api';
5
-
6
- /**
7
- * Centralized IDP fetch helper
8
- * - Injects Bearer from Redis session
9
- * - If access token is expired/near-expiry, triggers one refresh and retries fetch once
10
- * - Returns parsed JSON and HTTP status
11
- */
12
- export interface IdpFetchResult<T = any> {
13
- ok: boolean;
14
- status: number;
15
- json: T | null;
16
- attemptedRefresh: boolean;
17
- }
18
-
19
- function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
20
- const headers: Record<string, string> = {
21
- Accept: 'application/json',
22
- 'Content-Type': 'application/json',
23
- ...extra,
24
- };
25
- const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
26
- if (xfwd) headers['X-Forwarded-For'] = xfwd.split(',')[0].trim();
27
- const ua = req.headers.get('user-agent');
28
- if (ua) headers['User-Agent'] = ua;
29
- if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
30
- return headers;
31
- }
32
-
33
- async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?: string; accessToken?: string }> {
34
- const token = await getTokenTestAware(req);
35
- // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
36
- const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
37
- console.log('[IDP_FETCH] ensureFreshAccessToken:', {
38
- hasToken: !!token,
39
- sessionToken: sessionToken?.substring(0, 20) + '...',
40
- pathname: req.nextUrl.pathname
41
- });
42
- if (!sessionToken) {
43
- console.warn('[IDP_FETCH] No sessionToken found in JWT - Bearer token will NOT be sent!');
44
- return {};
45
- }
46
- let session = await getSession(sessionToken);
47
- // Fall back to Better Auth: decode session from cookies to get IDP tokens
48
- if (!session?.idpAccessToken) {
49
- try {
50
- const { decodeSession } = await import('../server/decode-session');
51
- const decoded = await decodeSession(req.cookies);
52
- if (decoded?.sessionData?.idpAccessToken) {
53
- session = decoded.sessionData;
54
- }
55
- } catch { /* decodeSession unavailable in edge */ }
56
- }
57
- console.log('[IDP_FETCH] Redis session lookup:', {
58
- hasSession: !!session,
59
- hasAccessToken: !!session?.idpAccessToken,
60
- bearerKeyId: session?.bearerKeyId || 'NOT_SET',
61
- idpClientId: session?.idpClientId || 'NOT_SET',
62
- });
63
- if (!session?.idpAccessToken) return { sessionToken };
64
- const now = Date.now();
65
- const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
66
- // Treat tokens as effectively expired if within 5 minutes of expiry
67
- if (timeLeft > 5 * 60 * 1000) {
68
- return { sessionToken, accessToken: session.idpAccessToken };
69
- }
70
- // attempt refresh once via centralized internal API helper
71
- try {
72
- await internalRefresh(req.headers.get('cookie') || '', sessionToken);
73
- } catch {}
74
- session = await getSession(sessionToken);
75
- return { sessionToken, accessToken: session?.idpAccessToken };
76
- }
77
-
78
- export async function idpFetchJSON<T = any>(
79
- req: NextRequest,
80
- targetUrl: string,
81
- init: RequestInit = {}
82
- ): Promise<IdpFetchResult<T>> {
83
- let attemptedRefresh = false;
84
- let { accessToken, sessionToken } = await ensureFreshAccessToken(req);
85
-
86
- const makeCall = async (bearer?: string) => {
87
- const res = await fetch(targetUrl, {
88
- ...init,
89
- headers: buildHeaders(req, bearer, init.headers as any),
90
- });
91
- let json: any = null;
92
- try { json = await res.json(); } catch { json = null; }
93
- return { res, json } as const;
94
- };
95
-
96
- // First attempt
97
- let { res, json } = await makeCall(accessToken);
98
- if ((res.status === 401 || res.status === 403) && sessionToken && !attemptedRefresh) {
99
- attemptedRefresh = true;
100
- try {
101
- // Use centralized internal API helper for server-to-server calls
102
- const rf = await internalRefresh(req.headers.get('cookie') || '', sessionToken);
103
- if (rf.ok) {
104
- const fresh = await getSession(sessionToken);
105
- ({ res, json } = await makeCall(fresh?.idpAccessToken));
106
- }
107
- } catch {}
108
- }
109
-
110
- return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh };
1
+ import { NextRequest } from 'next/server';
2
+ import { getTokenTestAware } from './test-aware-get-token';
3
+ import { getSession } from './session-store';
4
+ import { internalRefresh } from './internal-api';
5
+
6
+ /**
7
+ * Centralized IDP fetch helper
8
+ * - Injects Bearer from Redis session
9
+ * - If access token is expired/near-expiry, triggers one refresh and retries fetch once
10
+ * - Returns parsed JSON and HTTP status
11
+ */
12
+ export interface IdpFetchResult<T = any> {
13
+ ok: boolean;
14
+ status: number;
15
+ json: T | null;
16
+ attemptedRefresh: boolean;
17
+ }
18
+
19
+ function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
20
+ const headers: Record<string, string> = {
21
+ Accept: 'application/json',
22
+ 'Content-Type': 'application/json',
23
+ ...extra,
24
+ };
25
+ const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
26
+ if (xfwd) headers['X-Forwarded-For'] = xfwd.split(',')[0].trim();
27
+ const ua = req.headers.get('user-agent');
28
+ if (ua) headers['User-Agent'] = ua;
29
+ if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
30
+ return headers;
31
+ }
32
+
33
+ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?: string; accessToken?: string; hasRefreshToken?: boolean }> {
34
+ const token = await getTokenTestAware(req);
35
+ // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
36
+ const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
37
+ console.log('[IDP_FETCH] ensureFreshAccessToken:', {
38
+ hasToken: !!token,
39
+ sessionToken: sessionToken?.substring(0, 20) + '...',
40
+ pathname: req.nextUrl.pathname
41
+ });
42
+ if (!sessionToken) {
43
+ console.warn('[IDP_FETCH] No sessionToken found in JWT - Bearer token will NOT be sent!');
44
+ return {};
45
+ }
46
+ let session = await getSession(sessionToken);
47
+ // Fall back to Better Auth Redis key when app session is missing/stale
48
+ if (!session || !session.idpAccessToken) {
49
+ const { getBetterAuthSession } = await import('./session-store');
50
+ const baSession = await getBetterAuthSession(sessionToken);
51
+ if (baSession?.idpAccessToken) {
52
+ session = baSession;
53
+ }
54
+ }
55
+ // Fall back to Better Auth: decode session from cookies to get IDP tokens
56
+ if (!session?.idpAccessToken) {
57
+ try {
58
+ const { decodeSession } = await import('../server/decode-session');
59
+ const decoded = await decodeSession(req.cookies);
60
+ if (decoded?.sessionData?.idpAccessToken) {
61
+ session = decoded.sessionData;
62
+ }
63
+ } catch { /* decodeSession unavailable in edge */ }
64
+ }
65
+ console.log('[IDP_FETCH] Redis session lookup:', {
66
+ hasSession: !!session,
67
+ hasAccessToken: !!session?.idpAccessToken,
68
+ hasRefreshToken: !!session?.idpRefreshToken,
69
+ bearerKeyId: session?.bearerKeyId || 'NOT_SET',
70
+ idpClientId: session?.idpClientId || 'NOT_SET',
71
+ });
72
+ if (!session?.idpAccessToken) return { sessionToken };
73
+ const hasRefreshToken = !!session.idpRefreshToken;
74
+ const now = Date.now();
75
+ const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
76
+ // Treat tokens as effectively expired if within 5 minutes of expiry
77
+ if (timeLeft > 5 * 60 * 1000) {
78
+ return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
79
+ }
80
+ // Only attempt refresh when we actually have a refresh token to use.
81
+ // Provisional tokens (issued before 2FA completion) have no refresh token.
82
+ if (hasRefreshToken) {
83
+ try {
84
+ await internalRefresh(req.headers.get('cookie') || '', sessionToken);
85
+ } catch {}
86
+ session = await getSession(sessionToken);
87
+ return { sessionToken, accessToken: session?.idpAccessToken, hasRefreshToken: !!session?.idpRefreshToken };
88
+ }
89
+ return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
90
+ }
91
+
92
+ export async function idpFetchJSON<T = any>(
93
+ req: NextRequest,
94
+ targetUrl: string,
95
+ init: RequestInit = {}
96
+ ): Promise<IdpFetchResult<T>> {
97
+ let attemptedRefresh = false;
98
+ let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
99
+
100
+ const makeCall = async (bearer?: string) => {
101
+ const res = await fetch(targetUrl, {
102
+ ...init,
103
+ headers: buildHeaders(req, bearer, init.headers as any),
104
+ });
105
+ let json: any = null;
106
+ try { json = await res.json(); } catch { json = null; }
107
+ return { res, json } as const;
108
+ };
109
+
110
+ // First attempt
111
+ let { res, json } = await makeCall(accessToken);
112
+ // Only attempt refresh on 401/403 if we actually have a refresh token.
113
+ // Provisional tokens (pre-2FA) have no refresh token — refreshing is futile.
114
+ if ((res.status === 401 || res.status === 403) && sessionToken && hasRefreshToken && !attemptedRefresh) {
115
+ attemptedRefresh = true;
116
+ try {
117
+ // Use centralized internal API helper for server-to-server calls
118
+ const rf = await internalRefresh(req.headers.get('cookie') || '', sessionToken);
119
+ if (rf.ok) {
120
+ const fresh = await getSession(sessionToken);
121
+ ({ res, json } = await makeCall(fresh?.idpAccessToken));
122
+ }
123
+ } catch {}
124
+ }
125
+
126
+ return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh };
111
127
  }