@payez/next-mvp 4.1.21 → 4.4.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 (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +120 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -4,17 +4,38 @@
4
4
  * ASK BEFORE EDITING - TESTED AND WORKING SYSTEM
5
5
  *
6
6
  * This handler manages the server-side refresh token cycle with:
7
- * - Better Auth session extraction
8
- * - Session token fallback for internal calls
7
+ * - Better Auth session extraction (the ONLY way a session is established here)
9
8
  * - PayEz IDP refresh token exchange
10
9
  * - Session state updates with new tokens
11
10
  * - Proper error handling and logging
12
11
  * - Single-use semantics enforcement
13
12
  *
14
- * @version 2.0
13
+ * ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
14
+ * This list previously advertised an x-session-token fallback for internal
15
+ * callers as a current capability. That behaviour has been REMOVED: a request presenting `x-session-token` with
16
+ * no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because
17
+ * there is no session to establish ownership of the token it names. Accepting
18
+ * it let a caller refresh a session they did not own.
19
+ *
20
+ * The line is corrected here rather than merely deleted, and it is called out
21
+ * rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
22
+ * into dist and into the .d.ts, so it reaches every consumer's editor as hover
23
+ * text. QAPert-NightHawk caught that the fixed build was still describing the
24
+ * removed capability as a current feature (34973) — her verifier tests code
25
+ * properties and reads no comments, so a correct build could have published
26
+ * documentation asserting the opposite of what the code does.
27
+ *
28
+ * That matters twice over: an internal caller relying on the fallback gets a
29
+ * silent behavioural break while the docs promise it still works, and a
30
+ * published package that misdescribes its own auth behaviour is the same
31
+ * amplifier that put other defects onto three surfaces — shipped code carrying
32
+ * instructions for using it wrongly.
33
+ *
34
+ * @version 2.1
15
35
  */
16
36
 
17
37
  import { NextRequest, NextResponse } from 'next/server';
38
+ import { randomUUID } from 'crypto';
18
39
  import { getSession as getBetterAuthSession } from '../../server/auth';
19
40
  import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, checkRefreshLock } from '../../lib/session-store';
20
41
  import { computeTokenExpiries } from '../../lib/token-expiry';
@@ -56,12 +77,55 @@ export function createRefreshHandler(config: RefreshConfig) {
56
77
  let userId = betterAuthSession?.user?.id;
57
78
 
58
79
  if (!sessionToken) {
59
- // Fallback: check for session token in header (for internal server-to-server calls)
80
+ // OWNERSHIP CHECK on the x-session-token fallback (246007). BAPert ruled
81
+ // 34708: ship it, accept the one-time re-login cost, do not gate on 240654.
82
+ //
83
+ // WHAT THIS CLOSED: the fallback took a session token from a CALLER-SUPPLIED
84
+ // header and acted on that session with no check that it belonged to the
85
+ // caller. /api/auth/* is in publicRoutes on the consuming surfaces, so an
86
+ // UNAUTHENTICATED caller who knew any session token could cause that session
87
+ // to be refreshed -- an identity assumption, and the precondition for the
88
+ // forced-logout chain via IDP reuse detection.
89
+ //
90
+ // WHY THE CHECK IS A HARD DENY RATHER THAN A COMPARISON, which is the part
91
+ // worth understanding before anyone "improves" it: this branch is reached
92
+ // ONLY when getBetterAuthSession() failed to resolve a session from the
93
+ // request's own cookie. The natural ownership check -- "the header may only
94
+ // name the session the caller's cookie already resolves to" -- therefore has
95
+ // nothing to compare against by construction. Any correct ownership test at
96
+ // this point evaluates to reject. So the check and outright removal coincide
97
+ // here; a more permissive version is not possible without moving the check
98
+ // somewhere it can actually see an authenticated identity.
99
+ //
100
+ // WHO THIS AFFECTS, and it is a deliberate accepted cost, not an oversight:
101
+ // internal callers (lib/internal-api internalRefresh, from api-handler and
102
+ // idp-fetch) forward the end user's cookie. Where that cookie resolves they
103
+ // never enter this branch and are unaffected. Where it does not -- sessions
104
+ // minted before the graft dual-write (8a5ac21) whose cookie carries only the
105
+ // __Secure- name, which the pre-240654 resolver could not read -- the refresh
106
+ // now fails and the user re-authenticates ONCE. Their next login writes both
107
+ // cookie names and they are permanently fine. That population cannot grow, does
108
+ // not age out (session TTL slides on every write), and does not self-heal on
109
+ // use (this handler sets no cookie) -- so the only ways out were to accept the
110
+ // re-login or leave the defect live. BAPert chose to accept it.
111
+ //
112
+ // If server-to-server refresh must work WITHOUT an end-user cookie, that needs
113
+ // real service authentication -- a shared secret, mTLS, or a network policy --
114
+ // never a header any client can set.
60
115
  const headerSessionToken = req.headers.get('x-session-token');
61
116
  if (headerSessionToken) {
62
- sessionToken = headerSessionToken;
63
- const currentSession = await getSession(sessionToken);
64
- userId = currentSession?.userId || currentSession?.email;
117
+ console.warn(
118
+ '[AUTH_REFRESH] REJECTED x-session-token: no cookie-resolved session to own it. ' +
119
+ 'A header-named session is not proof of ownership; the caller must present a ' +
120
+ 'session cookie this server can resolve.',
121
+ );
122
+ return NextResponse.json(
123
+ {
124
+ error: 'Session ownership could not be established',
125
+ code: 'UNAUTHORIZED',
126
+ },
127
+ { status: 401 },
128
+ );
65
129
  }
66
130
  }
67
131
 
@@ -114,20 +178,43 @@ export function createRefreshHandler(config: RefreshConfig) {
114
178
  console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
115
179
  console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
116
180
 
117
- // Try to acquire refresh lock to prevent concurrent refresh attempts
118
- const requestId = req.headers.get('x-request-id') ?? `refresh_${Date.now()}`;
181
+ // LOCK IDENTITY IS MINTED SERVER-SIDE. It must never come from a header.
182
+ //
183
+ // This was `req.headers.get('x-request-id') ?? \`refresh_${Date.now()}\``, and the
184
+ // value was used as the lock's `acquiredBy`. Below it, a branch let a caller
185
+ // PROCEED WITHOUT THE LOCK when the existing lock's acquiredBy equalled the id
186
+ // they had supplied. Since the caller chose that id, the branch was a lock
187
+ // bypass requiring no guesswork: send N concurrent requests all carrying the
188
+ // same x-request-id, and every one after the first skips the wait.
189
+ //
190
+ // That completed an attacker-controlled forced logout, because the serialisation
191
+ // is what prevents refresh-token REUSE, and the IDP treats reuse as compromise:
192
+ // IdentityRefreshTokenService:166 -> RevokeDescendantTokens ->
193
+ // IdentityRefreshTokenRepository:142 revokes every ACTIVE token for that USER
194
+ // issued after the reused one -- across all devices and all client apps. So a
195
+ // caller who knew one session token could log a user out everywhere by sending
196
+ // a few requests with a header value of their own choosing.
197
+ //
198
+ // Minting server-side removes the bypass by construction: no caller can produce
199
+ // a value that matches an in-flight lock, so the equality branch can never be
200
+ // true and is deleted rather than left inert -- an unreachable branch that
201
+ // trusts a header is a trap for whoever edits this next.
202
+ //
203
+ // The anti-deadlock intent that branch served is preserved by the wait path
204
+ // below, which is the correct handling anyway: a genuine duplicate waits for the
205
+ // in-flight refresh and returns its result instead of starting a second one.
206
+ // The header is still read, for TRACING ONLY, and never influences control flow.
207
+ const traceId = req.headers.get('x-request-id') ?? undefined;
208
+ const requestId = `refresh_${randomUUID()}`;
119
209
  const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
120
210
 
121
211
  let weAcquiredLock = false;
122
212
  let releaseLockVersion: number | undefined;
123
213
 
124
214
  if (!lockAcquired.acquired) {
125
- const existingLock = await checkRefreshLock(sessionToken);
126
- if (existingLock && existingLock.acquiredBy === requestId) {
127
- console.info('[AUTH_REFRESH] Proceeding under existing caller-held lock', { requestId });
128
- } else {
215
+ {
129
216
  // Wait for the lock to release, then check if token is now fresh
130
- console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId });
217
+ console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId, traceId });
131
218
 
132
219
  const maxWaitMs = 5000;
133
220
  const checkIntervalMs = 200;
@@ -643,7 +730,17 @@ export function createRefreshHandler(config: RefreshConfig) {
643
730
  bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
644
731
  authenticationMethods: amrClaims,
645
732
  authenticationLevel: acrLevel,
646
- mfaVerified: amrClaims.includes('mfa') || currentSession.mfaVerified,
733
+ // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
734
+ // for a fact with an expiry. mfaVerified is derived from amrClaims,
735
+ // which is FRESH every exchange (decoded from the token just
736
+ // returned, falling back to the prior session's amr only on a
737
+ // genuine decode failure a few lines up — not on "this token
738
+ // doesn't currently carry mfa"). ORing in currentSession.mfaVerified
739
+ // made the flag MONOTONIC against a claim that DECAYS (mfa_time,
740
+ // 10 minutes): once true, it stayed true forever regardless of
741
+ // what the current token actually says, silently re-opening the
742
+ // 2FA bypass with no bug in the bridge. Re-derive only.
743
+ mfaVerified: amrClaims.includes('mfa'),
647
744
  mfaCompletedAt: mfaTime,
648
745
  mfaExpiresAt: mfaExpires,
649
746
  mfaValidityHours: mfaValidityHours
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { NextRequest, NextResponse } from 'next/server';
13
13
  import { cookies } from 'next/headers';
14
- import { deleteSession } from '../../lib/session-store';
14
+ import { deleteSessionEverywhere } from '../../lib/session-store';
15
15
  import { getSession } from '../../server/auth';
16
16
  import {
17
17
  getSessionCookieName,
@@ -146,10 +146,11 @@ export function createSignoutHandler() {
146
146
  }
147
147
  }
148
148
 
149
- // Delete Redis session if UUID was extracted
149
+ // Delete the session from EVERY store (181922): canonical + BA secondary.
150
+ // Canonical-only deletion let the ba: record resurrect the session.
150
151
  if (redisSessionToken) {
151
152
  try {
152
- await deleteSession(redisSessionToken);
153
+ await deleteSessionEverywhere(redisSessionToken);
153
154
  console.info('[SIGNOUT] Redis session cleanup successful', {
154
155
  sessionToken: redisSessionToken.substring(0, 8) + '...'
155
156
  });
@@ -81,8 +81,11 @@ export function createVerifyCodeHandler() {
81
81
  refreshTokenExpires
82
82
  );
83
83
 
84
- // Mark 2FA as complete
85
- await mark2FAComplete(sessionToken);
84
+ // Mark 2FA as complete.
85
+ // 339563: pass the stepped-up access token so the session gets an
86
+ // mfaExpiresAt alongside mfaVerified. Without it the gate's `> 0` guard
87
+ // treats the missing expiry as "never expires".
88
+ await mark2FAComplete(sessionToken, accessToken);
86
89
 
87
90
  console.info('[VERIFY-CODE] 2FA completion successful', {
88
91
  sessionToken: sessionToken.substring(0, 8) + '...',
@@ -1,175 +1,175 @@
1
- /**
2
- * Verify Backup Code 2FA Challenge Handler
3
- *
4
- * Verifies a single-use recovery code AT LOGIN and completes the 2FA flow,
5
- * transitioning the session to MFA-verified.
6
- *
7
- * WHY THIS EXISTS (2026-08-02)
8
- * The package shipped backup-code GENERATION (backup-codes.ts, POST
9
- * /api/Account/mfa/backup-codes) and no way to SPEND one. Codes could be
10
- * generated, displayed once, written down — and then never used to satisfy a
11
- * login challenge. Exactly the gap TOTP had before totp-verify: the enrolment
12
- * half without the challenge half.
13
- *
14
- * THIS IS THE FACTOR OF LAST RESORT. A user reaches for a backup code when they
15
- * have lost the authenticator, so it is the one path that must work when
16
- * everything else has already failed. A recovery mechanism that cannot be
17
- * redeemed is worse than none: it invites people to rely on it.
18
- *
19
- * FIELD NAME: the IDP expects { "code": ... }
20
- * (MfaBackupCodeVerifyRequest, [JsonPropertyName("code")]). The dash in
21
- * 'xxxx-xxxx' is optional per the IDP contract, so the code is forwarded as
22
- * typed and normalisation is left to the IDP rather than guessed here.
23
- *
24
- * SINGLE USE, CONSUMED ATOMICALLY. The IDP hash-compares against unused codes
25
- * and stamps used_at + used_by_ip in the same operation (MfaController 5g).
26
- * NEVER retry this call on a network error or an ambiguous response — a retry
27
- * can burn a second code, and the user has ten.
28
- *
29
- * DO NOT MAKE GENERATION TRANSITION THE SESSION, the same trap totp-confirm
30
- * carries: generating recovery codes is not proof of possessing one.
31
- *
32
- * THE STORED 2FA METHOD IS 'totp', NOT 'backup_code'. READ THIS BEFORE
33
- * "CORRECTING" IT — it looks wrong and is not.
34
- *
35
- * 1. IT MATCHES THE IDP. MfaController 5g sets
36
- * `TwoFactorMethod = TwoFactorMethodType.Totp` for this exact path,
37
- * commented "Backup code = recovery proof of the TOTP factor", and the
38
- * reissued token's amr carries mfa+totp. A backup code is not a separate
39
- * factor; it is recovery proof of the authenticator factor.
40
- *
41
- * 2. INVENTING A NEW VALUE SILENTLY BREAKS TOKEN REFRESH. refresh.ts:279
42
- * resolves the method with ['sms','totp','email'].includes(m). Anything
43
- * outside that set is dropped, so `two_factor_method` never reaches the
44
- * IDP on refresh, and a session established with a recovery code refreshes
45
- * differently from one established with the authenticator — for the same
46
- * underlying factor. It fails quietly, days later, as "sessions
47
- * occasionally drop", with nothing pointing back to this string.
48
- *
49
- * The type union (email|sms|totp) is not the reason for the choice, it is a
50
- * coincidence that caught it. Widening the union would have compiled and
51
- * shipped the refresh bug.
52
- *
53
- * @package @payez/next-mvp
54
- */
55
-
56
- import { NextRequest, NextResponse } from 'next/server';
57
- import { idpFetchJSON } from '../../lib/idp-fetch';
58
- import { ENV_CONFIG } from '../../config/env';
59
- import { getTokenTestAware } from '../../lib/test-aware-get-token';
60
- import { transitionTo2FASession } from '../../lib/session-store';
61
- import { jwtDecode } from '../../lib/jwt-decode';
62
-
63
- export async function POST(req: NextRequest) {
64
- try {
65
- const body = await req.json();
66
- const code = body.code || body.backupCode || body.verificationCode || body.verification_code;
67
-
68
- if (!code) {
69
- return NextResponse.json(
70
- {
71
- success: false,
72
- error: 'Backup code is required',
73
- code: 'INVALID_REQUEST',
74
- },
75
- { status: 400 }
76
- );
77
- }
78
-
79
- const result = await idpFetchJSON(
80
- req,
81
- `${ENV_CONFIG.IDP_URL}/api/ExternalAuth/twofa/backup-code/verify`,
82
- {
83
- method: 'POST',
84
- headers: { 'Content-Type': 'application/json' },
85
- body: JSON.stringify({ code }),
86
- }
87
- );
88
-
89
- if (!result.ok) {
90
- return NextResponse.json(
91
- {
92
- success: false,
93
- error: result.json?.message || 'Verification failed',
94
- code: result.json?.code || 'IDP_ERROR',
95
- meta: { attemptedRefresh: result.attemptedRefresh },
96
- },
97
- { status: result.status }
98
- );
99
- }
100
-
101
- const unwrappedData = result.json?.data || result.json;
102
-
103
- // Only a challenge that returns fresh tokens may transition the session.
104
- let sessionTransitioned = false;
105
-
106
- if (unwrappedData.access_token && unwrappedData.refresh_token) {
107
- const token = await getTokenTestAware(req);
108
- const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
109
-
110
- if (sessionToken) {
111
- let accessTokenExpires = Date.now() + 15 * 60 * 1000;
112
- try {
113
- const decoded = jwtDecode<{ exp?: number }>(unwrappedData.access_token);
114
- if (decoded?.exp) accessTokenExpires = decoded.exp * 1000;
115
- } catch {
116
- console.warn('[VERIFY_BACKUP_CODE] Could not decode access token, using default expiration');
117
- }
118
-
119
- let refreshTokenExpires = Date.now() + 3 * 24 * 60 * 60 * 1000;
120
- try {
121
- const decoded = jwtDecode<{ exp?: number }>(unwrappedData.refresh_token);
122
- if (decoded?.exp) refreshTokenExpires = decoded.exp * 1000;
123
- } catch {
124
- // Refresh token may carry no exp claim; default stands.
125
- }
126
-
127
- await transitionTo2FASession(
128
- sessionToken,
129
- {
130
- accessToken: unwrappedData.access_token,
131
- refreshToken: unwrappedData.refresh_token,
132
- accessTokenExpires,
133
- refreshTokenExpires,
134
- },
135
- // 'totp' is deliberate and load-bearing. See "THE STORED 2FA METHOD"
136
- // in the file header before changing it.
137
- 'totp'
138
- );
139
-
140
- sessionTransitioned = true;
141
- console.log('[VERIFY_BACKUP_CODE] Session transitioned to 2FA-verified');
142
- } else {
143
- console.warn('[VERIFY_BACKUP_CODE] IDP accepted the code but no session token was resolvable — session NOT transitioned. THE CODE IS ALREADY SPENT.');
144
- }
145
- } else {
146
- console.warn('[VERIFY_BACKUP_CODE] IDP returned 2xx without tokens — session NOT transitioned. THE CODE IS ALREADY SPENT.');
147
- }
148
-
149
- // twoFactorSessionVerified reports WHAT ACTUALLY HAPPENED, never a copy of
150
- // `success`. It matters more here than anywhere else: the code is consumed
151
- // the moment the IDP accepts it, so a client that trusted a hardcoded true
152
- // would show the user as verified, leave the session unverified, and have
153
- // burned one of their ten recovery codes to do it.
154
- return NextResponse.json(
155
- {
156
- success: true,
157
- verificationSuccessful: true,
158
- twoFactorSessionVerified: sessionTransitioned,
159
- codeConsumed: true,
160
- message: unwrappedData.message || 'Backup code verified successfully',
161
- },
162
- { status: 200 }
163
- );
164
- } catch (error) {
165
- console.error('[VERIFY_BACKUP_CODE] Error:', error);
166
- return NextResponse.json(
167
- {
168
- success: false,
169
- error: 'Failed to verify backup code',
170
- code: 'INTERNAL_ERROR',
171
- },
172
- { status: 500 }
173
- );
174
- }
175
- }
1
+ /**
2
+ * Verify Backup Code 2FA Challenge Handler
3
+ *
4
+ * Verifies a single-use recovery code AT LOGIN and completes the 2FA flow,
5
+ * transitioning the session to MFA-verified.
6
+ *
7
+ * WHY THIS EXISTS (2026-08-02)
8
+ * The package shipped backup-code GENERATION (backup-codes.ts, POST
9
+ * /api/Account/mfa/backup-codes) and no way to SPEND one. Codes could be
10
+ * generated, displayed once, written down — and then never used to satisfy a
11
+ * login challenge. Exactly the gap TOTP had before totp-verify: the enrolment
12
+ * half without the challenge half.
13
+ *
14
+ * THIS IS THE FACTOR OF LAST RESORT. A user reaches for a backup code when they
15
+ * have lost the authenticator, so it is the one path that must work when
16
+ * everything else has already failed. A recovery mechanism that cannot be
17
+ * redeemed is worse than none: it invites people to rely on it.
18
+ *
19
+ * FIELD NAME: the IDP expects { "code": ... }
20
+ * (MfaBackupCodeVerifyRequest, [JsonPropertyName("code")]). The dash in
21
+ * 'xxxx-xxxx' is optional per the IDP contract, so the code is forwarded as
22
+ * typed and normalisation is left to the IDP rather than guessed here.
23
+ *
24
+ * SINGLE USE, CONSUMED ATOMICALLY. The IDP hash-compares against unused codes
25
+ * and stamps used_at + used_by_ip in the same operation (MfaController 5g).
26
+ * NEVER retry this call on a network error or an ambiguous response — a retry
27
+ * can burn a second code, and the user has ten.
28
+ *
29
+ * DO NOT MAKE GENERATION TRANSITION THE SESSION, the same trap totp-confirm
30
+ * carries: generating recovery codes is not proof of possessing one.
31
+ *
32
+ * THE STORED 2FA METHOD IS 'totp', NOT 'backup_code'. READ THIS BEFORE
33
+ * "CORRECTING" IT — it looks wrong and is not.
34
+ *
35
+ * 1. IT MATCHES THE IDP. MfaController 5g sets
36
+ * `TwoFactorMethod = TwoFactorMethodType.Totp` for this exact path,
37
+ * commented "Backup code = recovery proof of the TOTP factor", and the
38
+ * reissued token's amr carries mfa+totp. A backup code is not a separate
39
+ * factor; it is recovery proof of the authenticator factor.
40
+ *
41
+ * 2. INVENTING A NEW VALUE SILENTLY BREAKS TOKEN REFRESH. refresh.ts:279
42
+ * resolves the method with ['sms','totp','email'].includes(m). Anything
43
+ * outside that set is dropped, so `two_factor_method` never reaches the
44
+ * IDP on refresh, and a session established with a recovery code refreshes
45
+ * differently from one established with the authenticator — for the same
46
+ * underlying factor. It fails quietly, days later, as "sessions
47
+ * occasionally drop", with nothing pointing back to this string.
48
+ *
49
+ * The type union (email|sms|totp) is not the reason for the choice, it is a
50
+ * coincidence that caught it. Widening the union would have compiled and
51
+ * shipped the refresh bug.
52
+ *
53
+ * @package @payez/next-mvp
54
+ */
55
+
56
+ import { NextRequest, NextResponse } from 'next/server';
57
+ import { idpFetchJSON } from '../../lib/idp-fetch';
58
+ import { ENV_CONFIG } from '../../config/env';
59
+ import { getTokenTestAware } from '../../lib/test-aware-get-token';
60
+ import { transitionTo2FASession } from '../../lib/session-store';
61
+ import { jwtDecode } from '../../lib/jwt-decode';
62
+
63
+ export async function POST(req: NextRequest) {
64
+ try {
65
+ const body = await req.json();
66
+ const code = body.code || body.backupCode || body.verificationCode || body.verification_code;
67
+
68
+ if (!code) {
69
+ return NextResponse.json(
70
+ {
71
+ success: false,
72
+ error: 'Backup code is required',
73
+ code: 'INVALID_REQUEST',
74
+ },
75
+ { status: 400 }
76
+ );
77
+ }
78
+
79
+ const result = await idpFetchJSON(
80
+ req,
81
+ `${ENV_CONFIG.IDP_URL}/api/ExternalAuth/twofa/backup-code/verify`,
82
+ {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({ code }),
86
+ }
87
+ );
88
+
89
+ if (!result.ok) {
90
+ return NextResponse.json(
91
+ {
92
+ success: false,
93
+ error: result.json?.message || 'Verification failed',
94
+ code: result.json?.code || 'IDP_ERROR',
95
+ meta: { attemptedRefresh: result.attemptedRefresh },
96
+ },
97
+ { status: result.status }
98
+ );
99
+ }
100
+
101
+ const unwrappedData = result.json?.data || result.json;
102
+
103
+ // Only a challenge that returns fresh tokens may transition the session.
104
+ let sessionTransitioned = false;
105
+
106
+ if (unwrappedData.access_token && unwrappedData.refresh_token) {
107
+ const token = await getTokenTestAware(req);
108
+ const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
109
+
110
+ if (sessionToken) {
111
+ let accessTokenExpires = Date.now() + 15 * 60 * 1000;
112
+ try {
113
+ const decoded = jwtDecode<{ exp?: number }>(unwrappedData.access_token);
114
+ if (decoded?.exp) accessTokenExpires = decoded.exp * 1000;
115
+ } catch {
116
+ console.warn('[VERIFY_BACKUP_CODE] Could not decode access token, using default expiration');
117
+ }
118
+
119
+ let refreshTokenExpires = Date.now() + 3 * 24 * 60 * 60 * 1000;
120
+ try {
121
+ const decoded = jwtDecode<{ exp?: number }>(unwrappedData.refresh_token);
122
+ if (decoded?.exp) refreshTokenExpires = decoded.exp * 1000;
123
+ } catch {
124
+ // Refresh token may carry no exp claim; default stands.
125
+ }
126
+
127
+ await transitionTo2FASession(
128
+ sessionToken,
129
+ {
130
+ accessToken: unwrappedData.access_token,
131
+ refreshToken: unwrappedData.refresh_token,
132
+ accessTokenExpires,
133
+ refreshTokenExpires,
134
+ },
135
+ // 'totp' is deliberate and load-bearing. See "THE STORED 2FA METHOD"
136
+ // in the file header before changing it.
137
+ 'totp'
138
+ );
139
+
140
+ sessionTransitioned = true;
141
+ console.log('[VERIFY_BACKUP_CODE] Session transitioned to 2FA-verified');
142
+ } else {
143
+ console.warn('[VERIFY_BACKUP_CODE] IDP accepted the code but no session token was resolvable — session NOT transitioned. THE CODE IS ALREADY SPENT.');
144
+ }
145
+ } else {
146
+ console.warn('[VERIFY_BACKUP_CODE] IDP returned 2xx without tokens — session NOT transitioned. THE CODE IS ALREADY SPENT.');
147
+ }
148
+
149
+ // twoFactorSessionVerified reports WHAT ACTUALLY HAPPENED, never a copy of
150
+ // `success`. It matters more here than anywhere else: the code is consumed
151
+ // the moment the IDP accepts it, so a client that trusted a hardcoded true
152
+ // would show the user as verified, leave the session unverified, and have
153
+ // burned one of their ten recovery codes to do it.
154
+ return NextResponse.json(
155
+ {
156
+ success: true,
157
+ verificationSuccessful: true,
158
+ twoFactorSessionVerified: sessionTransitioned,
159
+ codeConsumed: true,
160
+ message: unwrappedData.message || 'Backup code verified successfully',
161
+ },
162
+ { status: 200 }
163
+ );
164
+ } catch (error) {
165
+ console.error('[VERIFY_BACKUP_CODE] Error:', error);
166
+ return NextResponse.json(
167
+ {
168
+ success: false,
169
+ error: 'Failed to verify backup code',
170
+ code: 'INTERNAL_ERROR',
171
+ },
172
+ { status: 500 }
173
+ );
174
+ }
175
+ }
@@ -1,41 +1,41 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- import { idpFetchJSON } from '../../lib/idp-fetch';
3
- import { ENV_CONFIG } from '../../config/env';
4
-
5
- /**
6
- * Generate MFA backup codes (POST /api/Account/mfa/backup-codes, user JWT).
7
- * Regeneration REPLACES the prior set — the response carries the new codes
8
- * once; they are shown once and never retrievable again.
9
- */
10
- export async function POST(req: NextRequest) {
11
- let body = '{}';
12
- try {
13
- const raw = await req.text();
14
- if (raw && raw.trim().length > 0) body = raw;
15
- } catch {}
16
-
17
- const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/mfa/backup-codes`, {
18
- method: 'POST',
19
- headers: { 'Content-Type': 'application/json' },
20
- body,
21
- });
22
-
23
- if (!result.ok) {
24
- return NextResponse.json(
25
- {
26
- success: false,
27
- message: 'Upstream error',
28
- error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
29
- meta: { attemptedRefresh: result.attemptedRefresh },
30
- },
31
- { status: result.status },
32
- );
33
- }
34
-
35
- const bodyJson: any = result.json;
36
- if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
38
- return NextResponse.json(bodyJson, { status: 200 });
39
- }
40
- return NextResponse.json(bodyJson ?? {}, { status: 200 });
41
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { idpFetchJSON } from '../../lib/idp-fetch';
3
+ import { ENV_CONFIG } from '../../config/env';
4
+
5
+ /**
6
+ * Generate MFA backup codes (POST /api/Account/mfa/backup-codes, user JWT).
7
+ * Regeneration REPLACES the prior set — the response carries the new codes
8
+ * once; they are shown once and never retrievable again.
9
+ */
10
+ export async function POST(req: NextRequest) {
11
+ let body = '{}';
12
+ try {
13
+ const raw = await req.text();
14
+ if (raw && raw.trim().length > 0) body = raw;
15
+ } catch {}
16
+
17
+ const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/mfa/backup-codes`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body,
21
+ });
22
+
23
+ if (!result.ok) {
24
+ return NextResponse.json(
25
+ {
26
+ success: false,
27
+ message: 'Upstream error',
28
+ error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
29
+ meta: { attemptedRefresh: result.attemptedRefresh },
30
+ },
31
+ { status: result.status },
32
+ );
33
+ }
34
+
35
+ const bodyJson: any = result.json;
36
+ if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
+ if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
38
+ return NextResponse.json(bodyJson, { status: 200 });
39
+ }
40
+ return NextResponse.json(bodyJson ?? {}, { status: 200 });
41
+ }