@payez/next-mvp 4.12.0 → 4.13.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/api-handlers/auth/credential-login.js +28 -4
  3. package/dist/api-handlers/mfa/totp-confirm.js +8 -4
  4. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  5. package/dist/api-handlers/session/viability.js +6 -2
  6. package/dist/auth/better-auth.d.ts +6 -0
  7. package/dist/auth/better-auth.js +29 -2
  8. package/dist/auth/session-reset-link.d.ts +32 -0
  9. package/dist/auth/session-reset-link.js +45 -1
  10. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  11. package/dist/components/auth/CredentialSignInForm.js +13 -4
  12. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  13. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  14. package/dist/components/mfa/QrCode.d.ts +6 -0
  15. package/dist/components/mfa/QrCode.js +14 -0
  16. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  17. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  18. package/dist/edge/middleware.d.ts +1 -1
  19. package/dist/edge/middleware.js +2 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  23. package/dist/lib/credential-sign-in-client.js +32 -6
  24. package/dist/lib/ensure-fresh-access-token.js +15 -10
  25. package/dist/lib/idp-client-config.js +4 -1
  26. package/dist/lib/login-error-message.d.ts +3 -0
  27. package/dist/lib/login-error-message.js +12 -1
  28. package/dist/lib/mfa-json-guard.d.ts +12 -0
  29. package/dist/lib/mfa-json-guard.js +19 -0
  30. package/dist/lib/nextauth-secret.js +3 -1
  31. package/dist/lib/payez-client-secret.d.ts +14 -0
  32. package/dist/lib/payez-client-secret.js +41 -0
  33. package/dist/lib/qrcode.d.ts +22 -0
  34. package/dist/lib/qrcode.js +362 -0
  35. package/dist/lib/sign-in-failure.d.ts +35 -0
  36. package/dist/lib/sign-in-failure.js +85 -0
  37. package/dist/lib/twofa-rejection.d.ts +2 -0
  38. package/dist/lib/twofa-rejection.js +5 -2
  39. package/dist/middleware/create-middleware.d.ts +24 -0
  40. package/dist/middleware/create-middleware.js +56 -9
  41. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  42. package/dist/middleware/prefetch-guard-status.js +42 -0
  43. package/dist/pages/enroll/page.d.ts +7 -0
  44. package/dist/pages/enroll/page.js +15 -0
  45. package/dist/pages/login/page.js +1 -1
  46. package/dist/routes/auth/session-reset.d.ts +11 -1
  47. package/dist/routes/auth/session-reset.js +38 -13
  48. package/dist/routes/auth/viability.js +11 -2
  49. package/package.json +11 -1
  50. package/src/api-handlers/auth/credential-login.ts +29 -4
  51. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  52. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  53. package/src/api-handlers/session/viability.ts +6 -2
  54. package/src/auth/better-auth.ts +27 -2
  55. package/src/auth/session-reset-link.ts +47 -0
  56. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  57. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  58. package/src/components/mfa/QrCode.tsx +24 -0
  59. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  60. package/src/edge/middleware.ts +1 -1
  61. package/src/index.ts +1 -1
  62. package/src/lib/credential-sign-in-client.ts +48 -8
  63. package/src/lib/ensure-fresh-access-token.ts +15 -10
  64. package/src/lib/idp-client-config.ts +5 -1
  65. package/src/lib/login-error-message.ts +13 -1
  66. package/src/lib/mfa-json-guard.ts +19 -0
  67. package/src/lib/nextauth-secret.ts +3 -1
  68. package/src/lib/payez-client-secret.ts +39 -0
  69. package/src/lib/qrcode.ts +348 -0
  70. package/src/lib/sign-in-failure.ts +88 -0
  71. package/src/lib/twofa-rejection.ts +7 -2
  72. package/src/middleware/create-middleware.ts +69 -9
  73. package/src/middleware/prefetch-guard-status.ts +56 -0
  74. package/src/pages/enroll/page.tsx +22 -0
  75. package/src/pages/login/page.tsx +2 -1
  76. package/src/routes/auth/session-reset.ts +46 -11
  77. package/src/routes/auth/viability.ts +10 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog — @payez/next-mvp
2
2
 
3
+ ## 4.13.0 (2026-11-05; published under dist-tag `next` only, after 4.12.1; `latest` stays 4.8.9 until Jon says otherwise)
4
+
5
+ A minor. Numbered 4.13.0 because everything new is additive or opt-in and no export was removed (two exports were added: `./components/mfa/TotpEnrollPanel` and `./dist/pages/enroll`), but three changes alter behaviour for every consumer and are listed first so nobody is surprised. Built from GitHub `keelsecurity/keelauth-next` main (the code is e0ccb0b9; this release commit changes only this file and the version). Base: 4.12.1 (PR 1500 only). PR numbers are the GitHub-era numbers where the PR was opened after the cutover, else the ADO number. Consumers keep their own pins: nothing here bumps or pins any consumer, and the portal stays on 4.12.1 until its owner says otherwise.
6
+
7
+ **Behaviour changes for every consumer (read first)**
8
+
9
+ - **CHANGE (PAY-2218 P2, PR 1752): the session cookies are expired only by a request that is provably a top-level navigation.** Decision table, `isKnownNonNavigationRequest` (new export): `Sec-Fetch-Mode` present and not `navigate`, or `Sec-Fetch-Dest` present and not `document` -> NOT cleared (the redirect is still returned); `navigate`/`document` -> cleared as before; no `Sec-Fetch-*` at all: NOT cleared when `rsc`, `next-router-prefetch`, `next-router-state-tree` or `sec-purpose`/`purpose: prefetch` is present, otherwise cleared as before (curl, old clients, server-to-server). Fixes the router-prefetch sign-out measured on real Next 15.5.9 and 16.2.12 servers with real Chromium. Consequence: a dead cookie is now cleared only by a real navigation to a protected page; an API fetch that gets 401 or a client-side link click does not wipe it, and the login page itself does not clear. One test changed on purpose: an `rsc`-only request no longer clears.
10
+ - **CHANGE (PAY-2467, PR 1612): a session whose IdP answer said a second factor is owed stays gated whatever the tenant config says (fail closed).** The viability override used to fire only on the Better-Auth-record fallback; it now also fires on the canonical session row. An IdP-discarded refresh token is treated as a dead session, not a pending second factor. No code change needed by consumers.
11
+ - **CHANGE (PAY-2568 / PAY-2279, PRs 1719, 1614, 1730): a failed credential sign-in shows a fixed sentence chosen by a stable code, never the server text** (detail in the entries below), including the code step.
12
+
13
+ **Next-version behaviour table (PAY-2218)**
14
+
15
+ | Next | Router-prefetch headers in middleware | Cookie clear after P2 | `PAYEZ_PREFETCH_GUARD_DEAD` log (PR 2) |
16
+ |---|---|---|---|
17
+ | 15.x (measured 15.5.9) | stripped, no opt-out | keys on Sec-Fetch; real-browser gate passes | printed once per process (proven on a real `next start`) |
18
+ | 16.0-16.1 (measured 16.1.5 source) | stripped, no opt-out | same | printed once |
19
+ | 16.2.x (measured 16.2.12) | kept only with `skipProxyUrlNormalize: true`; stripped without it | same (gate passes WITHOUT the flag) | silent by design (the flag cannot be read from user code) |
20
+ | 14 | not measured | not measured | silent |
21
+
22
+ Not covered: Safari/Firefox (Chromium only), non-browser clients and browsers that send no Sec-Fetch headers (they keep the old behaviour).
23
+
24
+ **New, opt-in**
25
+
26
+ - **PAYEZ_CLIENT_SECRET (PAY-1460 3b, PR 1745).** New optional env var, read at call time, never logged. When set it is sent as `client_secret` in the bodies of `sign-client-assertion`, `client-config` and `next-auth/secret`; unset or blank sends exactly the old bodies. Needed before the IdP enforces a client secret for the client (Core PR 1747 / keelsecurity/payez-core #3). A WRONG value is rejected 401 and 5 wrong values in 15 minutes from one source lock that client+source out: verify the secret on a non-prod rig before it goes into a task definition. An app must run this release with the secret set BEFORE its client id leaves `LegacyOpenClientIds`, or it gets the public view.
27
+ - **PAYEZ_PROVIDER_SECRET_MISSING_REDACTED (PR 1745).** New log line, once per provider per process, naming only the provider: the IdP answered `X-PayEz-Secrets-Redacted: true` and no `<PROVIDER>_CLIENT_SECRET` is set; social sign-in for that provider will fail at the code exchange. Set the env var.
28
+ - **PAYEZ_PREFETCH_GUARD_DEAD (PAY-2218 P1, keelauth-next PR 2).** New log line, information only (see the table).
29
+ - **Signed-in authenticator enrolment page (PAY-2631, PR 1726)** for generated apps: new exports `./dist/pages/enroll` and `./components/mfa/TotpEnrollPanel`.
30
+ - **Credential sign-in code step offers enrolment when the IdP says `enroll_mfa` (PAY-2630, PR 1687).** Host-gated, no default `enrollHref`.
31
+ - **Session-reset links can fall back to a cookie-derived key when the edge cannot see the env secret (PAY-2218, PR 1639).** Explicit opt-in, default OFF (`sessionResetCookieKey`, `createSessionResetHandler({ cookieKey: true })`); the strict route logs one value-free warning for a half-set deployment.
32
+
33
+ **Smaller**
34
+
35
+ - **FIX (PAY-2554, PR 1606):** an OAuth callback failure (`/api/auth/error`) redirects with a fixed `OAUTH_SIGN_IN_FAILED` code and the login page shows a sentence.
36
+ - **FIX (PAY-2568, PR 1613):** `createAuthGetHandler` logs the Better Auth error code of an `/api/auth/error` redirect.
37
+ - **FIX (PAY-2279, PR 1614):** login page sentence for `REGISTRATION_NOT_ALLOWED`.
38
+
39
+ **Not in this package**: `packages/cli` changes (PR 1686 stop-before-scaffold on `KEYS_NO_DEVELOPER_TENANT`, PR 1703 non-zero exit on a failed provision, and the CLI half of PR 1726) ship with `@payez/cli`, not `@payez/next-mvp`. Test-only: PR 1751, PR 1555.
40
+
41
+ **Release checks run (NextPert, 2026-11-04)**: package builds from main e0ccb0b9 with `tsc` and no errors; full package suite 1559 tests, 1 failure (`C1`, TypeScript shape rows, fails on main and is unrelated); `exports` map has no NEW dangling target versus 4.12.1 (the dangling targets are PRE-EXISTING, in the published 4.12.1 too, not new in this release; tracked in PAY-2718); `npm pack` (not published) from a clean LF `git archive` of main e0ccb0b9 into a temp dir ran `prepack` (build + `check:publish-eol`) green and produced `payez-next-mvp-4.12.1.tgz` (965 files, 1.3 MB; the version string is still 4.12.1 because nothing was bumped); a throwaway consumer build of VibeID-Customer-Portal 2d0bcf18 (Next 16.2.12, default bundler) against that tarball passed `next build`. At the time of these checks NOT done: `npm ci` from the private feed (my token returns 401), `npm publish`, the postpublish health check, a browser/sign-in walk of the consumer. The consumer build proves the package resolves and compiles, not that sign-in works.
42
+
43
+ ## Earlier entries now rolled into 4.13.0 (previously "Unreleased: PAY-2568 / PAY-2279, draft PR; no release, no pin")
44
+
45
+ - **CHANGE: a failed credential sign-in shows a fixed sentence chosen by a stable code, never the server text.** `lib/sign-in-failure` is one closed table: `invalid_credentials` ("Incorrect email or password.", wrong password, unknown account and any other refusal, identical), `not_a_member` (the IdP code `NotAMemberOfThisApp` at any HTTP status - the public door relays it as 400 - shown only to someone who proved the credentials), `too_many_attempts` (429, `AccountLocked`, `UserLockedOut`, one sentence for all). `/api/auth/admin-login` now answers `code` + the fixed sentence in `error`; the browser client takes the sentence from the table by code and ignores the body text (an unmapped refusal gets one generic sentence, `sign_in_failed`). Each rendered error carries `data-error-code` (form and login page). Allowlisted policy refusals (SsoRequired, InvalidRequest) still show their detail under code `policy_message`, as a plain text node, at most 200 characters (BAPert 85432); no other server text reaches the page. **What changes for a caller:** the 429 sentence and the `?error=CredentialsSignin` sentence changed; a custom UI that read `body.error` still gets a fixed sentence there. Rows: `test/signin-failure-copy.test.cjs` S1-S7 (RED on master, six mutants).
46
+ - **FIX: the code step of a credential sign-in (wrong, unavailable, locked) shows its own sentence again (BAPert 85795).** The code-step branch of `api-handlers/auth/credential-login` sent no `code`, so after the change above every second-factor refusal fell to the generic "Could not sign in. Check your details and try again.", including the "problem on our side, not with your code" answer. The branch now sends `invalid_second_factor` ("That code is not correct. Check the code and try again."), `second_factor_unavailable` (the existing our-side sentence, with the Email-me-a-code offer kept) or `too_many_attempts`, chosen by the existing `classifyTwofaRejection` and never from IdP wording. The lockout sentence now states the window: `Too many sign-in attempts. Try again in N minutes, or reset your password.`, N = the server's `retryAfter` (seconds) rounded up to whole minutes and clamped to 1..60; with no usable number it stays the plain sentence (only a number is ever printed). The password-step 429 no longer invents a `retryAfter` of 60 when the IdP sent no Retry-After. A plain IdP 429 on the code step (a lane rate limit, not a `locked` lockout) has its own code `second_factor_rate_limited`: "Too many attempts. Try again in N minutes." (same N rule; plain fallback "Too many attempts. Wait a few minutes and try again."), so it no longer tells a user to reset their password; it makes no claim that the code is still valid (unproven here), and `twofa-rejection` no longer invents a 60 s `retryAfter`. Sentences lost by the 1719 regression in this lane: the wrong-code sentence, the our-side-fault sentence (with its Email-me-a-code offer text) and the lockout window. Test: `test/second-factor-copy.test.cjs` (T1-T15, mutants RED).
47
+
48
+ ## 4.12.1 (to be published under dist-tag `next` only, after 4.12.0; `latest` stays 4.8.9 until Jon says otherwise)
49
+
50
+ A patch cut from the 4.12.0 tree carrying ONE change: the PAY-2425 hygiene fix below (PR 1500).
51
+
52
+ - **CHANGE (PAY-2425, hygiene): the refresh no longer invents authentication claims for an OAuth (Google) session.** `lib/ensure-fresh-access-token` answered an OAuth session whose stored authentication methods were empty with `amr ['pwd','mfa']`, `two_factor_method 'oauth'` and an `acr` raised from '1' to '2' ("OAuth IS a form of multi-factor auth"), and echoed a stored `mfaVerified` as `two_factor_verified` whatever the session's methods said. OAuth is federated sign-in, not a second factor (BAPert 77404). The refresh body now carries only what the session records: empty stays empty, `acr` is the session's own level, no method is named from silence, and `two_factor_verified` is sent only when the session's recorded methods contain `mfa`, `sms`, `totp` or `email`. A session that really attests `mfa` still sends it. The IdP ignores these request fields and mints `amr`/`acr` from the stored refresh row, so this removes a false claim from the wire; it does not establish that any bypass existed or is closed. **What changes for a caller:** a Google user on a `require2FA` client is asked for a code, as before, because the IdP decides that; a Google-only client that wants no second factor sets `require2FA = false` explicitly. An earlier version of this fix on `nextpert/375639-amr-fabrication` (e6e3b6c3, e3b3bb12) was never merged; the refresh path has since moved into this helper. Rows: `test/next-mvp-pay-2425-no-fabricated-mfa.test.cjs` (R1/R2/R3, C1, N1 and four mutants).
53
+
3
54
  ## 4.12.0 (to be published under dist-tag `next` only, after 4.11.0; `latest` stays 4.8.9 until Jon says otherwise)
4
55
 
5
56
  A minor: PII-in-logs groups 1, 2, 3a and 3b, the GOV-023 signout BREAKING change, the logout-401 FIX (PAY-2396), and the BREAKING site-logger signatures (3b: callers of `siteEvents.loginFailed` / `siteEvents.error` must change).
@@ -41,6 +41,7 @@ exports.POST = POST;
41
41
  * lib/session-store.js").
42
42
  */
43
43
  const server_1 = require("next/server");
44
+ const sign_in_failure_1 = require("../../lib/sign-in-failure");
44
45
  const jose_1 = require("jose");
45
46
  const idp_client_config_1 = require("../../lib/idp-client-config");
46
47
  const client_key_1 = require("../../lib/client-key");
@@ -306,9 +307,13 @@ async function POST(req) {
306
307
  // (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
307
308
  // classification told to "try again".
308
309
  const rejection = (0, twofa_rejection_1.classifyTwofaRejection)(laneResults, idpResponse.headers.get('retry-after'));
310
+ // PAY-2568 follow-up (BAPert 85795): the page takes its sentence from a CODE in the closed table, so this lane must send one.
311
+ // The branch already decided the cause (classifyTwofaRejection); the code follows its status, the IdP's wording is never used.
312
+ const rejectionCode = rejection.status === 429 ? (rejection.rateLimited ? 'second_factor_rate_limited' : 'too_many_attempts') : rejection.status === 502 ? 'second_factor_unavailable' : 'invalid_second_factor';
309
313
  return server_1.NextResponse.json({
310
314
  success: false,
311
- error: rejection.error,
315
+ code: rejectionCode,
316
+ error: (0, sign_in_failure_1.signInFailureMessage)(rejectionCode),
312
317
  ...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
313
318
  // PAY-2284: the card shows a real "email me a code" action only when this is true.
314
319
  ...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
@@ -365,15 +370,21 @@ async function POST(req) {
365
370
  }
366
371
  if (idpResponse.status === 429) {
367
372
  const retryAfter = idpResponse.headers.get('retry-after');
368
- return server_1.NextResponse.json({ success: false, error: 'Too many attempts. Please try again later.', retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60 }, { status: 429 });
373
+ return server_1.NextResponse.json({
374
+ success: false,
375
+ code: 'too_many_attempts',
376
+ error: (0, sign_in_failure_1.signInFailureMessage)('too_many_attempts'),
377
+ // BAPert 85807: the page prints this number as minutes, so only a real Retry-After is sent; no invented default.
378
+ ...(retryAfter && Number.isFinite(parseInt(retryAfter, 10)) && parseInt(retryAfter, 10) > 0 ? { retryAfter: parseInt(retryAfter, 10) } : {}),
379
+ }, { status: 429 });
369
380
  }
370
381
  if (!idpResponse.ok) {
371
382
  const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
372
- return server_1.NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: idpResponse.status });
383
+ return server_1.NextResponse.json(refusalBody(f, idpResponse.status), { status: idpResponse.status });
373
384
  }
374
385
  if (data?.success === false) {
375
386
  const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
376
- return server_1.NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: 200 });
387
+ return server_1.NextResponse.json(refusalBody(f, idpResponse.status), { status: 200 });
377
388
  }
378
389
  // Unwrap the IDP's canonical envelope, same shape login.js normalizes.
379
390
  const result = data?.data?.result ?? data?.result ?? data;
@@ -685,6 +696,19 @@ function publicRefusalMessage(f) {
685
696
  return f.detail;
686
697
  return UNIFORM_REFUSAL;
687
698
  }
699
+ /**
700
+ * The browser-facing body of a refusal (BAPert 85415): a stable machine `code` from the closed table in lib/sign-in-failure, and
701
+ * the fixed sentence for it. The IdP's own message text is never copied in. The one exception is the allowlisted policy refusals
702
+ * (SsoRequired / SsoRequiredBreakGlass / InvalidRequest): a property of the email domain or the request, not of an account, and the
703
+ * user cannot proceed without being told; they carry code 'policy_message' and the allowlisted detail (see SAFE_TO_SHOW).
704
+ */
705
+ function refusalBody(f, status) {
706
+ const policy = f.code && SAFE_TO_SHOW.has(f.code) ? (0, sign_in_failure_1.capPolicyDetail)(f.detail) : null;
707
+ if (policy)
708
+ return { success: false, code: 'policy_message', error: policy };
709
+ const code = (0, sign_in_failure_1.classifySignInFailure)(status, f.code);
710
+ return { success: false, code, error: (0, sign_in_failure_1.signInFailureMessage)(code) };
711
+ }
688
712
  function logIdpAuthRefusal(branch, httpStatus, data) {
689
713
  const f = extractIdpFailure(data);
690
714
  const d = (data ?? {});
@@ -5,6 +5,7 @@ const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../lib/idp-fetch");
6
6
  const env_1 = require("../../config/env");
7
7
  const safe_error_response_1 = require("../../lib/safe-error-response");
8
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
8
9
  /**
9
10
  * Confirm TOTP enrolment with the first authenticator code
10
11
  * (POST /api/Account/mfa/totp/confirm, user JWT). Body forwarded verbatim —
@@ -12,6 +13,9 @@ const safe_error_response_1 = require("../../lib/safe-error-response");
12
13
  * the method ACTIVE.
13
14
  */
14
15
  async function POST(req) {
16
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
17
+ if (refused)
18
+ return refused;
15
19
  let body = '{}';
16
20
  try {
17
21
  const raw = await req.text();
@@ -30,13 +34,13 @@ async function POST(req) {
30
34
  message: 'Upstream error',
31
35
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: (0, safe_error_response_1.projectIdpError)(result.json) },
32
36
  meta: { attemptedRefresh: result.attemptedRefresh },
33
- }, { status: result.status });
37
+ }, { status: result.status, headers: mfa_json_guard_1.NO_STORE });
34
38
  }
35
39
  const bodyJson = result.json;
36
40
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
41
  if (bodyJson.success === true)
38
- return server_1.NextResponse.json(bodyJson.data, { status: 200 });
39
- return server_1.NextResponse.json(bodyJson, { status: 200 });
42
+ return server_1.NextResponse.json(bodyJson.data, { status: 200, headers: mfa_json_guard_1.NO_STORE });
43
+ return server_1.NextResponse.json(bodyJson, { status: 200, headers: mfa_json_guard_1.NO_STORE });
40
44
  }
41
- return server_1.NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return server_1.NextResponse.json(bodyJson ?? {}, { status: 200, headers: mfa_json_guard_1.NO_STORE });
42
46
  }
@@ -5,6 +5,7 @@ const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../lib/idp-fetch");
6
6
  const env_1 = require("../../config/env");
7
7
  const safe_error_response_1 = require("../../lib/safe-error-response");
8
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
8
9
  /**
9
10
  * Begin TOTP enrolment (POST /api/Account/mfa/totp/enroll, user JWT).
10
11
  * Returns the shared secret / otpauth URI for the QR. An enrolment left
@@ -12,6 +13,9 @@ const safe_error_response_1 = require("../../lib/safe-error-response");
12
13
  * as "Active" (MFA honesty fixes, 2026-08-01).
13
14
  */
14
15
  async function POST(req) {
16
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
17
+ if (refused)
18
+ return refused;
15
19
  let body = '{}';
16
20
  try {
17
21
  const raw = await req.text();
@@ -30,13 +34,13 @@ async function POST(req) {
30
34
  message: 'Upstream error',
31
35
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: (0, safe_error_response_1.projectIdpError)(result.json) },
32
36
  meta: { attemptedRefresh: result.attemptedRefresh },
33
- }, { status: result.status });
37
+ }, { status: result.status, headers: mfa_json_guard_1.NO_STORE });
34
38
  }
35
39
  const bodyJson = result.json;
36
40
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
41
  if (bodyJson.success === true)
38
- return server_1.NextResponse.json(bodyJson.data, { status: 200 });
39
- return server_1.NextResponse.json(bodyJson, { status: 200 });
42
+ return server_1.NextResponse.json(bodyJson.data, { status: 200, headers: mfa_json_guard_1.NO_STORE });
43
+ return server_1.NextResponse.json(bodyJson, { status: 200, headers: mfa_json_guard_1.NO_STORE });
40
44
  }
41
- return server_1.NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return server_1.NextResponse.json(bodyJson ?? {}, { status: 200, headers: mfa_json_guard_1.NO_STORE });
42
46
  }
@@ -65,6 +65,8 @@ async function GET(req) {
65
65
  if (betterAuthSession && sessionToken) {
66
66
  // Try legacy session store first, then Better Auth format
67
67
  let sessionData = await (0, session_store_1.getSession)(sessionToken);
68
+ // PAY-2467: see routes/auth/viability.ts - a boolean mfaVerified on the raw canonical row is a measurement.
69
+ const fromCanonicalStore = !!sessionData;
68
70
  if (!sessionData) {
69
71
  // Better Auth stores sessions with ba:{appSlug}:{token} prefix
70
72
  sessionData = await (0, session_store_1.getBetterAuthSession)(sessionToken);
@@ -113,9 +115,11 @@ async function GET(req) {
113
115
  // only ever SUPPRESS this override, never grant access. Anything that GRANTS must
114
116
  // keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
115
117
  // void if this signal reaches an auth decision.
116
- if (sessionData.mfaSignalPresent === true &&
118
+ if ((sessionData.mfaSignalPresent === true || (fromCanonicalStore && typeof sessionData.mfaVerified === 'boolean')) &&
117
119
  sessionData.mfaVerified === false &&
118
- !sessionData.idpRefreshToken) {
120
+ !sessionData.idpRefreshToken &&
121
+ // same exception as routes/auth/viability: an IdP-discarded refresh token is a dead session, not a pending second factor
122
+ !String(sessionData?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN')) {
119
123
  requires2FA = true;
120
124
  }
121
125
  // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
@@ -623,6 +623,12 @@ export declare const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string>;
623
623
  * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
624
624
  */
625
625
  export declare function expireSetCookies(setCookies: readonly string[]): string[];
626
+ /**
627
+ * PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
628
+ * token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
629
+ * or absolute; the base is a placeholder used only for parsing.
630
+ */
631
+ export declare function authErrorCodeFromLocation(location: string): string | null;
626
632
  /**
627
633
  * Create a production-ready GET handler for the auth catch-all route.
628
634
  *
@@ -57,6 +57,7 @@ exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
57
57
  exports.storeIdpTokensInSession = storeIdpTokensInSession;
58
58
  exports.storePayezIdpSession = storePayezIdpSession;
59
59
  exports.expireSetCookies = expireSetCookies;
60
+ exports.authErrorCodeFromLocation = authErrorCodeFromLocation;
60
61
  exports.createAuthGetHandler = createAuthGetHandler;
61
62
  require("server-only");
62
63
  const node_async_hooks_1 = require("node:async_hooks");
@@ -1376,6 +1377,20 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
1376
1377
  console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)', 'code=' + errorCode);
1377
1378
  return new Response(null, { status: 302, headers });
1378
1379
  }
1380
+ /**
1381
+ * PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
1382
+ * token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
1383
+ * or absolute; the base is a placeholder used only for parsing.
1384
+ */
1385
+ function authErrorCodeFromLocation(location) {
1386
+ try {
1387
+ const code = new URL(location, 'http://placeholder.invalid').searchParams.get('error');
1388
+ return code && /^[A-Za-z0-9_.-]{1,64}$/.test(code) ? code : null;
1389
+ }
1390
+ catch {
1391
+ return null;
1392
+ }
1393
+ }
1379
1394
  /**
1380
1395
  * Create a production-ready GET handler for the auth catch-all route.
1381
1396
  *
@@ -1411,7 +1426,16 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1411
1426
  if (response.status === 302) {
1412
1427
  const location = response.headers.get('location') || '';
1413
1428
  if (location.includes('/api/auth/error') || location.includes('please_restart')) {
1414
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1429
+ // PAY-2568: log WHICH error. Better Auth routes every callback failure to /api/auth/error?error=<code>
1430
+ // (signup_disabled, state_mismatch, ...); the old fixed 'OAuth state error' sentence made distinct faults
1431
+ // look identical. Only the code is logged - never the Location (it can carry other params).
1432
+ const authErrorCode = authErrorCodeFromLocation(location);
1433
+ if (authErrorCode) {
1434
+ console.warn('[BETTER_AUTH] auth error code=' + authErrorCode + ', redirecting to login');
1435
+ }
1436
+ else {
1437
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1438
+ }
1415
1439
  // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1416
1440
  // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1417
1441
  // runtime and again on node 24:
@@ -1448,7 +1472,10 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1448
1472
  // Works whether loginPath is relative (package default '/account-auth/login',
1449
1473
  // and the graft passes '/sign-in') or absolute — an absolute Location is also
1450
1474
  // legal, so a consumer passing a full URL still behaves.
1451
- return new Response(null, { status: 302, headers: { location: loginPath } });
1475
+ // PAY-2554: the redirect used to carry NO code, so the login page showed nothing and looked like nothing happened.
1476
+ // It now carries the same neutral OAUTH_SIGN_IN_FAILED the abort path uses (the login page maps it to one sentence;
1477
+ // no provider text, URL or internal detail goes on the URL).
1478
+ return new Response(null, { status: 302, headers: { location: `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${exports.OAUTH_SIGN_IN_FAILED_ERROR}` } });
1452
1479
  }
1453
1480
  }
1454
1481
  // After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
@@ -15,6 +15,26 @@ export interface ResetParams {
15
15
  }
16
16
  /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
17
17
  export declare function resetSecret(): string | undefined;
18
+ /**
19
+ * The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
20
+ * key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
21
+ */
22
+ export declare const RESET_COOKIE_KEY_CONTEXT = "payez:session-reset:v1:cookie-derived";
23
+ /**
24
+ * The secret the reset link is signed and verified with.
25
+ *
26
+ * 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
27
+ * 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
28
+ * Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
29
+ * produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
30
+ * the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
31
+ * RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
32
+ * session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
33
+ * set or shadow cookies and log the victim out, so the scheme adds no capability.
34
+ * 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
35
+ * Never log the cookie value or the returned key.
36
+ */
37
+ export declare function resetSigningSecret(envSecret: string | undefined, sessionCookieValue: string | undefined): Promise<string | undefined>;
18
38
  /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
19
39
  export declare function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined;
20
40
  /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
@@ -32,6 +52,18 @@ export declare function loginLocation(p: ResetParams): string;
32
52
  * `nowMs` is injectable for tests.
33
53
  */
34
54
  export declare function signedResetHref(route: string, p: ResetParams, secret: string, sessionCookieValue: string | undefined, nowMs?: number): Promise<string>;
55
+ /**
56
+ * VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
57
+ * secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
58
+ * signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
59
+ * cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
60
+ * signed with neither key, or with a key derived from a different cookie, is refused.
61
+ */
62
+ export declare function readResetRequestAnyKey(search: URLSearchParams, envSecret: string | undefined, sessionCookieValue: string | undefined, nowMs?: number): Promise<{
63
+ params: ResetParams;
64
+ signed: boolean;
65
+ via: 'env' | 'cookie' | null;
66
+ }>;
35
67
  /**
36
68
  * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
37
69
  * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RESET_ERRORS = exports.RESET_REASONS = exports.RESET_KEY_PURPOSE = exports.RESET_LINK_TTL_SECONDS = exports.DEFAULT_SESSION_RESET_ROUTE = exports.LOGIN_PAGE = void 0;
3
+ exports.RESET_COOKIE_KEY_CONTEXT = exports.RESET_ERRORS = exports.RESET_REASONS = exports.RESET_KEY_PURPOSE = exports.RESET_LINK_TTL_SECONDS = exports.DEFAULT_SESSION_RESET_ROUTE = exports.LOGIN_PAGE = void 0;
4
4
  exports.resetSecret = resetSecret;
5
+ exports.resetSigningSecret = resetSigningSecret;
5
6
  exports.boundSessionCookie = boundSessionCookie;
6
7
  exports.normaliseResetParams = normaliseResetParams;
7
8
  exports.resetPayload = resetPayload;
8
9
  exports.loginLocation = loginLocation;
9
10
  exports.signedResetHref = signedResetHref;
11
+ exports.readResetRequestAnyKey = readResetRequestAnyKey;
10
12
  exports.readResetRequest = readResetRequest;
11
13
  /**
12
14
  * PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
@@ -85,6 +87,32 @@ function sameString(a, b) {
85
87
  function resetSecret() {
86
88
  return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
87
89
  }
90
+ /**
91
+ * The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
92
+ * key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
93
+ */
94
+ exports.RESET_COOKIE_KEY_CONTEXT = 'payez:session-reset:v1:cookie-derived';
95
+ /**
96
+ * The secret the reset link is signed and verified with.
97
+ *
98
+ * 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
99
+ * 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
100
+ * Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
101
+ * produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
102
+ * the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
103
+ * RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
104
+ * session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
105
+ * set or shadow cookies and log the victim out, so the scheme adds no capability.
106
+ * 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
107
+ * Never log the cookie value or the returned key.
108
+ */
109
+ async function resetSigningSecret(envSecret, sessionCookieValue) {
110
+ if (envSecret)
111
+ return envSecret;
112
+ if (!sessionCookieValue)
113
+ return undefined;
114
+ return b64url(await hmacBytes(encoder.encode(sessionCookieValue), exports.RESET_COOKIE_KEY_CONTEXT));
115
+ }
88
116
  /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
89
117
  function boundSessionCookie(get, plainName, secureName) {
90
118
  return get(secureName) || get(plainName) || undefined;
@@ -126,6 +154,22 @@ async function signedResetHref(route, p, secret, sessionCookieValue, nowMs = Dat
126
154
  q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
127
155
  return `${route}?${q.toString()}`;
128
156
  }
157
+ /**
158
+ * VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
159
+ * secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
160
+ * signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
161
+ * cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
162
+ * signed with neither key, or with a key derived from a different cookie, is refused.
163
+ */
164
+ async function readResetRequestAnyKey(search, envSecret, sessionCookieValue, nowMs = Date.now()) {
165
+ if (envSecret) {
166
+ const viaEnv = await readResetRequest(search, envSecret, sessionCookieValue, nowMs);
167
+ if (viaEnv.signed)
168
+ return { ...viaEnv, via: 'env' };
169
+ }
170
+ const viaCookie = await readResetRequest(search, await resetSigningSecret(undefined, sessionCookieValue), sessionCookieValue, nowMs);
171
+ return { ...viaCookie, via: viaCookie.signed ? 'cookie' : null };
172
+ }
129
173
  /**
130
174
  * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
131
175
  * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
@@ -27,7 +27,13 @@ interface Props {
27
27
  /** Test seam: navigation after success. */
28
28
  onSignedIn?: (destination: string) => void;
29
29
  showForgotPassword?: boolean;
30
+ /**
31
+ * PAY-2630: the enrolment page a user the IdP told to enrol is taken to after signing in. NO DEFAULT: the offer renders only when
32
+ * the host passes this, because the host is the one that serves the page (the CLI scaffold writes no /account/security, so a
33
+ * default would 404 a generated app's user right after a good sign-in).
34
+ */
35
+ enrollHref?: string;
30
36
  }
31
37
  export declare function resolveDestination(explicit?: string): string;
32
- export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword }: Props): import("react/jsx-runtime").JSX.Element;
38
+ export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref }: Props): import("react/jsx-runtime").JSX.Element;
33
39
  export default CredentialSignInForm;
@@ -10,6 +10,7 @@ const safe_callback_1 = require("../../auth/safe-callback");
10
10
  const authorize_resume_1 = require("../../lib/authorize-resume");
11
11
  const code_step_copy_1 = require("../../lib/code-step-copy");
12
12
  const DevCodeNotice_1 = require("./DevCodeNotice");
13
+ const EnrollAuthenticatorOffer_1 = require("./EnrollAuthenticatorOffer");
13
14
  const credential_sign_in_client_1 = require("../../lib/credential-sign-in-client");
14
15
  const inputStyle = {
15
16
  borderColor: 'var(--border-default, #e5e7eb)',
@@ -30,26 +31,31 @@ function resolveDestination(explicit) {
30
31
  }
31
32
  return (0, safe_callback_1.safeCallbackPath)((0, authorize_resume_1.authorizeResumeHref)(returnUrl), (0, safe_callback_1.safeCallbackPath)(raw, '/dashboard'));
32
33
  }
33
- function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false }) {
34
+ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }) {
34
35
  const [step, setStep] = (0, react_1.useState)('credentials');
35
36
  const [email, setEmail] = (0, react_1.useState)('');
36
37
  const [password, setPassword] = (0, react_1.useState)('');
37
38
  const [code, setCode] = (0, react_1.useState)('');
38
39
  const [busy, setBusy] = (0, react_1.useState)(false);
39
40
  const [error, setError] = (0, react_1.useState)(null);
41
+ const [errorCode, setErrorCode] = (0, react_1.useState)(null);
40
42
  const [fieldError, setFieldError] = (0, react_1.useState)(null);
41
43
  const [codeRequest, setCodeRequest] = (0, react_1.useState)(code_step_copy_1.NO_CODE_REQUEST);
42
44
  const [offerEmailCode, setOfferEmailCode] = (0, react_1.useState)(false);
43
45
  const [devCode, setDevCode] = (0, react_1.useState)(null);
44
46
  const [emailCodeMsg, setEmailCodeMsg] = (0, react_1.useState)(null);
47
+ // PAY-2630: what the IdP said is outstanding on this client, and whether the user kept the 'take me to enrol' offer ticked.
48
+ const [nextStep, setNextStep] = (0, react_1.useState)(null);
49
+ const [enrolAfter, setEnrolAfter] = (0, react_1.useState)(true);
45
50
  async function submit(withCode) {
46
51
  setBusy(true);
47
52
  setError(null);
53
+ setErrorCode(null);
48
54
  setOfferEmailCode(false);
49
55
  try {
50
56
  const r = await (0, credential_sign_in_client_1.credentialSignIn)({ email, password, code: withCode }, fetchImpl);
51
57
  if (r.kind === 'success') {
52
- const destination = resolveDestination(callbackUrl);
58
+ const destination = (0, credential_sign_in_client_1.postSignInDestination)(resolveDestination(callbackUrl), nextStep, enrolAfter, enrollHref);
53
59
  if (onSignedIn)
54
60
  onSignedIn(destination);
55
61
  else
@@ -59,10 +65,12 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
59
65
  if (r.kind === 'two_factor') {
60
66
  setCodeRequest(r.codeRequest);
61
67
  setDevCode(r.devCode);
68
+ setNextStep(r.nextStep);
62
69
  setStep('code');
63
70
  return;
64
71
  }
65
72
  setError(r.message);
73
+ setErrorCode(r.code);
66
74
  if (r.kind === 'error')
67
75
  setOfferEmailCode(r.offerEmailCode);
68
76
  }
@@ -94,6 +102,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
94
102
  setStep('credentials');
95
103
  setCodeRequest(code_step_copy_1.NO_CODE_REQUEST);
96
104
  setDevCode(null);
105
+ setNextStep(null);
97
106
  setCode('');
98
107
  setFieldError(null);
99
108
  setError(null);
@@ -121,12 +130,12 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
121
130
  }
122
131
  }
123
132
  const problem = fieldError ?? error;
124
- const problemLine = problem && ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "credential-sign-in-error", className: "text-sm mt-3", style: { color: 'var(--color-error, #ef4444)' }, children: problem }));
133
+ const problemLine = problem && ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "credential-sign-in-error", "data-error-code": fieldError ? 'invalid_input' : (errorCode ?? undefined), className: "text-sm mt-3", style: { color: 'var(--color-error, #ef4444)' }, children: problem }));
125
134
  if (step === 'code') {
126
135
  return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in-code-step", className: "text-left", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm mb-4", style: { color: 'var(--text-primary, #111827)' }, children: (0, code_step_copy_1.codeStepInstruction)(email, codeRequest) }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: onCodeSubmit, noValidate: true, className: "space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "credential-code", className: "block text-sm font-medium mb-2", style: { color: 'var(--text-primary, #111827)' }, children: "Authentication code" }), (0, jsx_runtime_1.jsx)("input", { id: "credential-code", name: "code", type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, placeholder: "000000", value: code, disabled: busy, onChange: (e) => {
127
136
  setCode(e.target.value);
128
137
  setFieldError(null);
129
- }, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
138
+ }, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)(EnrollAuthenticatorOffer_1.EnrollAuthenticatorOffer, { nextStep: enrollHref ? nextStep : null, checked: enrolAfter, onChange: setEnrolAfter, disabled: busy }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
130
139
  }
131
140
  return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in", className: "text-left", children: [(0, jsx_runtime_1.jsx)(TraditionalAuthSection_1.TraditionalAuthSection, { email: email, password: password, onEmailChange: (v) => {
132
141
  setEmail(v);
@@ -0,0 +1,21 @@
1
+ import type { NextStep } from '../../lib/credential-sign-in-client';
2
+ /**
3
+ * PAY-2630: the code step's offer to set up an authenticator.
4
+ *
5
+ * WHY. The IdP answers the first sign-in turn with next_step 'enroll_mfa' when the user has NO usable second factor on this
6
+ * client (factors are per client). The step used to ignore that and show only "enter the code we emailed you", so a user of a
7
+ * generated app could never enrol a factor on that client. Measured on the PAY-2305 walk (client 90662, user with a TOTP on
8
+ * client 12 only): requiresTwoFactor true, sentVia 'email', nextStep 'enroll_mfa', and a page with Verify / Back and nothing else.
9
+ *
10
+ * WHAT IT IS, AND IS NOT. It renders ONLY for 'enroll_mfa'. It names the lane the user is on (the emailed code) and offers to take
11
+ * them to the enrolment page right after they sign in with it. It does NOT enrol in place: the enrolment endpoints need a signed-in
12
+ * session and this step has none yet (the provisional bearer is parked server-side). For 'mfa_verify', null or anything else it
13
+ * renders nothing: a user who HAS a factor must not be told to set one up.
14
+ */
15
+ export declare function EnrollAuthenticatorOffer({ nextStep, checked, onChange, disabled, }: {
16
+ nextStep: NextStep;
17
+ checked: boolean;
18
+ onChange: (next: boolean) => void;
19
+ disabled?: boolean;
20
+ }): import("react/jsx-runtime").JSX.Element | null;
21
+ export default EnrollAuthenticatorOffer;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnrollAuthenticatorOffer = EnrollAuthenticatorOffer;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /**
6
+ * PAY-2630: the code step's offer to set up an authenticator.
7
+ *
8
+ * WHY. The IdP answers the first sign-in turn with next_step 'enroll_mfa' when the user has NO usable second factor on this
9
+ * client (factors are per client). The step used to ignore that and show only "enter the code we emailed you", so a user of a
10
+ * generated app could never enrol a factor on that client. Measured on the PAY-2305 walk (client 90662, user with a TOTP on
11
+ * client 12 only): requiresTwoFactor true, sentVia 'email', nextStep 'enroll_mfa', and a page with Verify / Back and nothing else.
12
+ *
13
+ * WHAT IT IS, AND IS NOT. It renders ONLY for 'enroll_mfa'. It names the lane the user is on (the emailed code) and offers to take
14
+ * them to the enrolment page right after they sign in with it. It does NOT enrol in place: the enrolment endpoints need a signed-in
15
+ * session and this step has none yet (the provisional bearer is parked server-side). For 'mfa_verify', null or anything else it
16
+ * renders nothing: a user who HAS a factor must not be told to set one up.
17
+ */
18
+ function EnrollAuthenticatorOffer({ nextStep, checked, onChange, disabled, }) {
19
+ if (nextStep !== 'enroll_mfa')
20
+ return null;
21
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "enroll-authenticator-offer", className: "mt-4 text-sm", style: { color: 'var(--text-primary, #111827)' }, children: [(0, jsx_runtime_1.jsx)("p", { "data-testid": "enroll-authenticator-lane", children: "You have no authenticator set up for this app yet, so this time you sign in with the emailed code." }), (0, jsx_runtime_1.jsxs)("label", { className: "flex items-center gap-2 mt-2", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", "data-testid": "enroll-authenticator-checkbox", checked: checked, disabled: disabled, onChange: (e) => onChange(e.target.checked) }), (0, jsx_runtime_1.jsx)("span", { children: "Take me to set up an authenticator right after I sign in" })] })] }));
22
+ }
23
+ exports.default = EnrollAuthenticatorOffer;
@@ -0,0 +1,6 @@
1
+ /** PAY-2631: client-side QR from an otpauth:// URI. Inline SVG, quiet zone of 4 modules; no network, no library. */
2
+ export declare function QrCode({ value, size }: {
3
+ value: string;
4
+ size?: number;
5
+ }): import("react/jsx-runtime").JSX.Element;
6
+ export default QrCode;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QrCode = QrCode;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const qrcode_1 = require("../../lib/qrcode");
7
+ /** PAY-2631: client-side QR from an otpauth:// URI. Inline SVG, quiet zone of 4 modules; no network, no library. */
8
+ function QrCode({ value, size = 168 }) {
9
+ const qr = (0, react_1.useMemo)(() => (0, qrcode_1.encodeQr)(value), [value]);
10
+ const path = (0, react_1.useMemo)(() => (0, qrcode_1.qrToSvgPath)(qr), [qr]);
11
+ const modules = qr.size;
12
+ return ((0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: `-4 -4 ${modules + 8} ${modules + 8}`, role: "img", "aria-label": "QR code for authenticator setup", "data-testid": "totp-qr", children: [(0, jsx_runtime_1.jsx)("rect", { x: -4, y: -4, width: modules + 8, height: modules + 8, fill: "#ffffff" }), (0, jsx_runtime_1.jsx)("path", { d: path, fill: "#212126" })] }));
13
+ }
14
+ exports.default = QrCode;
@@ -0,0 +1,6 @@
1
+ export declare const STEP_UP_SENTENCE = "This session was signed in with an emailed or texted code only. Sign in with your current authenticator, then try again.";
2
+ export declare function TotpEnrollPanel({ fetchImpl, onConfirmed }?: {
3
+ fetchImpl?: typeof fetch;
4
+ onConfirmed?: () => void;
5
+ }): import("react/jsx-runtime").JSX.Element;
6
+ export default TotpEnrollPanel;