@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.
- package/dist/auth/better-auth.js +4 -1
- package/dist/lib/ensure-fresh-access-token.js +19 -3
- package/dist/middleware/auth-decision.js +1 -1
- package/package.json +1 -1
- package/src/auth/better-auth.ts +4 -1
- package/src/lib/ensure-fresh-access-token.ts +18 -3
- package/src/middleware/auth-decision.ts +43 -43
package/dist/auth/better-auth.js
CHANGED
|
@@ -326,7 +326,10 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
326
326
|
return false;
|
|
327
327
|
}
|
|
328
328
|
// Build IDP token data
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
package/src/auth/better-auth.ts
CHANGED
|
@@ -342,7 +342,10 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
// Build IDP token data
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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; }
|