@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/src/lib/session-store.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { randomBytes } from 'crypto';
|
|
|
13
13
|
import { SessionData } from '../models/SessionModel';
|
|
14
14
|
import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
|
|
15
15
|
import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
16
|
+
import { tokenLogId } from './token-log-id';
|
|
16
17
|
|
|
17
18
|
// Re-export SessionData for consumers
|
|
18
19
|
export type { SessionData } from '../models/SessionModel';
|
|
@@ -293,11 +294,29 @@ export async function setSession(sessionToken: string, data: SessionData): Promi
|
|
|
293
294
|
.exec();
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Describe a credential-bearing value for a log line WITHOUT the value: its kind only.
|
|
299
|
+
* Deliberately no length or fingerprint either - both narrow the value down.
|
|
300
|
+
*/
|
|
301
|
+
function describeTokenValueForLog(v: unknown): string {
|
|
302
|
+
if (v === undefined) return 'undefined';
|
|
303
|
+
if (v === null) return 'null';
|
|
304
|
+
if (typeof v === 'string') return v.length === 0 ? 'empty string' : 'non-empty string (value withheld)';
|
|
305
|
+
return `${typeof v} (value withheld)`;
|
|
306
|
+
}
|
|
307
|
+
|
|
296
308
|
/**
|
|
297
309
|
* Updates tokens within an existing session.
|
|
310
|
+
*
|
|
311
|
+
* CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
|
|
312
|
+
* row was absent and EVERY field in `updates` was discarded — it is a dropped
|
|
313
|
+
* write, not a no-op on unchanged data. Ignoring it is how a token refresh came
|
|
314
|
+
* to report success while persisting nothing.
|
|
315
|
+
*
|
|
298
316
|
* @param sessionToken The session token to update.
|
|
299
317
|
* @param updates Partial session data to update.
|
|
300
|
-
* @returns The updated session data, or null if the session was not found
|
|
318
|
+
* @returns The updated session data, or null if the session was not found — in
|
|
319
|
+
* which case NOTHING WAS WRITTEN.
|
|
301
320
|
*/
|
|
302
321
|
export async function updateSession(
|
|
303
322
|
sessionToken: string,
|
|
@@ -309,6 +328,74 @@ export async function updateSession(
|
|
|
309
328
|
// Get current session to merge with updates
|
|
310
329
|
const currentSession = await getSession(sessionToken);
|
|
311
330
|
if (!currentSession) {
|
|
331
|
+
/**
|
|
332
|
+
* 320072: THIS IS A DROPPED WRITE, AND IT USED TO LEAVE NO TRACE.
|
|
333
|
+
*
|
|
334
|
+
* Returning null here is correct — there is no row to merge into — but it
|
|
335
|
+
* was previously done in total silence: no throw, no log. Every caller that
|
|
336
|
+
* ignores the return value therefore believed it had persisted, and the
|
|
337
|
+
* most damaging caller did exactly that (ensure-fresh-access-token).
|
|
338
|
+
*
|
|
339
|
+
* The canonical row can legitimately be absent: better-auth.ts writes the
|
|
340
|
+
* `ba:` record and the canonical row in two steps, and the second is
|
|
341
|
+
* wrapped in a try/catch that only console.warn()s — a deliberate,
|
|
342
|
+
* documented degradation that leaves the BA session correct and this row
|
|
343
|
+
* missing. That decision is fine. What was NOT fine is that the READ side
|
|
344
|
+
* reported success anyway, so nothing ever surfaced it.
|
|
345
|
+
*
|
|
346
|
+
* Logged at error, not warn: the caller's data is gone. Callers that can
|
|
347
|
+
* tolerate a missing row should check the null return — which is now the
|
|
348
|
+
* documented contract — rather than rely on this being quiet.
|
|
349
|
+
*/
|
|
350
|
+
/**
|
|
351
|
+
* DO NOT LOG `key` HERE. `getSessionKey()` (line 21) is
|
|
352
|
+
* `${getSessionPrefix()}${token}` — the prefix plus the WHOLE session
|
|
353
|
+
* token — so logging it writes a live, replayable credential into
|
|
354
|
+
* console.error, and logs travel further than the process that wrote them.
|
|
355
|
+
* The redacting idiom in this file is `substring(0, 8) + '...'`.
|
|
356
|
+
*
|
|
357
|
+
* NOTE ON WHAT THIS COMMENT NO LONGER CLAIMS. It used to say this was the
|
|
358
|
+
* single unredacted departure in the file, and gave a site count. Both were
|
|
359
|
+
* wrong: a RAW refresh token was being emitted at the
|
|
360
|
+
* REFRESH_TOKEN_BEING_CLEARED site below, and the count was stale. QAPert found that
|
|
361
|
+
* leak while reviewing PR 96 — he looked *because* this comment asserted
|
|
362
|
+
* there was nothing to find, which is the worst thing a comment can do.
|
|
363
|
+
* It is fixed in the same commit as this rewrite.
|
|
364
|
+
*
|
|
365
|
+
* So the counts are gone deliberately. A tally in prose decays silently on
|
|
366
|
+
* the next edit and then actively misleads; that decay is the defect being
|
|
367
|
+
* repaired here, and reproducing it in the fix would be absurd. If you need
|
|
368
|
+
* a number, measure it — do not trust this paragraph for one.
|
|
369
|
+
*
|
|
370
|
+
* What replaces the prose is enforcement. The invariant is
|
|
371
|
+
* NO RAW CREDENTIAL IS EVER EMITTED FROM THIS FUNCTION, and it is pinned by
|
|
372
|
+
* executable assertions, not by this text:
|
|
373
|
+
* - Case 4 (session token, this log)
|
|
374
|
+
* - Case 5 (refresh token, the REFRESH_TOKEN_BEING_CLEARED log below)
|
|
375
|
+
* in test/ensure-fresh-persist-failure.test.cjs. Both were proven to fail
|
|
376
|
+
* when their fix is mutated out. Delete a redaction and a test goes red;
|
|
377
|
+
* delete this comment and nothing does. Trust the tests.
|
|
378
|
+
*
|
|
379
|
+
* Caught by QAPert on 320072 and made binding by BAPert (R1043 s1).
|
|
380
|
+
*
|
|
381
|
+
* The prefix is logged SEPARATELY and deliberately, because it is the field
|
|
382
|
+
* that makes this line diagnostic rather than decorative: a "no canonical
|
|
383
|
+
* row" miss has two very different causes, and only the prefix tells them
|
|
384
|
+
* apart —
|
|
385
|
+
* wrong keyspace (slug/prefix disagreement — the 241875 / 213416 defect
|
|
386
|
+
* documented at line 111, where a writer and a reader
|
|
387
|
+
* resolved different slugs and every lookup silently
|
|
388
|
+
* missed), versus
|
|
389
|
+
* absent row (the documented better-auth.ts two-step degradation).
|
|
390
|
+
* The prefix is an app slug, not a secret. Redacting it too would leave a
|
|
391
|
+
* log that says only "something was missing".
|
|
392
|
+
*/
|
|
393
|
+
console.error('[SESSION-STORE] UPDATE_DROPPED_NO_CANONICAL_ROW', {
|
|
394
|
+
keyPrefix: getSessionPrefix(),
|
|
395
|
+
sessionToken: tokenLogId(sessionToken),
|
|
396
|
+
updatedFields: Object.keys(updates),
|
|
397
|
+
note: 'updateSession found no session row; ALL updates were discarded and null was returned',
|
|
398
|
+
});
|
|
312
399
|
return null;
|
|
313
400
|
}
|
|
314
401
|
|
|
@@ -322,10 +409,15 @@ export async function updateSession(
|
|
|
322
409
|
|
|
323
410
|
if (hadRefreshToken && !willHaveRefreshToken) {
|
|
324
411
|
console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
|
|
325
|
-
sessionToken: sessionToken
|
|
412
|
+
sessionToken: tokenLogId(sessionToken),
|
|
326
413
|
userId: currentSession.userId,
|
|
327
414
|
updateKeys: Object.keys(updates),
|
|
328
|
-
|
|
415
|
+
// NEVER the value: a refresh token is a long-lived credential and this line ships to the
|
|
416
|
+
// log sink. The old `idpRefreshToken || refreshToken` printed a live legacy token raw when
|
|
417
|
+
// idpRefreshToken was null/"" (which clears) and refreshToken still held one.
|
|
418
|
+
// Test: test/session-store-refresh-token-not-logged.test.cjs
|
|
419
|
+
idpRefreshTokenInUpdate: describeTokenValueForLog((updates as any).idpRefreshToken),
|
|
420
|
+
legacyRefreshTokenInUpdate: describeTokenValueForLog((updates as any).refreshToken),
|
|
329
421
|
refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
|
|
330
422
|
stack: new Error().stack
|
|
331
423
|
});
|
|
@@ -380,15 +472,66 @@ function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData>
|
|
|
380
472
|
|
|
381
473
|
const fields: Partial<SessionData> = {};
|
|
382
474
|
|
|
475
|
+
/**
|
|
476
|
+
* *** TEST THE CONVERTED VALUE, NOT THE CLAIM'S PRESENCE. ***
|
|
477
|
+
*
|
|
478
|
+
* This guard previously branched on `claim !== undefined && claim !== null`
|
|
479
|
+
* and then wrote `Number(claim) * 1000`. Those are two different questions,
|
|
480
|
+
* and for a non-numeric claim they disagree: the claim IS present, so the
|
|
481
|
+
* branch is taken, and the value written is NaN. That single NaN defeats both
|
|
482
|
+
* guards in this system at once, in opposite directions:
|
|
483
|
+
*
|
|
484
|
+
* - `mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined` is TRUE
|
|
485
|
+
* for NaN, so R908(e) certifies a ceremony it never observed and GRANTS
|
|
486
|
+
* mfaVerified.
|
|
487
|
+
* - consumers read `mfaExpiresAt || 0`, and NaN is FALSY, so that collapses
|
|
488
|
+
* to 0, `0 > 0` is false, and the session NEVER expires.
|
|
489
|
+
*
|
|
490
|
+
* Net: `mfaVerified: true` that nothing can ever expire — the exact
|
|
491
|
+
* pre-339563 fail-open described at the top of this function, restored
|
|
492
|
+
* through a malformed claim, and silent (neither warning below fires on NaN).
|
|
493
|
+
*
|
|
494
|
+
* So a claim that will not convert is treated as NO CLAIM: the key is OMITTED
|
|
495
|
+
* and R908(e) withholds the grant, identical to the absent-claim lane. Absent
|
|
496
|
+
* and unreadable are the same epistemic state — we do not know the expiry —
|
|
497
|
+
* and they must not produce opposite security outcomes.
|
|
498
|
+
*
|
|
499
|
+
* Omitted, never set-to-undefined: updateSession() merges with
|
|
500
|
+
* `{ ...current, ...updates }`, so a present-but-undefined key would CLOBBER
|
|
501
|
+
* a good stored value. Pinned by test/mfa-expiry-malformed-claim.test.cjs.
|
|
502
|
+
*
|
|
503
|
+
* *** `Number.isFinite` ALONE IS NOT ENOUGH — IT MUST ALSO BE POSITIVE. ***
|
|
504
|
+
* `Number([])` is 0, and 0 is perfectly finite. But the consumer guard reads
|
|
505
|
+
* `mfaExpiresAt || 0`, and 0 is FALSY, so a stored zero is indistinguishable
|
|
506
|
+
* from a missing value and fails open in precisely the same way NaN does —
|
|
507
|
+
* while still passing `!== undefined` and so still earning the R908(e) grant.
|
|
508
|
+
* The honest invariant is not "is it a number" but "can this value ever
|
|
509
|
+
* expire a session", and only a positive one can. A zero/negative
|
|
510
|
+
* `mfa_expires` is meaningless anyway (epoch 1970 or earlier).
|
|
511
|
+
*
|
|
512
|
+
* The `typeof` gate rejects objects and arrays outright rather than letting
|
|
513
|
+
* JS coercion invent a number for them: `[]` -> 0 and `[5]` -> 5 are accidents
|
|
514
|
+
* of coercion, not claims the IDP made.
|
|
515
|
+
*/
|
|
516
|
+
const claimToNumber = (claim: unknown): number | undefined => {
|
|
517
|
+
if (typeof claim !== 'number' && typeof claim !== 'string') return undefined;
|
|
518
|
+
if (claim === '') return undefined;
|
|
519
|
+
const n = Number(claim);
|
|
520
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
521
|
+
};
|
|
522
|
+
|
|
383
523
|
// Claims are Unix SECONDS; SessionData stores milliseconds.
|
|
384
|
-
|
|
385
|
-
|
|
524
|
+
const mfaExpires = claimToNumber(decoded.mfa_expires);
|
|
525
|
+
if (mfaExpires !== undefined) {
|
|
526
|
+
fields.mfaExpiresAt = mfaExpires * 1000;
|
|
386
527
|
}
|
|
387
|
-
|
|
388
|
-
|
|
528
|
+
const mfaTime = claimToNumber(decoded.mfa_time);
|
|
529
|
+
if (mfaTime !== undefined) {
|
|
530
|
+
fields.mfaCompletedAt = mfaTime * 1000;
|
|
389
531
|
}
|
|
390
|
-
|
|
391
|
-
|
|
532
|
+
const mfaValidityHours = claimToNumber(decoded.mfa_validity_hours);
|
|
533
|
+
if (mfaValidityHours !== undefined) {
|
|
534
|
+
fields.mfaValidityHours = mfaValidityHours;
|
|
392
535
|
}
|
|
393
536
|
|
|
394
537
|
return fields;
|
|
@@ -419,7 +562,7 @@ export async function transitionTo2FASession(
|
|
|
419
562
|
const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
|
|
420
563
|
|
|
421
564
|
console.log('[transitionTo2FASession] Called with:', {
|
|
422
|
-
sessionToken: sessionToken
|
|
565
|
+
sessionToken: tokenLogId(sessionToken),
|
|
423
566
|
mfaMethod,
|
|
424
567
|
hasAccessToken: !!newAccessToken,
|
|
425
568
|
hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
|
|
@@ -483,7 +626,7 @@ export async function transitionTo2FASession(
|
|
|
483
626
|
'If this fires for a lane that DID complete a real ceremony, the defect is ' +
|
|
484
627
|
'on the emit side (R908(b)) - do not "fix" it by minting the flag here.',
|
|
485
628
|
{
|
|
486
|
-
sessionToken: sessionToken
|
|
629
|
+
sessionToken: tokenLogId(sessionToken),
|
|
487
630
|
mfaMethod,
|
|
488
631
|
hasAccessToken: !!newAccessToken,
|
|
489
632
|
}
|
|
@@ -699,7 +842,7 @@ export async function acquireRefreshLock(
|
|
|
699
842
|
|
|
700
843
|
if (result === 'OK') {
|
|
701
844
|
console.debug('[SESSION-STORE] Refresh lock acquired', {
|
|
702
|
-
sessionToken: sessionToken
|
|
845
|
+
sessionToken: tokenLogId(sessionToken),
|
|
703
846
|
requestId,
|
|
704
847
|
lockVersion
|
|
705
848
|
});
|
|
@@ -709,7 +852,7 @@ export async function acquireRefreshLock(
|
|
|
709
852
|
// Lock already exists, check if we should wait
|
|
710
853
|
if (maxWaitMs > 0) {
|
|
711
854
|
console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
|
|
712
|
-
sessionToken: sessionToken
|
|
855
|
+
sessionToken: tokenLogId(sessionToken),
|
|
713
856
|
requestId,
|
|
714
857
|
maxWaitMs
|
|
715
858
|
});
|
|
@@ -718,7 +861,7 @@ export async function acquireRefreshLock(
|
|
|
718
861
|
}
|
|
719
862
|
|
|
720
863
|
console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
|
|
721
|
-
sessionToken: sessionToken
|
|
864
|
+
sessionToken: tokenLogId(sessionToken),
|
|
722
865
|
requestId
|
|
723
866
|
});
|
|
724
867
|
|
|
@@ -726,7 +869,7 @@ export async function acquireRefreshLock(
|
|
|
726
869
|
}
|
|
727
870
|
} catch (error) {
|
|
728
871
|
console.error('[SESSION-STORE] Failed to acquire refresh lock', {
|
|
729
|
-
sessionToken: sessionToken
|
|
872
|
+
sessionToken: tokenLogId(sessionToken),
|
|
730
873
|
requestId,
|
|
731
874
|
error: error instanceof Error ? error.message : String(error)
|
|
732
875
|
});
|
|
@@ -760,7 +903,7 @@ async function waitForRefreshLockRelease(
|
|
|
760
903
|
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
761
904
|
} catch (error) {
|
|
762
905
|
console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
|
|
763
|
-
sessionToken: sessionToken
|
|
906
|
+
sessionToken: tokenLogId(sessionToken),
|
|
764
907
|
requestId,
|
|
765
908
|
error: error instanceof Error ? error.message : String(error)
|
|
766
909
|
});
|
|
@@ -769,7 +912,7 @@ async function waitForRefreshLockRelease(
|
|
|
769
912
|
}
|
|
770
913
|
|
|
771
914
|
console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
|
|
772
|
-
sessionToken: sessionToken
|
|
915
|
+
sessionToken: tokenLogId(sessionToken),
|
|
773
916
|
requestId,
|
|
774
917
|
waitedMs: Date.now() - startTime
|
|
775
918
|
});
|
|
@@ -823,33 +966,33 @@ export async function releaseRefreshLock(
|
|
|
823
966
|
|
|
824
967
|
if (result === 1) {
|
|
825
968
|
console.debug('[SESSION-STORE] Refresh lock released successfully', {
|
|
826
|
-
sessionToken: sessionToken
|
|
969
|
+
sessionToken: tokenLogId(sessionToken),
|
|
827
970
|
requestId,
|
|
828
971
|
lockVersion
|
|
829
972
|
});
|
|
830
973
|
return true;
|
|
831
974
|
} else if (result === 0) {
|
|
832
975
|
console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
|
|
833
|
-
sessionToken: sessionToken
|
|
976
|
+
sessionToken: tokenLogId(sessionToken),
|
|
834
977
|
requestId
|
|
835
978
|
});
|
|
836
979
|
return false;
|
|
837
980
|
} else if (result === -1) {
|
|
838
981
|
console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
|
|
839
|
-
sessionToken: sessionToken
|
|
982
|
+
sessionToken: tokenLogId(sessionToken),
|
|
840
983
|
requestId
|
|
841
984
|
});
|
|
842
985
|
return false;
|
|
843
986
|
} else if (result === -2) {
|
|
844
987
|
console.error('[SESSION-STORE] Lock version mismatch during release', {
|
|
845
|
-
sessionToken: sessionToken
|
|
988
|
+
sessionToken: tokenLogId(sessionToken),
|
|
846
989
|
requestId,
|
|
847
990
|
lockVersion
|
|
848
991
|
});
|
|
849
992
|
return false;
|
|
850
993
|
} else {
|
|
851
994
|
console.error('[SESSION-STORE] Unexpected result from lock release script', {
|
|
852
|
-
sessionToken: sessionToken
|
|
995
|
+
sessionToken: tokenLogId(sessionToken),
|
|
853
996
|
requestId,
|
|
854
997
|
result
|
|
855
998
|
});
|
|
@@ -857,7 +1000,7 @@ export async function releaseRefreshLock(
|
|
|
857
1000
|
}
|
|
858
1001
|
} catch (error) {
|
|
859
1002
|
console.error('[SESSION-STORE] Failed to release refresh lock', {
|
|
860
|
-
sessionToken: sessionToken
|
|
1003
|
+
sessionToken: tokenLogId(sessionToken),
|
|
861
1004
|
requestId,
|
|
862
1005
|
error: error instanceof Error ? error.message : String(error)
|
|
863
1006
|
});
|
|
@@ -882,7 +1025,7 @@ export async function checkRefreshLock(sessionToken: string): Promise<RefreshLoc
|
|
|
882
1025
|
return JSON.parse(lockData) as RefreshLockInfo;
|
|
883
1026
|
} catch (error) {
|
|
884
1027
|
console.error('[SESSION-STORE] Failed to check refresh lock', {
|
|
885
|
-
sessionToken: sessionToken
|
|
1028
|
+
sessionToken: tokenLogId(sessionToken),
|
|
886
1029
|
error: error instanceof Error ? error.message : String(error)
|
|
887
1030
|
});
|
|
888
1031
|
|
package/src/lib/startup-init.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import 'server-only';
|
|
14
14
|
import { getIDPClientConfig, clearConfigRedisCache, type IDPClientConfig } from './idp-client-config';
|
|
15
|
+
import { slugStateReason } from './app-slug';
|
|
15
16
|
|
|
16
17
|
let initializationStarted = false;
|
|
17
18
|
let initializationComplete = false;
|
|
@@ -59,9 +60,21 @@ export async function ensureInitialized(): Promise<void> {
|
|
|
59
60
|
// Mark as started
|
|
60
61
|
initializationStarted = true;
|
|
61
62
|
|
|
62
|
-
// Start initialization
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Start initialization.
|
|
64
|
+
//
|
|
65
|
+
// PAY-642: a FAILED attempt must not stay installed. This used to be
|
|
66
|
+
// `initializationPromise = performInitialization(); await initializationPromise;` with no reset, and the
|
|
67
|
+
// in-progress check above runs BEFORE the backoff check, so a pod that booted while the IdP was down replayed
|
|
68
|
+
// its first rejection to every caller forever: the 30 s backoff could never lead to a retry. Same latch, same
|
|
69
|
+
// fix, as createSingleFlight (lib/single-flight.ts): callers of the failing attempt still see the rejection
|
|
70
|
+
// (loud), then the attempt clears itself, identity-guarded so a slow failure never evicts a newer attempt.
|
|
71
|
+
// After a failure the backoff check above governs when the next attempt may start.
|
|
72
|
+
const attempt = performInitialization();
|
|
73
|
+
initializationPromise = attempt;
|
|
74
|
+
attempt.catch(() => {
|
|
75
|
+
if (initializationPromise === attempt) initializationPromise = null;
|
|
76
|
+
});
|
|
77
|
+
await attempt;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/**
|
|
@@ -168,8 +181,11 @@ async function performInitialization(): Promise<void> {
|
|
|
168
181
|
// Step 3: Validate cookie name consistency
|
|
169
182
|
// This catches bugs where getJwtCookieName() returns a different name than
|
|
170
183
|
// what auth-options.ts configures, which causes sessions to fail in production
|
|
171
|
-
const { validateCookieNameConsistency, getSessionCookieName } = await import('./app-slug');
|
|
184
|
+
const { validateCookieNameConsistency, getSessionCookieName, assertAppSlugMatchesIdp, assertIdpSlugKnown } = await import('./app-slug');
|
|
172
185
|
validateCookieNameConsistency();
|
|
186
|
+
// rigpert 60743: refuse to run with two cookie-name sources (IdP clientSlug vs APP_SLUG/CLIENT_ID).
|
|
187
|
+
assertIdpSlugKnown(cachedIDPConfig?.clientSlug);
|
|
188
|
+
assertAppSlugMatchesIdp(cachedIDPConfig?.clientSlug);
|
|
173
189
|
console.log('[STARTUP] Cookie name consistency validated:', getSessionCookieName());
|
|
174
190
|
|
|
175
191
|
// All done
|
|
@@ -225,6 +241,15 @@ ${connectionLine}║ Possible causes:
|
|
|
225
241
|
* Get the cached IDP config after initialization.
|
|
226
242
|
* Returns null if not yet initialized.
|
|
227
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* For health endpoints (rigpert 60765): a guard that does not stop the process is an empty red unless the mismatch is visible
|
|
246
|
+
* where operators and promote-fe look. Returns `SLUG_MISMATCH: cookie=<idp slug> gate=<app slug>` while the drift holds, else null;
|
|
247
|
+
* a health route should answer non-200 with this as the reason. Null until the IdP config has been fetched.
|
|
248
|
+
*/
|
|
249
|
+
export function getSlugMismatchReason(): string | null {
|
|
250
|
+
return slugStateReason(cachedIDPConfig?.clientSlug, initializationComplete || initializationFailed);
|
|
251
|
+
}
|
|
252
|
+
|
|
228
253
|
export function getStartupIDPConfig(): IDPClientConfig | null {
|
|
229
254
|
return cachedIDPConfig;
|
|
230
255
|
}
|
|
@@ -25,6 +25,7 @@ import { getSession as getRedisSession, SessionData } from './session-store';
|
|
|
25
25
|
import { getSession as getBetterAuthSession } from '../server/auth';
|
|
26
26
|
import { getRedis } from './redis';
|
|
27
27
|
import { getAppSlug } from './app-slug';
|
|
28
|
+
import { tokenLogId } from './token-log-id';
|
|
28
29
|
|
|
29
30
|
// 5 minute threshold for "needs refresh" - matches refresh handler pattern
|
|
30
31
|
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
|
|
@@ -437,7 +438,7 @@ export async function ensureFreshToken(
|
|
|
437
438
|
}
|
|
438
439
|
|
|
439
440
|
console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
|
|
440
|
-
sessionToken: sessionToken
|
|
441
|
+
sessionToken: tokenLogId(sessionToken),
|
|
441
442
|
accessTokenExpires: tokenExpiresStr,
|
|
442
443
|
now: new Date().toISOString(),
|
|
443
444
|
needsRefresh: needsRefreshNow,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tokenLogId - a log-safe correlation id for a session token (PR 154 follow-up, BAPert ruling on PAY-1427:
|
|
3
|
+
* "no token material in logs, not even partial").
|
|
4
|
+
*
|
|
5
|
+
* The package used to log `sessionToken.substring(0, 8) + '...'` so lines about one session could be
|
|
6
|
+
* correlated. Those 8 characters ARE token material. This returns `h:` + the first 12 hex characters of
|
|
7
|
+
* SHA-256(token) instead: the same correlation (one token -> one stable id, across every log line and
|
|
8
|
+
* process), with nothing of the token itself.
|
|
9
|
+
*
|
|
10
|
+
* Synchronous and dependency-free on purpose: the call sites include Edge middleware
|
|
11
|
+
* (create-middleware, auth-decision) and browser code (authStore, SessionSync), where node:crypto is
|
|
12
|
+
* unavailable and Web Crypto's digest is async. SHA-256 below follows FIPS 180-4 and is verified
|
|
13
|
+
* against node:crypto in test/session-token-log-id.test.cjs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const K = new Uint32Array([
|
|
17
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
18
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
19
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
20
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
21
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
22
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
23
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
24
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
|
|
28
|
+
|
|
29
|
+
/** SHA-256 of the UTF-8 encoding of `input`, as lowercase hex. */
|
|
30
|
+
export function sha256Hex(input: string): string {
|
|
31
|
+
const msg = new TextEncoder().encode(input);
|
|
32
|
+
const bitLen = msg.length * 8;
|
|
33
|
+
const padded = new Uint8Array(((msg.length + 9 + 63) >> 6) << 6);
|
|
34
|
+
padded.set(msg);
|
|
35
|
+
padded[msg.length] = 0x80;
|
|
36
|
+
const view = new DataView(padded.buffer);
|
|
37
|
+
view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
|
|
38
|
+
view.setUint32(padded.length - 4, bitLen >>> 0);
|
|
39
|
+
|
|
40
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
41
|
+
const w = new Uint32Array(64);
|
|
42
|
+
for (let off = 0; off < padded.length; off += 64) {
|
|
43
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
|
|
44
|
+
for (let i = 16; i < 64; i++) {
|
|
45
|
+
const s15 = w[i - 15];
|
|
46
|
+
const s2 = w[i - 2];
|
|
47
|
+
const sigma0 = rotr(s15, 7) ^ rotr(s15, 18) ^ (s15 >>> 3);
|
|
48
|
+
const sigma1 = rotr(s2, 17) ^ rotr(s2, 19) ^ (s2 >>> 10);
|
|
49
|
+
w[i] = (w[i - 16] + sigma0 + w[i - 7] + sigma1) | 0;
|
|
50
|
+
}
|
|
51
|
+
let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
|
52
|
+
for (let i = 0; i < 64; i++) {
|
|
53
|
+
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
|
|
54
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
|
|
55
|
+
hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
|
|
56
|
+
}
|
|
57
|
+
h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0;
|
|
58
|
+
h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + hh) | 0;
|
|
59
|
+
}
|
|
60
|
+
let out = '';
|
|
61
|
+
for (let i = 0; i < 8; i++) out += (h[i] >>> 0).toString(16).padStart(8, '0');
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `h:` + 12 hex of SHA-256(token); `none` when there is no token. Never any part of the token itself. */
|
|
66
|
+
export function tokenLogId(token: string | null | undefined): string {
|
|
67
|
+
if (typeof token !== 'string' || token.length === 0) return 'none';
|
|
68
|
+
return 'h:' + sha256Hex(token).slice(0, 12);
|
|
69
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
|
|
3
|
+
*
|
|
4
|
+
* Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
|
|
5
|
+
* authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
|
|
6
|
+
* The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
|
|
7
|
+
* user-facing string:
|
|
8
|
+
* - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
|
|
9
|
+
* `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
|
|
10
|
+
* (or a DTO's TYPE NAME for a typed body);
|
|
11
|
+
* - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
|
|
12
|
+
* message is "An error occurred" and the real text sits only in error.details.message.
|
|
13
|
+
*
|
|
14
|
+
* So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
|
|
15
|
+
* - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
|
|
16
|
+
* - a stable `code` the UI can switch on;
|
|
17
|
+
* - one of the fixed sentences below.
|
|
18
|
+
*/
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
|
|
21
|
+
export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
|
|
22
|
+
|
|
23
|
+
export type TwoFactorVerifyFailureCode =
|
|
24
|
+
| 'INVALID_CODE'
|
|
25
|
+
| 'LOCKED'
|
|
26
|
+
| 'RATE_LIMITED'
|
|
27
|
+
| 'SESSION_REJECTED'
|
|
28
|
+
| 'VERIFY_UNAVAILABLE'
|
|
29
|
+
| 'VERIFY_FAILED';
|
|
30
|
+
|
|
31
|
+
const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
|
|
32
|
+
totp: 'Invalid authentication code. Please try again.',
|
|
33
|
+
sms: 'Invalid verification code. Please try again.',
|
|
34
|
+
email: 'Invalid verification code. Please try again.',
|
|
35
|
+
backup_code: 'Invalid backup code. Please try again.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE'>, string> = {
|
|
39
|
+
LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
|
|
40
|
+
RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
|
|
41
|
+
SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
|
|
42
|
+
VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
|
|
43
|
+
VERIFY_FAILED: 'We could not verify that code. Please try again.',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
|
|
48
|
+
* ToString envelope.
|
|
49
|
+
*
|
|
50
|
+
* CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with word
|
|
51
|
+
* boundaries around `code` and `locked`. The literal that landed on disk was not the two
|
|
52
|
+
* characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
|
|
53
|
+
* and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
|
|
54
|
+
* literal backspace control character where a word-boundary assertion was intended matches
|
|
55
|
+
* nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
|
|
56
|
+
* because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
|
|
57
|
+
* line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
|
|
58
|
+
* load-bearing for the shipped control cases (a bare code=locked substring is specific enough
|
|
59
|
+
* here - the IdP field name is literally code), so this drops them rather than re-risk the same
|
|
60
|
+
* invisible-byte trap.
|
|
61
|
+
*/
|
|
62
|
+
const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
|
|
66
|
+
* the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
|
|
67
|
+
* the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
|
|
68
|
+
*/
|
|
69
|
+
const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
|
|
70
|
+
|
|
71
|
+
const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every string the IDP might have put anywhere in the error envelope, unwrapped from
|
|
75
|
+
* `{ message = X }` ToString form. Classification input only.
|
|
76
|
+
*/
|
|
77
|
+
export function upstreamErrorTexts(json: unknown): string[] {
|
|
78
|
+
const out: string[] = [];
|
|
79
|
+
const add = (v: unknown) => {
|
|
80
|
+
if (typeof v !== 'string' || !v) return;
|
|
81
|
+
out.push(v);
|
|
82
|
+
const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
|
|
83
|
+
if (m) out.push(m[1]);
|
|
84
|
+
};
|
|
85
|
+
const j = json as Record<string, any> | null | undefined;
|
|
86
|
+
if (!j || typeof j !== 'object') return out;
|
|
87
|
+
add(j.message);
|
|
88
|
+
add(j.code);
|
|
89
|
+
add(j.error);
|
|
90
|
+
if (j.error && typeof j.error === 'object') {
|
|
91
|
+
add(j.error.message);
|
|
92
|
+
add(j.error.code);
|
|
93
|
+
const d = j.error.details;
|
|
94
|
+
if (d && typeof d === 'object') { add(d.message); add(d.Message); add(d.code); add(d.errorCode); }
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
|
|
100
|
+
// PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
|
|
101
|
+
// the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
|
|
102
|
+
//
|
|
103
|
+
// PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
|
|
104
|
+
// code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
|
|
105
|
+
// just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
|
|
106
|
+
// still RATE_LIMITED, below.
|
|
107
|
+
if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t))) return 'LOCKED';
|
|
108
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
109
|
+
// PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
|
|
110
|
+
if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t))) return 'VERIFY_UNAVAILABLE';
|
|
111
|
+
if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t))) return 'INVALID_CODE';
|
|
112
|
+
if (status === 401 || status === 403) return 'SESSION_REJECTED';
|
|
113
|
+
if (status >= 500 || status === 0) return 'VERIFY_UNAVAILABLE';
|
|
114
|
+
return 'VERIFY_FAILED';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function twoFactorVerifyFailure(
|
|
118
|
+
result: { status: number; json: unknown; attemptedRefresh: boolean },
|
|
119
|
+
method: TwoFactorVerifyMethod,
|
|
120
|
+
): NextResponse {
|
|
121
|
+
const code = classifyTwoFactorVerifyFailure(result.status, result.json);
|
|
122
|
+
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code];
|
|
123
|
+
return NextResponse.json(
|
|
124
|
+
{ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
|
|
125
|
+
{ status: result.status },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
|
|
131
|
+
* "wrong code" outcome, so it gets its own small classification, but the same rule applies:
|
|
132
|
+
* upstream text/code never reach the response. The portal renders `data.message || data.error`,
|
|
133
|
+
* so no `message` field is emitted on failure.
|
|
134
|
+
*/
|
|
135
|
+
export type TwoFactorSendMethod = 'sms' | 'email';
|
|
136
|
+
|
|
137
|
+
export type TwoFactorSendFailureCode =
|
|
138
|
+
| 'RATE_LIMITED'
|
|
139
|
+
| 'SESSION_REJECTED'
|
|
140
|
+
| 'SEND_UNAVAILABLE'
|
|
141
|
+
| 'SEND_FAILED';
|
|
142
|
+
|
|
143
|
+
const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
|
|
144
|
+
|
|
145
|
+
export function classifyTwoFactorSendFailure(status: number): TwoFactorSendFailureCode {
|
|
146
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
147
|
+
if (status === 401 || status === 403) return 'SESSION_REJECTED';
|
|
148
|
+
if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
|
|
149
|
+
return 'SEND_FAILED';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function twoFactorSendCodeFailure(
|
|
153
|
+
result: { status: number; attemptedRefresh: boolean },
|
|
154
|
+
method: TwoFactorSendMethod,
|
|
155
|
+
): NextResponse {
|
|
156
|
+
const code = classifyTwoFactorSendFailure(result.status);
|
|
157
|
+
const label = SEND_LABEL[method];
|
|
158
|
+
const error =
|
|
159
|
+
code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
|
|
160
|
+
: code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
|
|
161
|
+
: code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
|
|
162
|
+
: `We could not send a ${label} code. Please try again.`;
|
|
163
|
+
return NextResponse.json(
|
|
164
|
+
{ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
|
|
165
|
+
{ status: result.status },
|
|
166
|
+
);
|
|
167
|
+
}
|