@payez/next-mvp 4.1.10 → 4.1.11-idealvibe.1
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 +22 -18
- package/dist/client/better-auth-client.d.ts +14 -2
- package/dist/lib/ensure-fresh-access-token.js +13 -2
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +21 -3
- package/package.json +1 -1
- 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/validate.ts +43 -43
- package/src/api-handlers/session/viability.ts +142 -142
- package/src/auth/better-auth.ts +463 -455
- 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 +13 -2
- 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 +695 -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 +497 -470
- 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
package/dist/auth/better-auth.js
CHANGED
|
@@ -126,24 +126,16 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
|
126
126
|
// Redis-backed session storage via secondaryStorage
|
|
127
127
|
secondaryStorage: {
|
|
128
128
|
get: async (key) => {
|
|
129
|
-
|
|
130
|
-
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
129
|
+
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
135
130
|
},
|
|
136
131
|
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
|
-
}
|
|
132
|
+
const redis = (0, redis_1.getRedis)();
|
|
133
|
+
if (ttl) {
|
|
134
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
|
|
145
138
|
}
|
|
146
|
-
catch { /* Redis unavailable — cookie cache still works */ }
|
|
147
139
|
},
|
|
148
140
|
delete: async (key) => {
|
|
149
141
|
try {
|
|
@@ -327,8 +319,20 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
327
319
|
return false;
|
|
328
320
|
}
|
|
329
321
|
// Build IDP token data
|
|
330
|
-
//
|
|
331
|
-
//
|
|
322
|
+
// ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
|
|
323
|
+
// !hasRefreshToken was a reliable "2FA required" signal.
|
|
324
|
+
//
|
|
325
|
+
// BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
|
|
326
|
+
// oauth-callback even for require_2fa tenants that haven't done 2FA yet.
|
|
327
|
+
// This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
|
|
328
|
+
// bypassing the 2FA gate entirely.
|
|
329
|
+
//
|
|
330
|
+
// FIX: Always set mfaVerified=false on the initial OAuth exchange.
|
|
331
|
+
// The tenant's require_2fa setting is enforced by the viability endpoint
|
|
332
|
+
// (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
|
|
333
|
+
// mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
|
|
334
|
+
// Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
|
|
335
|
+
// IDP correctly withholds refresh_token until post-2FA.
|
|
332
336
|
const hasRefreshToken = !!result.refresh_token;
|
|
333
337
|
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
|
|
334
338
|
const idpTokenData = {
|
|
@@ -342,7 +346,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
342
346
|
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
343
347
|
image: image,
|
|
344
348
|
roles: result.user?.roles || result.roles || [],
|
|
345
|
-
mfaVerified:
|
|
349
|
+
mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
|
|
346
350
|
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
347
351
|
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
348
352
|
};
|
|
@@ -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,8 +158,16 @@ 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;
|
|
161
169
|
const body = {
|
|
162
|
-
refresh_token:
|
|
170
|
+
refresh_token: sentRefreshToken,
|
|
163
171
|
amr: authMethods,
|
|
164
172
|
acr: acrValue,
|
|
165
173
|
};
|
|
@@ -209,7 +217,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
209
217
|
// (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
|
|
210
218
|
// a concurrent rotation is NOT a terminal condition.
|
|
211
219
|
const postRefresh = await (0, session_store_1.getSession)(sessionToken);
|
|
212
|
-
|
|
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;
|
|
213
224
|
if (tokenUnchanged) {
|
|
214
225
|
await (0, session_store_1.updateSession)(sessionToken, {
|
|
215
226
|
idpRefreshToken: '',
|
|
@@ -489,27 +489,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
|
|
|
489
489
|
const lockKey = getRefreshLockKey(sessionToken);
|
|
490
490
|
try {
|
|
491
491
|
// 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
|
|
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
|
|
513
513
|
`;
|
|
514
514
|
const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
|
|
515
515
|
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,6 +31,8 @@ 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;
|
|
34
36
|
}
|
|
35
37
|
export type EnsureFreshTokenResult = TokenResult | TokenError;
|
|
36
38
|
/**
|
|
@@ -150,7 +150,16 @@ 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
|
-
|
|
153
|
+
// WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
|
|
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
|
+
};
|
|
154
163
|
}
|
|
155
164
|
// For other 401s, check if maybe session was refreshed by another request
|
|
156
165
|
if (response.status === 401 && retryCount < maxRetries) {
|
|
@@ -318,12 +327,21 @@ async function ensureFreshToken(request) {
|
|
|
318
327
|
if (!refreshResult.success) {
|
|
319
328
|
// Check for terminal state - session cannot be recovered
|
|
320
329
|
if (refreshResult.terminal) {
|
|
321
|
-
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired
|
|
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.
|
|
322
338
|
return {
|
|
323
339
|
success: false,
|
|
324
340
|
error: 'SESSION_EXPIRED_NO_REFRESH',
|
|
325
|
-
message: 'Session expired. Please sign in again.',
|
|
341
|
+
message: refreshResult.message || 'Session expired. Please sign in again.',
|
|
326
342
|
terminal: true,
|
|
343
|
+
code: refreshResult.code,
|
|
344
|
+
resolution: refreshResult.resolution,
|
|
327
345
|
};
|
|
328
346
|
}
|
|
329
347
|
console.warn('[TOKEN_LIFECYCLE] Refresh failed');
|
package/package.json
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
# CI Check: Detect incorrect internal API URL patterns
|
|
4
|
-
#
|
|
5
|
-
# This script fails if code is using req.url or req.nextUrl.origin to construct
|
|
6
|
-
# URLs for internal API calls. These patterns cause 403 errors in Kubernetes
|
|
7
|
-
# because they use the external HTTPS URL instead of INTERNAL_API_URL.
|
|
8
|
-
#
|
|
9
|
-
# CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
|
|
10
|
-
# INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
|
|
11
|
-
#
|
|
12
|
-
# NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
|
|
13
|
-
# The problem is when you construct a NEW path using req.url as the base.
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
set -e
|
|
17
|
-
|
|
18
|
-
SEARCH_PATH="${1:-.}"
|
|
19
|
-
ERRORS_FOUND=0
|
|
20
|
-
|
|
21
|
-
echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
|
|
22
|
-
echo "========================================================================"
|
|
23
|
-
|
|
24
|
-
# Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
|
|
25
|
-
# This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
|
|
26
|
-
# But NOT: new URL(req.url) which is just parsing the request
|
|
27
|
-
echo ""
|
|
28
|
-
echo "Checking for: new URL('/api/...', req.url) pattern"
|
|
29
|
-
if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
30
|
-
echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
|
|
31
|
-
ERRORS_FOUND=1
|
|
32
|
-
fi
|
|
33
|
-
|
|
34
|
-
# Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
|
|
35
|
-
echo ""
|
|
36
|
-
echo "Checking for: new URL(..., req.nextUrl) pattern"
|
|
37
|
-
if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
38
|
-
echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
|
|
39
|
-
ERRORS_FOUND=1
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
|
-
# Pattern 3: fetch with req.nextUrl.origin and /api/
|
|
43
|
-
echo ""
|
|
44
|
-
echo "Checking for: fetch with req.nextUrl.origin and /api/"
|
|
45
|
-
if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
46
|
-
echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
|
|
47
|
-
ERRORS_FOUND=1
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
# Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
|
|
51
|
-
echo ""
|
|
52
|
-
echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
|
|
53
|
-
if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
|
|
54
|
-
echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
|
|
55
|
-
# Not failing on this one as it might be intentional in some cases (client-side refresh)
|
|
56
|
-
fi
|
|
57
|
-
|
|
58
|
-
echo ""
|
|
59
|
-
echo "========================================================================"
|
|
60
|
-
|
|
61
|
-
if [ $ERRORS_FOUND -eq 1 ]; then
|
|
62
|
-
echo "FAILED: Dangerous internal URL patterns detected!"
|
|
63
|
-
echo ""
|
|
64
|
-
echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
|
|
65
|
-
echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
|
|
66
|
-
echo ""
|
|
67
|
-
echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
|
|
68
|
-
echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
|
|
69
|
-
exit 1
|
|
70
|
-
else
|
|
71
|
-
echo "PASSED: No dangerous internal URL patterns found."
|
|
72
|
-
exit 0
|
|
73
|
-
fi
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# CI Check: Detect incorrect internal API URL patterns
|
|
4
|
+
#
|
|
5
|
+
# This script fails if code is using req.url or req.nextUrl.origin to construct
|
|
6
|
+
# URLs for internal API calls. These patterns cause 403 errors in Kubernetes
|
|
7
|
+
# because they use the external HTTPS URL instead of INTERNAL_API_URL.
|
|
8
|
+
#
|
|
9
|
+
# CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
|
|
10
|
+
# INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
|
|
11
|
+
#
|
|
12
|
+
# NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
|
|
13
|
+
# The problem is when you construct a NEW path using req.url as the base.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
set -e
|
|
17
|
+
|
|
18
|
+
SEARCH_PATH="${1:-.}"
|
|
19
|
+
ERRORS_FOUND=0
|
|
20
|
+
|
|
21
|
+
echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
|
|
22
|
+
echo "========================================================================"
|
|
23
|
+
|
|
24
|
+
# Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
|
|
25
|
+
# This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
|
|
26
|
+
# But NOT: new URL(req.url) which is just parsing the request
|
|
27
|
+
echo ""
|
|
28
|
+
echo "Checking for: new URL('/api/...', req.url) pattern"
|
|
29
|
+
if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
30
|
+
echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
|
|
31
|
+
ERRORS_FOUND=1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
|
|
35
|
+
echo ""
|
|
36
|
+
echo "Checking for: new URL(..., req.nextUrl) pattern"
|
|
37
|
+
if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
38
|
+
echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
|
|
39
|
+
ERRORS_FOUND=1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Pattern 3: fetch with req.nextUrl.origin and /api/
|
|
43
|
+
echo ""
|
|
44
|
+
echo "Checking for: fetch with req.nextUrl.origin and /api/"
|
|
45
|
+
if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
|
|
46
|
+
echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
|
|
47
|
+
ERRORS_FOUND=1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
|
|
51
|
+
echo ""
|
|
52
|
+
echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
|
|
53
|
+
if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
|
|
54
|
+
echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
|
|
55
|
+
# Not failing on this one as it might be intentional in some cases (client-side refresh)
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
echo ""
|
|
59
|
+
echo "========================================================================"
|
|
60
|
+
|
|
61
|
+
if [ $ERRORS_FOUND -eq 1 ]; then
|
|
62
|
+
echo "FAILED: Dangerous internal URL patterns detected!"
|
|
63
|
+
echo ""
|
|
64
|
+
echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
|
|
65
|
+
echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
|
|
66
|
+
echo ""
|
|
67
|
+
echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
|
|
68
|
+
echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
|
|
69
|
+
exit 1
|
|
70
|
+
else
|
|
71
|
+
echo "PASSED: No dangerous internal URL patterns found."
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
package/src/api/index.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @payez/next-mvp API Module Exports
|
|
3
|
-
*
|
|
4
|
-
* Provides enhanced API route handlers with automatic token management
|
|
5
|
-
*
|
|
6
|
-
* @version 2.0.0
|
|
7
|
-
* @since auth-ready-v2
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export {
|
|
11
|
-
createAuthHandler,
|
|
12
|
-
type AuthContext,
|
|
13
|
-
type AuthHandlerOptions,
|
|
14
|
-
type HandlerFunction
|
|
15
|
-
} from './auth-handler';
|
|
16
|
-
|
|
17
|
-
// Default export for convenience
|
|
1
|
+
/**
|
|
2
|
+
* @payez/next-mvp API Module Exports
|
|
3
|
+
*
|
|
4
|
+
* Provides enhanced API route handlers with automatic token management
|
|
5
|
+
*
|
|
6
|
+
* @version 2.0.0
|
|
7
|
+
* @since auth-ready-v2
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createAuthHandler,
|
|
12
|
+
type AuthContext,
|
|
13
|
+
type AuthHandlerOptions,
|
|
14
|
+
type HandlerFunction
|
|
15
|
+
} from './auth-handler';
|
|
16
|
+
|
|
17
|
+
// Default export for convenience
|
|
18
18
|
export { default } from './auth-handler';
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
-
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
-
|
|
5
|
-
// IDP masked-info is POST and uses capital 'A' in /api/Account
|
|
6
|
-
export async function POST(req: NextRequest) {
|
|
7
|
-
const url = `${ENV_CONFIG.IDP_URL}/api/Account/masked-info`;
|
|
8
|
-
|
|
9
|
-
// Forward request body if present; IDP often accepts empty object
|
|
10
|
-
let body = '{}';
|
|
11
|
-
try {
|
|
12
|
-
const raw = await req.text();
|
|
13
|
-
if (raw && raw.trim().length > 0) body = raw;
|
|
14
|
-
} catch {}
|
|
15
|
-
|
|
16
|
-
const result = await idpFetchJSON(req, url, {
|
|
17
|
-
method: 'POST',
|
|
18
|
-
headers: { 'Content-Type': 'application/json' },
|
|
19
|
-
body
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
if (!result.ok) {
|
|
23
|
-
return NextResponse.json(
|
|
24
|
-
{
|
|
25
|
-
success: false,
|
|
26
|
-
message: 'Upstream error',
|
|
27
|
-
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
28
|
-
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
29
|
-
},
|
|
30
|
-
{ status: result.status }
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const bodyJson: any = result.json;
|
|
35
|
-
// Unwrap if IDP returns envelope { success, data }
|
|
36
|
-
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
37
|
-
if ((bodyJson as any).success === true) {
|
|
38
|
-
return NextResponse.json((bodyJson as any).data, { status: 200 });
|
|
39
|
-
}
|
|
40
|
-
return NextResponse.json(bodyJson, { status: 200 });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Passthrough otherwise
|
|
44
|
-
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
45
|
-
}
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
|
|
5
|
+
// IDP masked-info is POST and uses capital 'A' in /api/Account
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
const url = `${ENV_CONFIG.IDP_URL}/api/Account/masked-info`;
|
|
8
|
+
|
|
9
|
+
// Forward request body if present; IDP often accepts empty object
|
|
10
|
+
let body = '{}';
|
|
11
|
+
try {
|
|
12
|
+
const raw = await req.text();
|
|
13
|
+
if (raw && raw.trim().length > 0) body = raw;
|
|
14
|
+
} catch {}
|
|
15
|
+
|
|
16
|
+
const result = await idpFetchJSON(req, url, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!result.ok) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{
|
|
25
|
+
success: false,
|
|
26
|
+
message: 'Upstream error',
|
|
27
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
28
|
+
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
29
|
+
},
|
|
30
|
+
{ status: result.status }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bodyJson: any = result.json;
|
|
35
|
+
// Unwrap if IDP returns envelope { success, data }
|
|
36
|
+
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
37
|
+
if ((bodyJson as any).success === true) {
|
|
38
|
+
return NextResponse.json((bodyJson as any).data, { status: 200 });
|
|
39
|
+
}
|
|
40
|
+
return NextResponse.json(bodyJson, { status: 200 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Passthrough otherwise
|
|
44
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
45
|
+
}
|