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