@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* tokenLogId - a log-safe correlation id for a session token (PR 154 follow-up, BAPert ruling on PAY-1427:
|
|
4
|
+
* "no token material in logs, not even partial").
|
|
5
|
+
*
|
|
6
|
+
* The package used to log `sessionToken.substring(0, 8) + '...'` so lines about one session could be
|
|
7
|
+
* correlated. Those 8 characters ARE token material. This returns `h:` + the first 12 hex characters of
|
|
8
|
+
* SHA-256(token) instead: the same correlation (one token -> one stable id, across every log line and
|
|
9
|
+
* process), with nothing of the token itself.
|
|
10
|
+
*
|
|
11
|
+
* Synchronous and dependency-free on purpose: the call sites include Edge middleware
|
|
12
|
+
* (create-middleware, auth-decision) and browser code (authStore, SessionSync), where node:crypto is
|
|
13
|
+
* unavailable and Web Crypto's digest is async. SHA-256 below follows FIPS 180-4 and is verified
|
|
14
|
+
* against node:crypto in test/session-token-log-id.test.cjs.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.sha256Hex = sha256Hex;
|
|
18
|
+
exports.tokenLogId = tokenLogId;
|
|
19
|
+
const K = new Uint32Array([
|
|
20
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
21
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
22
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
23
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
24
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
25
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
26
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
27
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
28
|
+
]);
|
|
29
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
30
|
+
/** SHA-256 of the UTF-8 encoding of `input`, as lowercase hex. */
|
|
31
|
+
function sha256Hex(input) {
|
|
32
|
+
const msg = new TextEncoder().encode(input);
|
|
33
|
+
const bitLen = msg.length * 8;
|
|
34
|
+
const padded = new Uint8Array(((msg.length + 9 + 63) >> 6) << 6);
|
|
35
|
+
padded.set(msg);
|
|
36
|
+
padded[msg.length] = 0x80;
|
|
37
|
+
const view = new DataView(padded.buffer);
|
|
38
|
+
view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
|
|
39
|
+
view.setUint32(padded.length - 4, bitLen >>> 0);
|
|
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++)
|
|
44
|
+
w[i] = view.getUint32(off + i * 4);
|
|
45
|
+
for (let i = 16; i < 64; i++) {
|
|
46
|
+
const s15 = w[i - 15];
|
|
47
|
+
const s2 = w[i - 2];
|
|
48
|
+
const sigma0 = rotr(s15, 7) ^ rotr(s15, 18) ^ (s15 >>> 3);
|
|
49
|
+
const sigma1 = rotr(s2, 17) ^ rotr(s2, 19) ^ (s2 >>> 10);
|
|
50
|
+
w[i] = (w[i - 16] + sigma0 + w[i - 7] + sigma1) | 0;
|
|
51
|
+
}
|
|
52
|
+
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];
|
|
53
|
+
for (let i = 0; i < 64; i++) {
|
|
54
|
+
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
|
|
55
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
|
|
56
|
+
hh = g;
|
|
57
|
+
g = f;
|
|
58
|
+
f = e;
|
|
59
|
+
e = (d + t1) | 0;
|
|
60
|
+
d = c;
|
|
61
|
+
c = b;
|
|
62
|
+
b = a;
|
|
63
|
+
a = (t1 + t2) | 0;
|
|
64
|
+
}
|
|
65
|
+
h[0] = (h[0] + a) | 0;
|
|
66
|
+
h[1] = (h[1] + b) | 0;
|
|
67
|
+
h[2] = (h[2] + c) | 0;
|
|
68
|
+
h[3] = (h[3] + d) | 0;
|
|
69
|
+
h[4] = (h[4] + e) | 0;
|
|
70
|
+
h[5] = (h[5] + f) | 0;
|
|
71
|
+
h[6] = (h[6] + g) | 0;
|
|
72
|
+
h[7] = (h[7] + hh) | 0;
|
|
73
|
+
}
|
|
74
|
+
let out = '';
|
|
75
|
+
for (let i = 0; i < 8; i++)
|
|
76
|
+
out += (h[i] >>> 0).toString(16).padStart(8, '0');
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** `h:` + 12 hex of SHA-256(token); `none` when there is no token. Never any part of the token itself. */
|
|
80
|
+
function tokenLogId(token) {
|
|
81
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
82
|
+
return 'none';
|
|
83
|
+
return 'h:' + sha256Hex(token).slice(0, 12);
|
|
84
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
|
|
21
|
+
export type TwoFactorVerifyFailureCode = 'INVALID_CODE' | 'LOCKED' | 'RATE_LIMITED' | 'SESSION_REJECTED' | 'STEP_UP_FACTOR_NOT_ALLOWED' | 'VERIFY_UNAVAILABLE' | 'VERIFY_FAILED';
|
|
22
|
+
/**
|
|
23
|
+
* PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
|
|
24
|
+
* A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
|
|
25
|
+
* IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
|
|
26
|
+
* this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
|
|
27
|
+
*
|
|
28
|
+
* On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
|
|
29
|
+
* object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
|
|
30
|
+
* The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
|
|
31
|
+
*
|
|
32
|
+
* NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
|
|
33
|
+
* maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
|
|
34
|
+
* missing or malformed lists give EMPTY lists, never invented ones.
|
|
35
|
+
*/
|
|
36
|
+
export declare const STEP_UP_REFUSAL_CODE = "STEP_UP_FACTOR_NOT_ALLOWED";
|
|
37
|
+
declare const STEP_UP_METHODS: readonly ["totp", "webauthn", "backup_code"];
|
|
38
|
+
declare const STEP_UP_ACTIONS: readonly ["use_authenticator", "reset_password", "enroll_authenticator"];
|
|
39
|
+
export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
|
|
40
|
+
export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
|
|
41
|
+
export type StepUpNextStep = {
|
|
42
|
+
action: 'use_authenticator';
|
|
43
|
+
methods: StepUpMethod[];
|
|
44
|
+
} | {
|
|
45
|
+
action: Exclude<StepUpAction, 'use_authenticator'>;
|
|
46
|
+
};
|
|
47
|
+
export type StepUpRefusal = {
|
|
48
|
+
allowed_methods: StepUpMethod[];
|
|
49
|
+
next_steps: StepUpNextStep[];
|
|
50
|
+
};
|
|
51
|
+
/** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
|
|
52
|
+
export declare function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null;
|
|
53
|
+
/**
|
|
54
|
+
* Every string the IDP might have put anywhere in the error envelope, unwrapped from
|
|
55
|
+
* `{ message = X }` ToString form. Classification input only.
|
|
56
|
+
*/
|
|
57
|
+
export declare function upstreamErrorTexts(json: unknown): string[];
|
|
58
|
+
export declare function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode;
|
|
59
|
+
export declare function twoFactorVerifyFailure(result: {
|
|
60
|
+
status: number;
|
|
61
|
+
json: unknown;
|
|
62
|
+
attemptedRefresh: boolean;
|
|
63
|
+
}, method: TwoFactorVerifyMethod): NextResponse;
|
|
64
|
+
/**
|
|
65
|
+
* PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
|
|
66
|
+
* "wrong code" outcome, so it gets its own small classification, but the same rule applies:
|
|
67
|
+
* upstream text/code never reach the response. The portal renders `data.message || data.error`,
|
|
68
|
+
* so no `message` field is emitted on failure.
|
|
69
|
+
*/
|
|
70
|
+
export type TwoFactorSendMethod = 'sms' | 'email';
|
|
71
|
+
export type TwoFactorSendFailureCode = 'RATE_LIMITED' | 'SESSION_REJECTED' | 'STEP_UP_FACTOR_NOT_ALLOWED' | 'SEND_UNAVAILABLE' | 'SEND_FAILED';
|
|
72
|
+
export declare function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode;
|
|
73
|
+
export declare function twoFactorSendCodeFailure(result: {
|
|
74
|
+
status: number;
|
|
75
|
+
attemptedRefresh: boolean;
|
|
76
|
+
json?: unknown;
|
|
77
|
+
}, method: TwoFactorSendMethod): NextResponse;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.STEP_UP_REFUSAL_CODE = void 0;
|
|
4
|
+
exports.stepUpRefusal = stepUpRefusal;
|
|
5
|
+
exports.upstreamErrorTexts = upstreamErrorTexts;
|
|
6
|
+
exports.classifyTwoFactorVerifyFailure = classifyTwoFactorVerifyFailure;
|
|
7
|
+
exports.twoFactorVerifyFailure = twoFactorVerifyFailure;
|
|
8
|
+
exports.classifyTwoFactorSendFailure = classifyTwoFactorSendFailure;
|
|
9
|
+
exports.twoFactorSendCodeFailure = twoFactorSendCodeFailure;
|
|
10
|
+
/**
|
|
11
|
+
* PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
|
|
12
|
+
*
|
|
13
|
+
* Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
|
|
14
|
+
* authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
|
|
15
|
+
* The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
|
|
16
|
+
* user-facing string:
|
|
17
|
+
* - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
|
|
18
|
+
* `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
|
|
19
|
+
* (or a DTO's TYPE NAME for a typed body);
|
|
20
|
+
* - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
|
|
21
|
+
* message is "An error occurred" and the real text sits only in error.details.message.
|
|
22
|
+
*
|
|
23
|
+
* So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
|
|
24
|
+
* - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
|
|
25
|
+
* - a stable `code` the UI can switch on;
|
|
26
|
+
* - one of the fixed sentences below.
|
|
27
|
+
*/
|
|
28
|
+
const server_1 = require("next/server");
|
|
29
|
+
const INVALID_CODE_TEXT = {
|
|
30
|
+
totp: 'Invalid authentication code. Please try again.',
|
|
31
|
+
sms: 'Invalid verification code. Please try again.',
|
|
32
|
+
email: 'Invalid verification code. Please try again.',
|
|
33
|
+
backup_code: 'Invalid backup code. Please try again.',
|
|
34
|
+
};
|
|
35
|
+
const FIXED_TEXT = {
|
|
36
|
+
LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
|
|
37
|
+
RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
|
|
38
|
+
SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
|
|
39
|
+
VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
|
|
40
|
+
VERIFY_FAILED: 'We could not verify that code. Please try again.',
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
|
|
44
|
+
* ToString envelope.
|
|
45
|
+
*
|
|
46
|
+
* CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with word
|
|
47
|
+
* boundaries around `code` and `locked`. The literal that landed on disk was not the two
|
|
48
|
+
* characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
|
|
49
|
+
* and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
|
|
50
|
+
* literal backspace control character where a word-boundary assertion was intended matches
|
|
51
|
+
* nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
|
|
52
|
+
* because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
|
|
53
|
+
* line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
|
|
54
|
+
* load-bearing for the shipped control cases (a bare code=locked substring is specific enough
|
|
55
|
+
* here - the IdP field name is literally code), so this drops them rather than re-risk the same
|
|
56
|
+
* invisible-byte trap.
|
|
57
|
+
*/
|
|
58
|
+
const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
|
|
59
|
+
/**
|
|
60
|
+
* MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
|
|
61
|
+
* the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
|
|
62
|
+
* the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
|
|
63
|
+
*/
|
|
64
|
+
const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
|
|
65
|
+
const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
|
|
66
|
+
/**
|
|
67
|
+
* PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
|
|
68
|
+
* A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
|
|
69
|
+
* IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
|
|
70
|
+
* this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
|
|
71
|
+
*
|
|
72
|
+
* On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
|
|
73
|
+
* object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
|
|
74
|
+
* The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
|
|
75
|
+
*
|
|
76
|
+
* NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
|
|
77
|
+
* maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
|
|
78
|
+
* missing or malformed lists give EMPTY lists, never invented ones.
|
|
79
|
+
*/
|
|
80
|
+
exports.STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
81
|
+
const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'];
|
|
82
|
+
const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'];
|
|
83
|
+
const STEP_UP_TEXT = 'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
|
|
84
|
+
function allowedMethods(v) {
|
|
85
|
+
if (!Array.isArray(v))
|
|
86
|
+
return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const m of v) {
|
|
89
|
+
if (STEP_UP_METHODS.includes(m) && !out.includes(m))
|
|
90
|
+
out.push(m);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
|
|
95
|
+
function stepUpRefusal(status, json) {
|
|
96
|
+
if (status !== 403 || !json || typeof json !== 'object')
|
|
97
|
+
return null;
|
|
98
|
+
const j = json;
|
|
99
|
+
const err = j.error;
|
|
100
|
+
let code;
|
|
101
|
+
let src = j;
|
|
102
|
+
if (err && typeof err === 'object') {
|
|
103
|
+
const e = err;
|
|
104
|
+
code = e.code;
|
|
105
|
+
src = e.details && typeof e.details === 'object' ? e.details : {};
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
code = err;
|
|
109
|
+
}
|
|
110
|
+
if (code !== exports.STEP_UP_REFUSAL_CODE)
|
|
111
|
+
return null;
|
|
112
|
+
const next_steps = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
|
|
115
|
+
if (!step || typeof step !== 'object')
|
|
116
|
+
continue;
|
|
117
|
+
const action = step.action;
|
|
118
|
+
if (typeof action !== 'string' || !STEP_UP_ACTIONS.includes(action) || seen.has(action))
|
|
119
|
+
continue;
|
|
120
|
+
seen.add(action);
|
|
121
|
+
next_steps.push(action === 'use_authenticator'
|
|
122
|
+
? { action, methods: allowedMethods(step.methods) }
|
|
123
|
+
: { action: action });
|
|
124
|
+
}
|
|
125
|
+
return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
|
|
126
|
+
}
|
|
127
|
+
function stepUpResponse(refusal, attemptedRefresh) {
|
|
128
|
+
return server_1.NextResponse.json({ success: false, error: STEP_UP_TEXT, code: exports.STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } }, { status: 403 });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Every string the IDP might have put anywhere in the error envelope, unwrapped from
|
|
132
|
+
* `{ message = X }` ToString form. Classification input only.
|
|
133
|
+
*/
|
|
134
|
+
function upstreamErrorTexts(json) {
|
|
135
|
+
const out = [];
|
|
136
|
+
const add = (v) => {
|
|
137
|
+
if (typeof v !== 'string' || !v)
|
|
138
|
+
return;
|
|
139
|
+
out.push(v);
|
|
140
|
+
const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
|
|
141
|
+
if (m)
|
|
142
|
+
out.push(m[1]);
|
|
143
|
+
};
|
|
144
|
+
const j = json;
|
|
145
|
+
if (!j || typeof j !== 'object')
|
|
146
|
+
return out;
|
|
147
|
+
add(j.message);
|
|
148
|
+
add(j.code);
|
|
149
|
+
add(j.error);
|
|
150
|
+
if (j.error && typeof j.error === 'object') {
|
|
151
|
+
add(j.error.message);
|
|
152
|
+
add(j.error.code);
|
|
153
|
+
const d = j.error.details;
|
|
154
|
+
if (d && typeof d === 'object') {
|
|
155
|
+
add(d.message);
|
|
156
|
+
add(d.Message);
|
|
157
|
+
add(d.code);
|
|
158
|
+
add(d.errorCode);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function classifyTwoFactorVerifyFailure(status, json) {
|
|
164
|
+
// PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
|
|
165
|
+
if (stepUpRefusal(status, json))
|
|
166
|
+
return 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
167
|
+
// PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
|
|
168
|
+
// the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
|
|
169
|
+
//
|
|
170
|
+
// PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
|
|
171
|
+
// code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
|
|
172
|
+
// just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
|
|
173
|
+
// still RATE_LIMITED, below.
|
|
174
|
+
if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t)))
|
|
175
|
+
return 'LOCKED';
|
|
176
|
+
if (status === 429)
|
|
177
|
+
return 'RATE_LIMITED';
|
|
178
|
+
// PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
|
|
179
|
+
if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t)))
|
|
180
|
+
return 'VERIFY_UNAVAILABLE';
|
|
181
|
+
if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t)))
|
|
182
|
+
return 'INVALID_CODE';
|
|
183
|
+
if (status === 401 || status === 403)
|
|
184
|
+
return 'SESSION_REJECTED';
|
|
185
|
+
if (status >= 500 || status === 0)
|
|
186
|
+
return 'VERIFY_UNAVAILABLE';
|
|
187
|
+
return 'VERIFY_FAILED';
|
|
188
|
+
}
|
|
189
|
+
function twoFactorVerifyFailure(result, method) {
|
|
190
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
191
|
+
if (refusal)
|
|
192
|
+
return stepUpResponse(refusal, result.attemptedRefresh);
|
|
193
|
+
const code = classifyTwoFactorVerifyFailure(result.status, result.json);
|
|
194
|
+
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code];
|
|
195
|
+
return server_1.NextResponse.json({ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status });
|
|
196
|
+
}
|
|
197
|
+
const SEND_LABEL = { sms: 'text message', email: 'email' };
|
|
198
|
+
function classifyTwoFactorSendFailure(status, json) {
|
|
199
|
+
if (stepUpRefusal(status, json))
|
|
200
|
+
return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
|
|
201
|
+
if (status === 429)
|
|
202
|
+
return 'RATE_LIMITED';
|
|
203
|
+
if (status === 401 || status === 403)
|
|
204
|
+
return 'SESSION_REJECTED';
|
|
205
|
+
if (status >= 500 || status === 0)
|
|
206
|
+
return 'SEND_UNAVAILABLE';
|
|
207
|
+
return 'SEND_FAILED';
|
|
208
|
+
}
|
|
209
|
+
function twoFactorSendCodeFailure(result, method) {
|
|
210
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
211
|
+
if (refusal)
|
|
212
|
+
return stepUpResponse(refusal, result.attemptedRefresh);
|
|
213
|
+
const code = classifyTwoFactorSendFailure(result.status, result.json);
|
|
214
|
+
const label = SEND_LABEL[method];
|
|
215
|
+
const error = code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
|
|
216
|
+
: code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
|
|
217
|
+
: code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
|
|
218
|
+
: `We could not send a ${label} code. Please try again.`;
|
|
219
|
+
return server_1.NextResponse.json({ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status });
|
|
220
|
+
}
|
|
@@ -28,7 +28,8 @@ export interface AuthContext {
|
|
|
28
28
|
}
|
|
29
29
|
export interface SessionPointer {
|
|
30
30
|
exists: boolean;
|
|
31
|
-
|
|
31
|
+
/** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
|
|
32
|
+
sessionTokenId?: string | null;
|
|
32
33
|
expired?: boolean;
|
|
33
34
|
hasRefreshToken?: boolean;
|
|
34
35
|
roles: string[];
|
|
@@ -99,4 +100,6 @@ export interface MvpMiddlewareOptions {
|
|
|
99
100
|
/** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
|
|
100
101
|
rbacExemptPaths?: string[];
|
|
101
102
|
}
|
|
103
|
+
/** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
|
|
104
|
+
export declare const TWO_FACTOR_TIMEOUT_ERROR = "TwoFactorTimeout";
|
|
102
105
|
export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* });
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.TWO_FACTOR_TIMEOUT_ERROR = void 0;
|
|
21
22
|
exports.createMvpMiddleware = createMvpMiddleware;
|
|
22
23
|
const server_1 = require("next/server");
|
|
23
24
|
const route_config_1 = require("../auth/route-config");
|
|
@@ -105,6 +106,34 @@ function redirectTo(request, location, clearCookies = false) {
|
|
|
105
106
|
}
|
|
106
107
|
return response;
|
|
107
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Terminal response for a FAILED token refresh.
|
|
111
|
+
*
|
|
112
|
+
* For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
|
|
113
|
+
* receives 200 + an HTML login page, and the caller parses garbage instead of
|
|
114
|
+
* branching on an error. Those callers get a 401 JSON body they can branch on;
|
|
115
|
+
* document requests keep the existing redirect exactly as before.
|
|
116
|
+
*
|
|
117
|
+
* Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
|
|
118
|
+
* on a permanent failure (401/403) stale cookies must be cleared or the next request
|
|
119
|
+
* refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
|
|
120
|
+
* skipped clearing would convert a redirect loop into a silent refresh loop.
|
|
121
|
+
*
|
|
122
|
+
* Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
|
|
123
|
+
* redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
|
|
124
|
+
* higher would intercept 'refresh_needed' and disable middleware token refresh across
|
|
125
|
+
* the whole API surface (card 340024 C1).
|
|
126
|
+
*/
|
|
127
|
+
function refreshFailureResponse(request, location, errorCode, clearCookies = false) {
|
|
128
|
+
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
129
|
+
const response = server_1.NextResponse.json({ error: errorCode }, { status: 401 });
|
|
130
|
+
if (clearCookies) {
|
|
131
|
+
clearSessionCookies(response, request);
|
|
132
|
+
}
|
|
133
|
+
return response;
|
|
134
|
+
}
|
|
135
|
+
return redirectTo(request, location, clearCookies);
|
|
136
|
+
}
|
|
108
137
|
// =============================================================================
|
|
109
138
|
// AUTH DECISION ENGINE
|
|
110
139
|
// =============================================================================
|
|
@@ -115,6 +144,26 @@ function redirectTo(request, location, clearCookies = false) {
|
|
|
115
144
|
function makeAuthDecision(ctx) {
|
|
116
145
|
const { pathname, isPublicRoute, isLoginPage: onLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = ctx;
|
|
117
146
|
const safeCallback = getSafeCallbackUrl(pathname);
|
|
147
|
+
// --- PAY-884: a provisional (pre-2FA) session whose token has expired can never finish 2FA ---
|
|
148
|
+
// The IdP issues no refresh token until 2FA completes, and it validates token lifetime, so
|
|
149
|
+
// once the provisional access token expires neither refresh nor code verification can
|
|
150
|
+
// succeed. Sending the user to the verify-code page (or to error=SessionExpired, which the
|
|
151
|
+
// login page deliberately shows as NO message) strands them mid-2FA with no explanation.
|
|
152
|
+
// Say what happened instead, and clear the dead session so the next sign-in starts clean.
|
|
153
|
+
// Scoped to the verify-code page and protected pages: /api/ keeps its 401-JSON carve-out
|
|
154
|
+
// (307290), the login page stays reachable, and public pages are left to the existing arms.
|
|
155
|
+
if (isExpiredProvisionalSession(sessionPointer, sessionStatus) &&
|
|
156
|
+
!pathname.startsWith('/api/') &&
|
|
157
|
+
!onLoginPage &&
|
|
158
|
+
(pathname === VERIFY_CODE_PAGE || !isPublicRoute)) {
|
|
159
|
+
const callback = pathname === VERIFY_CODE_PAGE ? getSafeCallbackUrl(pathname, searchParams) : safeCallback;
|
|
160
|
+
return {
|
|
161
|
+
type: 'redirect',
|
|
162
|
+
location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(callback)}&error=${exports.TWO_FACTOR_TIMEOUT_ERROR}`,
|
|
163
|
+
reason: 'pre_2fa_token_expired',
|
|
164
|
+
clearCookies: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
118
167
|
// --- Public routes: Allow, but enforce 2FA for authenticated users ---
|
|
119
168
|
if (isPublicRoute && !onLoginPage) {
|
|
120
169
|
if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
|
|
@@ -221,6 +270,21 @@ function makeAuthDecision(ctx) {
|
|
|
221
270
|
// --- All checks passed ---
|
|
222
271
|
return { type: 'allow' };
|
|
223
272
|
}
|
|
273
|
+
/** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
|
|
274
|
+
exports.TWO_FACTOR_TIMEOUT_ERROR = 'TwoFactorTimeout';
|
|
275
|
+
/**
|
|
276
|
+
* PAY-884: 2FA outstanding, access token expired, and no refresh token to renew it.
|
|
277
|
+
* Only a viability answer that positively says the session exists qualifies (null = check failed).
|
|
278
|
+
*/
|
|
279
|
+
function isExpiredProvisionalSession(session, status) {
|
|
280
|
+
return (session.exists &&
|
|
281
|
+
status.exists === true &&
|
|
282
|
+
status.forceInvalid === false &&
|
|
283
|
+
status.requires2FA &&
|
|
284
|
+
!status.twoFactorComplete &&
|
|
285
|
+
session.expired === true &&
|
|
286
|
+
!session.hasRefreshToken);
|
|
287
|
+
}
|
|
224
288
|
/** Check if user needs 2FA redirect */
|
|
225
289
|
function needsTwoFactorRedirect(pathname, session, status) {
|
|
226
290
|
if (!session.exists || !status.exists)
|
|
@@ -311,9 +375,16 @@ function createMvpMiddleware(options = {}) {
|
|
|
311
375
|
// The warning is deliberately KEPT: it is the only operational signal that
|
|
312
376
|
// stale sessions are occurring, and it costs nothing to log before
|
|
313
377
|
// delegating. Do not re-introduce a `return` in this block.
|
|
314
|
-
if (!isPublic && sessionStatus.exists === false && sessionPointer.
|
|
378
|
+
if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
|
|
379
|
+
// PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
|
|
380
|
+
// always been a tokenLogId fingerprint (viability.ts builds it as
|
|
381
|
+
// `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
|
|
382
|
+
// operator reading this log that the line carried the token itself. The
|
|
383
|
+
// credential-in-logs source guard reads the key for exactly that reason:
|
|
384
|
+
// a field named `sessionToken` must hold a safe value, and proving that
|
|
385
|
+
// across an API boundary is not possible from the name alone.
|
|
315
386
|
log.warn('Stale session detected (delegating to decision engine)', {
|
|
316
|
-
|
|
387
|
+
sessionTokenId: sessionPointer.sessionTokenId,
|
|
317
388
|
pathname
|
|
318
389
|
});
|
|
319
390
|
}
|
|
@@ -348,7 +419,7 @@ async function checkViability(request, endpoint, log) {
|
|
|
348
419
|
};
|
|
349
420
|
const sessionPointer = {
|
|
350
421
|
exists: false,
|
|
351
|
-
|
|
422
|
+
sessionTokenId: undefined,
|
|
352
423
|
expired: false,
|
|
353
424
|
hasRefreshToken: false,
|
|
354
425
|
roles: [],
|
|
@@ -369,11 +440,11 @@ async function checkViability(request, endpoint, log) {
|
|
|
369
440
|
if (response.ok) {
|
|
370
441
|
const data = await response.json();
|
|
371
442
|
// Support both response formats (viability and refresh-viability)
|
|
372
|
-
sessionStatus.exists = data.authenticated ?? (data.
|
|
443
|
+
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
373
444
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
374
445
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
375
446
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
376
|
-
sessionPointer.
|
|
447
|
+
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
377
448
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
378
449
|
sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
|
|
379
450
|
sessionPointer.roles = data.roles || [];
|
|
@@ -457,7 +528,7 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
457
528
|
// Check if circuit breaker allows refresh attempt
|
|
458
529
|
if (!cb.canAttemptRefresh()) {
|
|
459
530
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
460
|
-
return
|
|
531
|
+
return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`, 'ServiceUnavailable', true);
|
|
461
532
|
}
|
|
462
533
|
try {
|
|
463
534
|
const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);
|
|
@@ -494,7 +565,7 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
494
565
|
clearingCookies: isPermanentFailure
|
|
495
566
|
});
|
|
496
567
|
onRefreshFailure?.(response.status, false);
|
|
497
|
-
return
|
|
568
|
+
return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`, errorParam, isPermanentFailure);
|
|
498
569
|
}
|
|
499
570
|
catch (error) {
|
|
500
571
|
const isNetwork = cb.isNetworkError(error);
|
|
@@ -508,6 +579,6 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
508
579
|
}
|
|
509
580
|
onRefreshFailure?.(0, isNetwork);
|
|
510
581
|
// Network error - don't clear cookies, might be transient
|
|
511
|
-
return
|
|
582
|
+
return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`, 'RefreshError', false);
|
|
512
583
|
}
|
|
513
584
|
}
|
package/dist/pages/login/page.js
CHANGED
|
@@ -131,6 +131,11 @@ function LoginForm({ altAuthSlot }) {
|
|
|
131
131
|
console.log('[LOGIN] SessionExpired detected - normal behavior');
|
|
132
132
|
friendlyError = null;
|
|
133
133
|
}
|
|
134
|
+
else if (urlError === 'TwoFactorTimeout') {
|
|
135
|
+
// PAY-884: the sign-in expired while the verification code was outstanding. Unlike
|
|
136
|
+
// SessionExpired this is NOT silent - the user was mid-2FA and must know why they are here.
|
|
137
|
+
friendlyError = 'Your verification step timed out before the code was confirmed. Please sign in again.';
|
|
138
|
+
}
|
|
134
139
|
else if (urlError === 'ServiceUnavailable') {
|
|
135
140
|
friendlyError = 'Authentication service is temporarily unavailable. Please try again in a few minutes.';
|
|
136
141
|
}
|
|
@@ -11,6 +11,7 @@ const SelectMethodStep_1 = require("../../components/recovery/SelectMethodStep")
|
|
|
11
11
|
const VerifyCodeStep_1 = require("../../components/recovery/VerifyCodeStep");
|
|
12
12
|
const SetPasswordStep_1 = require("../../components/recovery/SetPasswordStep");
|
|
13
13
|
const CompleteStep_1 = require("../../components/recovery/CompleteStep");
|
|
14
|
+
const RequestedStep_1 = require("../../components/recovery/RequestedStep");
|
|
14
15
|
const useTheme_1 = require("../../theme/useTheme");
|
|
15
16
|
function RecoveryContent() {
|
|
16
17
|
const router = (0, navigation_1.useRouter)();
|
|
@@ -24,13 +25,18 @@ function RecoveryContent() {
|
|
|
24
25
|
const [verificationCode, setVerificationCode] = (0, react_1.useState)('');
|
|
25
26
|
const [passwordResetToken, setPasswordResetToken] = (0, react_1.useState)(null);
|
|
26
27
|
const [error, setError] = (0, react_1.useState)(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] = (0, react_1.useState)(false);
|
|
27
30
|
const [loading, setLoading] = (0, react_1.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] = (0, react_1.useState)(false);
|
|
28
34
|
const handleInitiateRecovery = async () => {
|
|
29
35
|
setLoading(true);
|
|
30
36
|
setError(null);
|
|
31
37
|
try {
|
|
32
38
|
const response = await api_1.accountApi.initiateRecovery(email);
|
|
33
|
-
if (response.success && response.data
|
|
39
|
+
if (response.success && response.data?.recovery_session_token) {
|
|
34
40
|
setRecoverySession({
|
|
35
41
|
recoveryToken: response.data.recovery_session_token,
|
|
36
42
|
email: email,
|
|
@@ -43,7 +49,8 @@ function RecoveryContent() {
|
|
|
43
49
|
setCurrentStep('select-method');
|
|
44
50
|
}
|
|
45
51
|
else {
|
|
46
|
-
|
|
52
|
+
// No recovery session (unknown / locked / throttled): the ONE neutral step, never CompleteStep (rigpert 60711).
|
|
53
|
+
setCurrentStep('requested');
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
catch (err) {
|
|
@@ -105,9 +112,11 @@ function RecoveryContent() {
|
|
|
105
112
|
return;
|
|
106
113
|
setLoading(true);
|
|
107
114
|
setError(null);
|
|
115
|
+
setRecoveryExpired(false);
|
|
108
116
|
try {
|
|
109
117
|
const response = await api_1.accountApi.resetPasswordWithToken(recoverySession.email, passwordResetToken.token, password, confirmPassword);
|
|
110
118
|
if (response.success) {
|
|
119
|
+
setResetSucceeded(true);
|
|
111
120
|
setCurrentStep('complete');
|
|
112
121
|
}
|
|
113
122
|
else {
|
|
@@ -116,6 +125,7 @@ function RecoveryContent() {
|
|
|
116
125
|
}
|
|
117
126
|
catch (err) {
|
|
118
127
|
setError(err instanceof Error ? err.message : 'Failed to reset password.');
|
|
128
|
+
setRecoveryExpired(!!err && err.code === 'RECOVERY_EXPIRED');
|
|
119
129
|
}
|
|
120
130
|
finally {
|
|
121
131
|
setLoading(false);
|
|
@@ -129,9 +139,9 @@ function RecoveryContent() {
|
|
|
129
139
|
else if (currentStep === 'set-password')
|
|
130
140
|
setCurrentStep('verify-code');
|
|
131
141
|
};
|
|
132
|
-
return ((0, jsx_runtime_1.jsx)("div", { className: "w-full flex items-center justify-center p-6", style: { background: 'var(--bg-default)' }, children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-md w-full", children: [error && ((0, jsx_runtime_1.
|
|
142
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "w-full flex items-center justify-center p-6", style: { background: 'var(--bg-default)' }, children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-md w-full", children: [error && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4 bg-red-50 border border-red-200 rounded-2xl p-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-red-700 text-sm", children: error }), recoveryExpired && ((0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "text-sm font-medium underline", children: "Start password recovery again" }))] })), loading && ((0, jsx_runtime_1.jsx)("div", { className: "mb-4 rounded-2xl p-3", style: { background: 'var(--bg-card)', borderColor: 'var(--border-default)' }, children: (0, jsx_runtime_1.jsx)("p", { className: "text-sm", style: { color: 'var(--text-secondary)' }, children: "Processing..." }) })), currentStep === 'initiate' && ((0, jsx_runtime_1.jsx)(InitiateRecoveryStep_1.InitiateRecoveryStep, { email: email, setEmail: setEmail, onSubmit: handleInitiateRecovery, loading: loading })), currentStep === 'select-method' && recoverySession && ((0, jsx_runtime_1.jsx)(SelectMethodStep_1.SelectMethodStep, { session: recoverySession, onSelectMethod: handleSendCode, loading: loading })), currentStep === 'verify-code' && ((0, jsx_runtime_1.jsx)(VerifyCodeStep_1.VerifyCodeStep, { code: verificationCode, setCode: setVerificationCode, onSubmit: handleVerifyCode, onResend: () => selectedMethod && handleSendCode(selectedMethod), loading: loading, maskedDestination: selectedMethod === 'sms'
|
|
133
143
|
? recoverySession?.maskedPhone
|
|
134
|
-
: recoverySession?.maskedEmail })), currentStep === 'set-password' && ((0, jsx_runtime_1.jsx)(SetPasswordStep_1.SetPasswordStep, { onSubmit: handleResetPassword, loading: loading })), currentStep === 'complete' && ((0, jsx_runtime_1.jsx)(CompleteStep_1.CompleteStep, { onGoToLogin: () => router.push('/account-auth/login') })), currentStep !== 'complete' && currentStep !== 'initiate' && !loading && ((0, jsx_runtime_1.jsx)("div", { className: "mt-6 text-center", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleGoBack, className: "text-sm hover:underline font-medium", style: { color: 'var(--text-primary)' }, children: "\u2190 Go Back" }) }))] }) }));
|
|
144
|
+
: recoverySession?.maskedEmail })), currentStep === 'set-password' && ((0, jsx_runtime_1.jsx)(SetPasswordStep_1.SetPasswordStep, { onSubmit: handleResetPassword, loading: loading })), currentStep === 'requested' && (0, jsx_runtime_1.jsx)(RequestedStep_1.RequestedStep, { onGoToLogin: () => router.push('/account-auth/login') }), currentStep === 'complete' && ((0, jsx_runtime_1.jsx)(CompleteStep_1.CompleteStep, { onGoToLogin: () => router.push(resetSucceeded ? '/account-auth/login?notice=password-set' : '/account-auth/login') })), currentStep !== 'complete' && currentStep !== 'requested' && currentStep !== 'initiate' && !loading && ((0, jsx_runtime_1.jsx)("div", { className: "mt-6 text-center", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleGoBack, className: "text-sm hover:underline font-medium", style: { color: 'var(--text-primary)' }, children: "\u2190 Go Back" }) }))] }) }));
|
|
135
145
|
}
|
|
136
146
|
function LoadingFallback() {
|
|
137
147
|
const colors = (0, useTheme_1.useColors)();
|