@payez/next-mvp 4.5.6 → 4.7.9

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 (68) hide show
  1. package/dist/api-handlers/auth/credential-login-email-code.d.ts +24 -0
  2. package/dist/api-handlers/auth/credential-login-email-code.js +83 -0
  3. package/dist/api-handlers/auth/credential-login.d.ts +41 -0
  4. package/dist/api-handlers/auth/credential-login.js +858 -0
  5. package/dist/api-handlers/oauth/resume.d.ts +16 -0
  6. package/dist/api-handlers/oauth/resume.js +207 -0
  7. package/dist/auth/better-auth.d.ts +14 -2
  8. package/dist/auth/better-auth.js +59 -34
  9. package/dist/auth/idp-oauth.d.ts +106 -0
  10. package/dist/auth/idp-oauth.js +280 -0
  11. package/dist/auth/safe-callback.js +15 -2
  12. package/dist/auth/session-cookie-clear.d.ts +7 -0
  13. package/dist/auth/session-cookie-clear.js +24 -0
  14. package/dist/auth/session-reset-link.d.ts +43 -0
  15. package/dist/auth/session-reset-link.js +144 -0
  16. package/dist/components/auth/CredentialSignInForm.d.ts +33 -0
  17. package/dist/components/auth/CredentialSignInForm.js +134 -0
  18. package/dist/lib/authorize-resume.d.ts +87 -0
  19. package/dist/lib/authorize-resume.js +190 -0
  20. package/dist/lib/bearer-fetch.d.ts +17 -0
  21. package/dist/lib/bearer-fetch.js +32 -0
  22. package/dist/lib/code-step-copy.d.ts +53 -0
  23. package/dist/lib/code-step-copy.js +73 -0
  24. package/dist/lib/credential-sign-in-client.d.ts +59 -0
  25. package/dist/lib/credential-sign-in-client.js +89 -0
  26. package/dist/lib/device-id.d.ts +37 -0
  27. package/dist/lib/device-id.js +70 -0
  28. package/dist/lib/lock-window.d.ts +29 -0
  29. package/dist/lib/lock-window.js +53 -0
  30. package/dist/lib/login-error-message.d.ts +18 -0
  31. package/dist/lib/login-error-message.js +47 -0
  32. package/dist/lib/twofa-rejection.d.ts +20 -0
  33. package/dist/lib/twofa-rejection.js +73 -0
  34. package/dist/middleware/create-middleware.d.ts +25 -1
  35. package/dist/middleware/create-middleware.js +75 -27
  36. package/dist/pages/login/page.js +11 -37
  37. package/dist/routes/auth/credential-login-email-code.d.ts +6 -0
  38. package/dist/routes/auth/credential-login-email-code.js +10 -0
  39. package/dist/routes/auth/credential-login.d.ts +6 -0
  40. package/dist/routes/auth/credential-login.js +10 -0
  41. package/dist/routes/auth/session-reset.d.ts +21 -0
  42. package/dist/routes/auth/session-reset.js +40 -0
  43. package/dist/routes/oauth/resume.d.ts +11 -0
  44. package/dist/routes/oauth/resume.js +16 -0
  45. package/package.json +1102 -1042
  46. package/src/api-handlers/auth/credential-login-email-code.ts +92 -0
  47. package/src/api-handlers/auth/credential-login.ts +948 -0
  48. package/src/api-handlers/oauth/resume.ts +182 -0
  49. package/src/auth/better-auth.ts +81 -5
  50. package/src/auth/idp-oauth.ts +310 -0
  51. package/src/auth/safe-callback.ts +16 -2
  52. package/src/auth/session-cookie-clear.ts +30 -0
  53. package/src/auth/session-reset-link.ts +159 -0
  54. package/src/components/auth/CredentialSignInForm.tsx +240 -0
  55. package/src/lib/authorize-resume.ts +187 -0
  56. package/src/lib/bearer-fetch.ts +31 -0
  57. package/src/lib/code-step-copy.ts +83 -0
  58. package/src/lib/credential-sign-in-client.ts +101 -0
  59. package/src/lib/device-id.ts +67 -0
  60. package/src/lib/lock-window.ts +49 -0
  61. package/src/lib/login-error-message.ts +41 -0
  62. package/src/lib/twofa-rejection.ts +91 -0
  63. package/src/middleware/create-middleware.ts +124 -30
  64. package/src/pages/login/page.tsx +20 -34
  65. package/src/routes/auth/credential-login-email-code.ts +6 -0
  66. package/src/routes/auth/credential-login.ts +6 -0
  67. package/src/routes/auth/session-reset.ts +37 -0
  68. package/src/routes/oauth/resume.ts +11 -0
@@ -0,0 +1,948 @@
1
+ /**
2
+ * PAY-2302 (cut 1): THIS HANDLER NOW LIVES IN @payez/next-mvp. It was written as an app-local route in
3
+ * VibeID-Customer-Portal (src/app/api/auth/admin-login/route.ts, card 238225) because the package had no credential sign-in:
4
+ * its Better Auth instance configures socialProviders only (no emailAndPassword) and createLoginHandler (api-handlers/auth/login.ts)
5
+ * proxies the IdP without writing a session. It moved here so a generated app (create-next-app) can sign a user in; the portal
6
+ * re-exports it. Where the history below says "the package", "app-local" or "the graft", read it as written BEFORE the move.
7
+ *
8
+ * Admin password sign-in — card 238225 stage-2.
9
+ *
10
+ * Deliberately NOT a better-auth flow: better-auth's own auth/better-auth.js
11
+ * only configures socialProviders (Google) - zero emailAndPassword - and
12
+ * BAPert's ruling (32776, re 32769) was explicit: keep the shared package
13
+ * untouched, land this as an app-local custom route instead. It talks to the
14
+ * IDP the same way @payez/next-mvp's createLoginHandler does
15
+ * (api-handlers/auth/login.js: POST {IDP_URL}/api/ExternalAuth/login), which
16
+ * is the exact endpoint QAPert's TOTP fixture already proved works (mail
17
+ * 32622). createLoginHandler itself was not reused here - its own doc
18
+ * comment says it normalizes "for NextAuth authorize()", a caller this
19
+ * better-auth-only package no longer has.
20
+ *
21
+ * Two-step, same shape testing/qa/H1-AUTH-MECHANISM-2026-07-31.md documents
22
+ * for the IDP: an access token with NO refresh token means 2FA is
23
+ * outstanding (refresh is deliberately withheld until the second factor
24
+ * clears). Step 1 (email+password) either finishes in one shot or comes back
25
+ * asking for a code; step 2 resubmits email+password+code together - the IDP
26
+ * endpoint accepts two_factor_code directly on the same login call, so this
27
+ * never needs the already-authenticated totp-verify/verify-code endpoints
28
+ * (those add a *second* factor to an existing session; this establishes the
29
+ * first one).
30
+ *
31
+ * Session write: this is the "legacy JWT + Redis" path decode-session.js
32
+ * still actively maintains as a fallback (see its 183047 rework, and
33
+ * session-cookie-shape.js's JWS branch) - not dead NextAuth-era code, just
34
+ * unused by anything else in the package because better-auth mints its own
35
+ * ba-hmac cookies for every OTHER sign-in path. Nothing else in the package
36
+ * currently WRITES this shape; this route is the one place that now does,
37
+ * by design (BAPert 32776: "custom route ... writes session via
38
+ * lib/session-store.js").
39
+ */
40
+ import { NextRequest, NextResponse } from 'next/server';
41
+ import { SignJWT } from 'jose';
42
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
43
+ import { resolveClientKey } from '../../lib/client-key';
44
+ import { createSession, SESSION_TTL } from '../../lib/session-store';
45
+ import { getSessionCookieName, getSecureSessionCookieName } from '../../lib/app-slug';
46
+ import { createHash } from 'crypto';
47
+ import { DEVICE_ID_HEADER, isValidDeviceId } from '../../lib/device-id';
48
+ import { classifyTwofaRejection, type TwofaLaneResult } from '../../lib/twofa-rejection';
49
+ import { bearerFetch } from '../../lib/bearer-fetch';
50
+
51
+ // `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
52
+
53
+ /**
54
+ * The device id to forward to the IDP for THIS caller — card 240566.
55
+ *
56
+ * Previously a single hardcoded constant was sent for everybody, so to the
57
+ * IDP's rate limiter the entire portal was one device: any attacker's failed
58
+ * attempts drained the throttle budget for every real user (shared-fate
59
+ * lockout), and per-device risk signals meant nothing.
60
+ *
61
+ * Order of preference:
62
+ * 1. The browser's own persisted id (src/lib/device-id.ts), sent as the
63
+ * X-Device-Id header. Validated against the exact shape before it is
64
+ * forwarded -- this value crosses the network from an untrusted client
65
+ * and is interpolated into a Cookie header, so an unvalidated value
66
+ * would let a caller inject arbitrary cookie content. The regex admits
67
+ * only `payez-PayEz-<32 lowercase hex>`, which contains no delimiter
68
+ * that could break out of the cookie.
69
+ * 2. A stable id DERIVED from the caller (IP + user-agent, hashed). This is
70
+ * the no-JS / non-browser / storage-blocked path. It still separates
71
+ * callers from each other, so shared-fate is closed even when the client
72
+ * never presents an id -- which is the whole point of the card. It is
73
+ * deliberately NOT the old global constant: falling back to that would
74
+ * have quietly preserved the very bug this fixes for every such caller.
75
+ *
76
+ * This id is a throttling/telemetry key and NEVER a credential. It is
77
+ * client-influenced by design and grants nothing on its own; the IDP's
78
+ * identifier- and IP-scoped limits remain the real control.
79
+ */
80
+ function resolveDeviceId(req: NextRequest): string {
81
+ const supplied = req.headers.get(DEVICE_ID_HEADER);
82
+ if (isValidDeviceId(supplied)) return supplied;
83
+
84
+ const ip =
85
+ req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
86
+ req.headers.get('x-real-ip') ||
87
+ 'unknown-ip';
88
+ const ua = req.headers.get('user-agent') || 'unknown-ua';
89
+ const digest = createHash('sha256').update(`${ip}|${ua}`).digest('hex').slice(0, 32);
90
+ return `payez-PayEz-${digest}`;
91
+ }
92
+
93
+ interface AdminLoginBody {
94
+ email?: unknown;
95
+ password?: unknown;
96
+ code?: unknown;
97
+ }
98
+
99
+ function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
100
+ try {
101
+ const payloadSegment = jwt.split('.')[1];
102
+ if (!payloadSegment) return null;
103
+ return JSON.parse(Buffer.from(payloadSegment, 'base64').toString('utf-8'));
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function decodeJwtExpiryMs(jwt: string): number | null {
110
+ const decoded = decodeJwtPayload(jwt);
111
+ return typeof decoded?.exp === 'number' ? (decoded.exp as number) * 1000 : null;
112
+ }
113
+
114
+
115
+ /**
116
+ * 268410: the routing key this door sends to the IDP.
117
+ *
118
+ * Was `String(config.clientId)` at five sites - NUMERIC-ONLY, no slug preference
119
+ * anywhere in the expression - on the AUTHENTICATION DOOR, which is the worst
120
+ * place in the codebase for the arm the IDP accepts with no existence check.
121
+ * Falls back to the numeric exactly as before, so no working deployment changes
122
+ * behaviour; the fallback is now logged and attributable.
123
+ *
124
+ * NOTE: the `idpClientId` field written into the session record below is NOT a
125
+ * routing key - it is a record of which client the session was minted against,
126
+ * it is read nowhere, and it is deliberately left as the numeric id.
127
+ */
128
+ function clientKey(config: { clientSlug?: string | null; clientId?: string | number | null }): string {
129
+ const { key } = resolveClientKey(config, 'graft/admin-login');
130
+ return key ?? '';
131
+ }
132
+
133
+ /**
134
+ * PAY-2314: the factors the code step should tell the user about, by the lane the send turn chose.
135
+ * null (a TOTP holder: nothing was sent) -> ['totp']
136
+ * 'sms' -> ['sms']
137
+ * 'email' (also: mfa/methods unreadable) -> ['email']
138
+ * The VERIFY chain is unchanged (it still tries totp as the stateless companion lane); this is only what we ADVERTISE.
139
+ */
140
+ function acceptedFactorsFor(sendLane: 'sms' | 'email' | null): readonly string[] {
141
+ return sendLane === null ? ['totp'] : sendLane === 'sms' ? ['sms'] : ['email'];
142
+ }
143
+
144
+ export async function POST(req: NextRequest) {
145
+ let body: AdminLoginBody;
146
+ try {
147
+ body = await req.json();
148
+ } catch {
149
+ return NextResponse.json({ success: false, error: 'Invalid JSON format' }, { status: 400 });
150
+ }
151
+
152
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
153
+ const password = typeof body.password === 'string' ? body.password : '';
154
+ const code = typeof body.code === 'string' && body.code.trim() ? body.code.trim() : undefined;
155
+
156
+ if (!email) {
157
+ return NextResponse.json({ success: false, error: 'Email is required' }, { status: 400 });
158
+ }
159
+ if (!password) {
160
+ return NextResponse.json({ success: false, error: 'Password is required' }, { status: 400 });
161
+ }
162
+
163
+ // 244276: this call was OUTSIDE any try/catch. getIDPClientConfig() throws on a
164
+ // config-resolution failure - an empty authSecret, or the circuit breaker open with
165
+ // no cached config - and an unhandled throw in a route handler becomes an EMPTY 500.
166
+ // The sign-in card reads any non-2xx without a structured body as a failed attempt
167
+ // and shows "Could not sign in. Check your details".
168
+ //
169
+ // So an INFRASTRUCTURE OUTAGE was reported to the user as THEIR MISTAKE. That is the
170
+ // worst direction for this error to point: the one person who cannot fix it is told
171
+ // it is their fault, and the people who can fix it never hear about it, because a
172
+ // user who believes they mistyped a password retries instead of reporting.
173
+ //
174
+ // BAPert 2026-08-24 15:39:03Z: Jon hit an unhandled backend exception on this path.
175
+ // This card was filed "severity LOW - papered over once the graft re-seeds client 76".
176
+ // It was not papered over. It reached the person the product exists for.
177
+ //
178
+ // 503 + a structured body, deliberately: 503 is the honest status for "our dependency
179
+ // is down, this is not about your request", it is not cached, and the structured body
180
+ // is what lets the card tell an outage from a rejection at all.
181
+ let config: Awaited<ReturnType<typeof getIDPClientConfig>>;
182
+ try {
183
+ config = await getIDPClientConfig();
184
+ } catch (err) {
185
+ console.error('[ADMIN_LOGIN] IDP client config unavailable — returning 503:', err);
186
+ return NextResponse.json(
187
+ {
188
+ success: false,
189
+ error: 'Sign-in is temporarily unavailable. This is a problem on our side, not with your details. Please try again shortly.',
190
+ code: 'IDP_CONFIG_UNAVAILABLE',
191
+ retryable: true,
192
+ },
193
+ { status: 503 },
194
+ );
195
+ }
196
+
197
+ const idpUrl = `${process.env.IDP_URL}/api/ExternalAuth/login`;
198
+
199
+ // CODE STEP (240379 follow-through): verify the emailed code against the
200
+ // provisional bearer parked at the requiresTwoFactor turn. twofa/email/verify
201
+ // returns the FULL token pair (IssueRefreshToken=true after 2FA completion)
202
+ // - that response then rides the same session-mint tail as a direct login.
203
+ let idpResponse: Response;
204
+ if (code) {
205
+ const provisional = req.cookies.get('adm2fa')?.value;
206
+ if (!provisional) {
207
+ return NextResponse.json(
208
+ { success: false, error: 'Your verification window expired. Sign in again.' },
209
+ { status: 200 },
210
+ );
211
+ }
212
+ // DUAL-LANE VERIFY (both allowed_2fa_methods on client 12): a 6-digit
213
+ // code could be either lane, so try TOTP first (MfaController
214
+ // /api/ExternalAuth/twofa/totp/verify, body {code} - the endpoint
215
+ // DotNetPert proved end-to-end in 33513; local HMAC check, cheap), then
216
+ // fall back to the emailed code (twofa/email/verify,
217
+ // body {verification_code}). Both return the same flat
218
+ // Success({access_token, refresh_token}) envelope on step-up.
219
+ const verifyHeaders = {
220
+ 'Content-Type': 'application/json',
221
+ Accept: 'application/json',
222
+ Authorization: `Bearer ${provisional}`,
223
+ 'X-Client-Id': clientKey(config),
224
+ };
225
+ /**
226
+ * 267479 (verify half): the send half learned to fire twofa/sms/send for an
227
+ * SMS holder, but THIS chain still only tried totp then email - so the code
228
+ * we had just texted Jon was never offered to the verifier that could accept
229
+ * it, and he was told "That code is not right or has expired." The code was
230
+ * right. It was minted into the SMS store and nothing ever asked that store.
231
+ *
232
+ * twofa/sms/verify takes the SAME DTO as the email lane
233
+ * (ExternalAuthController.cs:3390 -> IdentityTwoFactorAuthRequest.VerificationCode,
234
+ * exactly like :3544) and is on the provisional allowlist beside sms/send
235
+ * (JwtMiddleware.cs:195). Backend was ready; only this call was missing.
236
+ *
237
+ * THE LANE SET IS RESTRICTED, NOT MERELY REORDERED - 268164, QAPert-NightHawk.
238
+ * My first cut of this appended sms to the end of the chain and broke only on
239
+ * SUCCESS, so every FAILED verify still fell through to twofa/sms/verify even
240
+ * for a user who holds no SMS factor. That is not free, because the SMS
241
+ * attempt counter is keyed on the PHONE NUMBER ALONE:
242
+ *
243
+ * AcsSmsService.cs:227 GetCodeKey(phone) => $"sms_code_{phone}" // no user, no client
244
+ * :43 MaxAttempts = 5
245
+ * :171-174 at the limit: TryRemove - a STILL-VALID code is DESTROYED
246
+ *
247
+ * So a wrong code from anyone sharing a phone number could burn down another
248
+ * user's good code and tell THEM "Too many incorrect attempts." Before this
249
+ * commit the door never called sms/verify at all, so appending it was a
250
+ * regression I introduced, not a pre-existing risk.
251
+ *
252
+ * Rule: NEVER probe a lane we did not send to. sms/verify fires only when the
253
+ * send turn actually texted this user (adm2fa_lane === 'sms'). TOTP is always
254
+ * safe to try - it is a stateless local HMAC with no stored entry and nothing
255
+ * to destroy - so it stays as the companion lane. Anything else, including a
256
+ * missing cookie from an older client, gets exactly the historic totp -> email
257
+ * chain, so this can never be worse than the behaviour it replaced.
258
+ */
259
+ const sentLane = req.cookies.get('adm2fa_lane')?.value;
260
+ const laneSpec = {
261
+ totp: { path: 'twofa/totp/verify', body: { code } as Record<string, string> },
262
+ sms: { path: 'twofa/sms/verify', body: { verification_code: code } as Record<string, string> },
263
+ email: { path: 'twofa/email/verify', body: { verification_code: code } as Record<string, string> },
264
+ } as const;
265
+ type VerifyLane = keyof typeof laneSpec;
266
+ const order: VerifyLane[] = sentLane === 'sms' ? ['sms', 'totp'] : ['totp', 'email'];
267
+ const laneStatuses: number[] = [];
268
+ // PAY-903: each refused lane's body, so a TOTP `locked` answer is not lost behind a later lane.
269
+ const laneResults: TwofaLaneResult[] = [];
270
+ try {
271
+ // Seeded so TS sees a definite assignment; the loop always runs at least once.
272
+ idpResponse = new Response(null, { status: 500 });
273
+ for (const lane of order) {
274
+ const spec = laneSpec[lane];
275
+ idpResponse = await bearerFetch(`${process.env.IDP_URL}/api/ExternalAuth/${spec.path}`, {
276
+ method: 'POST',
277
+ headers: verifyHeaders,
278
+ body: JSON.stringify(spec.body),
279
+ });
280
+ laneStatuses.push(idpResponse.status);
281
+ if (idpResponse.ok) break;
282
+ // PAY-1608: each lane keeps its own Retry-After, so a lockout states the LOCKED lane's window.
283
+ laneResults.push({ lane, status: idpResponse.status, body: await idpResponse.clone().text().catch(() => ''), retryAfter: idpResponse.headers.get('retry-after') });
284
+ }
285
+ } catch (error) {
286
+ console.error('[ADMIN-LOGIN] twofa verify fetch failed:', error instanceof Error ? error.message : String(error));
287
+ return NextResponse.json({ success: false, error: 'Unable to reach the authentication service' }, { status: 503 });
288
+ }
289
+ if (!idpResponse.ok) {
290
+ const txt = await idpResponse.text().catch(() => '');
291
+ console.warn('[ADMIN-LOGIN] twofa verify rejected', { statuses: laneStatuses, body: txt.slice(0, 200) });
292
+
293
+ /**
294
+ * 268121 FE-CONTRACT RULING (NextPert). DotNetPert's abc5ce27f asked for this
295
+ * ruling BEFORE merge and landed first, so this is the FE half arriving after
296
+ * the backend half - stated plainly rather than quietly.
297
+ *
298
+ * THIS BRANCH USED TO COLLAPSE EVERY NON-OK STATUS INTO "That code is not
299
+ * right or has expired." That was survivable while the backend answered 200
300
+ * for everything. It is NOT survivable now: 268121 makes a delivery/serverfault
301
+ * answer 502/500, so an INFRASTRUCTURE failure was being reported to the user
302
+ * as THEIR TYPING MISTAKE.
303
+ *
304
+ * That is the exact inversion DotNetPert refused to ship on his side - he
305
+ * declined to 502 the login family because it would turn "wrong code" into
306
+ * "server broken". This is the same error running the other way: "server
307
+ * broken" presented as "wrong code". Blaming a user for a server fault also
308
+ * costs them real attempts, because they will retype a code that was never
309
+ * the problem - and on this door attempts are a scarce, shared resource
310
+ * (268164: the SMS counter is keyed on PHONE NUMBER).
311
+ *
312
+ * AND 429 WAS THE WORST CASE. A rate-limit refusal read as "your code is
313
+ * wrong", so a locked-out user was told to try again - which, below
314
+ * threshold, SETS A FRESH 1-HOUR WINDOW (RateLimitingMiddleware: the tripped
315
+ * gate returns before the increment, but a sub-threshold failure re-arms).
316
+ * The FE was actively coaching users into extending their own lockout. That
317
+ * ambiguity cost this floor the morning of 2026-08-26.
318
+ *
319
+ * Classification is by the MOST BLOCKING status seen across lanes, not the
320
+ * last one: with order=[sms,totp] a 429 on lane 1 and a 400 on lane 2 must
321
+ * report the 429, or the loop hides the thing the user most needs to know.
322
+ */
323
+ // PAY-903: the same ordering, now in src/lib/auth/twofa-rejection.ts, plus the IdP `locked` code
324
+ // (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
325
+ // classification told to "try again".
326
+ const rejection = classifyTwofaRejection(laneResults, idpResponse.headers.get('retry-after'));
327
+ return NextResponse.json(
328
+ {
329
+ success: false,
330
+ error: rejection.error,
331
+ ...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
332
+ // PAY-2284: the card shows a real "email me a code" action only when this is true.
333
+ ...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
334
+ },
335
+ { status: rejection.status },
336
+ );
337
+ }
338
+ const verifyData = await idpResponse.json().catch(() => null);
339
+ // Success helper wraps flat: {success, data:{access_token, refresh_token, ...}}
340
+ // (no `.result` nesting on this endpoint - measured shape).
341
+ const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
342
+ const vAccess: string | undefined = vresult?.access_token;
343
+ const vRefresh: string | undefined = vresult?.refresh_token;
344
+ if (!vAccess || !vRefresh) {
345
+ console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
346
+ return NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
347
+ }
348
+ const out = await mintSession(vAccess, vRefresh, email, vresult, config);
349
+ out.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
350
+ out.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
351
+ return out;
352
+ }
353
+
354
+ const idpPayload: Record<string, unknown> = {
355
+ username_or_email: email,
356
+ password,
357
+ client_id: clientKey(config),
358
+ client_type: 'partner',
359
+ };
360
+ try {
361
+ idpResponse = await fetch(idpUrl, {
362
+ method: 'POST',
363
+ headers: {
364
+ 'Content-Type': 'application/json',
365
+ Accept: 'application/json',
366
+ // 240566: per-CALLER device id, not the one hardcoded constant every
367
+ // portal user used to share. Same cookie name and shape
368
+ // createLoginHandler forwards, so the IDP contract is unchanged --
369
+ // only the value is now distinct per browser. See resolveDeviceId.
370
+ Cookie: `${DEVICE_ID_HEADER}=${resolveDeviceId(req)}`,
371
+ },
372
+ body: JSON.stringify(idpPayload),
373
+ });
374
+ } catch (error) {
375
+ console.error('[ADMIN-LOGIN] IDP fetch failed:', error instanceof Error ? error.message : String(error));
376
+ return NextResponse.json({ success: false, error: 'Unable to reach the authentication service' }, { status: 503 });
377
+ }
378
+
379
+ const rawText = await idpResponse.text();
380
+ let data: any;
381
+ try {
382
+ data = JSON.parse(rawText);
383
+ } catch {
384
+ console.error('[ADMIN-LOGIN] IDP returned non-JSON response', { status: idpResponse.status, body: rawText });
385
+ return NextResponse.json({ success: false, error: 'Invalid response from the authentication service' }, { status: 502 });
386
+ }
387
+
388
+ if (idpResponse.status === 429) {
389
+ const retryAfter = idpResponse.headers.get('retry-after');
390
+ return NextResponse.json(
391
+ { success: false, error: 'Too many attempts. Please try again later.', retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60 },
392
+ { status: 429 }
393
+ );
394
+ }
395
+
396
+ if (!idpResponse.ok) {
397
+ const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
398
+ return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: idpResponse.status });
399
+ }
400
+
401
+ if (data?.success === false) {
402
+ const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
403
+ return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: 200 });
404
+ }
405
+
406
+ // Unwrap the IDP's canonical envelope, same shape login.js normalizes.
407
+ const result = data?.data?.result ?? data?.result ?? data;
408
+ const accessToken: string | undefined = result?.access_token;
409
+ const refreshToken: string | undefined = result?.refresh_token;
410
+
411
+ // 2FA outstanding: access issued, refresh deliberately withheld. The
412
+ // H1-AUTH doc's "two_factor_code accepted on the same login call" shape
413
+ // DOES NOT EXIST on this IDP build - measured 2026-08-23 (240379/:4702):
414
+ // TwoFactorCode appears nowhere in External.Id.Api's login path. The real
415
+ // step-up contract is: login -> PROVISIONAL bearer (access, no refresh) ->
416
+ // POST twofa/email/send (Bearer provisional) -> emailed code ->
417
+ // POST twofa/email/verify {code} (Bearer provisional) -> FULL token pair.
418
+ // So: fire the email send NOW, park the provisional token in a short-lived
419
+ // httpOnly cookie, and let the code step verify against it (branch above).
420
+ const requiresTwoFactor = result?.two_factor_required === true || (!!accessToken && !refreshToken);
421
+ if (requiresTwoFactor) {
422
+ let emailSent = false;
423
+ /**
424
+ * 267479: WHICH FACTOR DOES THIS USER ACTUALLY HOLD?
425
+ *
426
+ * This door used to fire twofa/email/send at EVERYONE. That is wrong in two
427
+ * separate ways and Jon hit both:
428
+ * - he holds sms(confirmed) and NO usable TOTP (measured on the live 93 DB,
429
+ * DotNetPert mail 37564), and the deployed build CAN send it - AcsSmsService
430
+ * + twofa/sms/send are live at d873cea44 - but this file has never had an
431
+ * SMS branch at all, so his own factor was unreachable from the sign-in door;
432
+ * - a TOTP holder gets a mail they never needed, because TOTP has nothing to
433
+ * send (there is no twofa/totp/send BY DESIGN - DotNetPert).
434
+ *
435
+ * /api/Account/mfa/methods IS on the IDP's provisional allowlist
436
+ * (JwtMiddleware._mfaPendingAllowedPaths), as are twofa/sms/send and
437
+ * twofa/email/send - so the provisional bearer we already hold is enough to ask
438
+ * the question and then act on the answer.
439
+ *
440
+ * FAIL-SAFE: any failure, timeout or unreadable body leaves `usable` empty and
441
+ * we fall through to EMAIL, which is exactly the old behaviour. This can only
442
+ * add a correct SMS/TOTP branch; it cannot take away the lane that worked.
443
+ *
444
+ * KNOWN LIMIT, stated so nobody reads more into this than it does: mfa/methods
445
+ * reads user_mfa_methods ONLY. A TOTP factor living in the legacy
446
+ * user_client_security_settings.authenticator_key store is invisible here (266652)
447
+ * and such a user falls to email - again, the old behaviour, so no regression.
448
+ * There are currently ZERO legacy keys on 93, so that class is empty today.
449
+ */
450
+ let usableFactors: string[] = [];
451
+ if (accessToken) {
452
+ try {
453
+ const mResp = await bearerFetch(`${process.env.IDP_URL}/api/Account/mfa/methods`, {
454
+ method: 'GET',
455
+ headers: {
456
+ Accept: 'application/json',
457
+ Authorization: `Bearer ${accessToken}`,
458
+ 'X-Client-Id': clientKey(config),
459
+ },
460
+ });
461
+ if (mResp.ok) {
462
+ const mJson = await mResp.json().catch(() => null);
463
+ const rows: Array<Record<string, unknown>> = Array.isArray(mJson)
464
+ ? mJson
465
+ : Array.isArray((mJson as { data?: unknown })?.data)
466
+ ? ((mJson as { data: Array<Record<string, unknown>> }).data)
467
+ : [];
468
+ usableFactors = rows
469
+ // confirmed_at REQUIRED - the endpoint returns PENDING enrolments too, and a
470
+ // started-but-never-finished QR scan is not a factor you can challenge against.
471
+ .filter((r) => !!r.confirmed_at && !r.revoked_at)
472
+ .map((r) => String(r.method ?? '').toLowerCase());
473
+ } else {
474
+ console.warn('[ADMIN-LOGIN] mfa/methods unreadable, defaulting to email lane', mResp.status);
475
+ }
476
+ } catch (err) {
477
+ console.warn('[ADMIN-LOGIN] mfa/methods fetch failed, defaulting to email lane:', err instanceof Error ? err.message : String(err));
478
+ }
479
+ }
480
+ // PAY-2314: advertise what this user can actually use, derived from the SAME usableFactors that chose the lane
481
+ // (a fixed ['totp','email'] told a user with no authenticator to use one). An unreadable mfa/methods leaves
482
+ // usableFactors empty, which already chose the email lane -> ['email'].
483
+ // TOTP wins when present: there is nothing to send, so sending anything is noise.
484
+ // Otherwise send the code for the factor they actually hold.
485
+ const sendLane: 'sms' | 'email' | null = usableFactors.includes('totp')
486
+ ? null
487
+ : usableFactors.includes('sms')
488
+ ? 'sms'
489
+ : 'email';
490
+ /**
491
+ * 262392 on the SIGN-IN DOOR: the dev code echo exists, Jon asked for it by
492
+ * name ("i need the code to come back from the send request for dev
493
+ * testing"), and it is wired into /account-auth/verify-code - but NOT here,
494
+ * on the door he actually signs in through. This route threw the send body
495
+ * away entirely, reading only `sendResp.ok`.
496
+ *
497
+ * WHY THAT MATTERS MORE THAN A CONVENIENCE: ExternalAuthController.cs:3341
498
+ * attaches the echo REGARDLESS of response.Success, and says why - "the ACS
499
+ * send fails while the toll-free number is unverified, so gating the echo on
500
+ * a successful send would mean it never fires". Meanwhile ACS answers 202 =
501
+ * ACCEPTED, not delivered, and there are ZERO EventGrid subscriptions, so
502
+ * NOTHING in this system can observe whether the text actually arrived
503
+ * (DotNetPert-Scout, 268492).
504
+ *
505
+ * So on a dev rig the echo is not a nicety - it is the ONLY path to the code
506
+ * when delivery silently fails, and this door was dropping it.
507
+ *
508
+ * NO FRONTEND GATE, DELIBERATELY. The backend emits the field ONLY under
509
+ * IsDevelopment() && Sms:DevCodeEcho, and the DTO is
510
+ * [JsonIgnore(WhenWritingNull)] so a CLOSED GATE EMITS NO FIELD AT ALL.
511
+ * Absence IS the contract. A second FE-side flag could drift out of step with
512
+ * the server and show a UI that believes itself disabled while the server
513
+ * echoes - the exact two-gates defect class this codebase spent today
514
+ * chasing. We forward strictly what the server chose to send.
515
+ */
516
+ let devCode: string | undefined;
517
+ if (accessToken && sendLane) {
518
+ try {
519
+ const sendResp = await bearerFetch(`${process.env.IDP_URL}/api/ExternalAuth/twofa/${sendLane}/send`, {
520
+ method: 'POST',
521
+ headers: {
522
+ 'Content-Type': 'application/json',
523
+ Accept: 'application/json',
524
+ Authorization: `Bearer ${accessToken}`,
525
+ 'X-Client-Id': clientKey(config),
526
+ },
527
+ body: JSON.stringify({ client_id: clientKey(config) }),
528
+ });
529
+ emailSent = sendResp.ok;
530
+ // The body can only be consumed ONCE, and we now need it on BOTH paths -
531
+ // the failure log and the echo - so read it here rather than in a branch.
532
+ const sendBody = await sendResp.text().catch(() => '');
533
+ if (!sendResp.ok) {
534
+ console.error(`[ADMIN-LOGIN] twofa/${sendLane}/send failed`, sendResp.status, sendBody.slice(0, 200));
535
+ }
536
+ // Shape-tolerant on purpose: the standardized envelope nests as {data:{result}}
537
+ // on some routes and {data} on others. Betting on one shape would render
538
+ // nothing on the others, and a silent nothing is indistinguishable from
539
+ // "echo is off" - precisely the ambiguity this display exists to remove.
540
+ try {
541
+ const j: any = JSON.parse(sendBody);
542
+ const echoed =
543
+ j?.dev_verification_code ??
544
+ j?.data?.dev_verification_code ??
545
+ j?.data?.result?.dev_verification_code ??
546
+ j?.result?.dev_verification_code;
547
+ if (typeof echoed === 'string' && echoed) devCode = echoed;
548
+ } catch {
549
+ // not JSON - nothing to echo, and never a reason to fail the sign-in
550
+ }
551
+ } catch (err) {
552
+ console.error(`[ADMIN-LOGIN] twofa/${sendLane}/send fetch failed:`, err instanceof Error ? err.message : String(err));
553
+ }
554
+ }
555
+ // 267479: `method:'email'` WAS A HARDCODED LITERAL HERE, and it was a lie for
556
+ // every user whose factor was not email. It also threw away the backend's own
557
+ // answer: the IDP computes the right factor (256768 / MfaFactorPolicy) and
558
+ // returns `next_step`, which this file read ZERO times.
559
+ //
560
+ // Report what actually happened instead of asserting a method:
561
+ // sentVia which lane we ACTUALLY fired, or null for a TOTP holder
562
+ // (nothing to send). Drives the copy on the card.
563
+ // codeRequested the send's own ok. NOT a delivery receipt - per 267561
564
+ // twofa/email/send returns 200 even when the SMTP send threw,
565
+ // so this means "we asked for a code", never "one arrived".
566
+ // Any copy built on it must survive that. `emailSent` is kept
567
+ // as an alias purely so an older client does not break.
568
+ // acceptedFactors what the VERIFY lane will actually TRY for this user, and
569
+ // it is now lane-dependent rather than fixed: an SMS holder
570
+ // gets sms+totp, everyone else gets the historic totp+email.
571
+ // It is deliberately NOT the union - probing a lane we did
572
+ // not send to spends someone's attempt counter (268164).
573
+ const resp = NextResponse.json(
574
+ {
575
+ success: false,
576
+ requiresTwoFactor: true,
577
+ acceptedFactors: acceptedFactorsFor(sendLane),
578
+ sentVia: sendLane,
579
+ codeRequested: emailSent,
580
+ emailSent,
581
+ // Present ONLY when the server chose to echo (dev rigs). Absence is the
582
+ // contract - see the block above; do not add a second gate for it.
583
+ ...(devCode ? { devVerificationCode: devCode } : {}),
584
+ nextStep: result?.next_step ?? null,
585
+ },
586
+ { status: 200 },
587
+ );
588
+ if (accessToken) {
589
+ resp.cookies.set('adm2fa', accessToken, {
590
+ httpOnly: true,
591
+ secure: process.env.NODE_ENV === 'production',
592
+ sameSite: 'lax',
593
+ path: '/',
594
+ maxAge: 600, // matches the emailed code's usefulness window
595
+ });
596
+ // 267479: remember WHICH lane we fired. The verify turn is a separate
597
+ // request that would otherwise have to guess, and every wrong guess spends
598
+ // one of the user's remaining attempts. A TOTP holder has no send, so the
599
+ // lane is recorded as 'totp' and that is what gets tried first.
600
+ resp.cookies.set('adm2fa_lane', sendLane ?? 'totp', {
601
+ httpOnly: true,
602
+ secure: process.env.NODE_ENV === 'production',
603
+ sameSite: 'lax',
604
+ path: '/',
605
+ maxAge: 600,
606
+ });
607
+ }
608
+ return resp;
609
+ }
610
+
611
+ if (!accessToken || !refreshToken) {
612
+ console.error('[ADMIN-LOGIN] IDP success response missing tokens', { hasAccess: !!accessToken, hasRefresh: !!refreshToken });
613
+ return NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
614
+ }
615
+
616
+ return mintSession(accessToken, refreshToken, email, result, config);
617
+ }
618
+
619
+ /**
620
+ * WHY THIS EXISTS: the IDP TELLS US why it refused, and we were throwing it away.
621
+ *
622
+ * ExternalAuthController's login failure envelope is TWO-LAYER, and the two layers carry
623
+ * very different amounts of information:
624
+ *
625
+ * top level Message = "Authentication failed" <- generic, identical for every cause
626
+ * errors[0] Code / Message / Resolution / FieldName <- THE ACTUAL REASON
627
+ *
628
+ * Both relay branches below read only `data?.message`, i.e. the generic layer, and dropped
629
+ * `errors[0]` on the floor. So a refusal for SsoRequired, InvalidRequest, AccountLocked,
630
+ * EmailNotConfirmed, PasswordChangeRequired or a genuinely wrong password all presented to
631
+ * an operator as the SAME six words, with nothing written anywhere to tell them apart.
632
+ *
633
+ * That is the 268410 defect in a different costume: two materially different causes made
634
+ * INDISTINGUISHABLE at the boundary. It cost the floor a morning on 2026-08-26 - a shut door
635
+ * at :4793 that could not be attributed to a credential, a config, or a policy, because the
636
+ * one artifact that knew the answer was discarded in this function.
637
+ *
638
+ * *** THE REASON IS LOGGED, NOT RETURNED, AND THAT ASYMMETRY IS DELIBERATE. ***
639
+ *
640
+ * AuthErrorCode has UserNotFound AND InvalidCredentials as SEPARATE values
641
+ * (PayEz.DTOs/Models/Token/Internal/AuthErrorCode.cs). Returning the code to the browser
642
+ * would let an unauthenticated caller tell "no such account" from "wrong password" - a user
643
+ * ENUMERATION oracle on the sign-in door. AccountLocked and EmailNotConfirmed leak existence
644
+ * the same way. The generic top-level message is doing real security work, so the client
645
+ * response below is UNCHANGED and must stay that way.
646
+ *
647
+ * The server log is the correct place: operators can read it, the browser cannot. If a future
648
+ * change wants a code in the response, it needs a per-code allowlist argued on enumeration
649
+ * grounds - not a blanket passthrough.
650
+ */
651
+ type IdpFailure = {
652
+ code: string | null;
653
+ detail: string | null;
654
+ resolution: string | null;
655
+ fieldName: string | null;
656
+ shape: string;
657
+ };
658
+
659
+ /**
660
+ * 270725 (QAPert): THE REASON IS NOT WHERE I ASSUMED, AND MY FIRST FIX READ THE WRONG FIELD.
661
+ *
662
+ * 0019b66 read `data.errors[0]`, which is the shape ExternalTokenResponse serializes on the
663
+ * Ok() path. QAPert MEASURED the live 400 and it arrives as
664
+ * `error.details.errors[0].message`. Different path, different wrapper - so my diagnostic
665
+ * would have logged nulls forever and looked like "the IDP said nothing", which is EXACTLY
666
+ * the quietly-failing diagnostic my own comment warned about one commit earlier.
667
+ *
668
+ * So this probes BOTH shapes and RECORDS WHICH ONE MATCHED. If the envelope moves again the
669
+ * log says `shape: none` instead of silently reporting no reason - the failure is visible.
670
+ */
671
+ function extractIdpFailure(data: unknown): IdpFailure {
672
+ const d = (data ?? {}) as Record<string, any>;
673
+ const candidates: Array<[string, any]> = [
674
+ ['error.details.errors[0]', d?.error?.details?.errors?.[0]],
675
+ ['errors[0]', Array.isArray(d?.errors) ? d.errors[0] : undefined],
676
+ ['error.details', d?.error?.details],
677
+ ['error', typeof d?.error === 'object' ? d.error : undefined],
678
+ ];
679
+ for (const [shape, cand] of candidates) {
680
+ if (cand && typeof cand === 'object') {
681
+ const pick = (a: string, b: string) => cand[a] ?? cand[b] ?? null;
682
+ const code = pick('code', 'Code');
683
+ const detail = pick('message', 'Message');
684
+ if (code || detail) {
685
+ return {
686
+ code: code == null ? null : String(code),
687
+ detail: detail == null ? null : String(detail),
688
+ resolution: pick('resolution', 'Resolution'),
689
+ fieldName: pick('fieldName', 'FieldName'),
690
+ shape,
691
+ };
692
+ }
693
+ }
694
+ }
695
+ return { code: null, detail: null, resolution: null, fieldName: null, shape: 'none' };
696
+ }
697
+
698
+ /**
699
+ * WHICH CODES MAY BE SHOWN TO AN UNAUTHENTICATED CALLER. ALLOWLIST, DEFAULT-DENY.
700
+ *
701
+ * 270725 asks to surface `error.code` so the card can tell a lockout from a rejection.
702
+ * I am NOT doing that, and the card's OWN NULL CONTROL is the reason: it measured a
703
+ * nonexistent user returning `error.code = UserNotFound`. Surfacing the code therefore
704
+ * separates "no such account" from "wrong password" from "this account is locked" - a user
705
+ * ENUMERATION ORACLE on the sign-in door, reachable with no credential. AccountLocked leaks
706
+ * existence just as hard: a lockout only EXISTS for a real account.
707
+ *
708
+ * These codes leak nothing about whether an account exists, so they may be shown:
709
+ * SsoRequired / SsoRequiredBreakGlass - a property of the EMAIL DOMAIN's connection, not of
710
+ * any account, and the user cannot proceed without being told.
711
+ * InvalidRequest - a property of the request the caller just made.
712
+ */
713
+ const SAFE_TO_SHOW = new Set(['SsoRequired', 'SsoRequiredBreakGlass', 'InvalidRequest']);
714
+
715
+ /**
716
+ * THE UNIFORM REFUSAL. One string for typo, unknown account, and lockout alike.
717
+ *
718
+ * 270725's real harm is NOT that the user is under-informed - it is that the copy INVITES A
719
+ * RETRY. THE NUMBERS IN THIS PARAGRAPH WERE WRONG UNTIL 2026-09-03 AND THE CORRECTION IS
720
+ * WORTH MORE THAN THE ORIGINAL CLAIM, so it is recorded rather than quietly edited:
721
+ *
722
+ * IT SAID: "a sub-threshold retry SETS A FRESH ONE-HOUR WINDOW".
723
+ * MEASURED (PayEz.External.Id.Api/Helper/ServiceCollectionExtension.cs:66-68):
724
+ * MaxFailedAccessAttempts = 5
725
+ * DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15) <- FIFTEEN MINUTES, NOT SIXTY
726
+ * AND a sub-threshold failure sets NO window at all: UserValidationService.cs:209 calls
727
+ * AccessFailedAsync(), which INCREMENTS a counter; the window is armed only when the count
728
+ * reaches 5. So the original sentence was wrong on the duration (4x) and on the mechanism.
729
+ *
730
+ * WHAT IS STILL TRUE, AND WHY THE COPY BELOW DOES NOT CHANGE: repeated wrong passwords DO
731
+ * block sign-in, and "wait a few minutes" is ACCURATE for 15 minutes - it would have been
732
+ * actively misleading for 60. The copy was right while its own justification was not.
733
+ *
734
+ * ALSO MEASURED, and it bounds the harm further: AccessFailedAsync fires ONLY on
735
+ * !passwordValid (UserValidationService.cs:205-209), so a user retrying with the CORRECT
736
+ * password does not increment anything. The counter only moves on genuinely wrong passwords.
737
+ *
738
+ * NOT DRIVEN, DO NOT CITE AS FACT (BAPert R480): whether five FURTHER wrong passwords during
739
+ * an ALREADY-ACTIVE lockout move LockoutEnd. The password path increments before it checks
740
+ * the lock (unlike the TOTP path, MfaTotpService.cs:193-199, which returns BEFORE the
741
+ * increment and is therefore inert on retry), so it is a strong prediction from ordering plus
742
+ * stock Identity semantics - not a measurement. The check is five deliberate failures, then
743
+ * five more during the lockout, watching whether LockoutEnd moves.
744
+ *
745
+ * That harm can be fixed WITHOUT an oracle: warn about the cost of retrying in the copy shown
746
+ * for EVERY failure. A message that is identical across all three causes cannot discriminate
747
+ * between them, so it tells an attacker nothing - while telling a real
748
+ * user the one thing that actually protects them.
749
+ */
750
+ const UNIFORM_REFUSAL =
751
+ 'We could not sign you in. Check your email and password. ' +
752
+ 'If you have already tried a few times, wait a few minutes before trying again - ' +
753
+ 'repeated attempts temporarily block sign-in, even with the right password.';
754
+
755
+ /**
756
+ * Pick what the BROWSER is allowed to see. The full reason always goes to the log.
757
+ */
758
+ function publicRefusalMessage(f: IdpFailure): string {
759
+ if (f.code && SAFE_TO_SHOW.has(f.code) && f.detail) return f.detail;
760
+ return UNIFORM_REFUSAL;
761
+ }
762
+
763
+ function logIdpAuthRefusal(branch: string, httpStatus: number, data: unknown): IdpFailure {
764
+ const f = extractIdpFailure(data);
765
+ const d = (data ?? {}) as Record<string, unknown>;
766
+ console.error('[ADMIN-LOGIN] IDP REFUSED SIGN-IN - specific reason follows (not sent to the browser: enumeration)', {
767
+ branch,
768
+ httpStatus,
769
+ envelopeMessage: d.message ?? (typeof d.error === 'string' ? d.error : null) ?? null,
770
+ code: f.code,
771
+ detail: f.detail,
772
+ resolution: f.resolution,
773
+ fieldName: f.fieldName,
774
+ shape: f.shape,
775
+ shownToUser: publicRefusalMessage(f) === UNIFORM_REFUSAL ? 'UNIFORM (cause withheld)' : 'code-specific (allowlisted)',
776
+ });
777
+ return f;
778
+ }
779
+
780
+ /**
781
+ * Session-mint tail, shared by the direct-login success path and the
782
+ * twofa/email/verify step-up path (240379 email-2FA lane): claims-seeded
783
+ * roles/userId, createSession, JWS cookie under both names in prod.
784
+ */
785
+ async function mintSession(
786
+ accessToken: string,
787
+ refreshToken: string,
788
+ email: string,
789
+ result: any,
790
+ config: { clientId: string | number; authSecret: string },
791
+ ): Promise<NextResponse> {
792
+ const accessTokenExpires = decodeJwtExpiryMs(accessToken) ?? Date.now() + 15 * 60 * 1000;
793
+
794
+ // 240379 tier-2 fix — the IDP login envelope carries NO top-level `roles`
795
+ // or `user_id` (measured live against idp.payez.net, 2026-08-23): identity
796
+ // lives in the ACCESS TOKEN's claims (roles, user_id) and partially under
797
+ // `result.user` (user_name, roles). The old `result?.roles ?? []` mapping
798
+ // therefore seeded EVERY session with roles:[] and userId=email — so
799
+ // check-admin-role's hasAdminRole([]) denied even payez_admin holders and
800
+ // the admin console 307'd to /sign-in?error=SessionInvalid with the
801
+ // session freshly minted (measured: persona 903 with payez_admin in its
802
+ // JWT, session record roles:[] in redis-keelauth).
803
+ //
804
+ // The JWT came to us DIRECTLY from the IDP over TLS in this same response
805
+ // — decoding its claims here trusts exactly what storing its tokens
806
+ // already trusts; no client-supplied value is involved.
807
+ const claims = decodeJwtPayload(accessToken);
808
+ const claimRoles = Array.isArray(claims?.roles) ? (claims!.roles as string[]) : null;
809
+ const userRoles = Array.isArray(result?.user?.roles) && result.user.roles.length > 0
810
+ ? (result.user.roles as string[])
811
+ : null;
812
+
813
+ // 388680: the MFA expiry that BOUNDS mfaVerified/twoFactorComplete below.
814
+ // Normalised here rather than inline so the conversion is done exactly once
815
+ // and every rejected shape is visible in one place.
816
+ //
817
+ // Number(...) the claim arrives as a JSON STRING ("1789345863") on all
818
+ // 38 live records; SessionData.mfaExpiresAt is typed number.
819
+ // * 1000 every read site compares against Date.now() in MILLISECONDS
820
+ // (routes/auth/viability.ts, api-handlers/session/viability.ts,
821
+ // server/auth-guard.ts, all `n > 0 && n < Date.now()`).
822
+ // Writing raw SECONDS is the one shape that is actively
823
+ // dangerous in the other direction: a positive number far in
824
+ // the past reads as EXPIRED and fails closed on every login.
825
+ // > 0 && finite rejects the degenerate shapes so the field is OMITTED
826
+ // rather than written as a meaningless number:
827
+ // undefined/"abc" -> NaN, null/"" -> 0.
828
+ // Be precise about why, because it is NOT that these fail
829
+ // closed - MEASURED at all four read sites, `n > 0` is false
830
+ // for both NaN and 0, so either one reads as NEVER-EXPIRES,
831
+ // exactly like an absent field. They fail OPEN. Omitting is
832
+ // therefore behaviourally identical to writing them, and is
833
+ // preferred because it does not put a meaningless value in a
834
+ // security field, and because a stored NaN passes the
835
+ // `!== undefined` style check that 393082 is about.
836
+ //
837
+ // *** DO NOT "TIDY" THIS BACK TO MATCH THE OTHER WRITER. THE DEVIATION IS THE POINT. ***
838
+ // (Written when this handler lived in the portal and "upstream" meant the package's session-store; the portal's copy is now
839
+ // this file, and the weaker guard named below is the one still in lib/session-store.ts.)
840
+ // The upstream writer in @payez/next-mvp (dist/lib/session-store.js:358) guards
841
+ // this same field with `!== undefined && !== null`. That is WEAKER and it is
842
+ // weaker in the exact way this guard exists to prevent: it admits PRESENT-BUT-
843
+ // MALFORMED claims - mfa_expires:"abc", "", "1789345863x" - which pass the null
844
+ // check, hit Number(...) and store NaN in a security field. Matching upstream
845
+ // here would propagate that hole for the sake of symmetry. Ruled deliberate by
846
+ // BAPert 2026-09-12 (mail 53814): the stated purpose of the `!= null` shape was
847
+ // to never write NaN, and it does not achieve that; `Number.isFinite` does.
848
+ // The same hole on the package's own paths is filed as a finding on 393082 and
849
+ // is NOT fixed here.
850
+ const mfaExpiresSec = Number(claims?.mfa_expires);
851
+ const mfaExpiresAtMs =
852
+ Number.isFinite(mfaExpiresSec) && mfaExpiresSec > 0 ? mfaExpiresSec * 1000 : null;
853
+
854
+ const sessionToken = await createSession({
855
+ userId: String(claims?.user_id ?? result?.user_id ?? result?.userId ?? email),
856
+ email,
857
+ name: result?.user?.user_name ?? result?.name ?? result?.full_name,
858
+ idpAccessToken: accessToken,
859
+ idpRefreshToken: refreshToken,
860
+ idpAccessTokenExpires: accessTokenExpires,
861
+ // Reached only once the IDP has handed back a refresh token, i.e. no 2FA
862
+ // outstanding for this login - same "no tokens => no transition" posture
863
+ // totp-verify.js uses for the second-factor case.
864
+ //
865
+ // 388680: these two were written WITHOUT the expiry that bounds them. The
866
+ // constraint is present in the token - claims.mfa_expires, decoded above -
867
+ // and was being discarded, so a session stayed fully trusted for its whole
868
+ // TTL (~71h) after its MFA had expired. Measured live 2026-09-12: 20 of 38
869
+ // records had mfa_expires in the past while still carrying mfaVerified:true,
870
+ // each with ~35h of TTL left. Never drop a constraint the token carries.
871
+ mfaVerified: true,
872
+ twoFactorComplete: true,
873
+ ...(mfaExpiresAtMs !== null ? { mfaExpiresAt: mfaExpiresAtMs } : {}),
874
+ roles: claimRoles ?? userRoles ?? result?.roles ?? [],
875
+ idpClientId: String(config.clientId),
876
+ });
877
+
878
+ const secret = new TextEncoder().encode(config.authSecret);
879
+ const jwt = await new SignJWT({ sessionToken })
880
+ .setProtectedHeader({ alg: 'HS256' })
881
+ .setIssuedAt()
882
+ .setExpirationTime(Math.floor(Date.now() / 1000) + SESSION_TTL)
883
+ .sign(secret);
884
+
885
+ const isProd = process.env.NODE_ENV === 'production';
886
+
887
+ // 240379 PROD FIX — write the session under BOTH cookie names in production.
888
+ //
889
+ // The package's own read contract (app-slug.ts getJwtCookieName) says prod
890
+ // readers use the __Secure- name — but server/auth.ts's
891
+ // extractSessionTokenFromCookie (the Path-B fallback that resolves THIS
892
+ // route's legacy JWS sessions, and the only reader the viability route
893
+ // reaches for them) calls getSessionCookieName() and NEVER checks the
894
+ // __Secure- variant. Measured live on portal.keelauth.com: sign-in 200,
895
+ // record present in Redis (idp_admin_ui_graft:sess:{token}), and
896
+ // /api/session/viability still answered "No session found" — the minted
897
+ // session was invisible to every gate, so pages bounced back to /sign-in.
898
+ // On 93-dev NODE_ENV made writer and reader agree, which is why 238225's
899
+ // walk passed there and prod died.
900
+ //
901
+ // The reader-side fix is one line in the package (use getJwtCookieName());
902
+ // that is an MVP release (channel-drift 208574 caution).
903
+ //
904
+ // Set BOTH names. Every reader combination is covered (Path B reads the base
905
+ // name; SessionSync/signout/middleware handle the secure one; the graft
906
+ // middleware checks both), and both carry the same JWS so there is no
907
+ // divergence to reconcile. Signout/deny paths already clear both names.
908
+ //
909
+ // *** DO NOT RETIRE THIS DUAL-WRITE WHEN THE READER FIX SHIPS. ***
910
+ //
911
+ // An earlier version of this comment said "until then", which reads as a
912
+ // licence to drop one name as soon as the package reader is fixed. It is
913
+ // not, and that inference is the whole hazard — so it is corrected here.
914
+ // Shipping the reader is NOT the precondition; REDEPLOYING EVERY CONSUMER
915
+ // is. The reader fix is strictly additive (it looks at MORE names), so it
916
+ // can land alone, in either order, with no change to this file.
917
+ //
918
+ // The four cells (card 245246, governing analysis):
919
+ // old reader (base only) + BOTH written -> finds base WORKS (today)
920
+ // fixed reader (both) + BOTH written -> finds __Secure- WORKS
921
+ // fixed reader (both) + base only -> falls to base WORKS
922
+ // old reader (base only) + __Secure- ONLY -> finds NOTHING BREAKS
923
+ //
924
+ // Exactly one cell breaks, and retiring this dual-write is the ONLY path
925
+ // into it. Retirement requires all three, verified not assumed (card
926
+ // 245283): (1) the fixed reader PUBLISHED, not merely committed; (2) EVERY
927
+ // consumer repinned AND redeployed, checked against the artifact that runs
928
+ // (dist/ in the image, not src/); (3) a sign-in walked on a prod-shaped
929
+ // surface confirming the __Secure- name resolves.
930
+ //
931
+ // Enforced by qa/dual-write-retirement-gate.sh (wired into `npm run
932
+ // qa:static`), so deleting either write below fails CI loudly instead of
933
+ // failing silently in production at sign-in. Retire the gate in the same
934
+ // change as the dual-write, never before it.
935
+ const response = NextResponse.json({ success: true });
936
+ const cookieOpts = {
937
+ httpOnly: true,
938
+ secure: isProd,
939
+ sameSite: 'lax' as const,
940
+ path: '/',
941
+ maxAge: SESSION_TTL,
942
+ };
943
+ response.cookies.set(getSessionCookieName(), jwt, cookieOpts);
944
+ if (isProd) {
945
+ response.cookies.set(getSecureSessionCookieName(), jwt, cookieOpts);
946
+ }
947
+ return response;
948
+ }