@payez/next-mvp 4.12.1 → 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 (75) hide show
  1. package/CHANGELOG.md +45 -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/idp-client-config.js +4 -1
  25. package/dist/lib/login-error-message.d.ts +3 -0
  26. package/dist/lib/login-error-message.js +12 -1
  27. package/dist/lib/mfa-json-guard.d.ts +12 -0
  28. package/dist/lib/mfa-json-guard.js +19 -0
  29. package/dist/lib/nextauth-secret.js +3 -1
  30. package/dist/lib/payez-client-secret.d.ts +14 -0
  31. package/dist/lib/payez-client-secret.js +41 -0
  32. package/dist/lib/qrcode.d.ts +22 -0
  33. package/dist/lib/qrcode.js +362 -0
  34. package/dist/lib/sign-in-failure.d.ts +35 -0
  35. package/dist/lib/sign-in-failure.js +85 -0
  36. package/dist/lib/twofa-rejection.d.ts +2 -0
  37. package/dist/lib/twofa-rejection.js +5 -2
  38. package/dist/middleware/create-middleware.d.ts +24 -0
  39. package/dist/middleware/create-middleware.js +56 -9
  40. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  41. package/dist/middleware/prefetch-guard-status.js +42 -0
  42. package/dist/pages/enroll/page.d.ts +7 -0
  43. package/dist/pages/enroll/page.js +15 -0
  44. package/dist/pages/login/page.js +1 -1
  45. package/dist/routes/auth/session-reset.d.ts +11 -1
  46. package/dist/routes/auth/session-reset.js +38 -13
  47. package/dist/routes/auth/viability.js +11 -2
  48. package/package.json +11 -1
  49. package/src/api-handlers/auth/credential-login.ts +29 -4
  50. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  51. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  52. package/src/api-handlers/session/viability.ts +6 -2
  53. package/src/auth/better-auth.ts +27 -2
  54. package/src/auth/session-reset-link.ts +47 -0
  55. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  56. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  57. package/src/components/mfa/QrCode.tsx +24 -0
  58. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  59. package/src/edge/middleware.ts +1 -1
  60. package/src/index.ts +1 -1
  61. package/src/lib/credential-sign-in-client.ts +48 -8
  62. package/src/lib/idp-client-config.ts +5 -1
  63. package/src/lib/login-error-message.ts +13 -1
  64. package/src/lib/mfa-json-guard.ts +19 -0
  65. package/src/lib/nextauth-secret.ts +3 -1
  66. package/src/lib/payez-client-secret.ts +39 -0
  67. package/src/lib/qrcode.ts +348 -0
  68. package/src/lib/sign-in-failure.ts +88 -0
  69. package/src/lib/twofa-rejection.ts +7 -2
  70. package/src/middleware/create-middleware.ts +69 -9
  71. package/src/middleware/prefetch-guard-status.ts +56 -0
  72. package/src/pages/enroll/page.tsx +22 -0
  73. package/src/pages/login/page.tsx +2 -1
  74. package/src/routes/auth/session-reset.ts +46 -11
  75. package/src/routes/auth/viability.ts +10 -2
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { TotpEnrollPanel } from '../../components/mfa/TotpEnrollPanel';
5
+
6
+ /**
7
+ * PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
8
+ * what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
9
+ * scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
10
+ * because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
11
+ */
12
+ export default function EnrollPage() {
13
+ return (
14
+ <main style={{ maxWidth: 480, margin: '48px auto', padding: '0 16px' }}>
15
+ <h1>Set up an authenticator app</h1>
16
+ <TotpEnrollPanel />
17
+ <p style={{ marginTop: 24 }}>
18
+ <a href="/">Continue to the app</a>
19
+ </p>
20
+ </main>
21
+ );
22
+ }
@@ -29,7 +29,7 @@ import { Suspense } from 'react';
29
29
  import ReservedStatusBox from '../../components/reserved/ReservedStatusBox';
30
30
  import ReservedRecoveryWarning from '../../components/reserved/ReservedRecoveryWarning';
31
31
  import { useBranding, useColors, useLayout } from '../../theme/useTheme';
32
- import { friendlyLoginError, oauthFailureMessage } from '../../lib/login-error-message';
32
+ import { friendlyLoginError, loginErrorCode, oauthFailureMessage } from '../../lib/login-error-message';
33
33
 
34
34
  function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
35
35
  const searchParams = useSearchParams();
@@ -226,6 +226,7 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
226
226
  <div
227
227
  role="alert"
228
228
  data-testid="login-error"
229
+ data-error-code={loginError === undefined ? (loginErrorCode(urlError) ?? undefined) : undefined}
229
230
  className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
230
231
  >
231
232
  {shownError}
@@ -20,18 +20,53 @@ import { NextRequest, NextResponse } from 'next/server';
20
20
  import { isPrefetchRequest } from '../../middleware/create-middleware';
21
21
  import { expireSessionCookies } from '../../auth/session-cookie-clear';
22
22
  import { getSessionCookieName, getSecureSessionCookieName } from '../../lib/app-slug';
23
- import { boundSessionCookie, loginLocation, readResetRequest, resetSecret } from '../../auth/session-reset-link';
23
+ import { boundSessionCookie, loginLocation, readResetRequest, readResetRequestAnyKey, resetSecret } from '../../auth/session-reset-link';
24
24
 
25
25
  export const dynamic = 'force-dynamic';
26
26
 
27
- export async function GET(request: NextRequest): Promise<NextResponse> {
28
- if (isPrefetchRequest(request)) {
29
- return new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
30
- }
31
- const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
32
- const { params, signed } = await readResetRequest(request.nextUrl.searchParams, resetSecret(), bound);
33
- const response = NextResponse.redirect(new URL(loginLocation(params), request.url), 307);
34
- response.headers.set('cache-control', 'no-store');
35
- if (signed) expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
36
- return response;
27
+ export interface SessionResetOptions {
28
+ /**
29
+ * PAY-2218 opt-in, OFF by default (BAPert 83102). OFF: only a link signed with the env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) clears, so every app that can
30
+ * see the secret stays strict. ON: a link signed with the key derived from this request's own session cookie also clears. Turn it on only where the EDGE middleware
31
+ * cannot see the env secret, and set `sessionResetCookieKey: true` on createMvpMiddleware too, or the route refuses the link the middleware signed.
32
+ */
33
+ cookieKey?: boolean;
37
34
  }
35
+
36
+ export function createSessionResetHandler(options: SessionResetOptions = {}) {
37
+ const cookieKey = options.cookieKey === true;
38
+ let splitLogged = false;
39
+ let halfSetLogged = false;
40
+ return async function GET(request: NextRequest): Promise<NextResponse> {
41
+ if (isPrefetchRequest(request)) {
42
+ return new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
43
+ }
44
+ const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
45
+ const env = resetSecret();
46
+ const { params, signed, via } = cookieKey
47
+ ? await readResetRequestAnyKey(request.nextUrl.searchParams, env, bound)
48
+ : { ...(await readResetRequest(request.nextUrl.searchParams, env, bound)), via: null as 'env' | 'cookie' | null };
49
+ // The dev-93 prefetch walk reads this line: a link that verified ONLY with the cookie-derived key while this route sees the env secret is the
50
+ // edge-blind / route-sighted split deployment. Once per process, no values.
51
+ if (cookieKey && signed && via === 'cookie' && env && !splitLogged) {
52
+ splitLogged = true;
53
+ console.info('[next-mvp] session-reset: a link verified with the cookie-derived key while the env secret is visible to this route (the edge middleware signed without it: split deployment)');
54
+ }
55
+ // HALF-SET (NightHawk 83161): this route is strict (cookieKey OFF) but the middleware signed with the cookie-derived key (sessionResetCookieKey ON). The link is refused
56
+ // and the stale cookie survives. Probe ONLY refused links, for the cookie-derived key alone, and say so once per process. No values.
57
+ if (!cookieKey && !signed && !halfSetLogged) {
58
+ const probe = await readResetRequestAnyKey(request.nextUrl.searchParams, undefined, bound);
59
+ if (probe.signed) {
60
+ halfSetLogged = true;
61
+ console.warn('[next-mvp] session-reset: refused a link signed with the cookie-derived key because this route has cookieKey OFF; the middleware has sessionResetCookieKey ON. Set createSessionResetHandler({ cookieKey: true }) or turn the middleware option off');
62
+ }
63
+ }
64
+ const response = NextResponse.redirect(new URL(loginLocation(params), request.url), 307);
65
+ response.headers.set('cache-control', 'no-store');
66
+ if (signed) expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
67
+ return response;
68
+ };
69
+ }
70
+
71
+ /** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
72
+ export const GET = createSessionResetHandler();
@@ -199,6 +199,9 @@ export async function GET(req: NextRequest) {
199
199
  // stack just resolved the cookie cleanly. Keeps the two viability impls
200
200
  // (this route + `api-handlers/session/viability.ts`) in lockstep.
201
201
  let session = sessionToken ? await getRedisSession(sessionToken) : null;
202
+ // PAY-2467: a row read from the CANONICAL store is the raw JSON the callback wrote, so an `mfaVerified` boolean on it is a
203
+ // MEASUREMENT (an absent key is a legacy row). Only the BA-record fallback collapses absent to false and needs mfaSignalPresent.
204
+ const fromCanonicalStore = !!session;
202
205
  if (sessionToken && !session) {
203
206
  session = await getBetterAuthRedisSession(sessionToken);
204
207
  if (session) {
@@ -294,9 +297,14 @@ export async function GET(req: NextRequest) {
294
297
  * it names TENANT policy, and this override is not tenant state.
295
298
  */
296
299
  const oauthSessionNeedsTwoFactor =
297
- session?.mfaSignalPresent === true &&
300
+ // PAY-2467: the signal is present on the BA-fallback path (mfaSignalPresent) OR when the canonical row itself carries a boolean.
301
+ // Before this, the override was dead on the canonical path every consumer mounts, so gating rested on tenant config alone.
302
+ (session?.mfaSignalPresent === true || (fromCanonicalStore && typeof session?.mfaVerified === 'boolean')) &&
298
303
  session?.mfaVerified === false &&
299
- !(session.idpRefreshToken || (session as any)?.refreshToken);
304
+ !(session.idpRefreshToken || (session as any)?.refreshToken) &&
305
+ // An IdP DISCARD (refresh.ts / ensure-fresh-access-token.ts write idpRefreshToken '' with refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>') leaves
306
+ // mfaVerified false and no refresh token too, but that is a DEAD session, not a pending second factor: it must not be sent to verify-code (QAPert 84816).
307
+ !String((session as any)?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
300
308
 
301
309
  // --- Session revocation status (PAY-1882 step 4 producer half) ---
302
310
  /*