@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.
- package/dist/api-handlers/auth/credential-login-email-code.d.ts +24 -0
- package/dist/api-handlers/auth/credential-login-email-code.js +83 -0
- package/dist/api-handlers/auth/credential-login.d.ts +41 -0
- package/dist/api-handlers/auth/credential-login.js +858 -0
- package/dist/api-handlers/oauth/resume.d.ts +16 -0
- package/dist/api-handlers/oauth/resume.js +207 -0
- package/dist/auth/better-auth.d.ts +14 -2
- package/dist/auth/better-auth.js +59 -34
- package/dist/auth/idp-oauth.d.ts +106 -0
- package/dist/auth/idp-oauth.js +280 -0
- package/dist/auth/safe-callback.js +10 -2
- package/dist/components/auth/CredentialSignInForm.d.ts +33 -0
- package/dist/components/auth/CredentialSignInForm.js +134 -0
- package/dist/lib/authorize-resume.d.ts +87 -0
- package/dist/lib/authorize-resume.js +190 -0
- package/dist/lib/bearer-fetch.d.ts +17 -0
- package/dist/lib/bearer-fetch.js +32 -0
- package/dist/lib/code-step-copy.d.ts +53 -0
- package/dist/lib/code-step-copy.js +73 -0
- package/dist/lib/credential-sign-in-client.d.ts +59 -0
- package/dist/lib/credential-sign-in-client.js +89 -0
- package/dist/lib/device-id.d.ts +37 -0
- package/dist/lib/device-id.js +70 -0
- package/dist/lib/lock-window.d.ts +29 -0
- package/dist/lib/lock-window.js +53 -0
- package/dist/lib/login-error-message.d.ts +18 -0
- package/dist/lib/login-error-message.js +47 -0
- package/dist/lib/twofa-rejection.d.ts +20 -0
- package/dist/lib/twofa-rejection.js +73 -0
- package/dist/middleware/create-middleware.d.ts +12 -1
- package/dist/middleware/create-middleware.js +19 -5
- package/dist/pages/login/page.js +11 -37
- package/dist/routes/auth/credential-login-email-code.d.ts +6 -0
- package/dist/routes/auth/credential-login-email-code.js +10 -0
- package/dist/routes/auth/credential-login.d.ts +6 -0
- package/dist/routes/auth/credential-login.js +10 -0
- package/dist/routes/oauth/resume.d.ts +11 -0
- package/dist/routes/oauth/resume.js +16 -0
- package/package.json +1102 -1047
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -0
- package/src/api-handlers/auth/credential-login.ts +948 -0
- package/src/api-handlers/oauth/resume.ts +182 -0
- package/src/auth/better-auth.ts +81 -5
- package/src/auth/idp-oauth.ts +310 -0
- package/src/auth/safe-callback.ts +11 -2
- package/src/components/auth/CredentialSignInForm.tsx +240 -0
- package/src/lib/authorize-resume.ts +187 -0
- package/src/lib/bearer-fetch.ts +31 -0
- package/src/lib/code-step-copy.ts +83 -0
- package/src/lib/credential-sign-in-client.ts +101 -0
- package/src/lib/device-id.ts +67 -0
- package/src/lib/lock-window.ts +49 -0
- package/src/lib/login-error-message.ts +41 -0
- package/src/lib/twofa-rejection.ts +91 -0
- package/src/middleware/create-middleware.ts +35 -4
- package/src/pages/login/page.tsx +20 -34
- package/src/routes/auth/credential-login-email-code.ts +6 -0
- package/src/routes/auth/credential-login.ts +6 -0
- 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
|
+
}
|