@payez/next-mvp 4.4.4 → 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 +33 -13
- 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 +65 -14
- 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 +35 -10
- 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 +90 -14
- 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
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,7 @@ import { resolveCallbackDestination } from '../auth/safe-callback';
|
|
|
23
23
|
import { getInternalApiUrl } from '../edge/internal-api-url';
|
|
24
24
|
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
25
25
|
import { checkPagePermission, isRBACEnabled } from './rbac-check';
|
|
26
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
28
29
|
// TYPES
|
|
@@ -40,7 +41,8 @@ export interface AuthContext {
|
|
|
40
41
|
|
|
41
42
|
export interface SessionPointer {
|
|
42
43
|
exists: boolean;
|
|
43
|
-
|
|
44
|
+
/** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
|
|
45
|
+
sessionTokenId?: string | null;
|
|
44
46
|
expired?: boolean;
|
|
45
47
|
hasRefreshToken?: boolean;
|
|
46
48
|
roles: string[];
|
|
@@ -208,6 +210,40 @@ function redirectTo(
|
|
|
208
210
|
return response;
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Terminal response for a FAILED token refresh.
|
|
215
|
+
*
|
|
216
|
+
* For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
|
|
217
|
+
* receives 200 + an HTML login page, and the caller parses garbage instead of
|
|
218
|
+
* branching on an error. Those callers get a 401 JSON body they can branch on;
|
|
219
|
+
* document requests keep the existing redirect exactly as before.
|
|
220
|
+
*
|
|
221
|
+
* Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
|
|
222
|
+
* on a permanent failure (401/403) stale cookies must be cleared or the next request
|
|
223
|
+
* refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
|
|
224
|
+
* skipped clearing would convert a redirect loop into a silent refresh loop.
|
|
225
|
+
*
|
|
226
|
+
* Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
|
|
227
|
+
* redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
|
|
228
|
+
* higher would intercept 'refresh_needed' and disable middleware token refresh across
|
|
229
|
+
* the whole API surface (card 340024 C1).
|
|
230
|
+
*/
|
|
231
|
+
function refreshFailureResponse(
|
|
232
|
+
request: NextRequest,
|
|
233
|
+
location: string,
|
|
234
|
+
errorCode: string,
|
|
235
|
+
clearCookies = false
|
|
236
|
+
): NextResponse {
|
|
237
|
+
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
238
|
+
const response = NextResponse.json({ error: errorCode }, { status: 401 });
|
|
239
|
+
if (clearCookies) {
|
|
240
|
+
clearSessionCookies(response, request);
|
|
241
|
+
}
|
|
242
|
+
return response;
|
|
243
|
+
}
|
|
244
|
+
return redirectTo(request, location, clearCookies);
|
|
245
|
+
}
|
|
246
|
+
|
|
211
247
|
// =============================================================================
|
|
212
248
|
// AUTH DECISION ENGINE
|
|
213
249
|
// =============================================================================
|
|
@@ -423,9 +459,16 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
423
459
|
// The warning is deliberately KEPT: it is the only operational signal that
|
|
424
460
|
// stale sessions are occurring, and it costs nothing to log before
|
|
425
461
|
// delegating. Do not re-introduce a `return` in this block.
|
|
426
|
-
if (!isPublic && sessionStatus.exists === false && sessionPointer.
|
|
462
|
+
if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
|
|
463
|
+
// PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
|
|
464
|
+
// always been a tokenLogId fingerprint (viability.ts builds it as
|
|
465
|
+
// `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
|
|
466
|
+
// operator reading this log that the line carried the token itself. The
|
|
467
|
+
// credential-in-logs source guard reads the key for exactly that reason:
|
|
468
|
+
// a field named `sessionToken` must hold a safe value, and proving that
|
|
469
|
+
// across an API boundary is not possible from the name alone.
|
|
427
470
|
log.warn('Stale session detected (delegating to decision engine)', {
|
|
428
|
-
|
|
471
|
+
sessionTokenId: sessionPointer.sessionTokenId,
|
|
429
472
|
pathname
|
|
430
473
|
});
|
|
431
474
|
}
|
|
@@ -470,7 +513,7 @@ async function checkViability(
|
|
|
470
513
|
};
|
|
471
514
|
const sessionPointer: SessionPointer = {
|
|
472
515
|
exists: false,
|
|
473
|
-
|
|
516
|
+
sessionTokenId: undefined,
|
|
474
517
|
expired: false,
|
|
475
518
|
hasRefreshToken: false,
|
|
476
519
|
roles: [],
|
|
@@ -493,11 +536,11 @@ async function checkViability(
|
|
|
493
536
|
if (response.ok) {
|
|
494
537
|
const data = await response.json();
|
|
495
538
|
// Support both response formats (viability and refresh-viability)
|
|
496
|
-
sessionStatus.exists = data.authenticated ?? (data.
|
|
539
|
+
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
497
540
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
498
541
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
499
542
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
500
|
-
sessionPointer.
|
|
543
|
+
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
501
544
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
502
545
|
sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
|
|
503
546
|
sessionPointer.roles = data.roles || [];
|
|
@@ -630,9 +673,10 @@ async function handleRefresh(
|
|
|
630
673
|
// Check if circuit breaker allows refresh attempt
|
|
631
674
|
if (!cb.canAttemptRefresh()) {
|
|
632
675
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
633
|
-
return
|
|
676
|
+
return refreshFailureResponse(
|
|
634
677
|
request,
|
|
635
678
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`,
|
|
679
|
+
'ServiceUnavailable',
|
|
636
680
|
true
|
|
637
681
|
);
|
|
638
682
|
}
|
|
@@ -677,9 +721,10 @@ async function handleRefresh(
|
|
|
677
721
|
|
|
678
722
|
onRefreshFailure?.(response.status, false);
|
|
679
723
|
|
|
680
|
-
return
|
|
724
|
+
return refreshFailureResponse(
|
|
681
725
|
request,
|
|
682
726
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
|
|
727
|
+
errorParam,
|
|
683
728
|
isPermanentFailure
|
|
684
729
|
);
|
|
685
730
|
} catch (error: any) {
|
|
@@ -697,9 +742,10 @@ async function handleRefresh(
|
|
|
697
742
|
onRefreshFailure?.(0, isNetwork);
|
|
698
743
|
|
|
699
744
|
// Network error - don't clear cookies, might be transient
|
|
700
|
-
return
|
|
745
|
+
return refreshFailureResponse(
|
|
701
746
|
request,
|
|
702
747
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`,
|
|
748
|
+
'RefreshError',
|
|
703
749
|
false
|
|
704
750
|
);
|
|
705
751
|
}
|
|
@@ -9,6 +9,7 @@ import { SelectMethodStep } from '../../components/recovery/SelectMethodStep';
|
|
|
9
9
|
import { VerifyCodeStep } from '../../components/recovery/VerifyCodeStep';
|
|
10
10
|
import { SetPasswordStep } from '../../components/recovery/SetPasswordStep';
|
|
11
11
|
import { CompleteStep } from '../../components/recovery/CompleteStep';
|
|
12
|
+
import { RequestedStep } from '../../components/recovery/RequestedStep';
|
|
12
13
|
import { useColors } from '../../theme/useTheme';
|
|
13
14
|
|
|
14
15
|
function RecoveryContent() {
|
|
@@ -24,7 +25,12 @@ function RecoveryContent() {
|
|
|
24
25
|
const [verificationCode, setVerificationCode] = useState('');
|
|
25
26
|
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(null);
|
|
26
27
|
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
// PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
|
|
29
|
+
const [recoveryExpired, setRecoveryExpired] = useState(false);
|
|
27
30
|
const [loading, setLoading] = useState(false);
|
|
31
|
+
// PAY-1588: true only after the IdP accepted the new password. The 'complete' step is ALSO reached from initiate()
|
|
32
|
+
// when no recovery session comes back, and that path must not tell the user a password was set.
|
|
33
|
+
const [resetSucceeded, setResetSucceeded] = useState(false);
|
|
28
34
|
|
|
29
35
|
const handleInitiateRecovery = async () => {
|
|
30
36
|
setLoading(true);
|
|
@@ -33,7 +39,7 @@ function RecoveryContent() {
|
|
|
33
39
|
try {
|
|
34
40
|
const response = await accountApi.initiateRecovery(email);
|
|
35
41
|
|
|
36
|
-
if (response.success && response.data
|
|
42
|
+
if (response.success && response.data?.recovery_session_token) {
|
|
37
43
|
setRecoverySession({
|
|
38
44
|
recoveryToken: response.data.recovery_session_token,
|
|
39
45
|
email: email,
|
|
@@ -45,7 +51,8 @@ function RecoveryContent() {
|
|
|
45
51
|
});
|
|
46
52
|
setCurrentStep('select-method');
|
|
47
53
|
} else {
|
|
48
|
-
|
|
54
|
+
// No recovery session (unknown / locked / throttled): the ONE neutral step, never CompleteStep (rigpert 60711).
|
|
55
|
+
setCurrentStep('requested');
|
|
49
56
|
}
|
|
50
57
|
} catch (err) {
|
|
51
58
|
setError(err instanceof Error ? err.message : 'Failed to initiate recovery. Please try again.');
|
|
@@ -110,6 +117,7 @@ function RecoveryContent() {
|
|
|
110
117
|
|
|
111
118
|
setLoading(true);
|
|
112
119
|
setError(null);
|
|
120
|
+
setRecoveryExpired(false);
|
|
113
121
|
|
|
114
122
|
try {
|
|
115
123
|
const response = await accountApi.resetPasswordWithToken(
|
|
@@ -120,12 +128,14 @@ function RecoveryContent() {
|
|
|
120
128
|
);
|
|
121
129
|
|
|
122
130
|
if (response.success) {
|
|
131
|
+
setResetSucceeded(true);
|
|
123
132
|
setCurrentStep('complete');
|
|
124
133
|
} else {
|
|
125
134
|
setError('Failed to reset password. Please try again.');
|
|
126
135
|
}
|
|
127
136
|
} catch (err) {
|
|
128
137
|
setError(err instanceof Error ? err.message : 'Failed to reset password.');
|
|
138
|
+
setRecoveryExpired(!!err && (err as { code?: string }).code === 'RECOVERY_EXPIRED');
|
|
129
139
|
} finally {
|
|
130
140
|
setLoading(false);
|
|
131
141
|
}
|
|
@@ -143,6 +153,11 @@ function RecoveryContent() {
|
|
|
143
153
|
{error && (
|
|
144
154
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-2xl p-3">
|
|
145
155
|
<p className="text-red-700 text-sm">{error}</p>
|
|
156
|
+
{recoveryExpired && (
|
|
157
|
+
<a href="/account-auth/recovery" className="text-sm font-medium underline">
|
|
158
|
+
Start password recovery again
|
|
159
|
+
</a>
|
|
160
|
+
)}
|
|
146
161
|
</div>
|
|
147
162
|
)}
|
|
148
163
|
|
|
@@ -191,13 +206,15 @@ function RecoveryContent() {
|
|
|
191
206
|
/>
|
|
192
207
|
)}
|
|
193
208
|
|
|
209
|
+
{currentStep === 'requested' && <RequestedStep onGoToLogin={() => router.push('/account-auth/login')} />}
|
|
210
|
+
|
|
194
211
|
{currentStep === 'complete' && (
|
|
195
212
|
<CompleteStep
|
|
196
|
-
onGoToLogin={() => router.push('/account-auth/login')}
|
|
213
|
+
onGoToLogin={() => router.push(resetSucceeded ? '/account-auth/login?notice=password-set' : '/account-auth/login')}
|
|
197
214
|
/>
|
|
198
215
|
)}
|
|
199
216
|
|
|
200
|
-
{currentStep !== 'complete' && currentStep !== 'initiate' && !loading && (
|
|
217
|
+
{currentStep !== 'complete' && currentStep !== 'requested' && currentStep !== 'initiate' && !loading && (
|
|
201
218
|
<div className="mt-6 text-center">
|
|
202
219
|
<button
|
|
203
220
|
type="button"
|