@payez/next-mvp 4.4.9 → 4.5.1
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/README.md +53 -0
- package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/send-email-code.js +11 -0
- package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
- package/dist/api-handlers/onboarding/set-password.js +15 -0
- package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/verify-code.js +14 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
- package/dist/auth/auth-decision.d.ts +5 -1
- package/dist/auth/auth-decision.js +11 -1
- package/dist/auth/password-reset-bridge.d.ts +70 -0
- package/dist/auth/password-reset-bridge.js +99 -0
- package/dist/auth/route-config.js +17 -0
- package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
- package/dist/components/auth/PasswordResetBridge.js +32 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- package/dist/lib/session-store.d.ts +17 -0
- package/dist/lib/session-store.js +33 -0
- package/dist/middleware/create-middleware.d.ts +10 -0
- package/dist/middleware/create-middleware.js +30 -1
- package/dist/pages/login/page.js +1 -1
- package/dist/pages/onboarding/index.d.ts +11 -0
- package/dist/pages/onboarding/index.js +20 -0
- package/dist/pages/onboarding/page.d.ts +1 -0
- package/dist/pages/onboarding/page.js +118 -0
- package/dist/pages/recovery/page.js +20 -7
- package/dist/routes/onboarding/index.d.ts +15 -0
- package/dist/routes/onboarding/index.js +22 -0
- package/dist/routes/onboarding/send-email-code.d.ts +2 -0
- package/dist/routes/onboarding/send-email-code.js +6 -0
- package/dist/routes/onboarding/set-password.d.ts +2 -0
- package/dist/routes/onboarding/set-password.js +6 -0
- package/dist/routes/onboarding/verify-code.d.ts +2 -0
- package/dist/routes/onboarding/verify-code.js +6 -0
- package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
- package/dist/routes/onboarding/verify-welcome-email.js +6 -0
- package/dist/server/auth.js +87 -3
- package/dist/vibe/sessions.d.ts +10 -22
- package/dist/vibe/sessions.js +159 -24
- package/package.json +56 -1
- package/src/api-handlers/onboarding/send-email-code.ts +18 -0
- package/src/api-handlers/onboarding/set-password.ts +21 -0
- package/src/api-handlers/onboarding/verify-code.ts +20 -0
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
- package/src/auth/auth-decision.ts +14 -1
- package/src/auth/password-reset-bridge.ts +98 -0
- package/src/auth/route-config.ts +17 -0
- package/src/components/auth/PasswordResetBridge.tsx +30 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/onboarding-proxy.ts +57 -0
- package/src/lib/session-store.ts +42 -0
- package/src/middleware/create-middleware.ts +26 -1
- package/src/pages/login/page.tsx +4 -7
- package/src/pages/onboarding/index.ts +11 -0
- package/src/pages/onboarding/page.tsx +212 -0
- package/src/pages/recovery/page.tsx +24 -8
- package/src/routes/onboarding/index.ts +15 -0
- package/src/routes/onboarding/send-email-code.ts +2 -0
- package/src/routes/onboarding/set-password.ts +2 -0
- package/src/routes/onboarding/verify-code.ts +2 -0
- package/src/routes/onboarding/verify-welcome-email.ts +2 -0
- package/src/server/auth.ts +90 -3
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +172 -27
package/dist/vibe/sessions.d.ts
CHANGED
|
@@ -210,29 +210,14 @@ export declare function warnNonNumericUserId(sessionToken: string | undefined, r
|
|
|
210
210
|
export declare function readSessionRevocationHmac(idpUserId: number): Promise<{
|
|
211
211
|
ok: boolean;
|
|
212
212
|
sessions: IVibeLoginSession[];
|
|
213
|
+
failure?: string;
|
|
213
214
|
}>;
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
* VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
|
|
221
|
-
* behaviour. Logged once, not per request.
|
|
222
|
-
* 'unknown' - revocation IS configured but the read FAILED (non-OK,
|
|
223
|
-
* timeout, throw). Enforcing middleware fails this CLOSED
|
|
224
|
-
* by default (revocationFailMode 'closed'); a Vibe-less
|
|
225
|
-
* consumer is 'not_configured', not 'unknown', so it is not
|
|
226
|
-
* signed out.
|
|
227
|
-
* 'revoked' - the session's own token hash matches a revoked row, or
|
|
228
|
-
* the user has no active session at all.
|
|
229
|
-
* 'active' - read OK and the session is not revoked.
|
|
230
|
-
*
|
|
231
|
-
* @param idpUserId - The user's IDP ID
|
|
232
|
-
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
233
|
-
* When absent, the user-wide check applies (legacy path).
|
|
234
|
-
*/
|
|
235
|
-
export declare function getRevocationStatus(idpUserId: number, sessionToken?: string): Promise<RevocationStatus>;
|
|
215
|
+
/** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
|
|
216
|
+
export declare function isConcurrentLimitBody(bodyText: string): boolean;
|
|
217
|
+
/** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
|
|
218
|
+
export declare function revocationRetryDelayMs(response: Response): number;
|
|
219
|
+
/** Which branch of the revocation decision produced the verdict (PAY-2136 reason log; never a value). */
|
|
220
|
+
export type RevocationRule = 'own_row_revoked' | 'legacy_no_active' | 'legacy_recent_2h' | 'none';
|
|
236
221
|
/**
|
|
237
222
|
* Get session statistics for admin dashboard.
|
|
238
223
|
*
|
|
@@ -246,3 +231,6 @@ export declare function getSessionStats(accessToken: string): Promise<{
|
|
|
246
231
|
byDevice: Record<string, number>;
|
|
247
232
|
recentLogins: number;
|
|
248
233
|
}>;
|
|
234
|
+
/** Test/ops hook: forget every cached verdict and in-flight read. */
|
|
235
|
+
export declare function resetRevocationCache(): void;
|
|
236
|
+
export declare function getRevocationStatus(idpUserId: number, sessionToken?: string): Promise<RevocationStatus>;
|
package/dist/vibe/sessions.js
CHANGED
|
@@ -46,10 +46,14 @@ exports.hashSessionToken = hashSessionToken;
|
|
|
46
46
|
exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
|
|
47
47
|
exports.warnNonNumericUserId = warnNonNumericUserId;
|
|
48
48
|
exports.readSessionRevocationHmac = readSessionRevocationHmac;
|
|
49
|
-
exports.
|
|
49
|
+
exports.isConcurrentLimitBody = isConcurrentLimitBody;
|
|
50
|
+
exports.revocationRetryDelayMs = revocationRetryDelayMs;
|
|
50
51
|
exports.getSessionStats = getSessionStats;
|
|
52
|
+
exports.resetRevocationCache = resetRevocationCache;
|
|
53
|
+
exports.getRevocationStatus = getRevocationStatus;
|
|
51
54
|
const crypto_1 = __importDefault(require("crypto"));
|
|
52
55
|
const token_utils_1 = require("../auth/utils/token-utils");
|
|
56
|
+
const token_log_id_1 = require("../lib/token-log-id");
|
|
53
57
|
// -----------------------------------------------------------------------------
|
|
54
58
|
// CONFIGURATION
|
|
55
59
|
// -----------------------------------------------------------------------------
|
|
@@ -457,21 +461,22 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
457
461
|
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
458
462
|
if (!idpUrl || !clientId || !signingKey) {
|
|
459
463
|
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
460
|
-
return { ok: false, sessions: [] };
|
|
464
|
+
return { ok: false, sessions: [], failure: 'misconfigured' };
|
|
461
465
|
}
|
|
462
466
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
463
467
|
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
'
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
468
|
+
// Signed per ATTEMPT: the retry below must not reuse a timestamp that has aged.
|
|
469
|
+
const send = () => {
|
|
470
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
471
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
472
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
473
|
+
const headers = {
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
'X-Vibe-Client-Id': clientId,
|
|
476
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
477
|
+
'X-Vibe-Signature': signature,
|
|
478
|
+
};
|
|
479
|
+
return fetch(proxyUrl, {
|
|
475
480
|
method: 'POST',
|
|
476
481
|
headers,
|
|
477
482
|
body: JSON.stringify({
|
|
@@ -485,10 +490,21 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
485
490
|
},
|
|
486
491
|
}),
|
|
487
492
|
});
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
let response = await send();
|
|
496
|
+
// PAY-2136: ONE retry, and only for the transient class. The portal's own prefetch burst exceeds the free
|
|
497
|
+
// tier's concurrency (CONCURRENT_LIMIT, HTTP 429). Keyed on the BODY code, never on Retry-After: the IdP proxy
|
|
498
|
+
// relays body and status but drops that header (PAY-2143). AUTH_BLOCKED / other 429s and every 5xx are NOT
|
|
499
|
+
// retried (a block is not transient; a retry only adds a counted failure).
|
|
500
|
+
if (response.status === 429 && isConcurrentLimitBody(await safeBodyText(response))) {
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, revocationRetryDelayMs(response)));
|
|
502
|
+
response = await send();
|
|
503
|
+
}
|
|
488
504
|
if (!response.ok) {
|
|
489
505
|
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
490
506
|
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
491
|
-
return { ok: false, sessions: [] };
|
|
507
|
+
return { ok: false, sessions: [], failure: String(response.status) }; // PAY-2136: status only, for the reason log
|
|
492
508
|
}
|
|
493
509
|
const data = await response.json();
|
|
494
510
|
const unwrapped = (data?.data ?? data);
|
|
@@ -496,8 +512,38 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
496
512
|
}
|
|
497
513
|
catch (error) {
|
|
498
514
|
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
499
|
-
return { ok: false, sessions: [] };
|
|
515
|
+
return { ok: false, sessions: [], failure: 'exception' };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Body text of a response WITHOUT consuming the caller's copy; '' on any failure. */
|
|
519
|
+
async function safeBodyText(response) {
|
|
520
|
+
try {
|
|
521
|
+
return await response.clone().text();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return '';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
|
|
528
|
+
function isConcurrentLimitBody(bodyText) {
|
|
529
|
+
try {
|
|
530
|
+
const b = JSON.parse(bodyText);
|
|
531
|
+
const nested = b?.error?.code;
|
|
532
|
+
const code = [nested, b?.code, b?.errorCode].find((c) => typeof c === 'string');
|
|
533
|
+
if (typeof code === 'string')
|
|
534
|
+
return code === 'CONCURRENT_LIMIT';
|
|
535
|
+
return false;
|
|
500
536
|
}
|
|
537
|
+
catch {
|
|
538
|
+
return /CONCURRENT_LIMIT/.test(bodyText);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
|
|
542
|
+
function revocationRetryDelayMs(response) {
|
|
543
|
+
const ra = Number(response.headers?.get?.('retry-after'));
|
|
544
|
+
if (Number.isFinite(ra) && ra > 0)
|
|
545
|
+
return Math.min(ra * 1000, 2000);
|
|
546
|
+
return 200 + Math.floor(Math.random() * 200);
|
|
501
547
|
}
|
|
502
548
|
/**
|
|
503
549
|
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
@@ -509,12 +555,15 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
509
555
|
* rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
|
|
510
556
|
*/
|
|
511
557
|
function findRevokingSession(sessions, sessionTokenHash) {
|
|
558
|
+
return findRevokingSessionWithRule(sessions, sessionTokenHash).row;
|
|
559
|
+
}
|
|
560
|
+
function findRevokingSessionWithRule(sessions, sessionTokenHash) {
|
|
512
561
|
if (sessions.length === 0)
|
|
513
|
-
return null;
|
|
562
|
+
return { row: null, rule: 'none' };
|
|
514
563
|
if (sessionTokenHash) {
|
|
515
564
|
const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
|
|
516
565
|
if (matching)
|
|
517
|
-
return matching.status === 'revoked' ? matching : null;
|
|
566
|
+
return matching.status === 'revoked' ? { row: matching, rule: 'own_row_revoked' } : { row: null, rule: 'none' };
|
|
518
567
|
}
|
|
519
568
|
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
520
569
|
// rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
|
|
@@ -525,11 +574,30 @@ function findRevokingSession(sessions, sessionTokenHash) {
|
|
|
525
574
|
if (!sessions.some(s => s.status === 'active')) {
|
|
526
575
|
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
527
576
|
if (revoked.length > 0)
|
|
528
|
-
return revoked[0];
|
|
577
|
+
return { row: revoked[0], rule: 'legacy_no_active' };
|
|
529
578
|
}
|
|
530
579
|
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
531
580
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
532
|
-
|
|
581
|
+
const recent = sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null;
|
|
582
|
+
return recent ? { row: recent, rule: 'legacy_recent_2h' } : { row: null, rule: 'none' };
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* PAY-2136: one shape-only line per NON-viable revocation verdict, naming WHICH rule fired. Never a token, userId,
|
|
586
|
+
* hash, or row content: the rule label, a read-failure status code, a row COUNT and the session's tokenLogId.
|
|
587
|
+
* Must never throw into the viability path.
|
|
588
|
+
*/
|
|
589
|
+
function logRevocationReason(verdict, reason, sessionToken, extra = {}) {
|
|
590
|
+
try {
|
|
591
|
+
console.warn('[REVOCATION] verdict', {
|
|
592
|
+
verdict,
|
|
593
|
+
reason,
|
|
594
|
+
sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
|
|
595
|
+
...extra,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
/* diagnostic only */
|
|
600
|
+
}
|
|
533
601
|
}
|
|
534
602
|
/**
|
|
535
603
|
* Determine whether a session is revoked, with an explicit UNKNOWN state
|
|
@@ -552,7 +620,7 @@ function findRevokingSession(sessions, sessionTokenHash) {
|
|
|
552
620
|
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
553
621
|
* When absent, the user-wide check applies (legacy path).
|
|
554
622
|
*/
|
|
555
|
-
async function
|
|
623
|
+
async function readRevocationStatus(idpUserId, sessionToken) {
|
|
556
624
|
const idpUrl = getEnv('IDP_URL');
|
|
557
625
|
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
558
626
|
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
@@ -573,16 +641,21 @@ async function getRevocationStatus(idpUserId, sessionToken) {
|
|
|
573
641
|
// do NOT report "no sessions". 'unknown' is the fail-closed state.
|
|
574
642
|
if (!idpUrl || !signingKey) {
|
|
575
643
|
console.warn('[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)');
|
|
644
|
+
logRevocationReason('unknown', 'misconfigured', sessionToken);
|
|
576
645
|
return 'unknown';
|
|
577
646
|
}
|
|
578
|
-
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
647
|
+
const { ok, sessions, failure } = await readSessionRevocationHmac(idpUserId);
|
|
579
648
|
if (!ok) {
|
|
580
649
|
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
650
|
+
logRevocationReason('unknown', `read_failed:${failure ?? 'unknown'}`, sessionToken);
|
|
581
651
|
return 'unknown';
|
|
582
652
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
:
|
|
653
|
+
const { row, rule } = findRevokingSessionWithRule(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined);
|
|
654
|
+
if (row) {
|
|
655
|
+
logRevocationReason('revoked', rule, sessionToken, { rows: sessions.length });
|
|
656
|
+
return 'revoked';
|
|
657
|
+
}
|
|
658
|
+
return 'active';
|
|
586
659
|
}
|
|
587
660
|
// -----------------------------------------------------------------------------
|
|
588
661
|
// STATISTICS
|
|
@@ -631,3 +704,65 @@ async function getSessionStats(accessToken) {
|
|
|
631
704
|
recentLogins: stats.recentLogins,
|
|
632
705
|
};
|
|
633
706
|
}
|
|
707
|
+
// -----------------------------------------------------------------------------
|
|
708
|
+
// PAY-2136: SINGLE-FLIGHT + SHORT RESULT CACHE for the revocation read
|
|
709
|
+
// -----------------------------------------------------------------------------
|
|
710
|
+
// Every middleware viability check does one revocation read through the IdP proxy for the SAME Vibe client. A page
|
|
711
|
+
// whose chrome prefetches ~4 links makes ~8 checks inside a second; the free tier allows 2 concurrent queries, so
|
|
712
|
+
// the 3rd+ read was a 429 CONCURRENT_LIMIT and (before this change) signed the user out. Now concurrent callers for
|
|
713
|
+
// ONE session share ONE in-flight read, and a CONFIRMED verdict is reused for a few seconds.
|
|
714
|
+
// - keyed by userId + the token HASH: never shared across sessions or users;
|
|
715
|
+
// - only 'active' / 'revoked' are cached: a failure ('unknown') is shared while in flight but never remembered, so
|
|
716
|
+
// the next request reads again; a revoked session is honoured at most REVOCATION_CACHE_MS late;
|
|
717
|
+
// - REVOCATION_CACHE_MS=0 disables the cache (single-flight still applies).
|
|
718
|
+
const DEFAULT_REVOCATION_CACHE_MS = 5000;
|
|
719
|
+
const MAX_REVOCATION_CACHE_ENTRIES = 2000;
|
|
720
|
+
const revocationInflight = new Map();
|
|
721
|
+
const revocationVerdicts = new Map();
|
|
722
|
+
function revocationCacheMs() {
|
|
723
|
+
const raw = process.env.REVOCATION_CACHE_MS;
|
|
724
|
+
if (raw === undefined || raw === '')
|
|
725
|
+
return DEFAULT_REVOCATION_CACHE_MS;
|
|
726
|
+
const n = Number(raw);
|
|
727
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, 30_000) : DEFAULT_REVOCATION_CACHE_MS;
|
|
728
|
+
}
|
|
729
|
+
/** Test/ops hook: forget every cached verdict and in-flight read. */
|
|
730
|
+
function resetRevocationCache() {
|
|
731
|
+
revocationInflight.clear();
|
|
732
|
+
revocationVerdicts.clear();
|
|
733
|
+
}
|
|
734
|
+
async function getRevocationStatus(idpUserId, sessionToken) {
|
|
735
|
+
// No token = the user-wide legacy path: nothing session-specific to key on, so no sharing.
|
|
736
|
+
if (!sessionToken)
|
|
737
|
+
return readRevocationStatus(idpUserId, sessionToken);
|
|
738
|
+
const key = `${idpUserId}:${hashSessionToken(sessionToken)}`;
|
|
739
|
+
const ttl = revocationCacheMs();
|
|
740
|
+
const now = Date.now();
|
|
741
|
+
const hit = revocationVerdicts.get(key);
|
|
742
|
+
if (hit && ttl > 0 && now - hit.at < ttl)
|
|
743
|
+
return hit.status;
|
|
744
|
+
if (hit)
|
|
745
|
+
revocationVerdicts.delete(key);
|
|
746
|
+
const existing = revocationInflight.get(key);
|
|
747
|
+
if (existing)
|
|
748
|
+
return existing;
|
|
749
|
+
const read = readRevocationStatus(idpUserId, sessionToken)
|
|
750
|
+
.then((status) => {
|
|
751
|
+
if (ttl > 0 && (status === 'active' || status === 'revoked')) {
|
|
752
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES) {
|
|
753
|
+
for (const [k, v] of revocationVerdicts)
|
|
754
|
+
if (now - v.at >= ttl)
|
|
755
|
+
revocationVerdicts.delete(k);
|
|
756
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES)
|
|
757
|
+
revocationVerdicts.clear();
|
|
758
|
+
}
|
|
759
|
+
revocationVerdicts.set(key, { status, at: Date.now() });
|
|
760
|
+
}
|
|
761
|
+
return status;
|
|
762
|
+
})
|
|
763
|
+
.finally(() => {
|
|
764
|
+
revocationInflight.delete(key);
|
|
765
|
+
});
|
|
766
|
+
revocationInflight.set(key, read);
|
|
767
|
+
return read;
|
|
768
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -575,6 +575,61 @@
|
|
|
575
575
|
"require": "./dist/pages/verify-code/page.js",
|
|
576
576
|
"default": "./dist/pages/verify-code/page.js"
|
|
577
577
|
},
|
|
578
|
+
"./dist/pages/onboarding": {
|
|
579
|
+
"types": "./dist/pages/onboarding/index.d.ts",
|
|
580
|
+
"require": "./dist/pages/onboarding/index.js",
|
|
581
|
+
"default": "./dist/pages/onboarding/index.js"
|
|
582
|
+
},
|
|
583
|
+
"./pages/onboarding": {
|
|
584
|
+
"types": "./dist/pages/onboarding/index.d.ts",
|
|
585
|
+
"require": "./dist/pages/onboarding/index.js",
|
|
586
|
+
"default": "./dist/pages/onboarding/index.js"
|
|
587
|
+
},
|
|
588
|
+
"./components/auth/PasswordResetBridge": {
|
|
589
|
+
"types": "./dist/components/auth/PasswordResetBridge.d.ts",
|
|
590
|
+
"require": "./dist/components/auth/PasswordResetBridge.js",
|
|
591
|
+
"default": "./dist/components/auth/PasswordResetBridge.js"
|
|
592
|
+
},
|
|
593
|
+
"./api-handlers/onboarding/verify-code": {
|
|
594
|
+
"types": "./dist/api-handlers/onboarding/verify-code.d.ts",
|
|
595
|
+
"require": "./dist/api-handlers/onboarding/verify-code.js",
|
|
596
|
+
"default": "./dist/api-handlers/onboarding/verify-code.js"
|
|
597
|
+
},
|
|
598
|
+
"./api-handlers/onboarding/send-email-code": {
|
|
599
|
+
"types": "./dist/api-handlers/onboarding/send-email-code.d.ts",
|
|
600
|
+
"require": "./dist/api-handlers/onboarding/send-email-code.js",
|
|
601
|
+
"default": "./dist/api-handlers/onboarding/send-email-code.js"
|
|
602
|
+
},
|
|
603
|
+
"./api-handlers/onboarding/verify-welcome-email": {
|
|
604
|
+
"types": "./dist/api-handlers/onboarding/verify-welcome-email.d.ts",
|
|
605
|
+
"require": "./dist/api-handlers/onboarding/verify-welcome-email.js",
|
|
606
|
+
"default": "./dist/api-handlers/onboarding/verify-welcome-email.js"
|
|
607
|
+
},
|
|
608
|
+
"./api-handlers/onboarding/set-password": {
|
|
609
|
+
"types": "./dist/api-handlers/onboarding/set-password.d.ts",
|
|
610
|
+
"require": "./dist/api-handlers/onboarding/set-password.js",
|
|
611
|
+
"default": "./dist/api-handlers/onboarding/set-password.js"
|
|
612
|
+
},
|
|
613
|
+
"./routes/onboarding/verify-code": {
|
|
614
|
+
"types": "./dist/routes/onboarding/verify-code.d.ts",
|
|
615
|
+
"require": "./dist/routes/onboarding/verify-code.js",
|
|
616
|
+
"default": "./dist/routes/onboarding/verify-code.js"
|
|
617
|
+
},
|
|
618
|
+
"./routes/onboarding/send-email-code": {
|
|
619
|
+
"types": "./dist/routes/onboarding/send-email-code.d.ts",
|
|
620
|
+
"require": "./dist/routes/onboarding/send-email-code.js",
|
|
621
|
+
"default": "./dist/routes/onboarding/send-email-code.js"
|
|
622
|
+
},
|
|
623
|
+
"./routes/onboarding/verify-welcome-email": {
|
|
624
|
+
"types": "./dist/routes/onboarding/verify-welcome-email.d.ts",
|
|
625
|
+
"require": "./dist/routes/onboarding/verify-welcome-email.js",
|
|
626
|
+
"default": "./dist/routes/onboarding/verify-welcome-email.js"
|
|
627
|
+
},
|
|
628
|
+
"./routes/onboarding/set-password": {
|
|
629
|
+
"types": "./dist/routes/onboarding/set-password.d.ts",
|
|
630
|
+
"require": "./dist/routes/onboarding/set-password.js",
|
|
631
|
+
"default": "./dist/routes/onboarding/set-password.js"
|
|
632
|
+
},
|
|
578
633
|
"./pages/admin-login": {
|
|
579
634
|
"types": "./dist/pages/admin-login/page.d.ts",
|
|
580
635
|
"require": "./dist/pages/admin-login/page.js",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/send-email-code (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
|
|
5
|
+
* POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
|
|
6
|
+
* token's BOUND email only, never from client input, so a tampered link cannot
|
|
7
|
+
* redirect the code. Also the resend leg the verify card's cooldown fires
|
|
8
|
+
* against.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest } from 'next/server';
|
|
11
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
15
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
16
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
17
|
+
return onboardingProxy(req, '/api/Onboarding/send-email-code');
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/set-password (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token, email, reset_token, new_password } - the locked
|
|
5
|
+
* contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
|
|
6
|
+
* email + reset_token come from the verify-code response; the IdP re-checks
|
|
7
|
+
* email against the token's bound user before consuming the single-use token.
|
|
8
|
+
* On success the IdP sets the password AND completes onboarding (issues tokens).
|
|
9
|
+
* Thin proxy - status and body pass through verbatim.
|
|
10
|
+
*/
|
|
11
|
+
import { NextRequest } from 'next/server';
|
|
12
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
13
|
+
|
|
14
|
+
export async function POST(req: NextRequest) {
|
|
15
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
16
|
+
const b = body as Record<string, unknown>;
|
|
17
|
+
if (!b?.onboard_token) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
|
+
if (!b?.email || !b?.reset_token) return onboardingBadRequest('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
|
|
19
|
+
if (!b?.new_password) return onboardingBadRequest('MISSING_PASSWORD', 'new_password is required');
|
|
20
|
+
return onboardingProxy(req, '/api/Onboarding/set-password');
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/verify-code (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
|
|
5
|
+
* IdP's POST /api/Onboarding/verify-code, which answers
|
|
6
|
+
* { success, reset_token, email, client_key } on success (OnboardingDtos.cs
|
|
7
|
+
* VerifyCodeResponse). reset_token + email are what the set-password step needs
|
|
8
|
+
* next. The onboard_token IS the auth; there is no session to attach.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest } from 'next/server';
|
|
11
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
15
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
16
|
+
const code = (body as Record<string, unknown>)?.code;
|
|
17
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
|
+
if (!code) return onboardingBadRequest('MISSING_CODE', 'code is required');
|
|
19
|
+
return onboardingProxy(req, '/api/Onboarding/verify-code');
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/verify-welcome-email (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
|
|
5
|
+
* POST /api/Onboarding/verify-welcome-email, which answers the masked contact
|
|
6
|
+
* info + expiry the verify card displays. The onboarding page calls this to
|
|
7
|
+
* render its "check your email / here is what we know" state.
|
|
8
|
+
*/
|
|
9
|
+
import { NextRequest } from 'next/server';
|
|
10
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
11
|
+
|
|
12
|
+
export async function POST(req: NextRequest) {
|
|
13
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
14
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
15
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
16
|
+
return onboardingProxy(req, '/api/Onboarding/verify-welcome-email');
|
|
17
|
+
}
|
|
@@ -26,6 +26,8 @@ export interface AuthContext {
|
|
|
26
26
|
twoFactorComplete: boolean;
|
|
27
27
|
};
|
|
28
28
|
circuitBreakerOpen: boolean;
|
|
29
|
+
/** PAY-2136: the request is a router prefetch. A prefetch never clears cookies (see isPrefetchRequest). */
|
|
30
|
+
isPrefetch?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// Output types for the decision engine
|
|
@@ -35,9 +37,20 @@ export type AuthAction =
|
|
|
35
37
|
| { type: 'service_error'; reason: string };
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Main decision function - pure, testable, deterministic
|
|
40
|
+
* Main decision function - pure, testable, deterministic.
|
|
41
|
+
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
42
|
+
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
39
43
|
*/
|
|
40
44
|
export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
45
|
+
const action = decide(context);
|
|
46
|
+
if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
|
|
47
|
+
const { clearCookies: _dropped, ...rest } = action;
|
|
48
|
+
return rest;
|
|
49
|
+
}
|
|
50
|
+
return action;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decide(context: AuthContext): AuthAction {
|
|
41
54
|
const {
|
|
42
55
|
pathname,
|
|
43
56
|
isPublicRoute,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: the fragment bridge on `/`.
|
|
3
|
+
*
|
|
4
|
+
* The IdP's post-verify redirect for a passwordless account lands on
|
|
5
|
+
* ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
|
|
6
|
+
* - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
|
|
7
|
+
* @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
|
|
8
|
+
* and nothing happens; their reset is lost.
|
|
9
|
+
*
|
|
10
|
+
* This is the PURE half (data in, data out) so a test can execute it without a
|
|
11
|
+
* DOM. The client component (PasswordResetBridge) calls it on mount with
|
|
12
|
+
* `window.location.hash`. The kit's reset surface is the existing
|
|
13
|
+
* `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
|
|
14
|
+
* bridge rewrites the fragment into that surface's OWN query shape and navigates
|
|
15
|
+
* once. It invents no new reset flow.
|
|
16
|
+
*
|
|
17
|
+
* CONTRACT:
|
|
18
|
+
* '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
|
|
19
|
+
* any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The reset surface the bridge hands off to, by its existing path. */
|
|
23
|
+
export const RECOVERY_PATH = '/account-auth/recovery';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
|
|
27
|
+
*
|
|
28
|
+
* The bridge now sends a VERIFIED passwordless user to
|
|
29
|
+
* /account-auth/recovery?token=<reset_token>&email=<addr>
|
|
30
|
+
* with a REAL reset token in hand. If the page then showed its request-a-code
|
|
31
|
+
* step, it would throw that token away and the flow would still dead-end - just
|
|
32
|
+
* one page later. So when a token is present the page must start at the
|
|
33
|
+
* set-password step and submit straight through resetPasswordWithToken.
|
|
34
|
+
*
|
|
35
|
+
* token present -> { step: 'set-password', resetToken: <token> }
|
|
36
|
+
* no token -> { step: 'initiate', resetToken: null } (today's behaviour)
|
|
37
|
+
*/
|
|
38
|
+
export function recoveryEntry(
|
|
39
|
+
token: string | null | undefined,
|
|
40
|
+
email: string | null | undefined,
|
|
41
|
+
): { step: 'set-password' | 'initiate'; resetToken: string | null; email: string } {
|
|
42
|
+
const tok = (token ?? '').trim();
|
|
43
|
+
return {
|
|
44
|
+
step: tok ? 'set-password' : 'initiate',
|
|
45
|
+
resetToken: tok || null,
|
|
46
|
+
email: (email ?? '').trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
|
|
52
|
+
* function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
|
|
53
|
+
* Removing the session half (so a deep-linked reset can submit) must NOT let a
|
|
54
|
+
* submit with NO token reach the API carrying an empty token - which is exactly
|
|
55
|
+
* what a guard-less submit would do. This decides it, in one testable place:
|
|
56
|
+
*
|
|
57
|
+
* resetSubmitTarget(resetToken, sessionEmail, queryEmail):
|
|
58
|
+
* - no token (after trim) -> null (REFUSE: never call the API with an empty token)
|
|
59
|
+
* - token, session email present -> { token, email: sessionEmail }
|
|
60
|
+
* - token, only a query email -> { token, email: queryEmail } (the deep-link case)
|
|
61
|
+
* - token, but NO email anywhere -> null (REFUSE: nothing to reset)
|
|
62
|
+
*
|
|
63
|
+
* Returns the exact { token, email } the caller must pass to
|
|
64
|
+
* accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
|
|
65
|
+
*/
|
|
66
|
+
export function resetSubmitTarget(
|
|
67
|
+
resetToken: string | null | undefined,
|
|
68
|
+
sessionEmail: string | null | undefined,
|
|
69
|
+
queryEmail: string | null | undefined,
|
|
70
|
+
): { token: string; email: string } | null {
|
|
71
|
+
const tok = (resetToken ?? '').trim();
|
|
72
|
+
if (!tok) return null;
|
|
73
|
+
const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
|
|
74
|
+
if (!email) return null;
|
|
75
|
+
return { token: tok, email };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The redirect the fragment asks for, or null when the fragment is not a reset
|
|
80
|
+
* request. `hash` is `window.location.hash` (leading '#' optional).
|
|
81
|
+
*
|
|
82
|
+
* Returns null - leaves `/` untouched - when:
|
|
83
|
+
* - the fragment is empty;
|
|
84
|
+
* - `password_reset_required` is absent or not exactly 'true';
|
|
85
|
+
* - no `reset_token` is present (nothing to reset with; not a real link).
|
|
86
|
+
*/
|
|
87
|
+
export function passwordResetRedirect(hash: string | null | undefined): string | null {
|
|
88
|
+
const raw = (hash ?? '').replace(/^#/, '');
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
const params = new URLSearchParams(raw);
|
|
91
|
+
if (params.get('password_reset_required') !== 'true') return null;
|
|
92
|
+
const token = params.get('reset_token') ?? '';
|
|
93
|
+
if (!token) return null;
|
|
94
|
+
const email = params.get('email') ?? '';
|
|
95
|
+
const qs = new URLSearchParams({ token });
|
|
96
|
+
if (email) qs.set('email', email);
|
|
97
|
+
return `${RECOVERY_PATH}?${qs.toString()}`;
|
|
98
|
+
}
|
package/src/auth/route-config.ts
CHANGED
|
@@ -63,6 +63,23 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
|
|
|
63
63
|
allowDuringCircuitBreakerOpen: true,
|
|
64
64
|
requiresRateLimit: false
|
|
65
65
|
},
|
|
66
|
+
// PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
|
|
67
|
+
// post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
|
|
68
|
+
// the invitee has no session (the onboard_token IS the credential), and the
|
|
69
|
+
// fragment lands on the already-public root. Listed explicitly, not left to
|
|
70
|
+
// the /account-auth/* wildcard, so the two conventional paths are visible here.
|
|
71
|
+
{
|
|
72
|
+
pattern: '/account-auth/onboarding',
|
|
73
|
+
description: 'IdP invite/onboarding landing page (token-driven, no session)',
|
|
74
|
+
allowDuringCircuitBreakerOpen: true,
|
|
75
|
+
requiresRateLimit: false
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
pattern: '/api/onboarding/*',
|
|
79
|
+
description: 'Onboarding API (token-driven, no session)',
|
|
80
|
+
allowDuringCircuitBreakerOpen: true,
|
|
81
|
+
requiresRateLimit: true
|
|
82
|
+
},
|
|
66
83
|
{
|
|
67
84
|
pattern: '/api/auth/*',
|
|
68
85
|
description: 'Authentication API endpoints',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { passwordResetRedirect } from '../../auth/password-reset-bridge';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
|
|
8
|
+
* redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
|
|
9
|
+
* (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
|
|
10
|
+
* the hash. With any other fragment it renders nothing and leaves `/` alone.
|
|
11
|
+
*
|
|
12
|
+
* It renders nothing and reads no user data - it only inspects the fragment.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // app/page.tsx
|
|
16
|
+
* import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
|
|
17
|
+
* export default function Home() {
|
|
18
|
+
* return (<><PasswordResetBridge /></>);
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
export function PasswordResetBridge() {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof window === 'undefined') return;
|
|
24
|
+
const target = passwordResetRedirect(window.location.hash);
|
|
25
|
+
if (target) window.location.replace(target);
|
|
26
|
+
}, []);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default PasswordResetBridge;
|
package/src/edge/middleware.ts
CHANGED
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
get2FABypassConfig
|
|
17
17
|
} from '../auth/route-config';
|
|
18
18
|
export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
|
|
19
|
-
export { createMvpMiddleware } from '../middleware/create-middleware';
|
|
19
|
+
export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
|
|
20
20
|
export type {
|
|
21
21
|
MvpMiddlewareOptions,
|
|
22
22
|
CircuitBreakerProvider,
|