@payez/next-mvp 4.4.5 → 4.4.7
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 +33 -8
- 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 +58 -14
- package/dist/auth/better-auth.js +125 -54
- package/dist/auth/utils/idp-client.js +2 -1
- package/dist/client/better-auth-client.d.ts +101 -956
- 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/config/env.d.ts +0 -2
- package/dist/config/env.js +0 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -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 +73 -45
- package/dist/lib/idp-fetch.js +1 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- 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 +78 -0
- package/dist/lib/two-factor-verify-failure.js +220 -0
- package/dist/middleware/create-middleware.d.ts +4 -1
- package/dist/middleware/create-middleware.js +79 -8
- package/dist/pages/login/page.js +5 -0
- 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 +6 -3
- 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/settings.js +4 -1
- package/dist/routes/auth/viability.d.ts +3 -2
- package/dist/routes/auth/viability.js +11 -7
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +32 -31
- 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/client.d.ts +132 -16
- package/dist/vibe/client.js +288 -82
- package/dist/vibe/document-unwrap.d.ts +50 -0
- package/dist/vibe/document-unwrap.js +78 -0
- package/dist/vibe/errors.d.ts +17 -0
- package/dist/vibe/errors.js +21 -1
- package/dist/vibe/generic.d.ts +2 -49
- package/dist/vibe/generic.js +17 -78
- package/dist/vibe/index.d.ts +1 -1
- package/dist/vibe/index.js +2 -1
- package/dist/vibe/sessions.js +2 -1
- package/package.json +13 -2
- package/scripts/verify-release-provenance.mjs +410 -0
- package/src/api/auth-handler.ts +5 -3
- package/src/api-handlers/account/change-password.ts +36 -8
- 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 +128 -61
- 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/config/env.ts +0 -2
- package/src/hooks/useViabilitySession.ts +2 -1
- package/src/index.ts +4 -0
- 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 +98 -45
- package/src/lib/idp-fetch.ts +1 -1
- package/src/lib/never-in-response.ts +64 -0
- 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 +248 -0
- package/src/middleware/create-middleware.ts +97 -9
- package/src/pages/login/page.tsx +4 -0
- 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 +6 -3
- package/src/routes/auth/logout.ts +4 -2
- package/src/routes/auth/session.ts +16 -10
- package/src/routes/auth/settings.ts +5 -1
- package/src/routes/auth/viability.ts +11 -7
- package/src/server/auth.ts +33 -35
- 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/client.ts +378 -91
- package/src/vibe/document-unwrap.ts +103 -0
- package/src/vibe/errors.ts +24 -0
- package/src/vibe/generic.ts +8 -100
- package/src/vibe/index.ts +1 -0
- package/src/vibe/sessions.ts +2 -1
|
@@ -46,6 +46,32 @@ export type EnsureFreshResult =
|
|
|
46
46
|
accessTokenExpires: number;
|
|
47
47
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
48
48
|
refreshed: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
51
|
+
*
|
|
52
|
+
* ON THIS BRANCH OF THE UNION IT IS ALWAYS `true`, AND THAT IS THE POINT.
|
|
53
|
+
* Every `ok: true` return either read the token back from the store or
|
|
54
|
+
* wrote it and confirmed the write. An unpersisted refresh does not reach
|
|
55
|
+
* an `ok: true` result at all — it returns `ok:false, SESSION_PERSIST_FAILED`
|
|
56
|
+
* from the other branch below.
|
|
57
|
+
*
|
|
58
|
+
* READ THIS BEFORE YOU DELETE THE FIELD. It is declared `boolean` rather
|
|
59
|
+
* than the literal `true` on purpose: it is the assertable witness that the
|
|
60
|
+
* escalation is still wired. If someone later re-weakens the persist site
|
|
61
|
+
* back to reporting success, this field is where that regression becomes
|
|
62
|
+
* visible to a test instead of invisible to everyone. Cases 2 and 7 in
|
|
63
|
+
* test/ensure-fresh-persist-failure.test.cjs assert on it.
|
|
64
|
+
*
|
|
65
|
+
* WHAT THIS COMMENT USED TO SAY, AND WHY IT IS GONE. It documented `false`
|
|
66
|
+
* as a live value on an ok:true result — "we hold a valid token that was
|
|
67
|
+
* not persisted" — because that WAS the contract before this merge, when
|
|
68
|
+
* the dropped write was reported as a success with a flag on it. Blending
|
|
69
|
+
* in the escalation made that sentence describe a state the code can no
|
|
70
|
+
* longer produce. A docblock asserting an impossible semantic is exactly
|
|
71
|
+
* the defect f5ac6dc3 was written to fix, one file over; reproducing it
|
|
72
|
+
* here while merging that fix would be absurd.
|
|
73
|
+
*/
|
|
74
|
+
persisted: boolean;
|
|
49
75
|
}
|
|
50
76
|
| {
|
|
51
77
|
ok: false;
|
|
@@ -113,7 +139,8 @@ export async function ensureFreshAccessToken(
|
|
|
113
139
|
const msUntilExpiry = effectiveExpires - now;
|
|
114
140
|
|
|
115
141
|
if (msUntilExpiry > safetyWindowMs) {
|
|
116
|
-
|
|
142
|
+
// Durable by construction: this token came OUT of the store and we wrote nothing.
|
|
143
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
if (!session.idpRefreshToken) {
|
|
@@ -152,6 +179,8 @@ export async function ensureFreshAccessToken(
|
|
|
152
179
|
accessToken: after.idpAccessToken,
|
|
153
180
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
154
181
|
refreshed: true,
|
|
182
|
+
// Read back from the store after the winner's refresh - durable.
|
|
183
|
+
persisted: true,
|
|
155
184
|
};
|
|
156
185
|
}
|
|
157
186
|
}
|
|
@@ -188,6 +217,8 @@ export async function ensureFreshAccessToken(
|
|
|
188
217
|
accessToken: latest.idpAccessToken,
|
|
189
218
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
190
219
|
refreshed: true,
|
|
220
|
+
// Post-lock re-read: another caller already refreshed and stored this.
|
|
221
|
+
persisted: true,
|
|
191
222
|
};
|
|
192
223
|
}
|
|
193
224
|
}
|
|
@@ -276,12 +307,37 @@ export async function ensureFreshAccessToken(
|
|
|
276
307
|
const tokenUnchanged =
|
|
277
308
|
!!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
278
309
|
if (tokenUnchanged) {
|
|
279
|
-
|
|
310
|
+
/**
|
|
311
|
+
* 320072: this is the OTHER updateSession call in this file, and it
|
|
312
|
+
* does NOT escalate on a null return the way the persist site below
|
|
313
|
+
* does. Checked, not overlooked: reaching here requires
|
|
314
|
+
* `tokenUnchanged`, which requires `postRefresh?.idpRefreshToken` to
|
|
315
|
+
* equal the token we sent — so the CAS read three lines up already
|
|
316
|
+
* proved the row exists. A missing row makes tokenUnchanged false and
|
|
317
|
+
* routes to the else branch instead, so the dropped-write path is
|
|
318
|
+
* very nearly unreachable here.
|
|
319
|
+
*
|
|
320
|
+
* "Very nearly" is why the return IS still captured. The residual both
|
|
321
|
+
* halves of this merge identified is real: the row can expire between
|
|
322
|
+
* that CAS read and this write, in which case the dead refresh token is
|
|
323
|
+
* NOT cleared and the "we discarded it" claim in the error below is
|
|
324
|
+
* false. That costs nothing to observe and cannot be inferred from any
|
|
325
|
+
* other line, so it is warned rather than assumed — see `if (!cleared)`
|
|
326
|
+
* immediately below. The RESULT is unchanged either way: the session is
|
|
327
|
+
* unusable and we return the same non-ok payload.
|
|
328
|
+
*/
|
|
329
|
+
const cleared = await updateSession(sessionToken, {
|
|
280
330
|
idpRefreshToken: '',
|
|
281
331
|
idpRefreshTokenExpires: undefined,
|
|
282
332
|
refreshTokenClearedAt: Date.now(),
|
|
283
333
|
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
284
334
|
});
|
|
335
|
+
if (!cleared) {
|
|
336
|
+
console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
|
|
337
|
+
code,
|
|
338
|
+
requestId,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
285
341
|
} else {
|
|
286
342
|
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
287
343
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
@@ -293,6 +349,8 @@ export async function ensureFreshAccessToken(
|
|
|
293
349
|
accessToken: postRefresh.idpAccessToken,
|
|
294
350
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
295
351
|
refreshed: true,
|
|
352
|
+
// `postRefresh` is a store read, so this token is by definition stored.
|
|
353
|
+
persisted: true,
|
|
296
354
|
};
|
|
297
355
|
}
|
|
298
356
|
}
|
|
@@ -351,7 +409,14 @@ export async function ensureFreshAccessToken(
|
|
|
351
409
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
352
410
|
const newKid = extractKidFromToken(newAccess);
|
|
353
411
|
|
|
354
|
-
|
|
412
|
+
// *** CARD 320072. CAPTURE THIS RETURN. ***
|
|
413
|
+
// `updateSession` returns null - and writes nothing - when the canonical
|
|
414
|
+
// session row is missing, which happens whenever the BA fallback above is what
|
|
415
|
+
// resolved this session. Discarding the return is what let this function
|
|
416
|
+
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
417
|
+
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
418
|
+
// block below; this line only makes the answer available.
|
|
419
|
+
const persisted = await updateSession(sessionToken, {
|
|
355
420
|
...session,
|
|
356
421
|
idpAccessToken: newAccess,
|
|
357
422
|
idpAccessTokenExpires: accessTokenExpires,
|
|
@@ -371,7 +436,71 @@ export async function ensureFreshAccessToken(
|
|
|
371
436
|
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
|
|
372
437
|
});
|
|
373
438
|
|
|
374
|
-
|
|
439
|
+
/**
|
|
440
|
+
* 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
|
|
441
|
+
*
|
|
442
|
+
* `updateSession` returns null when the canonical session row is absent,
|
|
443
|
+
* which silently throws away EVERYTHING above — the new access token, the
|
|
444
|
+
* rotated refresh token, decodedAccessToken, authenticationMethods,
|
|
445
|
+
* mfaVerified, mfaCompletedAt, mfaExpiresAt. This return value was
|
|
446
|
+
* previously ignored, and the line below then reported
|
|
447
|
+
* `ok: true, refreshed: true`.
|
|
448
|
+
*
|
|
449
|
+
* WHY THAT IS WORSE THAN A PLAIN LOST WRITE. By this point the exchange at
|
|
450
|
+
* the IDP has ALREADY HAPPENED, so if refresh tokens are single-use the
|
|
451
|
+
* token we sent is now spent and its replacement is the thing we just
|
|
452
|
+
* dropped. The stored credential is therefore dead, and every later attempt
|
|
453
|
+
* re-sends that same dead token and fails. The user is bounced to LOGIN —
|
|
454
|
+
* not to verify-code — with nothing in the logs naming the cause.
|
|
455
|
+
*
|
|
456
|
+
* It did not even surface as a wrong answer to our one caller: server/auth.ts
|
|
457
|
+
* calls getRedisSession immediately after us, which is this same `getSession`,
|
|
458
|
+
* so it finds the same missing row and returns a generic NO_SESSION one frame
|
|
459
|
+
* later. The false `ok: true` was thus invisible AND the real cause — a
|
|
460
|
+
* dropped persist — was reported as an unrelated missing session.
|
|
461
|
+
*
|
|
462
|
+
* Naming it here is the point: the failure is reported at its true cause,
|
|
463
|
+
* with a code that says the refresh succeeded and the PERSIST is what
|
|
464
|
+
* failed. Not retryable — retrying burns another refresh token the same way.
|
|
465
|
+
*
|
|
466
|
+
* This is the card's option (b), deliberately. Option (a) — teaching
|
|
467
|
+
* updateSession to write through to whichever store holds the session,
|
|
468
|
+
* mirroring the read fallback above — is the better end state but changes
|
|
469
|
+
* write-path storage semantics for every caller, which is not this card.
|
|
470
|
+
*
|
|
471
|
+
* AND IT IS ALSO LOGGED, WHICH IS NOT THE SAME THING (BAPert R1134 §7,
|
|
472
|
+
* re-affirmed in R1145 §2c). Returning a named code tells the CALLER. §7
|
|
473
|
+
* requires that `persisted:false` reach a log A HUMAN READS, because the one
|
|
474
|
+
* caller — server/auth.ts — converts every non-ok result into the same
|
|
475
|
+
* generic 401 and keeps no record of which code produced it. Without this
|
|
476
|
+
* line the distinction we just bought at the type level is erased one frame
|
|
477
|
+
* later and no operator ever sees the word PERSIST. The escalation is the
|
|
478
|
+
* contract; the warn is the evidence.
|
|
479
|
+
*/
|
|
480
|
+
if (!persisted) {
|
|
481
|
+
console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
|
|
482
|
+
requestId,
|
|
483
|
+
refreshTokenRotated: hasNewRefresh,
|
|
484
|
+
persisted: false,
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
code: 'SESSION_PERSIST_FAILED',
|
|
489
|
+
message:
|
|
490
|
+
'Token refresh succeeded at the IDP but the session could not be persisted; the rotated tokens were discarded.',
|
|
491
|
+
status: 401,
|
|
492
|
+
terminal: true,
|
|
493
|
+
retryable: false,
|
|
494
|
+
discardToken: true,
|
|
495
|
+
resolution: 'REAUTHENTICATE',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// `persisted: true` is not decoration and not a leftover: the `if` above is
|
|
500
|
+
// the only way out for a dropped write, so by construction anything reaching
|
|
501
|
+
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
502
|
+
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
503
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
375
504
|
} finally {
|
|
376
505
|
if (weHoldLock) {
|
|
377
506
|
await releaseRefreshLock(sessionToken, requestId, releaseVersion);
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
import 'server-only';
|
|
44
44
|
import { createHash, randomUUID } from 'crypto';
|
|
45
|
+
import { markNeverInResponse } from './never-in-response';
|
|
45
46
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
46
47
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
47
48
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -166,6 +167,81 @@ const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
|
|
166
167
|
const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
|
167
168
|
let operatorOverrideLogged = false;
|
|
168
169
|
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// PAY-1460 - SECRETS ARE ENV-FIRST
|
|
172
|
+
// ============================================================================
|
|
173
|
+
/**
|
|
174
|
+
* The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
|
|
175
|
+
* caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
|
|
176
|
+
* them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
|
|
177
|
+
*
|
|
178
|
+
* So both are now resolved ENV FIRST, config second:
|
|
179
|
+
* signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
|
|
180
|
+
* Uses the module-load snapshot above: this module publishes the config value into
|
|
181
|
+
* process.env, so a live read would mistake the IdP's value for the operator's.
|
|
182
|
+
* provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
|
|
183
|
+
* (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
|
|
184
|
+
*
|
|
185
|
+
* The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
|
|
186
|
+
* environment and the config are empty.
|
|
187
|
+
*
|
|
188
|
+
* Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
|
|
189
|
+
* written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
|
|
190
|
+
*/
|
|
191
|
+
export type SecretSource = string; // 'env:<VAR>' | 'idp-config' | 'none'
|
|
192
|
+
|
|
193
|
+
export function providerSecretEnvVar(provider: string): string {
|
|
194
|
+
return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const loggedSecretSources = new Map<string, SecretSource>();
|
|
198
|
+
function logSecretSource(what: string, source: SecretSource): void {
|
|
199
|
+
if (loggedSecretSources.get(what) === source) return;
|
|
200
|
+
loggedSecretSources.set(what, source);
|
|
201
|
+
console.log(`[IDP_CONFIG] ${what} source: ${source}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveAuthSecret(fromConfig: string | undefined): { value: string; source: SecretSource } {
|
|
205
|
+
if (OPERATOR_AUTH_SECRET) return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
|
|
206
|
+
if (OPERATOR_NEXTAUTH_SECRET) return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
|
|
207
|
+
if (fromConfig) return { value: fromConfig, source: 'idp-config' };
|
|
208
|
+
return { value: '', source: 'none' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
|
|
212
|
+
function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
|
|
213
|
+
const auth = resolveAuthSecret(config.authSecret);
|
|
214
|
+
logSecretSource('authSecret', auth.source);
|
|
215
|
+
// PAY-1529: every secret this module resolves is registered as never-in-response HERE, the one place
|
|
216
|
+
// both fetch paths and the Redis read pass through, so the runtime backstop knows the value whichever
|
|
217
|
+
// handler later tries to serialize it.
|
|
218
|
+
markNeverInResponse(auth.value);
|
|
219
|
+
return {
|
|
220
|
+
...config,
|
|
221
|
+
authSecret: auth.value,
|
|
222
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) => {
|
|
223
|
+
const envVar = providerSecretEnvVar(p.provider);
|
|
224
|
+
const fromEnv = process.env[envVar];
|
|
225
|
+
const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
|
|
226
|
+
logSecretSource(`${p.provider} clientSecret`, source);
|
|
227
|
+
const clientSecret = fromEnv || p.clientSecret;
|
|
228
|
+
markNeverInResponse(clientSecret);
|
|
229
|
+
return { ...p, clientSecret };
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
|
|
235
|
+
function stripEnvSecrets(config: IDPClientConfig): IDPClientConfig {
|
|
236
|
+
return {
|
|
237
|
+
...config,
|
|
238
|
+
authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
|
|
239
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) =>
|
|
240
|
+
process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p
|
|
241
|
+
),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
169
245
|
/** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
|
|
170
246
|
function publishAuthSecret(value: string, source: string): void {
|
|
171
247
|
if (OPERATOR_AUTH_SECRET) {
|
|
@@ -294,7 +370,8 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
|
|
|
294
370
|
const key = getRedisConfigKey();
|
|
295
371
|
const ttlSeconds = config.configCacheTtlSeconds || 300;
|
|
296
372
|
const data: RedisCachedConfig = {
|
|
297
|
-
|
|
373
|
+
// PAY-1460: never persist a secret the environment supplied.
|
|
374
|
+
config: stripEnvSecrets(config),
|
|
298
375
|
expiresAt: Date.now() + (ttlSeconds * 1000)
|
|
299
376
|
};
|
|
300
377
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
@@ -355,7 +432,10 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
|
|
|
355
432
|
|
|
356
433
|
// Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
|
|
357
434
|
if (!forceRefresh) {
|
|
358
|
-
|
|
435
|
+
// PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
|
|
436
|
+
// environment is re-applied on read, BEFORE the emptiness test below.
|
|
437
|
+
const cachedRaw = await getConfigFromRedis();
|
|
438
|
+
const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
|
|
359
439
|
// A cached entry is only USABLE if it carries a secret — see the note
|
|
360
440
|
// below. `redisConfig && !authSecret` must behave like a cache MISS, so
|
|
361
441
|
// the emptiness test belongs here, in the condition that gates the
|
|
@@ -505,7 +585,7 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
|
|
|
505
585
|
// This throw is what stops that. It is also what turned the
|
|
506
586
|
// 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
|
|
507
587
|
// of a silently forgeable app.
|
|
508
|
-
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
|
|
588
|
+
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
509
589
|
}
|
|
510
590
|
|
|
511
591
|
// Validated above — only now is it safe to cache or publish.
|
|
@@ -588,7 +668,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
|
|
|
588
668
|
|
|
589
669
|
if (!resp.ok) {
|
|
590
670
|
const txt = await resp.text().catch(() => 'Unknown error');
|
|
591
|
-
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status}
|
|
671
|
+
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
|
|
592
672
|
}
|
|
593
673
|
|
|
594
674
|
const body: any = await resp.json().catch(() => null);
|
|
@@ -603,7 +683,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
|
|
|
603
683
|
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
|
|
604
684
|
}
|
|
605
685
|
|
|
606
|
-
const config: IDPClientConfig = {
|
|
686
|
+
const config: IDPClientConfig = applyEnvSecrets({
|
|
607
687
|
clientId: String(rawClientId),
|
|
608
688
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
609
689
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
@@ -669,13 +749,13 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
|
|
|
669
749
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
670
750
|
},
|
|
671
751
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
672
|
-
};
|
|
752
|
+
});
|
|
673
753
|
|
|
674
754
|
if (!config.authSecret) {
|
|
675
755
|
// LOAD-BEARING — see the note on the external path's equivalent throw.
|
|
676
756
|
// An empty secret reaching betterAuth() becomes the PUBLIC default, not an
|
|
677
757
|
// error. Never replace this with a fallback.
|
|
678
|
-
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
|
|
758
|
+
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
679
759
|
}
|
|
680
760
|
|
|
681
761
|
console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
|
|
@@ -751,7 +831,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
751
831
|
|
|
752
832
|
if (!signingResp.ok) {
|
|
753
833
|
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
754
|
-
|
|
834
|
+
// PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
|
|
835
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
|
|
755
836
|
}
|
|
756
837
|
|
|
757
838
|
const signingBody: any = await signingResp.json().catch(() => null);
|
|
@@ -768,7 +849,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
768
849
|
) as string | undefined;
|
|
769
850
|
|
|
770
851
|
if (!client_assertion) {
|
|
771
|
-
|
|
852
|
+
// PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
|
|
853
|
+
console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
|
|
772
854
|
throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
|
|
773
855
|
}
|
|
774
856
|
|
|
@@ -783,43 +865,13 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
783
865
|
'X-Client-Id': clientIdStr,
|
|
784
866
|
'X-Correlation-Id': randomUUID().replace(/-/g, ''),
|
|
785
867
|
},
|
|
786
|
-
|
|
787
|
-
//
|
|
788
|
-
// This broker is the ONLY consumer of oauth_providers[].client_secret
|
|
789
|
-
// (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
|
|
790
|
-
// which performs the server-side code exchange). Every OTHER caller of
|
|
791
|
-
// ExternalAuth/client-config - notably every fielded acp-desktop - is
|
|
792
|
-
// pushed the same secrets today and reads none of them: the desktop is
|
|
793
|
-
// PKCE and dropped clientSecret from OAuthProviderConfig entirely.
|
|
794
|
-
//
|
|
795
|
-
// The IDP IGNORES this field today, so shipping it is a no-op and needs
|
|
796
|
-
// no coordination window. Phase 2 (DotNetPert, IDP side) makes
|
|
797
|
-
// client-config emit the secrets ONLY when the opt-in is present and
|
|
798
|
-
// redact otherwise - which is why phase 1 MUST be live first, or the
|
|
799
|
-
// broker loses the field and tenant OAuth sign-in dies.
|
|
800
|
-
//
|
|
801
|
-
// SCOPE, deliberately narrow - do NOT read this as a partial migration:
|
|
802
|
-
// the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
|
|
803
|
-
// endpoint (InternalClientConfig/{id}, GET) that is already
|
|
804
|
-
// authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
|
|
805
|
-
// is therefore not part of 217917's exposure. It deliberately sends no
|
|
806
|
-
// opt-in. If phase 2's redaction is implemented in shared config-
|
|
807
|
-
// assembly code rather than in ExternalAuthController.GetClientConfig,
|
|
808
|
-
// it will ALSO strip the internal route and break this broker's other
|
|
809
|
-
// path with no opt-in to save it. Phase 2 must gate on the request.
|
|
810
|
-
//
|
|
811
|
-
// NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
|
|
812
|
-
// the AUTOMATIC delivery of every tenant's provider secrets to every
|
|
813
|
-
// user laptop; it does not close disclosure to a determined caller.
|
|
814
|
-
// That residual is card 175047 (unauthenticated mint, global partner
|
|
815
|
-
// key) and 217917 does not close until 175047 lands or Jon accepts it.
|
|
816
|
-
body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
|
|
868
|
+
body: JSON.stringify({ client_assertion }),
|
|
817
869
|
cache: 'no-store'
|
|
818
870
|
} as RequestInit);
|
|
819
871
|
|
|
820
872
|
if (!configResp.ok) {
|
|
821
873
|
const txt = await configResp.text().catch(() => 'Unknown error');
|
|
822
|
-
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status}
|
|
874
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
|
|
823
875
|
}
|
|
824
876
|
|
|
825
877
|
const configBody: any = await configResp.json().catch(() => null);
|
|
@@ -832,7 +884,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
832
884
|
const configData = configBody?.data;
|
|
833
885
|
|
|
834
886
|
if (!configData || typeof configData !== 'object') {
|
|
835
|
-
|
|
887
|
+
// PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
|
|
888
|
+
console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
|
|
836
889
|
throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
|
|
837
890
|
}
|
|
838
891
|
|
|
@@ -845,7 +898,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
845
898
|
// Map response to our interface (IDP returns camelCase or snake_case).
|
|
846
899
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
847
900
|
// nextAuthSecret/next_auth_secret while IDP rename rolls out.
|
|
848
|
-
const config: IDPClientConfig = {
|
|
901
|
+
const config: IDPClientConfig = applyEnvSecrets({
|
|
849
902
|
clientId: String(rawClientId),
|
|
850
903
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
851
904
|
authSecret:
|
|
@@ -919,7 +972,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
919
972
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
920
973
|
},
|
|
921
974
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
922
|
-
};
|
|
975
|
+
});
|
|
923
976
|
|
|
924
977
|
// Debug: log what we got for baseClientUrl
|
|
925
978
|
console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
|
|
@@ -931,7 +984,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
|
|
|
931
984
|
if (!config.authSecret) {
|
|
932
985
|
// LOAD-BEARING — empty must fail here, never downstream. betterAuth()
|
|
933
986
|
// silently substitutes a public constant for an empty secret.
|
|
934
|
-
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
|
|
987
|
+
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
935
988
|
}
|
|
936
989
|
|
|
937
990
|
// Success - reset failure tracking
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -58,7 +58,7 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
|
|
|
58
58
|
const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
|
|
59
59
|
console.log('[IDP_FETCH] ensureFreshAccessToken:', {
|
|
60
60
|
hasToken: !!token,
|
|
61
|
-
|
|
61
|
+
hasSessionToken: !!sessionToken, // PAY-1427 / BAPert 58829: no token material in logs, not even a stub (was a 20-char prefix)
|
|
62
62
|
pathname: req.nextUrl.pathname
|
|
63
63
|
});
|
|
64
64
|
if (!sessionToken) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1529 runtime backstop: the signing secret and the OAuth provider client secrets must never be
|
|
3
|
+
* serialized into an HTTP response body.
|
|
4
|
+
*
|
|
5
|
+
* The build-time guard (test/secrets-never-in-response-source-guard.test.cjs) follows values through the
|
|
6
|
+
* source it can see. This is the layer for what it cannot see — a value that crosses a module boundary,
|
|
7
|
+
* a structuredClone / Object.assign copy, a helper in a consumer app. It tracks the secret by VALUE:
|
|
8
|
+
* the loader registers every resolved secret here at the moment it resolves it (applyEnvSecrets), and a
|
|
9
|
+
* response built with `secretSafeJson` / checked with `assertNoServerSecret` FAILS CLOSED — it throws,
|
|
10
|
+
* and the route answers 500 — if any registered value appears anywhere in the serialized body, under any
|
|
11
|
+
* key, at any depth, whatever the code that built it called it.
|
|
12
|
+
*
|
|
13
|
+
* Bounds, stated rather than implied:
|
|
14
|
+
* - Values shorter than MIN_SECRET_LENGTH are not registered. A short string matches innocent bodies and
|
|
15
|
+
* would turn this into an outage generator; real signing and provider secrets are far longer.
|
|
16
|
+
* - It protects responses that go through it. Adoption is per handler; the build-time guard is what
|
|
17
|
+
* covers the handlers that do not.
|
|
18
|
+
* - Nothing here ever logs, returns or throws a secret value.
|
|
19
|
+
*/
|
|
20
|
+
import { NextResponse } from 'next/server';
|
|
21
|
+
|
|
22
|
+
export const MIN_SECRET_LENGTH = 16;
|
|
23
|
+
|
|
24
|
+
const registered = new Set<string>();
|
|
25
|
+
|
|
26
|
+
/** Record a value that must never appear in a response body. Non-strings and short strings are ignored. */
|
|
27
|
+
export function markNeverInResponse(value: unknown): void {
|
|
28
|
+
if (typeof value !== 'string' || value.length < MIN_SECRET_LENGTH) return;
|
|
29
|
+
registered.add(value);
|
|
30
|
+
// JSON escapes some characters, so the serialized form of a secret containing `"` or `\` differs from
|
|
31
|
+
// the raw value. Register that form too, or such a secret would pass through a JSON body unseen.
|
|
32
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
33
|
+
if (escaped !== value) registered.add(escaped);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SecretInResponseError extends Error {
|
|
37
|
+
constructor(where: string) {
|
|
38
|
+
// The message names the route, never the value.
|
|
39
|
+
super(`[PAY-1529] Refused to serialize a server secret into the response of ${where}`);
|
|
40
|
+
this.name = 'SecretInResponseError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Throws SecretInResponseError if any registered secret appears in `body` once serialized. */
|
|
45
|
+
export function assertNoServerSecret(body: unknown, where: string): void {
|
|
46
|
+
if (registered.size === 0 || body === undefined || body === null) return;
|
|
47
|
+
let text: string;
|
|
48
|
+
try {
|
|
49
|
+
text = typeof body === 'string' ? body : JSON.stringify(body);
|
|
50
|
+
} catch {
|
|
51
|
+
// Unserializable: NextResponse.json would throw on it anyway. Fail closed rather than guess.
|
|
52
|
+
throw new SecretInResponseError(where);
|
|
53
|
+
}
|
|
54
|
+
if (!text) return;
|
|
55
|
+
for (const secret of registered) {
|
|
56
|
+
if (text.includes(secret)) throw new SecretInResponseError(where);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** NextResponse.json, refusing any body that carries a registered secret. */
|
|
61
|
+
export function secretSafeJson<T>(body: T, init: ResponseInit | undefined, where: string): NextResponse<T> {
|
|
62
|
+
assertNoServerSecret(body, where);
|
|
63
|
+
return NextResponse.json(body, init);
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
16
|
+
export function passwordRulesText(policy: PasswordPolicyLike | undefined | null): string {
|
|
17
|
+
if (!policy || typeof policy.min_length !== 'number') {
|
|
18
|
+
return "Use a password that meets this site's rules. We'll show what is missing as you type.";
|
|
19
|
+
}
|
|
20
|
+
const parts = [`at least ${policy.min_length} characters`];
|
|
21
|
+
if (policy.require_uppercase) parts.push('an upper-case letter');
|
|
22
|
+
if (policy.require_lowercase) parts.push('a lower-case letter');
|
|
23
|
+
if (policy.require_digit) parts.push('a number');
|
|
24
|
+
if (policy.require_special) parts.push('a symbol');
|
|
25
|
+
const [first, ...rest] = parts;
|
|
26
|
+
return rest.length ? `Your password needs ${first}, with ${rest.join(', ')}.` : `Your password needs ${first}.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
|
|
31
|
+
* portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
|
|
32
|
+
*/
|
|
33
|
+
export const PASSWORD_RULE_SENTENCES: Readonly<Record<string, string>> = {
|
|
34
|
+
min_length: 'Use a longer password.',
|
|
35
|
+
uppercase: 'Add an upper-case letter.',
|
|
36
|
+
lowercase: 'Add a lower-case letter.',
|
|
37
|
+
digit: 'Add a number.',
|
|
38
|
+
special: 'Add a symbol.',
|
|
39
|
+
unique_chars: 'Use more different characters.',
|
|
40
|
+
recent_password: "Choose a password you haven't used recently.",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function knownPasswordRules(rules: unknown): string[] {
|
|
44
|
+
if (!Array.isArray(rules)) return [];
|
|
45
|
+
return rules.filter((r): r is string => typeof r === 'string' && Object.prototype.hasOwnProperty.call(PASSWORD_RULE_SENTENCES, r));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function passwordRuleSentences(rules: string[]): string {
|
|
49
|
+
return rules.map((r) => PASSWORD_RULE_SENTENCES[r]).join(' ');
|
|
50
|
+
}
|