@payez/next-mvp 4.4.7 → 4.4.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/account/reset-password.js +3 -1
- package/dist/api-handlers/session/viability.js +32 -0
- package/dist/auth/better-auth.d.ts +18 -1
- package/dist/auth/better-auth.js +96 -20
- package/dist/components/auth/FederatedAuthSection.js +50 -21
- package/dist/hooks/useAvailableProviders.js +39 -17
- package/dist/lib/nextauth-secret.d.ts +51 -0
- package/dist/lib/nextauth-secret.js +141 -0
- package/dist/lib/safe-error-response.d.ts +8 -0
- package/dist/lib/safe-error-response.js +27 -0
- package/dist/middleware/create-middleware.d.ts +41 -0
- package/dist/middleware/create-middleware.js +74 -2
- package/dist/routes/auth/viability.d.ts +5 -0
- package/dist/routes/auth/viability.js +53 -0
- package/dist/utils/api.d.ts +6 -0
- package/dist/utils/api.js +10 -2
- package/dist/vibe/sessions.d.ts +88 -1
- package/dist/vibe/sessions.js +260 -19
- package/package.json +8 -2
- package/scripts/check-publish-eol.mjs +135 -0
- package/src/api-handlers/account/reset-password.ts +4 -2
- package/src/api-handlers/session/viability.ts +31 -0
- package/src/auth/better-auth.ts +100 -18
- package/src/components/auth/FederatedAuthSection.tsx +56 -22
- package/src/hooks/useAvailableProviders.ts +41 -18
- package/src/lib/nextauth-secret.ts +168 -0
- package/src/lib/safe-error-response.ts +23 -0
- package/src/middleware/create-middleware.ts +106 -2
- package/src/routes/auth/viability.ts +52 -0
- package/src/utils/api.ts +10 -1
- package/src/vibe/sessions.ts +291 -24
package/dist/vibe/sessions.js
CHANGED
|
@@ -42,8 +42,14 @@ exports.revokeAllUserSessions = revokeAllUserSessions;
|
|
|
42
42
|
exports.updateSessionActivity = updateSessionActivity;
|
|
43
43
|
exports.isSessionValid = isSessionValid;
|
|
44
44
|
exports.checkSessionRevocation = checkSessionRevocation;
|
|
45
|
+
exports.hashSessionToken = hashSessionToken;
|
|
46
|
+
exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
|
|
47
|
+
exports.warnNonNumericUserId = warnNonNumericUserId;
|
|
48
|
+
exports.readSessionRevocationHmac = readSessionRevocationHmac;
|
|
49
|
+
exports.getRevocationStatus = getRevocationStatus;
|
|
45
50
|
exports.getSessionStats = getSessionStats;
|
|
46
51
|
const crypto_1 = __importDefault(require("crypto"));
|
|
52
|
+
const token_utils_1 = require("../auth/utils/token-utils");
|
|
47
53
|
// -----------------------------------------------------------------------------
|
|
48
54
|
// CONFIGURATION
|
|
49
55
|
// -----------------------------------------------------------------------------
|
|
@@ -187,7 +193,9 @@ async function createLoginSession(input) {
|
|
|
187
193
|
async function getUserSessions(idpUserId, accessToken) {
|
|
188
194
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
189
195
|
const result = await vibeProxyRequest(endpoint, 'POST', accessToken, {
|
|
190
|
-
|
|
196
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
197
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
198
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
191
199
|
pageSize: 100,
|
|
192
200
|
});
|
|
193
201
|
return result?.data || result?.items || [];
|
|
@@ -206,11 +214,12 @@ async function getAllSessions(options = {}, accessToken) {
|
|
|
206
214
|
const query = {
|
|
207
215
|
page,
|
|
208
216
|
pageSize,
|
|
209
|
-
|
|
210
|
-
orderDir
|
|
217
|
+
// PAY-2037: Vibe's QueryRequest.OrderBy is an OBJECT; a string is a 400 and vibeProxyRequest
|
|
218
|
+
// turned that into an empty list. There is no `orderDir` (QAPert 69149).
|
|
219
|
+
orderBy: { created_at: 'desc' },
|
|
211
220
|
};
|
|
212
221
|
if (options.status) {
|
|
213
|
-
query.filter =
|
|
222
|
+
query.filter = { field: 'status', operator: 'eq', value: options.status };
|
|
214
223
|
}
|
|
215
224
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
216
225
|
const result = await vibeProxyRequest(endpoint, 'POST', accessToken, query);
|
|
@@ -243,8 +252,9 @@ async function getSessionById(sessionId, accessToken) {
|
|
|
243
252
|
*/
|
|
244
253
|
async function revokeSession(sessionId, adminEmail, accessToken, reason) {
|
|
245
254
|
const now = new Date().toISOString();
|
|
255
|
+
// PAY-2037: PATCH, not PUT - a PUT replaces the row and this body carries 4 of its fields.
|
|
246
256
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
247
|
-
const session = await vibeProxyRequest(endpoint, '
|
|
257
|
+
const session = await vibeProxyRequest(endpoint, 'PATCH', accessToken, {
|
|
248
258
|
status: 'revoked',
|
|
249
259
|
revoked_by: adminEmail,
|
|
250
260
|
revoked_at: now,
|
|
@@ -290,7 +300,7 @@ async function revokeAllUserSessions(idpUserId, adminEmail, accessToken, reason)
|
|
|
290
300
|
*/
|
|
291
301
|
async function updateSessionActivity(sessionId, accessToken) {
|
|
292
302
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
293
|
-
const result = await vibeProxyRequest(endpoint, '
|
|
303
|
+
const result = await vibeProxyRequest(endpoint, 'PATCH', accessToken, { last_activity: new Date().toISOString() });
|
|
294
304
|
return result !== null;
|
|
295
305
|
}
|
|
296
306
|
// -----------------------------------------------------------------------------
|
|
@@ -325,23 +335,254 @@ async function isSessionValid(sessionId, accessToken) {
|
|
|
325
335
|
* @param accessToken - Access token for auth
|
|
326
336
|
* @param sessionTokenHash - Optional token hash to check specific session
|
|
327
337
|
* @returns Revoked session info if enforcement needed, null otherwise
|
|
338
|
+
*
|
|
339
|
+
* @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
|
|
340
|
+
* same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
|
|
341
|
+
* a failed read ('unknown') from 'active' instead of throwing.
|
|
328
342
|
*/
|
|
329
|
-
async function checkSessionRevocation(idpUserId,
|
|
330
|
-
|
|
331
|
-
//
|
|
343
|
+
async function checkSessionRevocation(idpUserId, _accessToken, sessionTokenHash) {
|
|
344
|
+
// PAY-2037 step 1: same decision as getRevocationStatus, same HMAC-only read. The bearer is
|
|
345
|
+
// accepted for signature compatibility and never sent. A FAILED read throws - it must not read
|
|
346
|
+
// as "no revoked session" (that is the PAY-1880 fail-open this replaces).
|
|
347
|
+
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
348
|
+
if (!ok) {
|
|
349
|
+
throw new Error('[vibe-sessions] session revocation read failed (fail closed)');
|
|
350
|
+
}
|
|
351
|
+
return findRevokingSession(sessions, sessionTokenHash);
|
|
352
|
+
}
|
|
353
|
+
// BAPert 66320: log 'not_configured' ONCE, not per request - a Vibe-less
|
|
354
|
+
// deployment would otherwise emit a warn line on every viability check.
|
|
355
|
+
let warnedNotConfigured = false;
|
|
356
|
+
/**
|
|
357
|
+
* Hash a session token the same way record-login stores it.
|
|
358
|
+
* SHA256 hex, 64 chars. This must stay byte-identical to
|
|
359
|
+
* `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
|
|
360
|
+
* or per-session matching silently falls back to the user-wide path.
|
|
361
|
+
*/
|
|
362
|
+
function hashSessionToken(token) {
|
|
363
|
+
return crypto_1.default.createHash('sha256').update(token).digest('hex');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
|
|
367
|
+
* resolve the NUMERIC IdP user id the Vibe query filters on.
|
|
368
|
+
*
|
|
369
|
+
* *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
|
|
370
|
+
* USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
|
|
371
|
+
* and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
|
|
372
|
+
* an email, so reading `sub` yields 0 for the common case - and for a username that
|
|
373
|
+
* happens to be all digits it yields the WRONG id, which getRevocationStatus answers
|
|
374
|
+
* 'active' for a user with no rows: a fail-OPEN.
|
|
375
|
+
*
|
|
376
|
+
* Resolution order:
|
|
377
|
+
* 1. the token's `user_id` claim, when it is all digits;
|
|
378
|
+
* 2. a digits-only session.userId / session.user.id;
|
|
379
|
+
* 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
|
|
380
|
+
*
|
|
381
|
+
* session.userId is not a safe source on its own either: session-store.ts:143 falls
|
|
382
|
+
* back to user.email, and better-auth.ts stringifies whichever id it finds.
|
|
383
|
+
*/
|
|
384
|
+
function resolveNumericIdpUserId(session, accessToken) {
|
|
385
|
+
const isDigits = (v) => /^[0-9]+$/.test(String(v ?? ''));
|
|
386
|
+
if (accessToken) {
|
|
387
|
+
// The raw payload (jwtDecode returns every claim, typed or not); user_id, never sub.
|
|
388
|
+
const payload = (0, token_utils_1.decodeIdpAccessToken)(accessToken);
|
|
389
|
+
const claim = payload?.user_id;
|
|
390
|
+
if (isDigits(claim))
|
|
391
|
+
return Number(claim);
|
|
392
|
+
}
|
|
393
|
+
const s = (session ?? {});
|
|
394
|
+
const fromSession = s.userId ?? s.user?.id;
|
|
395
|
+
return isDigits(fromSession) ? Number(fromSession) : 0;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* BAPert 66480 condition 1: WARN once per session when the numeric IdP user id
|
|
399
|
+
* could not be resolved (userId is email-shaped, empty, or otherwise not all
|
|
400
|
+
* digits). A non-numeric id must show up as a log line, not as an unexplained
|
|
401
|
+
* sign-out. Shape only - NEVER the value.
|
|
402
|
+
*
|
|
403
|
+
* Keyed on a hash of the session token, so one WARN per session; the set is
|
|
404
|
+
* bounded so a long-lived process cannot grow it without limit.
|
|
405
|
+
*/
|
|
406
|
+
const warnedNonNumericUserIds = new Set();
|
|
407
|
+
function shapeOfUserId(v) {
|
|
408
|
+
const str = String(v ?? '');
|
|
409
|
+
if (!str)
|
|
410
|
+
return 'empty:0';
|
|
411
|
+
const kind = /^[0-9]+$/.test(str) ? 'digits' : str.includes('@') ? 'email' : 'other';
|
|
412
|
+
return `${kind}:${str.length}`;
|
|
413
|
+
}
|
|
414
|
+
function warnNonNumericUserIdOnce(sessionToken, rawUserId) {
|
|
415
|
+
const key = sessionToken ? hashSessionToken(sessionToken).slice(0, 16) : `nosession:${shapeOfUserId(rawUserId)}`;
|
|
416
|
+
if (warnedNonNumericUserIds.has(key))
|
|
417
|
+
return;
|
|
418
|
+
if (warnedNonNumericUserIds.size > 5000)
|
|
419
|
+
warnedNonNumericUserIds.clear();
|
|
420
|
+
warnedNonNumericUserIds.add(key);
|
|
421
|
+
console.warn('[REVOCATION] non-numeric userId, failing closed', { shape: shapeOfUserId(rawUserId) });
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Public entry point for the twins: call this when a session exists but its
|
|
425
|
+
* userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
|
|
426
|
+
* emits the once-per-session WARN. Shape only, never the value.
|
|
427
|
+
*/
|
|
428
|
+
function warnNonNumericUserId(sessionToken, rawUserId) {
|
|
429
|
+
warnNonNumericUserIdOnce(sessionToken, rawUserId);
|
|
430
|
+
return 'unknown';
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
|
|
434
|
+
*
|
|
435
|
+
* *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
|
|
436
|
+
* It is deliberately separate and deliberately sends NO Authorization header.
|
|
437
|
+
*
|
|
438
|
+
* WHY: the twin this must match is idealresume.online's checkSessionRevocation
|
|
439
|
+
* read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
|
|
440
|
+
* paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
|
|
441
|
+
* :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
|
|
442
|
+
* The comment at vibe-client.ts:188-206 there records why: a bearer on the
|
|
443
|
+
* collections path caused USER_REQUIRED and broke signups. getUserSessions()
|
|
444
|
+
* here ALWAYS sends the bearer, so it cannot stand in for this read without
|
|
445
|
+
* changing the credential the two twins are supposed to share.
|
|
446
|
+
*
|
|
447
|
+
* BAPert 66344 (option a): match the twin, HMAC-only.
|
|
448
|
+
*
|
|
449
|
+
* Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
|
|
450
|
+
* collapses a proxy outage and "user has no sessions" to `[]`, which is
|
|
451
|
+
* fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
|
|
452
|
+
* caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
|
|
453
|
+
*/
|
|
454
|
+
async function readSessionRevocationHmac(idpUserId) {
|
|
455
|
+
const idpUrl = getEnv('IDP_URL');
|
|
456
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
457
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
458
|
+
if (!idpUrl || !clientId || !signingKey) {
|
|
459
|
+
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
460
|
+
return { ok: false, sessions: [] };
|
|
461
|
+
}
|
|
462
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
463
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
464
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
465
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
466
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
467
|
+
const headers = {
|
|
468
|
+
'Content-Type': 'application/json',
|
|
469
|
+
'X-Vibe-Client-Id': clientId,
|
|
470
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
471
|
+
'X-Vibe-Signature': signature,
|
|
472
|
+
};
|
|
473
|
+
try {
|
|
474
|
+
const response = await fetch(proxyUrl, {
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers,
|
|
477
|
+
body: JSON.stringify({
|
|
478
|
+
endpoint,
|
|
479
|
+
method: 'POST',
|
|
480
|
+
data: {
|
|
481
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
482
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
483
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
484
|
+
pageSize: 100,
|
|
485
|
+
},
|
|
486
|
+
}),
|
|
487
|
+
});
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
490
|
+
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
491
|
+
return { ok: false, sessions: [] };
|
|
492
|
+
}
|
|
493
|
+
const data = await response.json();
|
|
494
|
+
const unwrapped = (data?.data ?? data);
|
|
495
|
+
return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
499
|
+
return { ok: false, sessions: [] };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
504
|
+
* Returns the row that revokes THIS session, or null when it is not revoked.
|
|
505
|
+
*
|
|
506
|
+
* A session whose own token hash matches a row is decided by that row ALONE. A matched ACTIVE
|
|
507
|
+
* session is never logged out because another device was revoked (idealresume PAY-1882 F3).
|
|
508
|
+
* Only when no row carries the hash (a legacy row minted before hashing) does the user-wide
|
|
509
|
+
* rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
|
|
510
|
+
*/
|
|
511
|
+
function findRevokingSession(sessions, sessionTokenHash) {
|
|
512
|
+
if (sessions.length === 0)
|
|
513
|
+
return null;
|
|
332
514
|
if (sessionTokenHash) {
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
return
|
|
515
|
+
const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
|
|
516
|
+
if (matching)
|
|
517
|
+
return matching.status === 'revoked' ? matching : null;
|
|
518
|
+
}
|
|
519
|
+
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
520
|
+
// rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
|
|
521
|
+
const revokedAt = (s) => (s.revoked_at ? new Date(s.revoked_at).getTime() : 0);
|
|
522
|
+
const newestFirst = (x, y) => revokedAt(y) - revokedAt(x);
|
|
523
|
+
// Check 1: no active row. The most recently revoked row (whenever it was revoked) is the verdict.
|
|
524
|
+
// All rows expired and none revoked is NOT revoked: expiry is not revocation.
|
|
525
|
+
if (!sessions.some(s => s.status === 'active')) {
|
|
526
|
+
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
527
|
+
if (revoked.length > 0)
|
|
528
|
+
return revoked[0];
|
|
529
|
+
}
|
|
530
|
+
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
531
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
532
|
+
return (sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Determine whether a session is revoked, with an explicit UNKNOWN state
|
|
536
|
+
* (PAY-1882 step 4 producer half).
|
|
537
|
+
*
|
|
538
|
+
* States:
|
|
539
|
+
* 'not_configured' - this deployment has no revocation source (IDP_URL /
|
|
540
|
+
* VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
|
|
541
|
+
* behaviour. Logged once, not per request.
|
|
542
|
+
* 'unknown' - revocation IS configured but the read FAILED (non-OK,
|
|
543
|
+
* timeout, throw). Enforcing middleware fails this CLOSED
|
|
544
|
+
* by default (revocationFailMode 'closed'); a Vibe-less
|
|
545
|
+
* consumer is 'not_configured', not 'unknown', so it is not
|
|
546
|
+
* signed out.
|
|
547
|
+
* 'revoked' - the session's own token hash matches a revoked row, or
|
|
548
|
+
* the user has no active session at all.
|
|
549
|
+
* 'active' - read OK and the session is not revoked.
|
|
550
|
+
*
|
|
551
|
+
* @param idpUserId - The user's IDP ID
|
|
552
|
+
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
553
|
+
* When absent, the user-wide check applies (legacy path).
|
|
554
|
+
*/
|
|
555
|
+
async function getRevocationStatus(idpUserId, sessionToken) {
|
|
556
|
+
const idpUrl = getEnv('IDP_URL');
|
|
557
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
558
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
559
|
+
// 'not_configured' is reserved for an app with NO revocation source at all:
|
|
560
|
+
// no VIBE_CLIENT_ID. BAPert 66320 - "the app has no revocation source
|
|
561
|
+
// (VIBE_CLIENT_ID absent / revocation not enabled)". Such a consumer stays
|
|
562
|
+
// viable, or a Vibe-less app would sign everyone out on every request.
|
|
563
|
+
if (!clientId) {
|
|
564
|
+
if (!warnedNotConfigured) {
|
|
565
|
+
warnedNotConfigured = true;
|
|
566
|
+
console.warn('[vibe-sessions] Revocation check not configured (VIBE_CLIENT_ID absent) - reporting not_configured (logged once)');
|
|
336
567
|
}
|
|
568
|
+
return 'not_configured';
|
|
569
|
+
}
|
|
570
|
+
// A client id IS present, so this app is a Vibe consumer that intends the
|
|
571
|
+
// revocation check - but the read is MISCONFIGURED (IDP_URL or VIBE_HMAC_KEY
|
|
572
|
+
// missing). BAPert 66344 condition 2: FAIL CLOSED. Do NOT fall back to bearer,
|
|
573
|
+
// do NOT report "no sessions". 'unknown' is the fail-closed state.
|
|
574
|
+
if (!idpUrl || !signingKey) {
|
|
575
|
+
console.warn('[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)');
|
|
576
|
+
return 'unknown';
|
|
577
|
+
}
|
|
578
|
+
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
579
|
+
if (!ok) {
|
|
580
|
+
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
581
|
+
return 'unknown';
|
|
337
582
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const recentlyRevoked = sessions.find(s => s.status === 'revoked' &&
|
|
342
|
-
s.revoked_at &&
|
|
343
|
-
new Date(s.revoked_at) > oneHourAgo);
|
|
344
|
-
return recentlyRevoked || null;
|
|
583
|
+
return findRevokingSession(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined)
|
|
584
|
+
? 'revoked'
|
|
585
|
+
: 'active';
|
|
345
586
|
}
|
|
346
587
|
// -----------------------------------------------------------------------------
|
|
347
588
|
// STATISTICS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.9",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
"pretest:ci": "npm run build",
|
|
18
18
|
"test:ci": "node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml \"test/*.test.cjs\"",
|
|
19
19
|
"prepack": "npm run build",
|
|
20
|
-
"prepublishOnly": "npm run build",
|
|
20
|
+
"prepublishOnly": "npm run check:publish-eol && npm run build",
|
|
21
21
|
"postpublish": "node scripts/check-publish-health.mjs",
|
|
22
22
|
"postversion": "node scripts/check-publish-health.mjs || echo \"[postversion] see above: local/registry are out of sync after this version bump (171900)\"",
|
|
23
23
|
"dev": "next dev",
|
|
24
24
|
"dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
|
|
25
25
|
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
|
|
26
|
+
"check:publish-eol": "node scripts/check-publish-eol.mjs",
|
|
26
27
|
"check:publish-health": "node scripts/check-publish-health.mjs",
|
|
27
28
|
"check:provenance": "node scripts/verify-release-provenance.mjs",
|
|
28
29
|
"census:reach": "node scripts/module-reach.mjs",
|
|
@@ -129,6 +130,11 @@
|
|
|
129
130
|
"require": "./dist/lib/session-store.js",
|
|
130
131
|
"default": "./dist/lib/session-store.js"
|
|
131
132
|
},
|
|
133
|
+
"./lib/nextauth-secret": {
|
|
134
|
+
"types": "./dist/lib/nextauth-secret.d.ts",
|
|
135
|
+
"require": "./dist/lib/nextauth-secret.js",
|
|
136
|
+
"default": "./dist/lib/nextauth-secret.js"
|
|
137
|
+
},
|
|
132
138
|
"./lib/ensure-fresh-access-token": {
|
|
133
139
|
"types": "./dist/lib/ensure-fresh-access-token.d.ts",
|
|
134
140
|
"require": "./dist/lib/ensure-fresh-access-token.js",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PAY-1998 — SHIPPED-SCRIPT LINE-ENDING GUARD (source-of-truth check, no network).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. @payez/next-mvp ships shell/node scripts inside the npm tarball
|
|
6
|
+
* and those run on LINUX build agents. A Windows checkout that stores CRLF packs
|
|
7
|
+
* CRLF, and bash on the agent fails with `$'\r': command not found` / `syntax error:
|
|
8
|
+
* unexpected end of file`. That is not hypothetical: it broke idealvibe's pipeline
|
|
9
|
+
* (08-09) and re-broke on 4.1.21 (published from a branch that did not carry the
|
|
10
|
+
* `*.sh text eol=lf` rule — 73 CRs in the tarball), and it is the same class that
|
|
11
|
+
* had idealresume prod live-broken.
|
|
12
|
+
*
|
|
13
|
+
* `.gitattributes` is the FIX-AT-SOURCE, and it IS on master now. The remaining gap
|
|
14
|
+
* is that NOTHING CHECKS IT AT RELEASE TIME: a version can still be cut from a branch
|
|
15
|
+
* that dropped the rule, or a new shipped script in an uncovered extension can slip
|
|
16
|
+
* through. This script is that release-time gate — it FAILS THE RELEASE, in the
|
|
17
|
+
* package's own publish path, so the defect is caught HERE instead of downstream in
|
|
18
|
+
* every consumer's CI.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT CHECKS:
|
|
21
|
+
* 1. `*.sh`, `*.mjs`, `*.cjs` that the package SHIPS (package.json "files") must
|
|
22
|
+
* contain NO CR bytes. These run on Linux (bash / node with a shebang).
|
|
23
|
+
* 2. The `*.sh text eol=lf` rule is still present in the repo's `.gitattributes` —
|
|
24
|
+
* so a future edit that quietly drops the rule fails the same check, not just a
|
|
25
|
+
* future CRLF checkout.
|
|
26
|
+
* 3. `*.ps1` is EXEMPT: PowerShell helpers are Windows-only and are deliberately
|
|
27
|
+
* kept CRLF (mirroring the .gitattributes rule).
|
|
28
|
+
*
|
|
29
|
+
* DESIGN (deliberate — do not soften):
|
|
30
|
+
* - EXITS NON-ZERO on any CR in an LF-required shipped script. A check that warns
|
|
31
|
+
* is a check that gets ignored; that is exactly how the 08-10 regression shipped.
|
|
32
|
+
* - NO NETWORK. Unlike check-publish-health.mjs (which reads the registry), this is
|
|
33
|
+
* a pure local scan, so it is safe to run in prepack/prepublishOnly.
|
|
34
|
+
* - Exit 2 = INCONCLUSIVE (could not enumerate/read), never a pass. A clean run that
|
|
35
|
+
* scanned zero files is reported and treated as inconclusive, not green — a guard
|
|
36
|
+
* that silently passes when it found nothing is the failure mode this file exists
|
|
37
|
+
* to prevent.
|
|
38
|
+
*
|
|
39
|
+
* Usage: node scripts/check-publish-eol.mjs
|
|
40
|
+
* Exit: 0 = every shipped LF-required script is CR-free, and the .gitattributes rule is present
|
|
41
|
+
* 1 = at least one shipped script has CR, or the .gitattributes rule is missing
|
|
42
|
+
* 2 = could not determine (nothing scanned / unreadable) — inconclusive, NOT a pass
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
import { dirname, join, extname } from 'node:path';
|
|
48
|
+
|
|
49
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const pkgDir = join(here, '..'); // packages/next-mvp
|
|
51
|
+
const repoRoot = join(pkgDir, '..', '..'); // repo root
|
|
52
|
+
|
|
53
|
+
// Extensions that MUST be LF (they execute on Linux build agents).
|
|
54
|
+
const LF_EXTS = new Set(['.sh', '.mjs', '.cjs']);
|
|
55
|
+
// Extensions explicitly allowed to stay CRLF (Windows-only helpers).
|
|
56
|
+
const CRLF_EXTS = new Set(['.ps1']);
|
|
57
|
+
|
|
58
|
+
function walk(dir, out = []) {
|
|
59
|
+
for (const name of readdirSync(dir)) {
|
|
60
|
+
const full = join(dir, name);
|
|
61
|
+
const st = statSync(full);
|
|
62
|
+
if (st.isDirectory()) walk(full, out);
|
|
63
|
+
else out.push(full);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function crCount(buf) {
|
|
69
|
+
let n = 0;
|
|
70
|
+
for (const b of buf) if (b === 0x0d) n++;
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let pkg;
|
|
75
|
+
try {
|
|
76
|
+
pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[publish-eol] INCONCLUSIVE: cannot read package.json: ${err.message}`);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The files the package ships. package.json itself always ships; "files" lists the rest.
|
|
83
|
+
const shipped = ['package.json', ...(pkg.files ?? [])];
|
|
84
|
+
|
|
85
|
+
const offenders = [];
|
|
86
|
+
let scanned = 0;
|
|
87
|
+
let exempt = 0;
|
|
88
|
+
|
|
89
|
+
for (const entry of shipped) {
|
|
90
|
+
const abs = join(pkgDir, entry);
|
|
91
|
+
if (!existsSync(abs)) continue; // a "files" glob that matches nothing here is fine
|
|
92
|
+
const st = statSync(abs);
|
|
93
|
+
const files = st.isDirectory() ? walk(abs) : [abs];
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
const ext = extname(f).toLowerCase();
|
|
96
|
+
if (CRLF_EXTS.has(ext)) { exempt++; continue; }
|
|
97
|
+
if (!LF_EXTS.has(ext)) continue;
|
|
98
|
+
scanned++;
|
|
99
|
+
const crs = crCount(readFileSync(f));
|
|
100
|
+
if (crs > 0) offenders.push({ file: f, crs });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. The .gitattributes rule must still be present at the source.
|
|
105
|
+
let gaRulePresent = false;
|
|
106
|
+
let gaPath = join(repoRoot, '.gitattributes');
|
|
107
|
+
try {
|
|
108
|
+
const ga = readFileSync(gaPath, 'utf8');
|
|
109
|
+
gaRulePresent = /^\s*\*\.sh\s+text\s+eol=lf\s*$/m.test(ga);
|
|
110
|
+
} catch {
|
|
111
|
+
gaRulePresent = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rel = (p) => p.replace(repoRoot + '\\', '').replace(repoRoot + '/', '');
|
|
115
|
+
|
|
116
|
+
if (offenders.length > 0) {
|
|
117
|
+
console.error('[publish-eol] FAIL: shipped script(s) contain CR (CRLF) and would break Linux agents.');
|
|
118
|
+
for (const o of offenders) console.error(`[publish-eol] ${rel(o.file)} CR=${o.crs}`);
|
|
119
|
+
console.error('[publish-eol] Fix: ensure the *.sh/*.mjs/*.cjs rule is in .gitattributes and renormalise (git add --renormalize .).');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!gaRulePresent) {
|
|
124
|
+
console.error(`[publish-eol] FAIL: the "*.sh text eol=lf" rule is MISSING from ${rel(gaPath)}.`);
|
|
125
|
+
console.error('[publish-eol] Without it, a Windows checkout packs CRLF and breaks every consumer pipeline (the 4.1.21 regression).');
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (scanned === 0) {
|
|
130
|
+
console.error('[publish-eol] INCONCLUSIVE: scanned 0 LF-required shipped scripts - the scan is broken, not the package. NOT a pass.');
|
|
131
|
+
process.exit(2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`[publish-eol] OK: ${scanned} shipped script(s) CR-free; ${exempt} Windows-only (*.ps1) exempt; .gitattributes rule present.`);
|
|
135
|
+
process.exit(0);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
3
3
|
import { resolveClientKey } from '../../lib/client-key';
|
|
4
|
-
import { idpErrorEnvelope, projectPasswordPolicyRules, projectResetTokenInvalid } from '../../lib/safe-error-response';
|
|
4
|
+
import { idpErrorEnvelope, projectPasswordPolicyRules, projectResetTokenInvalid, projectPasswordBreached } from '../../lib/safe-error-response';
|
|
5
5
|
|
|
6
6
|
function getIdpUrl(): string {
|
|
7
7
|
const url = process.env.IDP_URL;
|
|
@@ -73,8 +73,10 @@ export async function POST(req: NextRequest) {
|
|
|
73
73
|
if (!upstream.ok) {
|
|
74
74
|
// PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
|
|
75
75
|
const rules = projectPasswordPolicyRules(data);
|
|
76
|
+
// PAY-2103: a breached-password refusal is a distinct 400; project a BOOLEAN so the anonymous
|
|
77
|
+
// route carries no code text and the page picks its own sentence.
|
|
76
78
|
return NextResponse.json(
|
|
77
|
-
{ ...idpErrorEnvelope(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...(projectResetTokenInvalid(data) ? { reset_token_invalid: true } : {}) },
|
|
79
|
+
{ ...idpErrorEnvelope(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...(projectResetTokenInvalid(data) ? { reset_token_invalid: true } : {}), ...(projectPasswordBreached(data) ? { password_breached: true } : {}) },
|
|
78
80
|
{ status: upstream.status },
|
|
79
81
|
);
|
|
80
82
|
}
|
|
@@ -10,6 +10,7 @@ import { getSession } from '../../server/auth';
|
|
|
10
10
|
import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
|
|
11
11
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
12
12
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
13
|
+
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
13
14
|
|
|
14
15
|
export async function GET(req: NextRequest) {
|
|
15
16
|
try {
|
|
@@ -140,12 +141,42 @@ export async function GET(req: NextRequest) {
|
|
|
140
141
|
});
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
145
|
+
// The four-state status (BAPert 66320). The middleware fails CLOSED by
|
|
146
|
+
// default on 'unknown' (a failed Vibe read) and always on 'revoked';
|
|
147
|
+
// 'active' and 'not_configured' stay viable. A consumer that does not
|
|
148
|
+
// read revocationStatus is unaffected - absent is not revoked.
|
|
149
|
+
let revocationStatus: RevocationStatus = 'not_configured';
|
|
150
|
+
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
151
|
+
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
152
|
+
const revocationUserId = resolveNumericIdpUserId(sessionData, (sessionData as any).idpAccessToken);
|
|
153
|
+
if (revocationUserId > 0) {
|
|
154
|
+
try {
|
|
155
|
+
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
156
|
+
} catch {
|
|
157
|
+
// getRevocationStatus does not throw by contract; fail closed if it ever does.
|
|
158
|
+
revocationStatus = 'unknown';
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// N1 (NightHawk 66392): a session EXISTS but its userId is not a usable
|
|
162
|
+
// numeric IdP id, so the check could not be run. That is 'unknown'
|
|
163
|
+
// (could not determine), NOT 'not_configured' (no revocation source) -
|
|
164
|
+
// the two must not be conflated.
|
|
165
|
+
// BAPert 66480 condition 1: emit ONE WARN (shape only) per session.
|
|
166
|
+
revocationStatus = warnNonNumericUserId(sessionToken, sessionData.userId);
|
|
167
|
+
}
|
|
168
|
+
|
|
143
169
|
const response = {
|
|
144
170
|
authenticated: true,
|
|
145
171
|
sessionTokenId: tokenLogId(sessionToken), // PAY-1481: correlation id for middleware tracking, never the token
|
|
146
172
|
// 2FA fields - critical for middleware redirect logic
|
|
147
173
|
requires2FA, // From cached client config (client-wide setting)
|
|
148
174
|
twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
|
|
175
|
+
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
176
|
+
// boolean the middleware's existing force-invalid branch already reads.
|
|
177
|
+
revocationStatus,
|
|
178
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
179
|
+
revoked: revocationStatus === 'revoked',
|
|
149
180
|
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
150
181
|
//
|
|
151
182
|
// THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
|