@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.
- package/POST-INSTALL.md +408 -0
- package/README.md +7 -0
- package/dist/api-handlers/account/recovery/initiate.js +35 -2
- package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/send-code.js +35 -2
- package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/verify-code.js +35 -2
- package/dist/api-handlers/account/reset-password.js +35 -2
- package/dist/api-handlers/account/validate-password.js +21 -3
- package/dist/api-handlers/admin/analytics.js +2 -1
- package/dist/api-handlers/admin/audit.js +2 -1
- package/dist/api-handlers/admin/sessions.js +2 -1
- package/dist/api-handlers/admin/stats.js +2 -1
- package/dist/api-handlers/admin/users.js +2 -1
- package/dist/api-handlers/admin/vibe-data.js +2 -1
- package/dist/api-handlers/auth/jwks.js +10 -0
- package/dist/api-handlers/auth/login.js +5 -2
- package/dist/api-handlers/auth/passkey-signin.js +16 -1
- package/dist/api-handlers/auth/refresh.d.ts +23 -3
- package/dist/api-handlers/auth/refresh.js +107 -16
- package/dist/api-handlers/auth/signout.js +3 -2
- package/dist/api-handlers/auth/verify-code.js +5 -2
- package/dist/api-handlers/session/viability.js +27 -1
- package/dist/api-handlers/test/force-expire.d.ts +29 -15
- package/dist/api-handlers/test/force-expire.js +109 -14
- package/dist/auth/better-auth.d.ts +263 -6
- package/dist/auth/better-auth.js +437 -59
- package/dist/auth/utils/idp-client.js +1 -1
- package/dist/client/AuthContext.js +49 -17
- package/dist/client/better-auth-client.d.ts +62 -62
- package/dist/components/account/UserAvatarMenu.js +12 -0
- package/dist/components/auth/ModeAwareLoginPage.js +10 -8
- package/dist/components/auth/ModeAwareSignupPage.js +5 -2
- package/dist/components/auth/TraditionalAuthSection.js +5 -1
- package/dist/hooks/useAuthSettings.js +14 -2
- package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
- package/dist/hooks/usePublicAuthSettings.js +28 -14
- package/dist/lib/client-key.d.ts +61 -0
- package/dist/lib/client-key.js +72 -0
- package/dist/lib/demo-mode.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +5 -1
- package/dist/lib/idp-client-config.d.ts +25 -2
- package/dist/lib/idp-client-config.js +381 -36
- package/dist/lib/idp-fetch.d.ts +2 -0
- package/dist/lib/idp-fetch.js +31 -1
- package/dist/lib/idp-url-guard.d.ts +29 -0
- package/dist/lib/idp-url-guard.js +38 -0
- package/dist/lib/redis.js +77 -5
- package/dist/lib/session-cookie-shape.d.ts +69 -0
- package/dist/lib/session-cookie-shape.js +126 -0
- package/dist/lib/session-store.d.ts +35 -2
- package/dist/lib/session-store.js +135 -4
- package/dist/lib/single-flight.d.ts +35 -0
- package/dist/lib/single-flight.js +30 -0
- package/dist/lib/token-lifecycle.js +12 -0
- package/dist/middleware/create-middleware.js +12 -0
- package/dist/models/SessionModel.d.ts +41 -0
- package/dist/models/SessionModel.js +9 -0
- package/dist/pages/admin-login/page.d.ts +1 -1
- package/dist/pages/admin-login/page.js +5 -41
- package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
- package/dist/pages/security/EnhancedSecurityPage.js +42 -2
- package/dist/pages/security/index.d.ts +1 -0
- package/dist/pages/security/index.js +3 -0
- package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
- package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
- package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
- package/dist/pages/test-env/JwtInspectPage.js +166 -7
- package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
- package/dist/pages/test-env/RefreshTokenPage.js +22 -6
- package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
- package/dist/pages/test-env/TestEnvPage.js +16 -4
- package/dist/pages/test-env/guarded.d.ts +4 -0
- package/dist/pages/test-env/guarded.js +86 -0
- package/dist/pages/test-env/index.d.ts +50 -7
- package/dist/pages/test-env/index.js +58 -12
- package/dist/routes/auth/logout.js +3 -2
- package/dist/routes/auth/refresh.js +27 -1
- package/dist/routes/auth/session.d.ts +12 -3
- package/dist/routes/auth/session.js +92 -9
- package/dist/routes/auth/settings.d.ts +34 -4
- package/dist/routes/auth/settings.js +87 -33
- package/dist/routes/auth/viability.d.ts +1 -1
- package/dist/routes/auth/viability.js +122 -11
- package/dist/server/auth-guard.js +22 -15
- package/dist/server/auth.d.ts +177 -20
- package/dist/server/auth.js +34 -75
- package/dist/server/decode-session.js +84 -10
- package/dist/types/auth.d.ts +18 -0
- package/package.json +969 -958
- package/scripts/check-internal-url-usage.sh +73 -73
- package/scripts/check-publish-health.mjs +120 -0
- package/scripts/dev-broker.ps1 +71 -6
- package/scripts/print-client-config.mjs +162 -0
- package/scripts/server-only-stub.cjs +4 -0
- package/src/api-handlers/account/recovery/initiate.ts +35 -2
- package/src/api-handlers/account/recovery/send-code.ts +36 -2
- package/src/api-handlers/account/recovery/verify-code.ts +36 -2
- package/src/api-handlers/account/reset-password.ts +35 -2
- package/src/api-handlers/account/validate-password.ts +22 -3
- package/src/api-handlers/admin/analytics.ts +2 -1
- package/src/api-handlers/admin/audit.ts +2 -1
- package/src/api-handlers/admin/sessions.ts +2 -1
- package/src/api-handlers/admin/stats.ts +2 -1
- package/src/api-handlers/admin/users.ts +2 -1
- package/src/api-handlers/admin/vibe-data.ts +2 -1
- package/src/api-handlers/auth/jwks.ts +10 -0
- package/src/api-handlers/auth/login.ts +5 -2
- package/src/api-handlers/auth/passkey-signin.ts +112 -98
- package/src/api-handlers/auth/refresh.ts +112 -15
- package/src/api-handlers/auth/signout.ts +4 -3
- package/src/api-handlers/auth/verify-code.ts +5 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
- package/src/api-handlers/mfa/backup-codes.ts +41 -41
- package/src/api-handlers/mfa/methods.ts +46 -46
- package/src/api-handlers/mfa/totp-confirm.ts +42 -42
- package/src/api-handlers/mfa/totp-enroll.ts +42 -42
- package/src/api-handlers/passkeys/list.ts +31 -31
- package/src/api-handlers/passkeys/manage.ts +54 -54
- package/src/api-handlers/passkeys/register-options.ts +42 -42
- package/src/api-handlers/passkeys/register-verify.ts +41 -41
- package/src/api-handlers/session/viability.ts +29 -1
- package/src/api-handlers/test/force-expire.ts +118 -13
- package/src/auth/better-auth.ts +486 -51
- package/src/auth/utils/idp-client.ts +1 -1
- package/src/client/AuthContext.tsx +55 -19
- package/src/components/account/UserAvatarMenu.tsx +11 -0
- package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
- package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
- package/src/components/auth/TraditionalAuthSection.tsx +5 -1
- package/src/hooks/useAuthSettings.ts +14 -2
- package/src/hooks/usePublicAuthSettings.ts +38 -16
- package/src/lib/client-key.ts +88 -0
- package/src/lib/demo-mode.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +5 -1
- package/src/lib/idp-client-config.ts +399 -36
- package/src/lib/idp-fetch.ts +36 -1
- package/src/lib/idp-url-guard.ts +37 -0
- package/src/lib/redis.ts +84 -6
- package/src/lib/session-cookie-shape.ts +184 -0
- package/src/lib/session-store.ts +152 -6
- package/src/lib/single-flight.ts +64 -0
- package/src/lib/token-lifecycle.ts +12 -0
- package/src/middleware/create-middleware.ts +11 -0
- package/src/models/SessionModel.ts +49 -0
- package/src/pages/admin-login/page.tsx +17 -127
- package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
- package/src/pages/security/index.ts +3 -0
- package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
- package/src/pages/test-env/JwtInspectPage.tsx +235 -20
- package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
- package/src/pages/test-env/TestEnvPage.tsx +16 -4
- package/src/pages/test-env/guarded.tsx +81 -0
- package/src/pages/test-env/index.ts +59 -7
- package/src/routes/auth/logout.ts +4 -3
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +30 -1
- package/src/routes/auth/session.ts +98 -9
- package/src/routes/auth/settings.ts +144 -42
- package/src/routes/auth/viability.ts +129 -11
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/server/auth-guard.ts +24 -17
- package/src/server/auth.ts +37 -73
- package/src/server/decode-session.ts +95 -11
- package/src/templates/instrumentation.ts +21 -2
- package/src/types/auth.ts +20 -0
- package/dist/lib/auth-secret.d.ts +0 -17
- package/dist/lib/auth-secret.js +0 -116
- 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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|