@payez/next-mvp 4.1.21 → 4.4.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/POST-INSTALL.md +408 -0
- package/README.md +7 -0
- package/dist/api-handlers/account/recovery/initiate.js +35 -2
- package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/send-code.js +35 -2
- package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/verify-code.js +35 -2
- package/dist/api-handlers/account/reset-password.js +35 -2
- package/dist/api-handlers/account/validate-password.js +21 -3
- package/dist/api-handlers/admin/analytics.js +2 -1
- package/dist/api-handlers/admin/audit.js +2 -1
- package/dist/api-handlers/admin/sessions.js +2 -1
- package/dist/api-handlers/admin/stats.js +2 -1
- package/dist/api-handlers/admin/users.js +2 -1
- package/dist/api-handlers/admin/vibe-data.js +2 -1
- package/dist/api-handlers/auth/jwks.js +10 -0
- package/dist/api-handlers/auth/login.js +5 -2
- package/dist/api-handlers/auth/passkey-signin.js +16 -1
- package/dist/api-handlers/auth/refresh.d.ts +23 -3
- package/dist/api-handlers/auth/refresh.js +107 -16
- package/dist/api-handlers/auth/signout.js +3 -2
- package/dist/api-handlers/auth/verify-code.js +5 -2
- package/dist/api-handlers/session/viability.js +27 -1
- package/dist/api-handlers/test/force-expire.d.ts +29 -15
- package/dist/api-handlers/test/force-expire.js +109 -14
- package/dist/auth/better-auth.d.ts +263 -6
- package/dist/auth/better-auth.js +437 -59
- package/dist/auth/utils/idp-client.js +1 -1
- package/dist/client/AuthContext.js +49 -17
- package/dist/client/better-auth-client.d.ts +62 -62
- package/dist/components/account/UserAvatarMenu.js +12 -0
- package/dist/components/auth/ModeAwareLoginPage.js +10 -8
- package/dist/components/auth/ModeAwareSignupPage.js +5 -2
- package/dist/components/auth/TraditionalAuthSection.js +5 -1
- package/dist/hooks/useAuthSettings.js +14 -2
- package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
- package/dist/hooks/usePublicAuthSettings.js +28 -14
- package/dist/lib/client-key.d.ts +61 -0
- package/dist/lib/client-key.js +72 -0
- package/dist/lib/demo-mode.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +5 -1
- package/dist/lib/idp-client-config.d.ts +25 -2
- package/dist/lib/idp-client-config.js +381 -36
- package/dist/lib/idp-fetch.d.ts +2 -0
- package/dist/lib/idp-fetch.js +31 -1
- package/dist/lib/idp-url-guard.d.ts +29 -0
- package/dist/lib/idp-url-guard.js +38 -0
- package/dist/lib/redis.js +77 -5
- package/dist/lib/session-cookie-shape.d.ts +69 -0
- package/dist/lib/session-cookie-shape.js +126 -0
- package/dist/lib/session-store.d.ts +35 -2
- package/dist/lib/session-store.js +135 -4
- package/dist/lib/single-flight.d.ts +35 -0
- package/dist/lib/single-flight.js +30 -0
- package/dist/lib/token-lifecycle.js +12 -0
- package/dist/middleware/create-middleware.js +12 -0
- package/dist/models/SessionModel.d.ts +41 -0
- package/dist/models/SessionModel.js +9 -0
- package/dist/pages/admin-login/page.d.ts +1 -1
- package/dist/pages/admin-login/page.js +5 -41
- package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
- package/dist/pages/security/EnhancedSecurityPage.js +42 -2
- package/dist/pages/security/index.d.ts +1 -0
- package/dist/pages/security/index.js +3 -0
- package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
- package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
- package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
- package/dist/pages/test-env/JwtInspectPage.js +166 -7
- package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
- package/dist/pages/test-env/RefreshTokenPage.js +22 -6
- package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
- package/dist/pages/test-env/TestEnvPage.js +16 -4
- package/dist/pages/test-env/guarded.d.ts +4 -0
- package/dist/pages/test-env/guarded.js +86 -0
- package/dist/pages/test-env/index.d.ts +50 -7
- package/dist/pages/test-env/index.js +58 -12
- package/dist/routes/auth/logout.js +3 -2
- package/dist/routes/auth/refresh.js +27 -1
- package/dist/routes/auth/session.d.ts +12 -3
- package/dist/routes/auth/session.js +92 -9
- package/dist/routes/auth/settings.d.ts +34 -4
- package/dist/routes/auth/settings.js +87 -33
- package/dist/routes/auth/viability.d.ts +1 -1
- package/dist/routes/auth/viability.js +122 -11
- package/dist/server/auth-guard.js +22 -15
- package/dist/server/auth.d.ts +177 -20
- package/dist/server/auth.js +34 -75
- package/dist/server/decode-session.js +84 -10
- package/dist/types/auth.d.ts +18 -0
- package/package.json +969 -958
- package/scripts/check-internal-url-usage.sh +73 -73
- package/scripts/check-publish-health.mjs +162 -0
- package/scripts/dev-broker.ps1 +71 -6
- package/scripts/print-client-config.mjs +162 -0
- package/scripts/server-only-stub.cjs +4 -0
- package/src/api-handlers/account/recovery/initiate.ts +35 -2
- package/src/api-handlers/account/recovery/send-code.ts +36 -2
- package/src/api-handlers/account/recovery/verify-code.ts +36 -2
- package/src/api-handlers/account/reset-password.ts +35 -2
- package/src/api-handlers/account/validate-password.ts +22 -3
- package/src/api-handlers/admin/analytics.ts +2 -1
- package/src/api-handlers/admin/audit.ts +2 -1
- package/src/api-handlers/admin/sessions.ts +2 -1
- package/src/api-handlers/admin/stats.ts +2 -1
- package/src/api-handlers/admin/users.ts +2 -1
- package/src/api-handlers/admin/vibe-data.ts +2 -1
- package/src/api-handlers/auth/jwks.ts +10 -0
- package/src/api-handlers/auth/login.ts +5 -2
- package/src/api-handlers/auth/passkey-signin.ts +112 -98
- package/src/api-handlers/auth/refresh.ts +112 -15
- package/src/api-handlers/auth/signout.ts +4 -3
- package/src/api-handlers/auth/verify-code.ts +5 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
- package/src/api-handlers/mfa/backup-codes.ts +41 -41
- package/src/api-handlers/mfa/methods.ts +46 -46
- package/src/api-handlers/mfa/totp-confirm.ts +42 -42
- package/src/api-handlers/mfa/totp-enroll.ts +42 -42
- package/src/api-handlers/passkeys/list.ts +31 -31
- package/src/api-handlers/passkeys/manage.ts +54 -54
- package/src/api-handlers/passkeys/register-options.ts +42 -42
- package/src/api-handlers/passkeys/register-verify.ts +41 -41
- package/src/api-handlers/session/viability.ts +29 -1
- package/src/api-handlers/test/force-expire.ts +118 -13
- package/src/auth/better-auth.ts +486 -51
- package/src/auth/utils/idp-client.ts +1 -1
- package/src/client/AuthContext.tsx +55 -19
- package/src/components/account/UserAvatarMenu.tsx +11 -0
- package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
- package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
- package/src/components/auth/TraditionalAuthSection.tsx +5 -1
- package/src/hooks/useAuthSettings.ts +14 -2
- package/src/hooks/usePublicAuthSettings.ts +38 -16
- package/src/lib/client-key.ts +88 -0
- package/src/lib/demo-mode.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +5 -1
- package/src/lib/idp-client-config.ts +399 -36
- package/src/lib/idp-fetch.ts +36 -1
- package/src/lib/idp-url-guard.ts +37 -0
- package/src/lib/redis.ts +84 -6
- package/src/lib/session-cookie-shape.ts +184 -0
- package/src/lib/session-store.ts +152 -6
- package/src/lib/single-flight.ts +64 -0
- package/src/lib/token-lifecycle.ts +12 -0
- package/src/middleware/create-middleware.ts +11 -0
- package/src/models/SessionModel.ts +49 -0
- package/src/pages/admin-login/page.tsx +17 -127
- package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
- package/src/pages/security/index.ts +3 -0
- package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
- package/src/pages/test-env/JwtInspectPage.tsx +235 -20
- package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
- package/src/pages/test-env/TestEnvPage.tsx +16 -4
- package/src/pages/test-env/guarded.tsx +81 -0
- package/src/pages/test-env/index.ts +59 -7
- package/src/routes/auth/logout.ts +4 -3
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +30 -1
- package/src/routes/auth/session.ts +98 -9
- package/src/routes/auth/settings.ts +144 -42
- package/src/routes/auth/viability.ts +129 -11
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/server/auth-guard.ts +24 -17
- package/src/server/auth.ts +37 -73
- package/src/server/decode-session.ts +95 -11
- package/src/templates/instrumentation.ts +21 -2
- package/src/types/auth.ts +20 -0
- package/dist/lib/auth-secret.d.ts +0 -17
- package/dist/lib/auth-secret.js +0 -116
- package/src/lib/auth-secret.ts +0 -137
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/* ============================================================================
|
|
3
|
+
* ONE VALUE, ONE VARIABLE NAME. CLIENT_ID.
|
|
4
|
+
*
|
|
5
|
+
* DO NOT INTRODUCE A SECOND ENV VAR CARRYING A VALUE THAT ALREADY HAS ONE.
|
|
6
|
+
* Not with a prefix. Not "just for the client side". Not "temporarily".
|
|
7
|
+
*
|
|
8
|
+
* This value had THREE names before 2026-08-26:
|
|
9
|
+
* CLIENT_ID read by 8 server files - the real one
|
|
10
|
+
* NEXT_PUBLIC_CLIENT_ID read as a fallback in 6 places, ALL server-side
|
|
11
|
+
* NEXT_CLIENT_ID read by lib/demo-mode.ts, and set by dev-broker.ps1,
|
|
12
|
+
* which meant BROKER MODE NEVER PASSED THE CLIENT ID
|
|
13
|
+
* TO THE CONFIG AT ALL. The bug was the extra name.
|
|
14
|
+
*
|
|
15
|
+
* The NEXT_PUBLIC_ prefix exists so Next inlines a value into the BROWSER bundle.
|
|
16
|
+
* Nothing in the browser ever read it. So it bought nothing and merely LOOKED
|
|
17
|
+
* justified - which is worse than an obviously arbitrary name, because it stops
|
|
18
|
+
* the next person questioning it.
|
|
19
|
+
*
|
|
20
|
+
* IF YOU NEED THIS VALUE SOMEWHERE NEW, READ CLIENT_ID. If you genuinely need it
|
|
21
|
+
* in browser code, that is a real Next constraint - raise it, do not quietly add
|
|
22
|
+
* a second spelling and leave the collision for someone else to measure.
|
|
23
|
+
* ============================================================================ */
|
|
2
24
|
/**
|
|
3
25
|
* IDP Client Configuration
|
|
4
26
|
*
|
|
@@ -18,9 +40,39 @@
|
|
|
18
40
|
*
|
|
19
41
|
* @version 2.0.0 - Added Redis-backed caching
|
|
20
42
|
*/
|
|
21
|
-
var
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
44
|
+
if (k2 === undefined) k2 = k;
|
|
45
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
46
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
47
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
48
|
+
}
|
|
49
|
+
Object.defineProperty(o, k2, desc);
|
|
50
|
+
}) : (function(o, m, k, k2) {
|
|
51
|
+
if (k2 === undefined) k2 = k;
|
|
52
|
+
o[k2] = m[k];
|
|
53
|
+
}));
|
|
54
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
55
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
56
|
+
}) : function(o, v) {
|
|
57
|
+
o["default"] = v;
|
|
58
|
+
});
|
|
59
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
60
|
+
var ownKeys = function(o) {
|
|
61
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
62
|
+
var ar = [];
|
|
63
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
64
|
+
return ar;
|
|
65
|
+
};
|
|
66
|
+
return ownKeys(o);
|
|
67
|
+
};
|
|
68
|
+
return function (mod) {
|
|
69
|
+
if (mod && mod.__esModule) return mod;
|
|
70
|
+
var result = {};
|
|
71
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
72
|
+
__setModuleDefault(result, mod);
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
})();
|
|
24
76
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
77
|
exports.getIDPClientConfig = getIDPClientConfig;
|
|
26
78
|
exports.clearConfigCache = clearConfigCache;
|
|
@@ -28,10 +80,66 @@ exports.clearConfigRedisCache = clearConfigRedisCache;
|
|
|
28
80
|
exports.getEnabledProviders = getEnabledProviders;
|
|
29
81
|
require("server-only");
|
|
30
82
|
const crypto_1 = require("crypto");
|
|
31
|
-
|
|
83
|
+
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
84
|
+
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
85
|
+
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
86
|
+
// hook for the EDGE runtime as well as node, and ioredis internally does
|
|
87
|
+
// require('stream'), which does not exist on edge. The result was a BUILD ERROR in the
|
|
88
|
+
// browser overlay for a code path the edge build never executes.
|
|
89
|
+
//
|
|
90
|
+
// The NEXT_RUNTIME check in instrumentation.ts is a RUNTIME guard; the bundler still
|
|
91
|
+
// traces the import statically. Deferring the import to first use is what actually
|
|
92
|
+
// keeps ioredis out of the graph. All four call sites below are already async.
|
|
93
|
+
let _redisMod = null;
|
|
94
|
+
async function getRedis() {
|
|
95
|
+
if (!_redisMod)
|
|
96
|
+
_redisMod = (await Promise.resolve().then(() => __importStar(require('./redis')))).default;
|
|
97
|
+
return _redisMod;
|
|
98
|
+
}
|
|
32
99
|
// ============================================================================
|
|
33
100
|
// Cache & Fetch Deduplication
|
|
34
101
|
// ============================================================================
|
|
102
|
+
/**
|
|
103
|
+
* 261588 - THE OPERATOR'S SECRET, CAPTURED BEFORE THIS MODULE CAN CLOBBER IT.
|
|
104
|
+
*
|
|
105
|
+
* startup-init.ts:129 defers to an operator-set BETTER_AUTH_SECRET, and its sibling
|
|
106
|
+
* comment says that override is INTENDED ('allows deployment override for beta/staging').
|
|
107
|
+
* The config-refresh paths in this file then overwrote it minutes later, so a deliberate
|
|
108
|
+
* deployment override survived startup and was silently swapped on the first refresh -
|
|
109
|
+
* sessions signed before and after the swap carry DIFFERENT secrets. A real guard
|
|
110
|
+
* neutralised by a later path that did not get the memo.
|
|
111
|
+
*
|
|
112
|
+
* *** WHY THIS IS A MODULE-LOAD SNAPSHOT AND NOT `if (!process.env.BETTER_AUTH_SECRET)`. ***
|
|
113
|
+
* That naive guard is what the cache path uses (249808). It fixes the override, but it
|
|
114
|
+
* also means that once ANY refresh has populated the variable, NO later refresh can ever
|
|
115
|
+
* update it - so an IDP-side secret ROTATION silently stops propagating and the app keeps
|
|
116
|
+
* signing with a retired key. That trades one silent failure for another.
|
|
117
|
+
*
|
|
118
|
+
* Snapshotting at module load separates the two cases properly:
|
|
119
|
+
* operator set it -> never overwritten, for the process lifetime (the override is real)
|
|
120
|
+
* operator did not -> every refresh may publish, so rotation still works
|
|
121
|
+
*
|
|
122
|
+
* This is read ONCE, at import, before any function here has had a chance to write.
|
|
123
|
+
*/
|
|
124
|
+
const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
|
125
|
+
const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
|
126
|
+
let operatorOverrideLogged = false;
|
|
127
|
+
/** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
|
|
128
|
+
function publishAuthSecret(value, source) {
|
|
129
|
+
if (OPERATOR_AUTH_SECRET) {
|
|
130
|
+
if (!operatorOverrideLogged) {
|
|
131
|
+
operatorOverrideLogged = true;
|
|
132
|
+
console.log(`[IDP_CONFIG] BETTER_AUTH_SECRET was set by the deployment; keeping it and ignoring the ${source} value (261588). ` +
|
|
133
|
+
'Unset it if you want the IDP to supply the signing secret.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
process.env.BETTER_AUTH_SECRET = value;
|
|
138
|
+
}
|
|
139
|
+
if (!OPERATOR_NEXTAUTH_SECRET) {
|
|
140
|
+
process.env.NEXTAUTH_SECRET = value;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
35
143
|
let cachedConfig = null;
|
|
36
144
|
let cacheExpiry = 0;
|
|
37
145
|
let pendingFetch = null; // Prevents parallel fetches
|
|
@@ -39,20 +147,79 @@ let pendingFetch = null; // Prevents parallel fetches
|
|
|
39
147
|
// Redis Cache Configuration
|
|
40
148
|
// ============================================================================
|
|
41
149
|
const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
|
|
150
|
+
/**
|
|
151
|
+
* 263437 - THE CACHE KEY MUST NAME THE BACKEND THE VALUE CAME FROM.
|
|
152
|
+
*
|
|
153
|
+
* The key was `idp_config:{CLIENT_ID}` - client slug ONLY. The cached value is fully
|
|
154
|
+
* determined by (backend origin, clientId): Layer 3 below fetches from
|
|
155
|
+
* INTERNAL_IDP_URL || IDP_URL for CLIENT_ID. Two instances pointed at DIFFERENT IDP
|
|
156
|
+
* backends but carrying the same slug are therefore indistinguishable to this cache,
|
|
157
|
+
* and whichever writes last serves BOTH - with ITS backend's client scope.
|
|
158
|
+
*
|
|
159
|
+
* WHY NOT REDIS_INSTANCE_ID (the originally proposed discriminator):
|
|
160
|
+
* it names the STORE, not the backend. Two legs on separate stores cannot collide in
|
|
161
|
+
* the first place; put them on ONE store - the shape this fix exists to survive - and
|
|
162
|
+
* they report the SAME instance id, so the keys recollide and the bug returns with a
|
|
163
|
+
* fix sitting in front of it. A discriminator that is only distinct in the
|
|
164
|
+
* configuration where the collision is already impossible discriminates nothing.
|
|
165
|
+
*
|
|
166
|
+
* SCOPE NOTE, so this is not over-read the way 273385 was: an undiscriminated key is a
|
|
167
|
+
* LATENT hazard, not the explanation for any wrong-tenant symptom measured on the walk
|
|
168
|
+
* rigs. Measured 2026-08-28 from three seats - the 93 and AKS legs were never on one
|
|
169
|
+
* store, and each backend independently returns the same blob for this slug. If you are
|
|
170
|
+
* chasing a wrong clientId, the value came from the IDP record, not from this cache.
|
|
171
|
+
*/
|
|
172
|
+
function getConfigBackendTag() {
|
|
173
|
+
// Must mirror Layer 3's selection EXACTLY (INTERNAL_IDP_URL wins), or the key would
|
|
174
|
+
// name a backend the value did not come from.
|
|
175
|
+
const rawBackend = process.env.INTERNAL_IDP_URL || process.env.IDP_URL;
|
|
176
|
+
if (!rawBackend) {
|
|
177
|
+
throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set - the config cache key names the backend the cached value came from');
|
|
178
|
+
}
|
|
179
|
+
// ORIGIN, not the full URL: a path or trailing-slash difference is not a different
|
|
180
|
+
// backend, and letting it fork the key would silently halve the cache hit rate - a
|
|
181
|
+
// quieter bug in the same family. An unparseable value is used VERBATIM rather than
|
|
182
|
+
// collapsed to a constant, which would reintroduce the collision it is meant to stop.
|
|
183
|
+
let backendIdentity;
|
|
184
|
+
try {
|
|
185
|
+
backendIdentity = new URL(rawBackend).origin.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
backendIdentity = rawBackend.trim().toLowerCase();
|
|
189
|
+
}
|
|
190
|
+
// A short digest, not the raw origin: keeps keys bounded and does not publish the
|
|
191
|
+
// internal topology to anyone holding KEYS on a shared store.
|
|
192
|
+
return (0, crypto_1.createHash)('sha256').update(backendIdentity).digest('hex').slice(0, 12);
|
|
193
|
+
}
|
|
42
194
|
function getRedisConfigKey() {
|
|
43
|
-
|
|
44
|
-
|
|
195
|
+
// ONE ENV VAR: CLIENT_ID. Collapsed 2026-08-26.
|
|
196
|
+
// There were three names for this value - CLIENT_ID, NEXT_PUBLIC_CLIENT_ID and
|
|
197
|
+
// NEXT_CLIENT_ID. The NEXT_PUBLIC_ prefix exists so Next inlines a value into the
|
|
198
|
+
// BROWSER bundle; nothing in the browser ever read it, so it bought nothing and
|
|
199
|
+
// merely looked justified. Verified before removal: all 7 consuming sites set
|
|
200
|
+
// CLIENT_ID in their env; none relied on the fallback.
|
|
201
|
+
//
|
|
202
|
+
// AND IT MUST THROW, NOT DEFAULT. This clientId is the REDIS CACHE KEY. The old
|
|
203
|
+
// `|| 'default'` meant an app that failed to set the id did not fail - it read and
|
|
204
|
+
// wrote config under `idp_config:default`, silently sharing one cache slot with
|
|
205
|
+
// every other misconfigured app, while Layer 3 THREW for that same missing value.
|
|
206
|
+
// Two behaviours for one missing variable, and the silent one owned the cache.
|
|
207
|
+
const clientId = process.env.CLIENT_ID;
|
|
208
|
+
if (!clientId) {
|
|
209
|
+
throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID must be set - it is the config cache key, and defaulting it shares one cache entry across misconfigured apps');
|
|
210
|
+
}
|
|
211
|
+
return `${REDIS_CONFIG_KEY_PREFIX}${clientId}:${getConfigBackendTag()}`;
|
|
45
212
|
}
|
|
46
213
|
async function getConfigFromRedis() {
|
|
47
214
|
try {
|
|
48
215
|
const key = getRedisConfigKey();
|
|
49
|
-
const cached = await
|
|
216
|
+
const cached = await (await getRedis()).get(key);
|
|
50
217
|
if (!cached)
|
|
51
218
|
return null;
|
|
52
219
|
const parsed = JSON.parse(cached);
|
|
53
220
|
if (Date.now() >= parsed.expiresAt) {
|
|
54
221
|
// Expired, delete it
|
|
55
|
-
await
|
|
222
|
+
await (await getRedis()).del(key);
|
|
56
223
|
return null;
|
|
57
224
|
}
|
|
58
225
|
return parsed.config;
|
|
@@ -71,7 +238,7 @@ async function setConfigInRedis(config) {
|
|
|
71
238
|
expiresAt: Date.now() + (ttlSeconds * 1000)
|
|
72
239
|
};
|
|
73
240
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
74
|
-
await
|
|
241
|
+
await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
|
|
75
242
|
}
|
|
76
243
|
catch (error) {
|
|
77
244
|
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
|
|
@@ -101,7 +268,19 @@ const MAX_BACKOFF_MS = 30000; // 30 seconds max backoff
|
|
|
101
268
|
async function getIDPClientConfig(forceRefresh = false) {
|
|
102
269
|
const now = Date.now();
|
|
103
270
|
// Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
|
|
104
|
-
|
|
271
|
+
//
|
|
272
|
+
// The `cachedConfig.authSecret` term is DEFENCE IN DEPTH, not redundancy.
|
|
273
|
+
// Both writers are validated (the fetch path checks before caching; the
|
|
274
|
+
// Redis path requires a non-empty secret to count as a hit), so in principle
|
|
275
|
+
// an empty config can no longer be cached. This makes that property TRUE AT
|
|
276
|
+
// THE POINT OF USE instead of only provable by reading the whole file —
|
|
277
|
+
// DotNetPert 21184 and DotNetPert-Scout 21178 both argued for guarding the
|
|
278
|
+
// return, and they are right that it is the shape that cannot drift: a
|
|
279
|
+
// future third writer, or a cache restored from an older build's Redis
|
|
280
|
+
// entry, is caught here regardless of what the writers do.
|
|
281
|
+
// The cost is one boolean; the failure it prevents is every session signed
|
|
282
|
+
// with a secret published on npm.
|
|
283
|
+
if (!forceRefresh && cachedConfig && cachedConfig.authSecret && now < cacheExpiry) {
|
|
105
284
|
return cachedConfig;
|
|
106
285
|
}
|
|
107
286
|
// If a fetch is already in progress, wait for it instead of starting another
|
|
@@ -111,16 +290,98 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
111
290
|
// Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
|
|
112
291
|
if (!forceRefresh) {
|
|
113
292
|
const redisConfig = await getConfigFromRedis();
|
|
114
|
-
if
|
|
293
|
+
// A cached entry is only USABLE if it carries a secret — see the note
|
|
294
|
+
// below. `redisConfig && !authSecret` must behave like a cache MISS, so
|
|
295
|
+
// the emptiness test belongs here, in the condition that gates the
|
|
296
|
+
// `return`, and not merely around the assignment inside it.
|
|
297
|
+
// ── 249808 FOLLOW-THROUGH: THE CACHE MAY NEVER *ESTABLISH* THE SECRET. ──
|
|
298
|
+
//
|
|
299
|
+
// The rule stated just above - "a cached entry is only USABLE if it
|
|
300
|
+
// carries a secret" - was right and did not go far enough. The cache
|
|
301
|
+
// must also never be the FIRST source of the signing secret, because
|
|
302
|
+
// this blob is unauthenticated (bare JSON.parse, no MAC, and its own
|
|
303
|
+
// `expiresAt` sits inside the attacker-writable value).
|
|
304
|
+
//
|
|
305
|
+
// Guarding the ASSIGNMENT (the earlier 4.1.39 fix) protects a secret
|
|
306
|
+
// that already exists. It does nothing when none exists yet - and that
|
|
307
|
+
// window is real: the graft CATCHES the FATAL that startup-init throws
|
|
308
|
+
// when the IDP is unreachable at boot (graft instrumentation.ts:65-69,
|
|
309
|
+
// "BETTER_AUTH_SECRET may be unavailable until then"), so the app can
|
|
310
|
+
// serve requests with no secret established. The first request would
|
|
311
|
+
// then take the signing key from the cache - which is the whole
|
|
312
|
+
// forgery vector, arriving through the back door of a fix that had
|
|
313
|
+
// closed the front one.
|
|
314
|
+
//
|
|
315
|
+
// So: with no trusted secret in hand, THIS CACHE ENTRY IS A MISS. Fall
|
|
316
|
+
// through to Layer 3, the IDP fetch, which is authoritative and already
|
|
317
|
+
// guarded to throw on an empty secret. Escalation to truth, exactly as
|
|
318
|
+
// the empty-secret case above does.
|
|
319
|
+
//
|
|
320
|
+
// Accepts either name, matching startup-init.ts:147 which verifies
|
|
321
|
+
// `BETTER_AUTH_SECRET || NEXTAUTH_SECRET` during the rename transition.
|
|
322
|
+
//
|
|
323
|
+
// COST, bounded: only while no secret is established does this force an
|
|
324
|
+
// IDP round trip. startup-init calls getIDPClientConfig(TRUE) and so
|
|
325
|
+
// skips Layer 2 regardless, meaning normal boot is unaffected; once the
|
|
326
|
+
// secret exists the cache serves every non-forced caller as before. The
|
|
327
|
+
// only behaviour that changes is in the window where the alternative was
|
|
328
|
+
// trusting an unauthenticated store for a signing key.
|
|
329
|
+
const haveTrustedSecret = !!(process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET);
|
|
330
|
+
if (redisConfig && redisConfig.authSecret && haveTrustedSecret) {
|
|
115
331
|
// Restore to in-memory cache
|
|
116
332
|
cachedConfig = redisConfig;
|
|
117
333
|
cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
|
|
118
334
|
// Set BETTER_AUTH_SECRET from cached config (also set legacy
|
|
119
335
|
// NEXTAUTH_SECRET during the rename transition).
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
336
|
+
//
|
|
337
|
+
// ⚠ THE EMPTY CASE IS NOT A NO-OP — IT WAS, AND THAT WAS THE HOLE.
|
|
338
|
+
// The three FETCH paths (:225 external, :353 internal, post-parse)
|
|
339
|
+
// throw on an empty authSecret, because an empty secret reaching
|
|
340
|
+
// betterAuth() is silently replaced by the PUBLIC DEFAULT_SECRET
|
|
341
|
+
// (create-context.mjs:79) rather than rejected. This path used to
|
|
342
|
+
// just skip the assignment and `return redisConfig` below — so a
|
|
343
|
+
// cached config with an empty secret bypassed ALL THREE guards, and
|
|
344
|
+
// it is checked FIRST, before any of them. Redis persistence made it
|
|
345
|
+
// durable across restarts. Found by QAPert (21175) after I claimed
|
|
346
|
+
// the guards covered every path; they covered every FETCH path.
|
|
347
|
+
//
|
|
348
|
+
// Resolution is deliberately NOT a throw: a corrupt cache entry
|
|
349
|
+
// should not take the app down when the authoritative source is one
|
|
350
|
+
// call away. Treat it as a MISS and fall through to the fetch paths,
|
|
351
|
+
// which are guarded and will throw if the IDP is genuinely empty.
|
|
352
|
+
// That is escalation to truth, not a fallback that weakens the check.
|
|
353
|
+
// ── 249808: AN EXPLICIT BETTER_AUTH_SECRET NOW WINS OVER THE CACHE. ──
|
|
354
|
+
//
|
|
355
|
+
// These two assignments were UNCONDITIONAL. That made the Redis
|
|
356
|
+
// cache authoritative for the SESSION SIGNING KEY, and the cache is
|
|
357
|
+
// not a trusted input: getConfigFromRedis() does a bare JSON.parse
|
|
358
|
+
// with no signature or MAC, and its only freshness check is an
|
|
359
|
+
// `expiresAt` field that lives INSIDE the same attacker-writable
|
|
360
|
+
// blob. So anyone who could write that key chose the signing secret,
|
|
361
|
+
// and knowing the secret means minting a validly-signed session for
|
|
362
|
+
// any user — including one who has never logged in. Forgery, not
|
|
363
|
+
// theft: it survives flushing the store and revoking refresh tokens,
|
|
364
|
+
// because it needs neither.
|
|
365
|
+
//
|
|
366
|
+
// THE FILE ALREADY KNEW THE RIGHT PATTERN. Five lines below, a mere
|
|
367
|
+
// URL defers to an explicitly-configured value, with a comment
|
|
368
|
+
// blessing deployment override. startup-init.ts:129 defers on THIS
|
|
369
|
+
// VERY SECRET (`config.authSecret && !process.env.BETTER_AUTH_SECRET`).
|
|
370
|
+
// The signing key was the one value in the package taking an
|
|
371
|
+
// untrusted cache's word unconditionally — protected less carefully
|
|
372
|
+
// than a base URL.
|
|
373
|
+
//
|
|
374
|
+
// Deferring restores that consistency and makes an explicitly-set
|
|
375
|
+
// BETTER_AUTH_SECRET a REAL secret: a value the deployment owns and
|
|
376
|
+
// the cache cannot override.
|
|
377
|
+
//
|
|
378
|
+
// BOUND, stated so nobody over-reads this: it closes the vector only
|
|
379
|
+
// where BETTER_AUTH_SECRET IS ACTUALLY SET. Where it is unset the
|
|
380
|
+
// cache still supplies it and the exposure stands — so this is
|
|
381
|
+
// necessary, cheap and strictly-better, NOT sufficient on its own.
|
|
382
|
+
// Locking the store and setting the secret explicitly in every
|
|
383
|
+
// deployment remain required.
|
|
384
|
+
publishAuthSecret(redisConfig.authSecret, 'Redis-cached IDP config');
|
|
124
385
|
// Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
|
|
125
386
|
// Only set if not already defined (allows deployment override for beta/staging).
|
|
126
387
|
if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
|
|
@@ -132,9 +393,10 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
132
393
|
// Layer 3: Fetch from IDP
|
|
133
394
|
const internalIdpUrl = process.env.INTERNAL_IDP_URL;
|
|
134
395
|
const idpUrl = process.env.IDP_URL;
|
|
135
|
-
|
|
396
|
+
// CLIENT_ID only - see the note at the cache-key site above.
|
|
397
|
+
const clientIdStr = process.env.CLIENT_ID;
|
|
136
398
|
if (!clientIdStr) {
|
|
137
|
-
throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID
|
|
399
|
+
throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID must be set');
|
|
138
400
|
}
|
|
139
401
|
if (!internalIdpUrl && !idpUrl) {
|
|
140
402
|
throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
|
|
@@ -145,20 +407,43 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
145
407
|
: fetchConfigFromIDP(idpUrl, clientIdStr);
|
|
146
408
|
pendingFetch = fetcher
|
|
147
409
|
.then(async (config) => {
|
|
410
|
+
// ── VALIDATE BEFORE CACHING. ORDER IS THE WHOLE FIX. ────────────
|
|
411
|
+
// This block used to assign cachedConfig and await setConfigInRedis
|
|
412
|
+
// FIRST and throw afterwards — so a response with an empty secret
|
|
413
|
+
// poisoned BOTH caches on its way to failing. The throw only stopped
|
|
414
|
+
// THAT call: the next one hit the in-memory layer (`return
|
|
415
|
+
// cachedConfig`), which has no guard because it is a cache read, and
|
|
416
|
+
// handed the empty config to betterAuth() — where empty silently
|
|
417
|
+
// becomes the public DEFAULT_SECRET. Redis made it survive restarts.
|
|
418
|
+
//
|
|
419
|
+
// Guarding every cached RETURN would also work, and DotNetPert-Scout
|
|
420
|
+
// (21178) is right that it is the more robust shape in general. But
|
|
421
|
+
// there are four such returns and one writer, and the invariant we
|
|
422
|
+
// actually want is "an unvalidated config is never cached at all".
|
|
423
|
+
// Enforce it where it is cheapest to keep true.
|
|
424
|
+
if (!config.authSecret) {
|
|
425
|
+
// ⚠ LOAD-BEARING THROW — DO NOT SOFTEN TO A DEFAULT OR A WARNING.
|
|
426
|
+
// better-auth does NOT treat an empty secret as missing: at
|
|
427
|
+
// create-context.mjs:79 it substitutes DEFAULT_SECRET
|
|
428
|
+
// ("better-auth-secret-12345678901234567890"), which ships in the
|
|
429
|
+
// PUBLIC npm package, and its !secret guard can then never fire.
|
|
430
|
+
// The only remaining protection there is an isProduction gate — so
|
|
431
|
+
// outside production an empty secret means every session is signed
|
|
432
|
+
// with a string anyone can read off npm and forge.
|
|
433
|
+
// This throw is what stops that. It is also what turned the
|
|
434
|
+
// 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
|
|
435
|
+
// of a silently forgeable app.
|
|
436
|
+
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
|
|
437
|
+
}
|
|
438
|
+
// Validated above — only now is it safe to cache or publish.
|
|
439
|
+
publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
|
|
148
440
|
// Cache with TTL from response (default 5 minutes)
|
|
149
441
|
cachedConfig = config;
|
|
150
442
|
cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
|
|
151
|
-
// Store in Redis for persistence across module reloads
|
|
443
|
+
// Store in Redis for persistence across module reloads. This is the
|
|
444
|
+
// write that made a bad config outlive the process, so it must stay
|
|
445
|
+
// BELOW the guard, never above it.
|
|
152
446
|
await setConfigInRedis(config);
|
|
153
|
-
// Set BETTER_AUTH_SECRET from config (also set legacy
|
|
154
|
-
// NEXTAUTH_SECRET during the rename transition).
|
|
155
|
-
if (config.authSecret) {
|
|
156
|
-
process.env.BETTER_AUTH_SECRET = config.authSecret;
|
|
157
|
-
process.env.NEXTAUTH_SECRET = config.authSecret;
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
|
|
161
|
-
}
|
|
162
447
|
// Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
|
|
163
448
|
// Only set if not already defined (allows deployment override for beta/staging).
|
|
164
449
|
if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
|
|
@@ -185,7 +470,7 @@ function clearConfigCache() {
|
|
|
185
470
|
async function clearConfigRedisCache() {
|
|
186
471
|
try {
|
|
187
472
|
const key = getRedisConfigKey();
|
|
188
|
-
await
|
|
473
|
+
await (await getRedis()).del(key);
|
|
189
474
|
console.log('[IDP_CONFIG] Redis cache cleared:', key);
|
|
190
475
|
}
|
|
191
476
|
catch (error) {
|
|
@@ -249,8 +534,13 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
249
534
|
additionalParams: p.additionalParams ?? p.additional_params
|
|
250
535
|
})),
|
|
251
536
|
authSettings: {
|
|
252
|
-
|
|
253
|
-
|
|
537
|
+
// 257255: terminal `?? true` / `?? ['email','sms']` REMOVED. They
|
|
538
|
+
// made a document with NO auth policy indistinguishable from one
|
|
539
|
+
// carrying exactly those values, one layer below the route that
|
|
540
|
+
// reports policy - so even a corrected settings.ts would have
|
|
541
|
+
// validated a fabrication. Undefined now means "not carried".
|
|
542
|
+
require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa,
|
|
543
|
+
allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
|
|
254
544
|
mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
|
|
255
545
|
mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
|
|
256
546
|
sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
|
|
@@ -258,7 +548,25 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
258
548
|
allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
|
|
259
549
|
rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
|
|
260
550
|
lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
|
|
261
|
-
lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
|
|
551
|
+
lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
|
|
552
|
+
// 240447: read the tenant policy the IDP already sends. Applied to
|
|
553
|
+
// BOTH parse paths (fetchConfigFromInternalIDP and
|
|
554
|
+
// fetchConfigFromIDP) — fixing one would leave the other silently
|
|
555
|
+
// omitting these, which is the partial-fix trap.
|
|
556
|
+
//
|
|
557
|
+
// Same dual-casing idiom as every field above, but with NO
|
|
558
|
+
// `?? default` tail, deliberately. The others default because a
|
|
559
|
+
// wrong timeout is a nuisance; a wrong registration flag is a LIE
|
|
560
|
+
// about what the tenant permits, and the hardcoded `true` in
|
|
561
|
+
// routes/auth/settings was exactly that lie. `undefined` has to
|
|
562
|
+
// survive to the consumer so it can decide, instead of being
|
|
563
|
+
// handed a guess wearing the costume of a measurement.
|
|
564
|
+
allowPublicRegistration: configData.authSettings?.allowPublicRegistration ??
|
|
565
|
+
configData.auth_settings?.allow_public_registration,
|
|
566
|
+
allowSocialLogin: configData.authSettings?.allowSocialLogin ??
|
|
567
|
+
configData.auth_settings?.allow_social_login,
|
|
568
|
+
enablePasswordReset: configData.authSettings?.enablePasswordReset ??
|
|
569
|
+
configData.auth_settings?.enable_password_reset
|
|
262
570
|
},
|
|
263
571
|
branding: {
|
|
264
572
|
theme: configData.branding?.theme,
|
|
@@ -269,6 +577,9 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
269
577
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
270
578
|
};
|
|
271
579
|
if (!config.authSecret) {
|
|
580
|
+
// LOAD-BEARING — see the note on the external path's equivalent throw.
|
|
581
|
+
// An empty secret reaching betterAuth() becomes the PUBLIC default, not an
|
|
582
|
+
// error. Never replace this with a fallback.
|
|
272
583
|
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
|
|
273
584
|
}
|
|
274
585
|
console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
|
|
@@ -283,7 +594,12 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
283
594
|
const timeSinceFailure = Date.now() - lastFailureTime;
|
|
284
595
|
if (timeSinceFailure < CIRCUIT_OPEN_MS) {
|
|
285
596
|
// Circuit is open - return stale cache if available
|
|
286
|
-
|
|
597
|
+
// Stale cache is acceptable here; a stale config with NO SECRET is
|
|
598
|
+
// not. These two paths fire exactly when the IDP is unreachable —
|
|
599
|
+
// the worst moment to hand betterAuth() an empty secret, which it
|
|
600
|
+
// silently upgrades to the public DEFAULT_SECRET. Falling through to
|
|
601
|
+
// a hard failure is correct: unavailable beats forgeable.
|
|
602
|
+
if (cachedConfig && cachedConfig.authSecret) {
|
|
287
603
|
return cachedConfig;
|
|
288
604
|
}
|
|
289
605
|
throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
|
|
@@ -300,7 +616,12 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
300
616
|
if (timeSinceFailure < backoffMs) {
|
|
301
617
|
const remainingMs = backoffMs - timeSinceFailure;
|
|
302
618
|
// Return stale cache during backoff if available
|
|
303
|
-
|
|
619
|
+
// Stale cache is acceptable here; a stale config with NO SECRET is
|
|
620
|
+
// not. These two paths fire exactly when the IDP is unreachable —
|
|
621
|
+
// the worst moment to hand betterAuth() an empty secret, which it
|
|
622
|
+
// silently upgrades to the public DEFAULT_SECRET. Falling through to
|
|
623
|
+
// a hard failure is correct: unavailable beats forgeable.
|
|
624
|
+
if (cachedConfig && cachedConfig.authSecret) {
|
|
304
625
|
return cachedConfig;
|
|
305
626
|
}
|
|
306
627
|
throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
|
|
@@ -406,9 +727,13 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
406
727
|
console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
|
|
407
728
|
return topLevel;
|
|
408
729
|
}
|
|
409
|
-
return true
|
|
730
|
+
// 257255: was `return true`. A default here is a FABRICATED
|
|
731
|
+
// POLICY - QAPert measured the same client as require_2fa TRUE
|
|
732
|
+
// on AKS and FALSE on dev-93, so any constant is wrong in one
|
|
733
|
+
// environment by construction. Undefined = "not carried".
|
|
734
|
+
return undefined;
|
|
410
735
|
})(),
|
|
411
|
-
allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods
|
|
736
|
+
allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
|
|
412
737
|
mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
|
|
413
738
|
mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
|
|
414
739
|
sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
|
|
@@ -416,7 +741,25 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
416
741
|
allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
|
|
417
742
|
rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
|
|
418
743
|
lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
|
|
419
|
-
lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
|
|
744
|
+
lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
|
|
745
|
+
// 240447: read the tenant policy the IDP already sends. Applied to
|
|
746
|
+
// BOTH parse paths (fetchConfigFromInternalIDP and
|
|
747
|
+
// fetchConfigFromIDP) — fixing one would leave the other silently
|
|
748
|
+
// omitting these, which is the partial-fix trap.
|
|
749
|
+
//
|
|
750
|
+
// Same dual-casing idiom as every field above, but with NO
|
|
751
|
+
// `?? default` tail, deliberately. The others default because a
|
|
752
|
+
// wrong timeout is a nuisance; a wrong registration flag is a LIE
|
|
753
|
+
// about what the tenant permits, and the hardcoded `true` in
|
|
754
|
+
// routes/auth/settings was exactly that lie. `undefined` has to
|
|
755
|
+
// survive to the consumer so it can decide, instead of being
|
|
756
|
+
// handed a guess wearing the costume of a measurement.
|
|
757
|
+
allowPublicRegistration: configData.authSettings?.allowPublicRegistration ??
|
|
758
|
+
configData.auth_settings?.allow_public_registration,
|
|
759
|
+
allowSocialLogin: configData.authSettings?.allowSocialLogin ??
|
|
760
|
+
configData.auth_settings?.allow_social_login,
|
|
761
|
+
enablePasswordReset: configData.authSettings?.enablePasswordReset ??
|
|
762
|
+
configData.auth_settings?.enable_password_reset
|
|
420
763
|
},
|
|
421
764
|
branding: {
|
|
422
765
|
theme: configData.branding?.theme,
|
|
@@ -433,6 +776,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
433
776
|
throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
|
|
434
777
|
}
|
|
435
778
|
if (!config.authSecret) {
|
|
779
|
+
// LOAD-BEARING — empty must fail here, never downstream. betterAuth()
|
|
780
|
+
// silently substitutes a public constant for an empty secret.
|
|
436
781
|
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
|
|
437
782
|
}
|
|
438
783
|
// Success - reset failure tracking
|
package/dist/lib/idp-fetch.d.ts
CHANGED
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -33,19 +33,37 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.assertAbsoluteIdpUrl = void 0;
|
|
36
37
|
exports.idpFetchJSON = idpFetchJSON;
|
|
37
38
|
const test_aware_get_token_1 = require("./test-aware-get-token");
|
|
38
39
|
const session_store_1 = require("./session-store");
|
|
39
40
|
const internal_api_1 = require("./internal-api");
|
|
41
|
+
const idp_url_guard_1 = require("./idp-url-guard");
|
|
42
|
+
Object.defineProperty(exports, "assertAbsoluteIdpUrl", { enumerable: true, get: function () { return idp_url_guard_1.assertAbsoluteIdpUrl; } });
|
|
40
43
|
function buildHeaders(req, bearer, extra) {
|
|
41
44
|
const headers = {
|
|
42
45
|
Accept: 'application/json',
|
|
43
46
|
'Content-Type': 'application/json',
|
|
44
47
|
...extra,
|
|
45
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* 269334 / R21: forward the FULL X-Forwarded-For chain, do NOT collapse to [0].
|
|
51
|
+
*
|
|
52
|
+
* `.split(',')[0]` took the LEFT-most entry - the value the ORIGINAL client
|
|
53
|
+
* supplied, which is forgeable - and discarded the rest of the chain. That both
|
|
54
|
+
* (a) forwards the attacker-controlled value and (b) destroys the chain the
|
|
55
|
+
* receiving side needs to identify the real client by counting trusted hops from
|
|
56
|
+
* the right. This app cannot itself observe the TCP peer (Next 16.2.12 route
|
|
57
|
+
* handler, no socket), so the correct division of labour is: the IDP's ingress
|
|
58
|
+
* (Envoy, use_remote_address:true) APPENDS the real observed peer, the IDP counts
|
|
59
|
+
* xff_num_trusted_hops from the right, and THIS layer's only job is to not throw
|
|
60
|
+
* the chain away. Forwarding the full value is byte-identical to today for any
|
|
61
|
+
* consumer reading [0], so it cannot regress current behaviour; it only stops
|
|
62
|
+
* discarding the entries the trusted-hop logic will need.
|
|
63
|
+
*/
|
|
46
64
|
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
47
65
|
if (xfwd)
|
|
48
|
-
headers['X-Forwarded-For'] = xfwd
|
|
66
|
+
headers['X-Forwarded-For'] = xfwd;
|
|
49
67
|
const ua = req.headers.get('user-agent');
|
|
50
68
|
if (ua)
|
|
51
69
|
headers['User-Agent'] = ua;
|
|
@@ -117,7 +135,19 @@ async function ensureFreshAccessToken(req) {
|
|
|
117
135
|
async function idpFetchJSON(req, targetUrl, init = {}) {
|
|
118
136
|
let attemptedRefresh = false;
|
|
119
137
|
let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
|
|
138
|
+
(0, idp_url_guard_1.assertAbsoluteIdpUrl)(targetUrl);
|
|
120
139
|
const makeCall = async (bearer) => {
|
|
140
|
+
// NOTE (307290): this fetch is UNGUARDED, and so is the first call to it at
|
|
141
|
+
// the "First attempt" below. Only the JSON PARSE (try/catch immediately
|
|
142
|
+
// after) and the RETRY are guarded. A transport-level failure - DNS, refused,
|
|
143
|
+
// TLS, timeout - therefore throws straight out of idpFetchJSON, past the thin
|
|
144
|
+
// route handlers that call it, and Next answers 500. This is long-standing and
|
|
145
|
+
// NOT introduced by 307290; but 307290's middleware carve-out lets expired-token
|
|
146
|
+
// /api/ requests reach these handlers where middleware previously redirected
|
|
147
|
+
// them away, so it routes a NEW POPULATION at this pre-existing risk. If IDP
|
|
148
|
+
// reachability blips after that change, suspect this line before suspecting the
|
|
149
|
+
// carve-out. Correcting b4b8ffae's message, which wrongly said every risky
|
|
150
|
+
// branch here is try/caught.
|
|
121
151
|
const res = await fetch(targetUrl, {
|
|
122
152
|
...init,
|
|
123
153
|
headers: buildHeaders(req, bearer, init.headers),
|