@payez/next-mvp 4.4.5 → 4.4.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/README.md +10 -3
- package/dist/api/auth-handler.js +5 -3
- package/dist/api-handlers/account/change-password.js +6 -3
- package/dist/api-handlers/account/masked-info.js +2 -1
- package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
- package/dist/api-handlers/account/recovery/forward.js +105 -0
- package/dist/api-handlers/account/recovery/initiate.js +4 -0
- package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
- package/dist/api-handlers/account/recovery/send-code.js +11 -84
- package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
- package/dist/api-handlers/account/recovery/verify-code.js +10 -84
- package/dist/api-handlers/account/reset-password.js +24 -1
- package/dist/api-handlers/account/send-code.d.ts +1 -15
- package/dist/api-handlers/account/send-code.js +3 -6
- package/dist/api-handlers/account/update-phone.js +6 -1
- package/dist/api-handlers/account/validate-password.js +3 -1
- package/dist/api-handlers/account/verify-email.d.ts +1 -17
- package/dist/api-handlers/account/verify-email.js +4 -6
- package/dist/api-handlers/account/verify-sms.d.ts +1 -17
- package/dist/api-handlers/account/verify-sms.js +4 -6
- package/dist/api-handlers/admin/analytics.js +12 -4
- package/dist/api-handlers/admin/audit.js +12 -4
- package/dist/api-handlers/admin/redis-sessions.js +16 -8
- package/dist/api-handlers/admin/sessions.js +14 -6
- package/dist/api-handlers/admin/site-logs.js +39 -16
- package/dist/api-handlers/admin/stats.js +14 -4
- package/dist/api-handlers/admin/users.js +14 -6
- package/dist/api-handlers/admin/vibe-data.js +19 -11
- package/dist/api-handlers/auth/jwks.js +2 -1
- package/dist/api-handlers/auth/login.js +13 -4
- package/dist/api-handlers/auth/passkey-signin.js +6 -0
- package/dist/api-handlers/auth/refresh.js +38 -11
- package/dist/api-handlers/auth/signout.js +2 -1
- package/dist/api-handlers/auth/status.js +4 -1
- package/dist/api-handlers/auth/update-session.d.ts +25 -4
- package/dist/api-handlers/auth/update-session.js +38 -11
- package/dist/api-handlers/auth/validate.js +4 -2
- package/dist/api-handlers/auth/verify-code.js +3 -2
- package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
- package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
- package/dist/api-handlers/mfa/backup-codes.js +2 -1
- package/dist/api-handlers/mfa/methods.js +2 -1
- package/dist/api-handlers/mfa/totp-confirm.js +2 -1
- package/dist/api-handlers/mfa/totp-enroll.js +2 -1
- package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
- package/dist/api-handlers/mfa/totp-verify.js +4 -6
- package/dist/api-handlers/passkeys/list.js +2 -1
- package/dist/api-handlers/passkeys/manage.js +2 -1
- package/dist/api-handlers/passkeys/register-options.js +2 -1
- package/dist/api-handlers/passkeys/register-verify.js +2 -1
- package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
- package/dist/api-handlers/session/refresh-viability.js +10 -7
- package/dist/api-handlers/session/viability.js +4 -3
- package/dist/api-handlers/test/force-expire.js +5 -3
- package/dist/auth/auth-decision.js +2 -1
- package/dist/auth/better-auth.d.ts +14 -0
- package/dist/auth/better-auth.js +35 -49
- package/dist/auth/utils/idp-client.js +2 -1
- package/dist/components/admin/DataBrowserTab.js +4 -1
- package/dist/components/admin/SessionsTab.js +9 -3
- package/dist/components/recovery/RequestedStep.d.ts +11 -0
- package/dist/components/recovery/RequestedStep.js +14 -0
- package/dist/components/recovery/SetPasswordStep.js +10 -1
- package/dist/lib/app-slug.d.ts +21 -0
- package/dist/lib/app-slug.js +51 -0
- package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
- package/dist/lib/ensure-fresh-access-token.js +105 -4
- package/dist/lib/idp-client-config.d.ts +20 -0
- package/dist/lib/idp-client-config.js +66 -45
- package/dist/lib/idp-fetch.js +1 -1
- package/dist/lib/password-rules.d.ts +22 -0
- package/dist/lib/password-rules.js +43 -0
- package/dist/lib/safe-error-response.d.ts +67 -0
- package/dist/lib/safe-error-response.js +130 -0
- package/dist/lib/session-cookie-shape.d.ts +22 -19
- package/dist/lib/session-cookie-shape.js +14 -18
- package/dist/lib/session-store.d.ts +8 -1
- package/dist/lib/session-store.js +170 -24
- package/dist/lib/startup-init.d.ts +6 -0
- package/dist/lib/startup-init.js +30 -4
- package/dist/lib/token-lifecycle.js +2 -1
- package/dist/lib/token-log-id.d.ts +18 -0
- package/dist/lib/token-log-id.js +84 -0
- package/dist/lib/two-factor-verify-failure.d.ts +45 -0
- package/dist/lib/two-factor-verify-failure.js +143 -0
- package/dist/middleware/create-middleware.d.ts +2 -1
- package/dist/middleware/create-middleware.js +43 -8
- package/dist/pages/recovery/page.js +14 -4
- package/dist/pages/test-env/JwtInspectPage.js +7 -149
- package/dist/pages/test-env/RefreshTokenPage.js +4 -1
- package/dist/pages/test-env/redact-credentials.d.ts +6 -0
- package/dist/pages/test-env/redact-credentials.js +101 -0
- package/dist/pages/verify-code/page.js +2 -1
- package/dist/routes/auth/logout.d.ts +2 -1
- package/dist/routes/auth/logout.js +4 -2
- package/dist/routes/auth/session.d.ts +0 -3
- package/dist/routes/auth/session.js +16 -10
- package/dist/routes/auth/viability.d.ts +3 -2
- package/dist/routes/auth/viability.js +11 -7
- package/dist/server/auth.js +10 -6
- package/dist/server/decode-session.js +15 -26
- package/dist/server/verified-session-token.d.ts +13 -0
- package/dist/server/verified-session-token.js +103 -0
- package/dist/stores/authStore.js +14 -3
- package/dist/types/recovery.d.ts +1 -1
- package/dist/utils/api.d.ts +18 -0
- package/dist/utils/api.js +51 -9
- package/dist/vibe/sessions.js +2 -1
- package/package.json +2 -1
- package/scripts/verify-release-provenance.mjs +410 -0
- package/src/api/auth-handler.ts +5 -3
- package/src/api-handlers/account/change-password.ts +6 -3
- package/src/api-handlers/account/masked-info.ts +2 -1
- package/src/api-handlers/account/recovery/forward.ts +117 -0
- package/src/api-handlers/account/recovery/initiate.ts +3 -0
- package/src/api-handlers/account/recovery/send-code.ts +13 -99
- package/src/api-handlers/account/recovery/verify-code.ts +12 -99
- package/src/api-handlers/account/reset-password.ts +30 -1
- package/src/api-handlers/account/send-code.ts +3 -9
- package/src/api-handlers/account/update-phone.ts +6 -1
- package/src/api-handlers/account/validate-password.ts +3 -1
- package/src/api-handlers/account/verify-email.ts +4 -9
- package/src/api-handlers/account/verify-sms.ts +4 -9
- package/src/api-handlers/admin/analytics.ts +13 -5
- package/src/api-handlers/admin/audit.ts +13 -5
- package/src/api-handlers/admin/redis-sessions.ts +16 -8
- package/src/api-handlers/admin/sessions.ts +15 -7
- package/src/api-handlers/admin/site-logs.ts +39 -16
- package/src/api-handlers/admin/stats.ts +15 -5
- package/src/api-handlers/admin/users.ts +15 -7
- package/src/api-handlers/admin/vibe-data.ts +20 -12
- package/src/api-handlers/auth/jwks.ts +2 -1
- package/src/api-handlers/auth/login.ts +13 -4
- package/src/api-handlers/auth/passkey-signin.ts +4 -0
- package/src/api-handlers/auth/refresh.ts +38 -11
- package/src/api-handlers/auth/signout.ts +2 -1
- package/src/api-handlers/auth/status.ts +4 -1
- package/src/api-handlers/auth/update-session.ts +45 -11
- package/src/api-handlers/auth/validate.ts +4 -2
- package/src/api-handlers/auth/verify-code.ts +3 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
- package/src/api-handlers/mfa/backup-codes.ts +2 -1
- package/src/api-handlers/mfa/methods.ts +2 -1
- package/src/api-handlers/mfa/totp-confirm.ts +2 -1
- package/src/api-handlers/mfa/totp-enroll.ts +2 -1
- package/src/api-handlers/mfa/totp-verify.ts +4 -9
- package/src/api-handlers/passkeys/list.ts +2 -1
- package/src/api-handlers/passkeys/manage.ts +2 -1
- package/src/api-handlers/passkeys/register-options.ts +2 -1
- package/src/api-handlers/passkeys/register-verify.ts +2 -1
- package/src/api-handlers/session/refresh-viability.ts +10 -7
- package/src/api-handlers/session/viability.ts +4 -3
- package/src/api-handlers/test/force-expire.ts +5 -3
- package/src/auth/auth-decision.ts +2 -1
- package/src/auth/better-auth.ts +37 -55
- package/src/auth/utils/idp-client.ts +2 -1
- package/src/components/admin/DataBrowserTab.tsx +4 -1
- package/src/components/admin/SessionsTab.tsx +10 -3
- package/src/components/recovery/RequestedStep.tsx +30 -0
- package/src/components/recovery/SetPasswordStep.tsx +24 -3
- package/src/hooks/useViabilitySession.ts +2 -1
- package/src/lib/app-slug.ts +50 -0
- package/src/lib/ensure-fresh-access-token.ts +133 -4
- package/src/lib/idp-client-config.ts +91 -45
- package/src/lib/idp-fetch.ts +1 -1
- package/src/lib/password-rules.ts +50 -0
- package/src/lib/safe-error-response.ts +126 -0
- package/src/lib/session-cookie-shape.ts +23 -22
- package/src/lib/session-store.ts +167 -24
- package/src/lib/startup-init.ts +29 -4
- package/src/lib/token-lifecycle.ts +2 -1
- package/src/lib/token-log-id.ts +69 -0
- package/src/lib/two-factor-verify-failure.ts +167 -0
- package/src/middleware/create-middleware.ts +55 -9
- package/src/pages/recovery/page.tsx +21 -4
- package/src/pages/test-env/JwtInspectPage.tsx +11 -233
- package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
- package/src/pages/test-env/redact-credentials.ts +96 -0
- package/src/pages/verify-code/page.tsx +2 -1
- package/src/routes/auth/logout.ts +4 -2
- package/src/routes/auth/session.ts +16 -10
- package/src/routes/auth/viability.ts +11 -7
- package/src/server/auth.ts +10 -6
- package/src/server/decode-session.ts +16 -26
- package/src/server/verified-session-token.ts +103 -0
- package/src/stores/authStore.ts +16 -5
- package/src/types/recovery.ts +1 -1
- package/src/utils/api.ts +47 -8
- package/src/vibe/sessions.ts +2 -1
package/dist/lib/app-slug.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.getAppSlug = getAppSlug;
|
|
15
|
+
exports.slugMismatchReason = slugMismatchReason;
|
|
16
|
+
exports.assertIdpSlugKnown = assertIdpSlugKnown;
|
|
17
|
+
exports.slugStateReason = slugStateReason;
|
|
18
|
+
exports.assertAppSlugMatchesIdp = assertAppSlugMatchesIdp;
|
|
15
19
|
exports.clearSlugCache = clearSlugCache;
|
|
16
20
|
exports.getSessionPrefix = getSessionPrefix;
|
|
17
21
|
exports.getAnonPrefix = getAnonPrefix;
|
|
@@ -37,6 +41,53 @@ function getAppSlug() {
|
|
|
37
41
|
cachedSlug = process.env.APP_SLUG || process.env.CLIENT_ID || 'payez';
|
|
38
42
|
return cachedSlug;
|
|
39
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The session cookie name has TWO producers: better-auth mints `${idpConfig.clientSlug || getAppSlug()}.session-token`, while
|
|
46
|
+
* the gate and every reader use getSessionCookieName() = getAppSlug() = APP_SLUG || CLIENT_ID. When the IdP's slug is known and
|
|
47
|
+
* differs, the gate looks for a cookie that was never set and every better-auth (Google) session bounces to
|
|
48
|
+
* /sign-in?error=SessionInvalid straight after a successful 2FA (rigpert 60743, live on :4797 with CLIENT_ID=idp-admin-ui-graft
|
|
49
|
+
* against clientSlug idp_admin_ui_graft). FAIL CLOSED: refuse rather than run with two names. No IdP slug -> nothing to compare.
|
|
50
|
+
*/
|
|
51
|
+
function slugMismatchReason(idpClientSlug) {
|
|
52
|
+
if (!idpClientSlug)
|
|
53
|
+
return null;
|
|
54
|
+
const gateSlug = getAppSlug();
|
|
55
|
+
return idpClientSlug !== gateSlug ? `SLUG_MISMATCH: cookie=${idpClientSlug} gate=${gateSlug}` : null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* rigpert 60813: when the IdP returns NO client slug for this app (an unknown client: the config fetch failed or came back
|
|
59
|
+
* without one), startup must fail LOUD. Silently defaulting would run with a cookie name nobody verified against the IdP. The
|
|
60
|
+
* startup path swallows a failed config fetch when BETTER_AUTH_SECRET is supplied by env, so without this an unregistered
|
|
61
|
+
* CLIENT_ID (e.g. payez_idp_customer_web, absent on dev-93) would boot 'fine'.
|
|
62
|
+
*/
|
|
63
|
+
function assertIdpSlugKnown(idpClientSlug) {
|
|
64
|
+
if (idpClientSlug)
|
|
65
|
+
return;
|
|
66
|
+
throw new Error(`[FATAL] The IdP returned no client slug for this app (gate slug "${getAppSlug()}"). Either CLIENT_ID/APP_SLUG names a client ` +
|
|
67
|
+
`the IdP does not know, or its config could not be fetched. Refusing to start with an unverified session cookie name.`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The health-endpoint view: a mismatch is always reportable; an UNKNOWN slug is reported only once startup has actually been
|
|
71
|
+
* attempted (before that nothing is known yet and health must not go red on a pod that has not started initialising).
|
|
72
|
+
*/
|
|
73
|
+
function slugStateReason(idpClientSlug, startupAttempted) {
|
|
74
|
+
const mismatch = slugMismatchReason(idpClientSlug);
|
|
75
|
+
if (mismatch)
|
|
76
|
+
return mismatch;
|
|
77
|
+
if (startupAttempted && !idpClientSlug)
|
|
78
|
+
return `SLUG_UNKNOWN: the IdP returned no client slug for this app gate=${getAppSlug()}`;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function assertAppSlugMatchesIdp(idpClientSlug) {
|
|
82
|
+
if (!idpClientSlug)
|
|
83
|
+
return;
|
|
84
|
+
const gateSlug = getAppSlug();
|
|
85
|
+
if (slugMismatchReason(idpClientSlug) !== null) {
|
|
86
|
+
throw new Error(`[FATAL] Session cookie name would have two sources: the IdP client slug is "${idpClientSlug}" but this app's slug ` +
|
|
87
|
+
`(APP_SLUG, else CLIENT_ID) is "${gateSlug}". better-auth would set "${idpClientSlug}.session-token" while the gate reads ` +
|
|
88
|
+
`"${gateSlug}.session-token", so every better-auth session would be rejected. Set APP_SLUG (or CLIENT_ID) to "${idpClientSlug}".`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
40
91
|
/**
|
|
41
92
|
* Clears the cached slug (useful for testing).
|
|
42
93
|
*/
|
|
@@ -17,6 +17,32 @@ export type EnsureFreshResult = {
|
|
|
17
17
|
accessTokenExpires: number;
|
|
18
18
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
19
19
|
refreshed: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
22
|
+
*
|
|
23
|
+
* ON THIS BRANCH OF THE UNION IT IS ALWAYS `true`, AND THAT IS THE POINT.
|
|
24
|
+
* Every `ok: true` return either read the token back from the store or
|
|
25
|
+
* wrote it and confirmed the write. An unpersisted refresh does not reach
|
|
26
|
+
* an `ok: true` result at all — it returns `ok:false, SESSION_PERSIST_FAILED`
|
|
27
|
+
* from the other branch below.
|
|
28
|
+
*
|
|
29
|
+
* READ THIS BEFORE YOU DELETE THE FIELD. It is declared `boolean` rather
|
|
30
|
+
* than the literal `true` on purpose: it is the assertable witness that the
|
|
31
|
+
* escalation is still wired. If someone later re-weakens the persist site
|
|
32
|
+
* back to reporting success, this field is where that regression becomes
|
|
33
|
+
* visible to a test instead of invisible to everyone. Cases 2 and 7 in
|
|
34
|
+
* test/ensure-fresh-persist-failure.test.cjs assert on it.
|
|
35
|
+
*
|
|
36
|
+
* WHAT THIS COMMENT USED TO SAY, AND WHY IT IS GONE. It documented `false`
|
|
37
|
+
* as a live value on an ok:true result — "we hold a valid token that was
|
|
38
|
+
* not persisted" — because that WAS the contract before this merge, when
|
|
39
|
+
* the dropped write was reported as a success with a flag on it. Blending
|
|
40
|
+
* in the escalation made that sentence describe a state the code can no
|
|
41
|
+
* longer produce. A docblock asserting an impossible semantic is exactly
|
|
42
|
+
* the defect f5ac6dc3 was written to fix, one file over; reproducing it
|
|
43
|
+
* here while merging that fix would be absurd.
|
|
44
|
+
*/
|
|
45
|
+
persisted: boolean;
|
|
20
46
|
} | {
|
|
21
47
|
ok: false;
|
|
22
48
|
code: string;
|
|
@@ -66,7 +66,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
66
66
|
const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
|
|
67
67
|
const msUntilExpiry = effectiveExpires - now;
|
|
68
68
|
if (msUntilExpiry > safetyWindowMs) {
|
|
69
|
-
|
|
69
|
+
// Durable by construction: this token came OUT of the store and we wrote nothing.
|
|
70
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
|
|
70
71
|
}
|
|
71
72
|
if (!session.idpRefreshToken) {
|
|
72
73
|
return {
|
|
@@ -103,6 +104,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
103
104
|
accessToken: after.idpAccessToken,
|
|
104
105
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
105
106
|
refreshed: true,
|
|
107
|
+
// Read back from the store after the winner's refresh - durable.
|
|
108
|
+
persisted: true,
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
}
|
|
@@ -140,6 +143,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
140
143
|
accessToken: latest.idpAccessToken,
|
|
141
144
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
142
145
|
refreshed: true,
|
|
146
|
+
// Post-lock re-read: another caller already refreshed and stored this.
|
|
147
|
+
persisted: true,
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
}
|
|
@@ -229,12 +234,37 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
229
234
|
// between pre-lock read and our send would be misjudged.
|
|
230
235
|
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
231
236
|
if (tokenUnchanged) {
|
|
232
|
-
|
|
237
|
+
/**
|
|
238
|
+
* 320072: this is the OTHER updateSession call in this file, and it
|
|
239
|
+
* does NOT escalate on a null return the way the persist site below
|
|
240
|
+
* does. Checked, not overlooked: reaching here requires
|
|
241
|
+
* `tokenUnchanged`, which requires `postRefresh?.idpRefreshToken` to
|
|
242
|
+
* equal the token we sent — so the CAS read three lines up already
|
|
243
|
+
* proved the row exists. A missing row makes tokenUnchanged false and
|
|
244
|
+
* routes to the else branch instead, so the dropped-write path is
|
|
245
|
+
* very nearly unreachable here.
|
|
246
|
+
*
|
|
247
|
+
* "Very nearly" is why the return IS still captured. The residual both
|
|
248
|
+
* halves of this merge identified is real: the row can expire between
|
|
249
|
+
* that CAS read and this write, in which case the dead refresh token is
|
|
250
|
+
* NOT cleared and the "we discarded it" claim in the error below is
|
|
251
|
+
* false. That costs nothing to observe and cannot be inferred from any
|
|
252
|
+
* other line, so it is warned rather than assumed — see `if (!cleared)`
|
|
253
|
+
* immediately below. The RESULT is unchanged either way: the session is
|
|
254
|
+
* unusable and we return the same non-ok payload.
|
|
255
|
+
*/
|
|
256
|
+
const cleared = await (0, session_store_1.updateSession)(sessionToken, {
|
|
233
257
|
idpRefreshToken: '',
|
|
234
258
|
idpRefreshTokenExpires: undefined,
|
|
235
259
|
refreshTokenClearedAt: Date.now(),
|
|
236
260
|
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
237
261
|
});
|
|
262
|
+
if (!cleared) {
|
|
263
|
+
console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
|
|
264
|
+
code,
|
|
265
|
+
requestId,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
238
268
|
}
|
|
239
269
|
else {
|
|
240
270
|
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
@@ -247,6 +277,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
247
277
|
accessToken: postRefresh.idpAccessToken,
|
|
248
278
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
249
279
|
refreshed: true,
|
|
280
|
+
// `postRefresh` is a store read, so this token is by definition stored.
|
|
281
|
+
persisted: true,
|
|
250
282
|
};
|
|
251
283
|
}
|
|
252
284
|
}
|
|
@@ -301,7 +333,14 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
301
333
|
const acrLevel = String(decoded?.acr || session.authenticationLevel || '1');
|
|
302
334
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
303
335
|
const newKid = (0, token_utils_1.extractKidFromToken)(newAccess);
|
|
304
|
-
|
|
336
|
+
// *** CARD 320072. CAPTURE THIS RETURN. ***
|
|
337
|
+
// `updateSession` returns null - and writes nothing - when the canonical
|
|
338
|
+
// session row is missing, which happens whenever the BA fallback above is what
|
|
339
|
+
// resolved this session. Discarding the return is what let this function
|
|
340
|
+
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
341
|
+
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
342
|
+
// block below; this line only makes the answer available.
|
|
343
|
+
const persisted = await (0, session_store_1.updateSession)(sessionToken, {
|
|
305
344
|
...session,
|
|
306
345
|
idpAccessToken: newAccess,
|
|
307
346
|
idpAccessTokenExpires: accessTokenExpires,
|
|
@@ -320,7 +359,69 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
320
359
|
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : session.mfaExpiresAt,
|
|
321
360
|
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
|
|
322
361
|
});
|
|
323
|
-
|
|
362
|
+
/**
|
|
363
|
+
* 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
|
|
364
|
+
*
|
|
365
|
+
* `updateSession` returns null when the canonical session row is absent,
|
|
366
|
+
* which silently throws away EVERYTHING above — the new access token, the
|
|
367
|
+
* rotated refresh token, decodedAccessToken, authenticationMethods,
|
|
368
|
+
* mfaVerified, mfaCompletedAt, mfaExpiresAt. This return value was
|
|
369
|
+
* previously ignored, and the line below then reported
|
|
370
|
+
* `ok: true, refreshed: true`.
|
|
371
|
+
*
|
|
372
|
+
* WHY THAT IS WORSE THAN A PLAIN LOST WRITE. By this point the exchange at
|
|
373
|
+
* the IDP has ALREADY HAPPENED, so if refresh tokens are single-use the
|
|
374
|
+
* token we sent is now spent and its replacement is the thing we just
|
|
375
|
+
* dropped. The stored credential is therefore dead, and every later attempt
|
|
376
|
+
* re-sends that same dead token and fails. The user is bounced to LOGIN —
|
|
377
|
+
* not to verify-code — with nothing in the logs naming the cause.
|
|
378
|
+
*
|
|
379
|
+
* It did not even surface as a wrong answer to our one caller: server/auth.ts
|
|
380
|
+
* calls getRedisSession immediately after us, which is this same `getSession`,
|
|
381
|
+
* so it finds the same missing row and returns a generic NO_SESSION one frame
|
|
382
|
+
* later. The false `ok: true` was thus invisible AND the real cause — a
|
|
383
|
+
* dropped persist — was reported as an unrelated missing session.
|
|
384
|
+
*
|
|
385
|
+
* Naming it here is the point: the failure is reported at its true cause,
|
|
386
|
+
* with a code that says the refresh succeeded and the PERSIST is what
|
|
387
|
+
* failed. Not retryable — retrying burns another refresh token the same way.
|
|
388
|
+
*
|
|
389
|
+
* This is the card's option (b), deliberately. Option (a) — teaching
|
|
390
|
+
* updateSession to write through to whichever store holds the session,
|
|
391
|
+
* mirroring the read fallback above — is the better end state but changes
|
|
392
|
+
* write-path storage semantics for every caller, which is not this card.
|
|
393
|
+
*
|
|
394
|
+
* AND IT IS ALSO LOGGED, WHICH IS NOT THE SAME THING (BAPert R1134 §7,
|
|
395
|
+
* re-affirmed in R1145 §2c). Returning a named code tells the CALLER. §7
|
|
396
|
+
* requires that `persisted:false` reach a log A HUMAN READS, because the one
|
|
397
|
+
* caller — server/auth.ts — converts every non-ok result into the same
|
|
398
|
+
* generic 401 and keeps no record of which code produced it. Without this
|
|
399
|
+
* line the distinction we just bought at the type level is erased one frame
|
|
400
|
+
* later and no operator ever sees the word PERSIST. The escalation is the
|
|
401
|
+
* contract; the warn is the evidence.
|
|
402
|
+
*/
|
|
403
|
+
if (!persisted) {
|
|
404
|
+
console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
|
|
405
|
+
requestId,
|
|
406
|
+
refreshTokenRotated: hasNewRefresh,
|
|
407
|
+
persisted: false,
|
|
408
|
+
});
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
code: 'SESSION_PERSIST_FAILED',
|
|
412
|
+
message: 'Token refresh succeeded at the IDP but the session could not be persisted; the rotated tokens were discarded.',
|
|
413
|
+
status: 401,
|
|
414
|
+
terminal: true,
|
|
415
|
+
retryable: false,
|
|
416
|
+
discardToken: true,
|
|
417
|
+
resolution: 'REAUTHENTICATE',
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// `persisted: true` is not decoration and not a leftover: the `if` above is
|
|
421
|
+
// the only way out for a dropped write, so by construction anything reaching
|
|
422
|
+
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
423
|
+
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
424
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
324
425
|
}
|
|
325
426
|
finally {
|
|
326
427
|
if (weHoldLock) {
|
|
@@ -81,6 +81,26 @@ export interface IDPClientConfig {
|
|
|
81
81
|
branding: BrandingConfig;
|
|
82
82
|
baseClientUrl?: string;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
|
|
86
|
+
* caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
|
|
87
|
+
* them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
|
|
88
|
+
*
|
|
89
|
+
* So both are now resolved ENV FIRST, config second:
|
|
90
|
+
* signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
|
|
91
|
+
* Uses the module-load snapshot above: this module publishes the config value into
|
|
92
|
+
* process.env, so a live read would mistake the IdP's value for the operator's.
|
|
93
|
+
* provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
|
|
94
|
+
* (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
|
|
95
|
+
*
|
|
96
|
+
* The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
|
|
97
|
+
* environment and the config are empty.
|
|
98
|
+
*
|
|
99
|
+
* Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
|
|
100
|
+
* written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
|
|
101
|
+
*/
|
|
102
|
+
export type SecretSource = string;
|
|
103
|
+
export declare function providerSecretEnvVar(provider: string): string;
|
|
84
104
|
/**
|
|
85
105
|
* Get IDP client configuration with multi-tier caching.
|
|
86
106
|
*
|
|
@@ -74,6 +74,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
74
74
|
};
|
|
75
75
|
})();
|
|
76
76
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
77
|
+
exports.providerSecretEnvVar = providerSecretEnvVar;
|
|
77
78
|
exports.getIDPClientConfig = getIDPClientConfig;
|
|
78
79
|
exports.clearConfigCache = clearConfigCache;
|
|
79
80
|
exports.clearConfigRedisCache = clearConfigRedisCache;
|
|
@@ -124,6 +125,49 @@ async function getRedis() {
|
|
|
124
125
|
const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
|
125
126
|
const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
|
126
127
|
let operatorOverrideLogged = false;
|
|
128
|
+
function providerSecretEnvVar(provider) {
|
|
129
|
+
return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
|
|
130
|
+
}
|
|
131
|
+
const loggedSecretSources = new Map();
|
|
132
|
+
function logSecretSource(what, source) {
|
|
133
|
+
if (loggedSecretSources.get(what) === source)
|
|
134
|
+
return;
|
|
135
|
+
loggedSecretSources.set(what, source);
|
|
136
|
+
console.log(`[IDP_CONFIG] ${what} source: ${source}`);
|
|
137
|
+
}
|
|
138
|
+
function resolveAuthSecret(fromConfig) {
|
|
139
|
+
if (OPERATOR_AUTH_SECRET)
|
|
140
|
+
return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
|
|
141
|
+
if (OPERATOR_NEXTAUTH_SECRET)
|
|
142
|
+
return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
|
|
143
|
+
if (fromConfig)
|
|
144
|
+
return { value: fromConfig, source: 'idp-config' };
|
|
145
|
+
return { value: '', source: 'none' };
|
|
146
|
+
}
|
|
147
|
+
/** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
|
|
148
|
+
function applyEnvSecrets(config) {
|
|
149
|
+
const auth = resolveAuthSecret(config.authSecret);
|
|
150
|
+
logSecretSource('authSecret', auth.source);
|
|
151
|
+
return {
|
|
152
|
+
...config,
|
|
153
|
+
authSecret: auth.value,
|
|
154
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) => {
|
|
155
|
+
const envVar = providerSecretEnvVar(p.provider);
|
|
156
|
+
const fromEnv = process.env[envVar];
|
|
157
|
+
const source = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
|
|
158
|
+
logSecretSource(`${p.provider} clientSecret`, source);
|
|
159
|
+
return { ...p, clientSecret: fromEnv || p.clientSecret };
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
|
|
164
|
+
function stripEnvSecrets(config) {
|
|
165
|
+
return {
|
|
166
|
+
...config,
|
|
167
|
+
authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
|
|
168
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) => process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
127
171
|
/** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
|
|
128
172
|
function publishAuthSecret(value, source) {
|
|
129
173
|
if (OPERATOR_AUTH_SECRET) {
|
|
@@ -234,7 +278,8 @@ async function setConfigInRedis(config) {
|
|
|
234
278
|
const key = getRedisConfigKey();
|
|
235
279
|
const ttlSeconds = config.configCacheTtlSeconds || 300;
|
|
236
280
|
const data = {
|
|
237
|
-
|
|
281
|
+
// PAY-1460: never persist a secret the environment supplied.
|
|
282
|
+
config: stripEnvSecrets(config),
|
|
238
283
|
expiresAt: Date.now() + (ttlSeconds * 1000)
|
|
239
284
|
};
|
|
240
285
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
@@ -289,7 +334,10 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
289
334
|
}
|
|
290
335
|
// Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
|
|
291
336
|
if (!forceRefresh) {
|
|
292
|
-
|
|
337
|
+
// PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
|
|
338
|
+
// environment is re-applied on read, BEFORE the emptiness test below.
|
|
339
|
+
const cachedRaw = await getConfigFromRedis();
|
|
340
|
+
const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
|
|
293
341
|
// A cached entry is only USABLE if it carries a secret — see the note
|
|
294
342
|
// below. `redisConfig && !authSecret` must behave like a cache MISS, so
|
|
295
343
|
// the emptiness test belongs here, in the condition that gates the
|
|
@@ -433,7 +481,7 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
433
481
|
// This throw is what stops that. It is also what turned the
|
|
434
482
|
// 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
|
|
435
483
|
// of a silently forgeable app.
|
|
436
|
-
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
|
|
484
|
+
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
437
485
|
}
|
|
438
486
|
// Validated above — only now is it safe to cache or publish.
|
|
439
487
|
publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
|
|
@@ -503,7 +551,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
503
551
|
});
|
|
504
552
|
if (!resp.ok) {
|
|
505
553
|
const txt = await resp.text().catch(() => 'Unknown error');
|
|
506
|
-
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status}
|
|
554
|
+
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
|
|
507
555
|
}
|
|
508
556
|
const body = await resp.json().catch(() => null);
|
|
509
557
|
if (!body) {
|
|
@@ -514,7 +562,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
514
562
|
if (rawClientId === undefined || rawClientId === null) {
|
|
515
563
|
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
|
|
516
564
|
}
|
|
517
|
-
const config = {
|
|
565
|
+
const config = applyEnvSecrets({
|
|
518
566
|
clientId: String(rawClientId),
|
|
519
567
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
520
568
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
@@ -575,12 +623,12 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
575
623
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
576
624
|
},
|
|
577
625
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
578
|
-
};
|
|
626
|
+
});
|
|
579
627
|
if (!config.authSecret) {
|
|
580
628
|
// LOAD-BEARING — see the note on the external path's equivalent throw.
|
|
581
629
|
// An empty secret reaching betterAuth() becomes the PUBLIC default, not an
|
|
582
630
|
// error. Never replace this with a fallback.
|
|
583
|
-
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
|
|
631
|
+
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
584
632
|
}
|
|
585
633
|
console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
|
|
586
634
|
consecutiveFailures = 0;
|
|
@@ -649,7 +697,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
649
697
|
});
|
|
650
698
|
if (!signingResp.ok) {
|
|
651
699
|
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
652
|
-
|
|
700
|
+
// PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
|
|
701
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
|
|
653
702
|
}
|
|
654
703
|
const signingBody = await signingResp.json().catch(() => null);
|
|
655
704
|
if (!signingBody) {
|
|
@@ -660,7 +709,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
660
709
|
const client_assertion = (signingBody?.data?.client_assertion ??
|
|
661
710
|
signingBody?.data?.clientAssertion);
|
|
662
711
|
if (!client_assertion) {
|
|
663
|
-
|
|
712
|
+
// PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
|
|
713
|
+
console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
|
|
664
714
|
throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
|
|
665
715
|
}
|
|
666
716
|
// Step 2: Fetch client config using the assertion
|
|
@@ -673,42 +723,12 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
673
723
|
'X-Client-Id': clientIdStr,
|
|
674
724
|
'X-Correlation-Id': (0, crypto_1.randomUUID)().replace(/-/g, ''),
|
|
675
725
|
},
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
// This broker is the ONLY consumer of oauth_providers[].client_secret
|
|
679
|
-
// (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
|
|
680
|
-
// which performs the server-side code exchange). Every OTHER caller of
|
|
681
|
-
// ExternalAuth/client-config - notably every fielded acp-desktop - is
|
|
682
|
-
// pushed the same secrets today and reads none of them: the desktop is
|
|
683
|
-
// PKCE and dropped clientSecret from OAuthProviderConfig entirely.
|
|
684
|
-
//
|
|
685
|
-
// The IDP IGNORES this field today, so shipping it is a no-op and needs
|
|
686
|
-
// no coordination window. Phase 2 (DotNetPert, IDP side) makes
|
|
687
|
-
// client-config emit the secrets ONLY when the opt-in is present and
|
|
688
|
-
// redact otherwise - which is why phase 1 MUST be live first, or the
|
|
689
|
-
// broker loses the field and tenant OAuth sign-in dies.
|
|
690
|
-
//
|
|
691
|
-
// SCOPE, deliberately narrow - do NOT read this as a partial migration:
|
|
692
|
-
// the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
|
|
693
|
-
// endpoint (InternalClientConfig/{id}, GET) that is already
|
|
694
|
-
// authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
|
|
695
|
-
// is therefore not part of 217917's exposure. It deliberately sends no
|
|
696
|
-
// opt-in. If phase 2's redaction is implemented in shared config-
|
|
697
|
-
// assembly code rather than in ExternalAuthController.GetClientConfig,
|
|
698
|
-
// it will ALSO strip the internal route and break this broker's other
|
|
699
|
-
// path with no opt-in to save it. Phase 2 must gate on the request.
|
|
700
|
-
//
|
|
701
|
-
// NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
|
|
702
|
-
// the AUTOMATIC delivery of every tenant's provider secrets to every
|
|
703
|
-
// user laptop; it does not close disclosure to a determined caller.
|
|
704
|
-
// That residual is card 175047 (unauthenticated mint, global partner
|
|
705
|
-
// key) and 217917 does not close until 175047 lands or Jon accepts it.
|
|
706
|
-
body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
|
|
726
|
+
body: JSON.stringify({ client_assertion }),
|
|
707
727
|
cache: 'no-store'
|
|
708
728
|
});
|
|
709
729
|
if (!configResp.ok) {
|
|
710
730
|
const txt = await configResp.text().catch(() => 'Unknown error');
|
|
711
|
-
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status}
|
|
731
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
|
|
712
732
|
}
|
|
713
733
|
const configBody = await configResp.json().catch(() => null);
|
|
714
734
|
if (!configBody) {
|
|
@@ -717,7 +737,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
717
737
|
// Per PayEz API standard: response is wrapped in { success, data: {...} }
|
|
718
738
|
const configData = configBody?.data;
|
|
719
739
|
if (!configData || typeof configData !== 'object') {
|
|
720
|
-
|
|
740
|
+
// PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
|
|
741
|
+
console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
|
|
721
742
|
throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
|
|
722
743
|
}
|
|
723
744
|
// Validate required fields - handle both number and string client_id
|
|
@@ -728,7 +749,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
728
749
|
// Map response to our interface (IDP returns camelCase or snake_case).
|
|
729
750
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
730
751
|
// nextAuthSecret/next_auth_secret while IDP rename rolls out.
|
|
731
|
-
const config = {
|
|
752
|
+
const config = applyEnvSecrets({
|
|
732
753
|
clientId: String(rawClientId),
|
|
733
754
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
734
755
|
authSecret: configData.authSecret ??
|
|
@@ -798,7 +819,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
798
819
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
799
820
|
},
|
|
800
821
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
801
|
-
};
|
|
822
|
+
});
|
|
802
823
|
// Debug: log what we got for baseClientUrl
|
|
803
824
|
console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
|
|
804
825
|
// Validate we got what we need
|
|
@@ -808,7 +829,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
808
829
|
if (!config.authSecret) {
|
|
809
830
|
// LOAD-BEARING — empty must fail here, never downstream. betterAuth()
|
|
810
831
|
// silently substitutes a public constant for an empty secret.
|
|
811
|
-
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
|
|
832
|
+
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
812
833
|
}
|
|
813
834
|
// Success - reset failure tracking
|
|
814
835
|
consecutiveFailures = 0;
|
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -77,7 +77,7 @@ async function ensureFreshAccessToken(req) {
|
|
|
77
77
|
const sessionToken = (token?.sessionToken || token?.redisSessionId);
|
|
78
78
|
console.log('[IDP_FETCH] ensureFreshAccessToken:', {
|
|
79
79
|
hasToken: !!token,
|
|
80
|
-
|
|
80
|
+
hasSessionToken: !!sessionToken, // PAY-1427 / BAPert 58829: no token material in logs, not even a stub (was a 20-char prefix)
|
|
81
81
|
pathname: req.nextUrl.pathname
|
|
82
82
|
});
|
|
83
83
|
if (!sessionToken) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1591: the one sentence that states the password rules on the recovery set-password step.
|
|
3
|
+
*
|
|
4
|
+
* Built ONLY from the policy the IdP reported (usePasswordValidation.policy). A rule that is off is not stated, and with
|
|
5
|
+
* no policy at all we state no number: telling a user "at least 8 characters" when the client is configured for 12 is
|
|
6
|
+
* the same defect as saying nothing, one step later.
|
|
7
|
+
*/
|
|
8
|
+
export interface PasswordPolicyLike {
|
|
9
|
+
min_length?: number;
|
|
10
|
+
require_uppercase?: boolean;
|
|
11
|
+
require_lowercase?: boolean;
|
|
12
|
+
require_digit?: boolean;
|
|
13
|
+
require_special?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function passwordRulesText(policy: PasswordPolicyLike | undefined | null): string;
|
|
16
|
+
/**
|
|
17
|
+
* PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
|
|
18
|
+
* portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
|
|
19
|
+
*/
|
|
20
|
+
export declare const PASSWORD_RULE_SENTENCES: Readonly<Record<string, string>>;
|
|
21
|
+
export declare function knownPasswordRules(rules: unknown): string[];
|
|
22
|
+
export declare function passwordRuleSentences(rules: string[]): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PASSWORD_RULE_SENTENCES = void 0;
|
|
4
|
+
exports.passwordRulesText = passwordRulesText;
|
|
5
|
+
exports.knownPasswordRules = knownPasswordRules;
|
|
6
|
+
exports.passwordRuleSentences = passwordRuleSentences;
|
|
7
|
+
function passwordRulesText(policy) {
|
|
8
|
+
if (!policy || typeof policy.min_length !== 'number') {
|
|
9
|
+
return "Use a password that meets this site's rules. We'll show what is missing as you type.";
|
|
10
|
+
}
|
|
11
|
+
const parts = [`at least ${policy.min_length} characters`];
|
|
12
|
+
if (policy.require_uppercase)
|
|
13
|
+
parts.push('an upper-case letter');
|
|
14
|
+
if (policy.require_lowercase)
|
|
15
|
+
parts.push('a lower-case letter');
|
|
16
|
+
if (policy.require_digit)
|
|
17
|
+
parts.push('a number');
|
|
18
|
+
if (policy.require_special)
|
|
19
|
+
parts.push('a symbol');
|
|
20
|
+
const [first, ...rest] = parts;
|
|
21
|
+
return rest.length ? `Your password needs ${first}, with ${rest.join(', ')}.` : `Your password needs ${first}.`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
|
|
25
|
+
* portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
|
|
26
|
+
*/
|
|
27
|
+
exports.PASSWORD_RULE_SENTENCES = {
|
|
28
|
+
min_length: 'Use a longer password.',
|
|
29
|
+
uppercase: 'Add an upper-case letter.',
|
|
30
|
+
lowercase: 'Add a lower-case letter.',
|
|
31
|
+
digit: 'Add a number.',
|
|
32
|
+
special: 'Add a symbol.',
|
|
33
|
+
unique_chars: 'Use more different characters.',
|
|
34
|
+
recent_password: "Choose a password you haven't used recently.",
|
|
35
|
+
};
|
|
36
|
+
function knownPasswordRules(rules) {
|
|
37
|
+
if (!Array.isArray(rules))
|
|
38
|
+
return [];
|
|
39
|
+
return rules.filter((r) => typeof r === 'string' && Object.prototype.hasOwnProperty.call(exports.PASSWORD_RULE_SENTENCES, r));
|
|
40
|
+
}
|
|
41
|
+
function passwordRuleSentences(rules) {
|
|
42
|
+
return rules.map((r) => exports.PASSWORD_RULE_SENTENCES[r]).join(' ');
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1438: what a USER-FACING handler may put in an error response.
|
|
3
|
+
*
|
|
4
|
+
* Several of these routes are reachable before authentication (session check, viability probe,
|
|
5
|
+
* logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
|
|
6
|
+
*
|
|
7
|
+
* Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
|
|
8
|
+
* JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
|
|
9
|
+
* a code and a correlationId; the log line carries the exception and the same id.
|
|
10
|
+
*
|
|
11
|
+
* Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
|
|
12
|
+
* never passed through whole. Only the display contract is projected: code, message, resolution,
|
|
13
|
+
* request_id - each only when it is a string. `details` (free-form, may carry validation internals
|
|
14
|
+
* or exception data) is dropped, except for the password-policy messages change-password's UI reads.
|
|
15
|
+
*/
|
|
16
|
+
export interface IdpErrorProjection {
|
|
17
|
+
code?: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
resolution?: string;
|
|
20
|
+
request_id?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Log an exception with a fresh correlationId and return the id for the response body. */
|
|
23
|
+
export declare function logExceptionWithCorrelation(tag: string, err: unknown): string;
|
|
24
|
+
/** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
|
|
25
|
+
export declare function projectIdpError(json: unknown): IdpErrorProjection;
|
|
26
|
+
/**
|
|
27
|
+
* change-password's `details`: the security pages show `details.value[0].message` (else
|
|
28
|
+
* `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
|
|
29
|
+
* strings; drop everything else.
|
|
30
|
+
*/
|
|
31
|
+
export declare function projectPasswordPolicyDetails(details: unknown): {
|
|
32
|
+
message?: string;
|
|
33
|
+
value?: Array<{
|
|
34
|
+
message: string;
|
|
35
|
+
}>;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
|
|
39
|
+
* reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
|
|
40
|
+
* SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
|
|
41
|
+
* whitelisted projection. The success path keeps its passthrough.
|
|
42
|
+
*/
|
|
43
|
+
export declare function idpErrorEnvelope(json: unknown, fallbackMessage?: string): {
|
|
44
|
+
success: false;
|
|
45
|
+
message: string;
|
|
46
|
+
error: {
|
|
47
|
+
code?: string;
|
|
48
|
+
message: string;
|
|
49
|
+
resolution?: string;
|
|
50
|
+
};
|
|
51
|
+
request_id?: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
|
|
55
|
+
* [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
|
|
56
|
+
* unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
|
|
57
|
+
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
|
|
61
|
+
* ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
|
|
62
|
+
* expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
|
|
63
|
+
* Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
|
|
64
|
+
* no code at all, and calling that 'link expired' loops the user.
|
|
65
|
+
*/
|
|
66
|
+
export declare function projectResetTokenInvalid(json: unknown): boolean;
|
|
67
|
+
export declare function projectPasswordPolicyRules(json: unknown): string[] | undefined;
|