@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
|
@@ -113,6 +113,30 @@ export interface MvpMiddlewareOptions {
|
|
|
113
113
|
bypassPaths?: string[];
|
|
114
114
|
/** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
|
|
115
115
|
rbacExemptPaths?: string[];
|
|
116
|
+
/**
|
|
117
|
+
* PAY-1882 step 4 - how this app treats a producer that reports the session's
|
|
118
|
+
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
119
|
+
* throw - the status could not be determined).
|
|
120
|
+
*
|
|
121
|
+
* 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
|
|
122
|
+
* session is force-invalidated and the user re-auths.
|
|
123
|
+
* This is Jon's PAY-1880 ruling (option A, fail closed),
|
|
124
|
+
* 2026-09-27, and BAPert 66316/66320 restates it for
|
|
125
|
+
* step 4. Consequence, accepted explicitly: a Vibe
|
|
126
|
+
* outage signs this app's users out. 'not_configured'
|
|
127
|
+
* also fails closed here - both live consumers ARE
|
|
128
|
+
* configured (BAPert 66356), so for them it means a
|
|
129
|
+
* misconfiguration, not "no revocation source".
|
|
130
|
+
* 'open' - an unknown OR not-configured status stays viable
|
|
131
|
+
* (availability over fail-closed). Per-app opt-out for a
|
|
132
|
+
* deployment with no revocation source, or the two live
|
|
133
|
+
* consumers while Jon decides.
|
|
134
|
+
*
|
|
135
|
+
* Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
|
|
136
|
+
* `revocationStatus` (an older producer - unchanged behaviour, so no consumer
|
|
137
|
+
* is signed out for not implementing the check).
|
|
138
|
+
*/
|
|
139
|
+
revocationFailMode?: 'closed' | 'open';
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
// =============================================================================
|
|
@@ -436,6 +460,8 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
436
460
|
const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
|
|
437
461
|
const bypassPaths = options.bypassPaths || [];
|
|
438
462
|
const rbacExemptPaths = options.rbacExemptPaths || [];
|
|
463
|
+
// PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
|
|
464
|
+
const revocationFailMode = options.revocationFailMode || 'closed';
|
|
439
465
|
|
|
440
466
|
return async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
441
467
|
const { pathname, searchParams } = request.nextUrl;
|
|
@@ -472,7 +498,12 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
472
498
|
}
|
|
473
499
|
|
|
474
500
|
// --- Fetch session viability from internal API ---
|
|
475
|
-
const { sessionStatus, sessionPointer } = await checkViability(
|
|
501
|
+
const { sessionStatus, sessionPointer } = await checkViability(
|
|
502
|
+
request,
|
|
503
|
+
viabilityEndpoint,
|
|
504
|
+
log,
|
|
505
|
+
revocationFailMode
|
|
506
|
+
);
|
|
476
507
|
|
|
477
508
|
// --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
|
|
478
509
|
//
|
|
@@ -538,11 +569,43 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
538
569
|
};
|
|
539
570
|
}
|
|
540
571
|
|
|
572
|
+
/**
|
|
573
|
+
* The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
|
|
574
|
+
*
|
|
575
|
+
* Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
|
|
576
|
+
* that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
|
|
577
|
+
* producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
|
|
578
|
+
*
|
|
579
|
+
* RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
|
|
580
|
+
* decision is made from it plus failMode, and the folded booleans are ignored for
|
|
581
|
+
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
582
|
+
* absent (idealresume.online's older boolean-only route).
|
|
583
|
+
*/
|
|
584
|
+
export function decideForceInvalid(
|
|
585
|
+
data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
|
|
586
|
+
revocationFailMode: 'closed' | 'open',
|
|
587
|
+
): boolean {
|
|
588
|
+
const status = data.revocationStatus;
|
|
589
|
+
if (typeof status === 'string') {
|
|
590
|
+
if (status === 'revoked') return true;
|
|
591
|
+
if (status === 'active') return false;
|
|
592
|
+
// S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
|
|
593
|
+
// typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
|
|
594
|
+
// applies), NEVER as 'active'. Returning false here would let the first
|
|
595
|
+
// producer that emits an unexpected state silently switch revocation OFF -
|
|
596
|
+
// the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
|
|
597
|
+
return revocationFailMode === 'closed';
|
|
598
|
+
}
|
|
599
|
+
// No revocationStatus: an older boolean producer. Absence of both stays false.
|
|
600
|
+
return data.forceInvalid === true || data.revoked === true;
|
|
601
|
+
}
|
|
602
|
+
|
|
541
603
|
/** Check session viability via internal API */
|
|
542
604
|
async function checkViability(
|
|
543
605
|
request: NextRequest,
|
|
544
606
|
endpoint: string,
|
|
545
|
-
log: MiddlewareLogger
|
|
607
|
+
log: MiddlewareLogger,
|
|
608
|
+
revocationFailMode: 'closed' | 'open'
|
|
546
609
|
): Promise<{
|
|
547
610
|
sessionStatus: SessionStatus;
|
|
548
611
|
sessionPointer: SessionPointer;
|
|
@@ -581,6 +644,46 @@ async function checkViability(
|
|
|
581
644
|
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
582
645
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
583
646
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
647
|
+
// --- Session revocation (PAY-1882 Step 4) ---
|
|
648
|
+
// Two producer spellings are honoured, because both exist on the wire:
|
|
649
|
+
// - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
|
|
650
|
+
// - the three-state `revocationStatus` the package's own viability
|
|
651
|
+
// handlers now emit (PAY-1882 step 4).
|
|
652
|
+
//
|
|
653
|
+
// RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
|
|
654
|
+
// (a FAILED Vibe read) fails closed by default and is the ONLY state that
|
|
655
|
+
// the per-app `revocationFailMode` option can relax to viable.
|
|
656
|
+
//
|
|
657
|
+
// Four producer states (BAPert 66320), with the fail-closed ruling for
|
|
658
|
+
// 'not_configured' added by BAPert 66356:
|
|
659
|
+
// 'active' - Vibe read OK, not revoked -> viable
|
|
660
|
+
// 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
|
|
661
|
+
// 'unknown' - read FAILED -> force-invalid if 'closed'
|
|
662
|
+
// 'not_configured' - no revocation source -> force-invalid if 'closed'
|
|
663
|
+
//
|
|
664
|
+
// WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
|
|
665
|
+
// this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
|
|
666
|
+
// DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
|
|
667
|
+
// their committed manifests + the graft pipeline's own secret check), so for
|
|
668
|
+
// them 'not_configured' can only mean a MISCONFIGURATION - revocation
|
|
669
|
+
// silently switched off is exactly the PAY-1880 fail-open bug. An app with
|
|
670
|
+
// no revocation source that genuinely wants availability sets
|
|
671
|
+
// revocationFailMode 'open'.
|
|
672
|
+
//
|
|
673
|
+
// A MISSING revocationStatus (an older producer) keeps today's behaviour,
|
|
674
|
+
// i.e. NOT force-invalid: absence must never sign out a population that
|
|
675
|
+
// never opted into the check.
|
|
676
|
+
//
|
|
677
|
+
// *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
|
|
678
|
+
// The package's own producers emit `forceInvalid: revocationStatus ===
|
|
679
|
+
// 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
|
|
680
|
+
// FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
|
|
681
|
+
// was consulted, so `'open'` could never keep an 'unknown' session viable -
|
|
682
|
+
// the opt-out lever would be DEAD. So: when a producer supplies a
|
|
683
|
+
// revocations status, decide from it + failMode and ignore the folded
|
|
684
|
+
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
685
|
+
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
686
|
+
sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
|
|
584
687
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
585
688
|
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
586
689
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
@@ -792,3 +895,4 @@ async function handleRefresh(
|
|
|
792
895
|
);
|
|
793
896
|
}
|
|
794
897
|
}
|
|
898
|
+
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
24
24
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
25
25
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
26
|
+
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
@@ -262,6 +263,40 @@ export async function GET(req: NextRequest) {
|
|
|
262
263
|
session?.mfaVerified === false &&
|
|
263
264
|
!(session.idpRefreshToken || (session as any)?.refreshToken);
|
|
264
265
|
|
|
266
|
+
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
267
|
+
/*
|
|
268
|
+
* Resolve the four-state revocation status for this user (BAPert 66320).
|
|
269
|
+
* Read it HERE, above every authenticated return, so all three responses
|
|
270
|
+
* carry the SAME value - the same discipline as nextStep above.
|
|
271
|
+
*
|
|
272
|
+
* The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
|
|
273
|
+
* and always on 'revoked'; 'active' and 'not_configured' stay viable. A
|
|
274
|
+
* consumer that does NOT read revocationStatus is unaffected - absent is not
|
|
275
|
+
* revoked - so shipping this changes nothing for them until they opt in.
|
|
276
|
+
*/
|
|
277
|
+
let revocationStatus: RevocationStatus = 'not_configured';
|
|
278
|
+
if (session) {
|
|
279
|
+
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
280
|
+
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
281
|
+
const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
|
|
282
|
+
if (revocationUserId > 0) {
|
|
283
|
+
try {
|
|
284
|
+
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
285
|
+
} catch {
|
|
286
|
+
// getRevocationStatus does not throw by contract, but never let a
|
|
287
|
+
// revocation probe take down the viability answer: fail closed.
|
|
288
|
+
revocationStatus = 'unknown';
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
// N1 (NightHawk 66392): a session exists but carries no usable numeric
|
|
292
|
+
// IdP id, so the check could not run -> 'unknown' (could not determine),
|
|
293
|
+
// NOT 'not_configured' (no revocation source).
|
|
294
|
+
// BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
|
|
295
|
+
// id surfaces as a log line, not an unexplained sign-out.
|
|
296
|
+
revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
// Check access token expiry
|
|
266
301
|
const now = Math.floor(Date.now() / 1000);
|
|
267
302
|
// 240654 addendum 2: fall back to the Redis session's own expiry.
|
|
@@ -312,6 +347,11 @@ export async function GET(req: NextRequest) {
|
|
|
312
347
|
needsRefresh: true,
|
|
313
348
|
authenticated: true,
|
|
314
349
|
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
350
|
+
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
351
|
+
// boolean the middleware's existing force-invalid branch already reads.
|
|
352
|
+
revocationStatus,
|
|
353
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
354
|
+
revoked: revocationStatus === 'revoked',
|
|
315
355
|
// Clear names for middleware decision-making
|
|
316
356
|
tenantRequiresTwoFactor,
|
|
317
357
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -393,6 +433,10 @@ export async function GET(req: NextRequest) {
|
|
|
393
433
|
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
394
434
|
accessTokenExpired: true,
|
|
395
435
|
reason: 'Token expired',
|
|
436
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
437
|
+
revocationStatus,
|
|
438
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
439
|
+
revoked: revocationStatus === 'revoked',
|
|
396
440
|
// RBAC fields
|
|
397
441
|
roles: session?.roles || [],
|
|
398
442
|
clientId: resolveClientId(session?.idpClientId),
|
|
@@ -406,6 +450,10 @@ export async function GET(req: NextRequest) {
|
|
|
406
450
|
hasRefreshToken,
|
|
407
451
|
authenticated: true,
|
|
408
452
|
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
453
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
454
|
+
revocationStatus,
|
|
455
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
456
|
+
revoked: revocationStatus === 'revoked',
|
|
409
457
|
// Clear names
|
|
410
458
|
tenantRequiresTwoFactor,
|
|
411
459
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -428,6 +476,10 @@ export async function GET(req: NextRequest) {
|
|
|
428
476
|
viable: false,
|
|
429
477
|
needsRefresh: false,
|
|
430
478
|
authenticated: false,
|
|
479
|
+
// PAY-1882 step 4: an exception here means the status could not be read.
|
|
480
|
+
// Report 'unknown' (not a bare 500 with no revocation signal) so the
|
|
481
|
+
// middleware's fail-closed default applies rather than reading it as viable.
|
|
482
|
+
revocationStatus: 'unknown' as RevocationStatus,
|
|
431
483
|
error: 'Failed to check session',
|
|
432
484
|
code: 'INTERNAL_ERROR',
|
|
433
485
|
correlationId
|
package/src/utils/api.ts
CHANGED
|
@@ -107,6 +107,13 @@ export const RECOVERY_EXPIRED_TEXT = 'This reset link has expired or was already
|
|
|
107
107
|
|
|
108
108
|
export const RESET_NEUTRAL_TEXT = "We couldn't set that password. Check it meets the requirements shown and try again.";
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* PAY-2103 (AUTH-009): the IdP refuses a password found in a breach corpus; the route projects a
|
|
112
|
+
* `password_breached: true` boolean, and the user reads this. Distinct from the policy sentence,
|
|
113
|
+
* which would wrongly tell them to re-read the requirements.
|
|
114
|
+
*/
|
|
115
|
+
export const RESET_BREACHED_TEXT = 'This password appears in a known data breach. Choose a different one.';
|
|
116
|
+
|
|
110
117
|
/**
|
|
111
118
|
* Three tiers for a 400/422 from reset (rigpert 60822, QAPert 60820), decided ONLY on codes the IdP actually sends:
|
|
112
119
|
* policy details.code password_policy (projected by the handler as password_policy) -> the rule sentences
|
|
@@ -233,7 +240,9 @@ export class AccountApi {
|
|
|
233
240
|
const policy = !!body?.password_policy;
|
|
234
241
|
const rules = knownPasswordRules(body?.password_policy?.rules);
|
|
235
242
|
const tokenInvalid = body?.reset_token_invalid === true;
|
|
236
|
-
|
|
243
|
+
// PAY-2103: a breached password is its own outcome, not a policy refusal.
|
|
244
|
+
const breached = body?.password_breached === true;
|
|
245
|
+
const err = new Error(breached ? RESET_BREACHED_TEXT : resetFailureText(res.status, rules, policy, tokenInvalid)) as Error & { rules?: string[]; code?: string };
|
|
237
246
|
if (rules.length) err.rules = rules;
|
|
238
247
|
if ((res.status === 400 || res.status === 422) && tokenInvalid && !policy) err.code = 'RECOVERY_EXPIRED';
|
|
239
248
|
throw err;
|
package/src/vibe/sessions.ts
CHANGED
|
@@ -33,6 +33,7 @@ import crypto from 'crypto';
|
|
|
33
33
|
import type { IVibeLoginSession, SessionStatus } from './types';
|
|
34
34
|
import type { LocationInfo } from '../lib/geolocation';
|
|
35
35
|
import type { DeviceInfo } from '../lib/user-agent-parser';
|
|
36
|
+
import { decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
36
37
|
|
|
37
38
|
// -----------------------------------------------------------------------------
|
|
38
39
|
// CONFIGURATION
|
|
@@ -74,7 +75,7 @@ function generateHmacSignature(
|
|
|
74
75
|
*/
|
|
75
76
|
async function vibeProxyRequest<T>(
|
|
76
77
|
endpoint: string,
|
|
77
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
78
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
78
79
|
accessToken: string,
|
|
79
80
|
body?: unknown
|
|
80
81
|
): Promise<T | null> {
|
|
@@ -262,7 +263,9 @@ export async function getUserSessions(
|
|
|
262
263
|
'POST',
|
|
263
264
|
accessToken,
|
|
264
265
|
{
|
|
265
|
-
|
|
266
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
267
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
268
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
266
269
|
pageSize: 100,
|
|
267
270
|
}
|
|
268
271
|
);
|
|
@@ -288,12 +291,13 @@ export async function getAllSessions(
|
|
|
288
291
|
const query: Record<string, unknown> = {
|
|
289
292
|
page,
|
|
290
293
|
pageSize,
|
|
291
|
-
|
|
292
|
-
orderDir
|
|
294
|
+
// PAY-2037: Vibe's QueryRequest.OrderBy is an OBJECT; a string is a 400 and vibeProxyRequest
|
|
295
|
+
// turned that into an empty list. There is no `orderDir` (QAPert 69149).
|
|
296
|
+
orderBy: { created_at: 'desc' },
|
|
293
297
|
};
|
|
294
298
|
|
|
295
299
|
if (options.status) {
|
|
296
|
-
query.filter =
|
|
300
|
+
query.filter = { field: 'status', operator: 'eq', value: options.status };
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
@@ -345,11 +349,11 @@ export async function revokeSession(
|
|
|
345
349
|
reason?: string
|
|
346
350
|
): Promise<IVibeLoginSession | null> {
|
|
347
351
|
const now = new Date().toISOString();
|
|
348
|
-
|
|
352
|
+
// PAY-2037: PATCH, not PUT - a PUT replaces the row and this body carries 4 of its fields.
|
|
349
353
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
350
354
|
const session = await vibeProxyRequest<IVibeLoginSession>(
|
|
351
355
|
endpoint,
|
|
352
|
-
'
|
|
356
|
+
'PATCH',
|
|
353
357
|
accessToken,
|
|
354
358
|
{
|
|
355
359
|
status: 'revoked',
|
|
@@ -414,7 +418,7 @@ export async function updateSessionActivity(
|
|
|
414
418
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
415
419
|
const result = await vibeProxyRequest<IVibeLoginSession>(
|
|
416
420
|
endpoint,
|
|
417
|
-
'
|
|
421
|
+
'PATCH',
|
|
418
422
|
accessToken,
|
|
419
423
|
{ last_activity: new Date().toISOString() }
|
|
420
424
|
);
|
|
@@ -460,33 +464,296 @@ export async function isSessionValid(
|
|
|
460
464
|
* @param accessToken - Access token for auth
|
|
461
465
|
* @param sessionTokenHash - Optional token hash to check specific session
|
|
462
466
|
* @returns Revoked session info if enforcement needed, null otherwise
|
|
467
|
+
*
|
|
468
|
+
* @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
|
|
469
|
+
* same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
|
|
470
|
+
* a failed read ('unknown') from 'active' instead of throwing.
|
|
463
471
|
*/
|
|
464
472
|
export async function checkSessionRevocation(
|
|
465
473
|
idpUserId: number,
|
|
466
|
-
|
|
474
|
+
_accessToken: string,
|
|
467
475
|
sessionTokenHash?: string
|
|
468
476
|
): Promise<IVibeLoginSession | null> {
|
|
469
|
-
|
|
477
|
+
// PAY-2037 step 1: same decision as getRevocationStatus, same HMAC-only read. The bearer is
|
|
478
|
+
// accepted for signature compatibility and never sent. A FAILED read throws - it must not read
|
|
479
|
+
// as "no revoked session" (that is the PAY-1880 fail-open this replaces).
|
|
480
|
+
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
481
|
+
if (!ok) {
|
|
482
|
+
throw new Error('[vibe-sessions] session revocation read failed (fail closed)');
|
|
483
|
+
}
|
|
484
|
+
return findRevokingSession(sessions, sessionTokenHash);
|
|
485
|
+
}
|
|
470
486
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
487
|
+
// -----------------------------------------------------------------------------
|
|
488
|
+
// REVOCATION STATUS (PAY-1882 step 4 producer half)
|
|
489
|
+
// -----------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/** The four states a viability producer can report (BAPert 66320). */
|
|
492
|
+
export type RevocationStatus = 'active' | 'revoked' | 'unknown' | 'not_configured';
|
|
493
|
+
|
|
494
|
+
// BAPert 66320: log 'not_configured' ONCE, not per request - a Vibe-less
|
|
495
|
+
// deployment would otherwise emit a warn line on every viability check.
|
|
496
|
+
let warnedNotConfigured = false;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Hash a session token the same way record-login stores it.
|
|
500
|
+
* SHA256 hex, 64 chars. This must stay byte-identical to
|
|
501
|
+
* `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
|
|
502
|
+
* or per-session matching silently falls back to the user-wide path.
|
|
503
|
+
*/
|
|
504
|
+
export function hashSessionToken(token: string): string {
|
|
505
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
|
|
510
|
+
* resolve the NUMERIC IdP user id the Vibe query filters on.
|
|
511
|
+
*
|
|
512
|
+
* *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
|
|
513
|
+
* USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
|
|
514
|
+
* and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
|
|
515
|
+
* an email, so reading `sub` yields 0 for the common case - and for a username that
|
|
516
|
+
* happens to be all digits it yields the WRONG id, which getRevocationStatus answers
|
|
517
|
+
* 'active' for a user with no rows: a fail-OPEN.
|
|
518
|
+
*
|
|
519
|
+
* Resolution order:
|
|
520
|
+
* 1. the token's `user_id` claim, when it is all digits;
|
|
521
|
+
* 2. a digits-only session.userId / session.user.id;
|
|
522
|
+
* 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
|
|
523
|
+
*
|
|
524
|
+
* session.userId is not a safe source on its own either: session-store.ts:143 falls
|
|
525
|
+
* back to user.email, and better-auth.ts stringifies whichever id it finds.
|
|
526
|
+
*/
|
|
527
|
+
export function resolveNumericIdpUserId(session: unknown, accessToken?: string): number {
|
|
528
|
+
const isDigits = (v: unknown) => /^[0-9]+$/.test(String(v ?? ''));
|
|
529
|
+
if (accessToken) {
|
|
530
|
+
// The raw payload (jwtDecode returns every claim, typed or not); user_id, never sub.
|
|
531
|
+
const payload = decodeIdpAccessToken(accessToken) as Record<string, unknown> | null;
|
|
532
|
+
const claim = payload?.user_id;
|
|
533
|
+
if (isDigits(claim)) return Number(claim);
|
|
534
|
+
}
|
|
535
|
+
const s = (session ?? {}) as Record<string, any>;
|
|
536
|
+
const fromSession = s.userId ?? s.user?.id;
|
|
537
|
+
return isDigits(fromSession) ? Number(fromSession) : 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* BAPert 66480 condition 1: WARN once per session when the numeric IdP user id
|
|
542
|
+
* could not be resolved (userId is email-shaped, empty, or otherwise not all
|
|
543
|
+
* digits). A non-numeric id must show up as a log line, not as an unexplained
|
|
544
|
+
* sign-out. Shape only - NEVER the value.
|
|
545
|
+
*
|
|
546
|
+
* Keyed on a hash of the session token, so one WARN per session; the set is
|
|
547
|
+
* bounded so a long-lived process cannot grow it without limit.
|
|
548
|
+
*/
|
|
549
|
+
const warnedNonNumericUserIds = new Set<string>();
|
|
550
|
+
|
|
551
|
+
function shapeOfUserId(v: unknown): string {
|
|
552
|
+
const str = String(v ?? '');
|
|
553
|
+
if (!str) return 'empty:0';
|
|
554
|
+
const kind = /^[0-9]+$/.test(str) ? 'digits' : str.includes('@') ? 'email' : 'other';
|
|
555
|
+
return `${kind}:${str.length}`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function warnNonNumericUserIdOnce(sessionToken: string | undefined, rawUserId: unknown): void {
|
|
559
|
+
const key = sessionToken ? hashSessionToken(sessionToken).slice(0, 16) : `nosession:${shapeOfUserId(rawUserId)}`;
|
|
560
|
+
if (warnedNonNumericUserIds.has(key)) return;
|
|
561
|
+
if (warnedNonNumericUserIds.size > 5000) warnedNonNumericUserIds.clear();
|
|
562
|
+
warnedNonNumericUserIds.add(key);
|
|
563
|
+
console.warn('[REVOCATION] non-numeric userId, failing closed', { shape: shapeOfUserId(rawUserId) });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Public entry point for the twins: call this when a session exists but its
|
|
568
|
+
* userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
|
|
569
|
+
* emits the once-per-session WARN. Shape only, never the value.
|
|
570
|
+
*/
|
|
571
|
+
export function warnNonNumericUserId(sessionToken: string | undefined, rawUserId: unknown): 'unknown' {
|
|
572
|
+
warnNonNumericUserIdOnce(sessionToken, rawUserId);
|
|
573
|
+
return 'unknown';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
|
|
578
|
+
*
|
|
579
|
+
* *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
|
|
580
|
+
* It is deliberately separate and deliberately sends NO Authorization header.
|
|
581
|
+
*
|
|
582
|
+
* WHY: the twin this must match is idealresume.online's checkSessionRevocation
|
|
583
|
+
* read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
|
|
584
|
+
* paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
|
|
585
|
+
* :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
|
|
586
|
+
* The comment at vibe-client.ts:188-206 there records why: a bearer on the
|
|
587
|
+
* collections path caused USER_REQUIRED and broke signups. getUserSessions()
|
|
588
|
+
* here ALWAYS sends the bearer, so it cannot stand in for this read without
|
|
589
|
+
* changing the credential the two twins are supposed to share.
|
|
590
|
+
*
|
|
591
|
+
* BAPert 66344 (option a): match the twin, HMAC-only.
|
|
592
|
+
*
|
|
593
|
+
* Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
|
|
594
|
+
* collapses a proxy outage and "user has no sessions" to `[]`, which is
|
|
595
|
+
* fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
|
|
596
|
+
* caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
|
|
597
|
+
*/
|
|
598
|
+
export async function readSessionRevocationHmac(
|
|
599
|
+
idpUserId: number
|
|
600
|
+
): Promise<{ ok: boolean; sessions: IVibeLoginSession[] }> {
|
|
601
|
+
const idpUrl = getEnv('IDP_URL');
|
|
602
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
603
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
604
|
+
|
|
605
|
+
if (!idpUrl || !clientId || !signingKey) {
|
|
606
|
+
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
607
|
+
return { ok: false, sessions: [] };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
611
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
612
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
613
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
614
|
+
|
|
615
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
616
|
+
const headers: Record<string, string> = {
|
|
617
|
+
'Content-Type': 'application/json',
|
|
618
|
+
'X-Vibe-Client-Id': clientId,
|
|
619
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
620
|
+
'X-Vibe-Signature': signature,
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
const response = await fetch(proxyUrl, {
|
|
625
|
+
method: 'POST',
|
|
626
|
+
headers,
|
|
627
|
+
body: JSON.stringify({
|
|
628
|
+
endpoint,
|
|
629
|
+
method: 'POST',
|
|
630
|
+
data: {
|
|
631
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
632
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
633
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
634
|
+
pageSize: 100,
|
|
635
|
+
},
|
|
636
|
+
}),
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
if (!response.ok) {
|
|
640
|
+
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
641
|
+
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
642
|
+
return { ok: false, sessions: [] };
|
|
476
643
|
}
|
|
644
|
+
|
|
645
|
+
const data = await response.json();
|
|
646
|
+
const unwrapped = (data?.data ?? data) as { data?: IVibeLoginSession[]; items?: IVibeLoginSession[] };
|
|
647
|
+
return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
|
|
648
|
+
} catch (error) {
|
|
649
|
+
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
650
|
+
return { ok: false, sessions: [] };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
656
|
+
* Returns the row that revokes THIS session, or null when it is not revoked.
|
|
657
|
+
*
|
|
658
|
+
* A session whose own token hash matches a row is decided by that row ALONE. A matched ACTIVE
|
|
659
|
+
* session is never logged out because another device was revoked (idealresume PAY-1882 F3).
|
|
660
|
+
* Only when no row carries the hash (a legacy row minted before hashing) does the user-wide
|
|
661
|
+
* rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
|
|
662
|
+
*/
|
|
663
|
+
function findRevokingSession(
|
|
664
|
+
sessions: IVibeLoginSession[],
|
|
665
|
+
sessionTokenHash?: string
|
|
666
|
+
): IVibeLoginSession | null {
|
|
667
|
+
if (sessions.length === 0) return null;
|
|
668
|
+
|
|
669
|
+
if (sessionTokenHash) {
|
|
670
|
+
const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
|
|
671
|
+
if (matching) return matching.status === 'revoked' ? matching : null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
675
|
+
// rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
|
|
676
|
+
const revokedAt = (s: IVibeLoginSession) => (s.revoked_at ? new Date(s.revoked_at).getTime() : 0);
|
|
677
|
+
const newestFirst = (x: IVibeLoginSession, y: IVibeLoginSession) => revokedAt(y) - revokedAt(x);
|
|
678
|
+
|
|
679
|
+
// Check 1: no active row. The most recently revoked row (whenever it was revoked) is the verdict.
|
|
680
|
+
// All rows expired and none revoked is NOT revoked: expiry is not revocation.
|
|
681
|
+
if (!sessions.some(s => s.status === 'active')) {
|
|
682
|
+
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
683
|
+
if (revoked.length > 0) return revoked[0];
|
|
477
684
|
}
|
|
478
685
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
s =>
|
|
484
|
-
s.status === 'revoked' &&
|
|
485
|
-
s.revoked_at &&
|
|
486
|
-
new Date(s.revoked_at) > oneHourAgo
|
|
686
|
+
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
687
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
688
|
+
return (
|
|
689
|
+
sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null
|
|
487
690
|
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Determine whether a session is revoked, with an explicit UNKNOWN state
|
|
695
|
+
* (PAY-1882 step 4 producer half).
|
|
696
|
+
*
|
|
697
|
+
* States:
|
|
698
|
+
* 'not_configured' - this deployment has no revocation source (IDP_URL /
|
|
699
|
+
* VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
|
|
700
|
+
* behaviour. Logged once, not per request.
|
|
701
|
+
* 'unknown' - revocation IS configured but the read FAILED (non-OK,
|
|
702
|
+
* timeout, throw). Enforcing middleware fails this CLOSED
|
|
703
|
+
* by default (revocationFailMode 'closed'); a Vibe-less
|
|
704
|
+
* consumer is 'not_configured', not 'unknown', so it is not
|
|
705
|
+
* signed out.
|
|
706
|
+
* 'revoked' - the session's own token hash matches a revoked row, or
|
|
707
|
+
* the user has no active session at all.
|
|
708
|
+
* 'active' - read OK and the session is not revoked.
|
|
709
|
+
*
|
|
710
|
+
* @param idpUserId - The user's IDP ID
|
|
711
|
+
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
712
|
+
* When absent, the user-wide check applies (legacy path).
|
|
713
|
+
*/
|
|
714
|
+
export async function getRevocationStatus(
|
|
715
|
+
idpUserId: number,
|
|
716
|
+
sessionToken?: string
|
|
717
|
+
): Promise<RevocationStatus> {
|
|
718
|
+
const idpUrl = getEnv('IDP_URL');
|
|
719
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
720
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
721
|
+
|
|
722
|
+
// 'not_configured' is reserved for an app with NO revocation source at all:
|
|
723
|
+
// no VIBE_CLIENT_ID. BAPert 66320 - "the app has no revocation source
|
|
724
|
+
// (VIBE_CLIENT_ID absent / revocation not enabled)". Such a consumer stays
|
|
725
|
+
// viable, or a Vibe-less app would sign everyone out on every request.
|
|
726
|
+
if (!clientId) {
|
|
727
|
+
if (!warnedNotConfigured) {
|
|
728
|
+
warnedNotConfigured = true;
|
|
729
|
+
console.warn(
|
|
730
|
+
'[vibe-sessions] Revocation check not configured (VIBE_CLIENT_ID absent) - reporting not_configured (logged once)'
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
return 'not_configured';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// A client id IS present, so this app is a Vibe consumer that intends the
|
|
737
|
+
// revocation check - but the read is MISCONFIGURED (IDP_URL or VIBE_HMAC_KEY
|
|
738
|
+
// missing). BAPert 66344 condition 2: FAIL CLOSED. Do NOT fall back to bearer,
|
|
739
|
+
// do NOT report "no sessions". 'unknown' is the fail-closed state.
|
|
740
|
+
if (!idpUrl || !signingKey) {
|
|
741
|
+
console.warn(
|
|
742
|
+
'[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)'
|
|
743
|
+
);
|
|
744
|
+
return 'unknown';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
748
|
+
|
|
749
|
+
if (!ok) {
|
|
750
|
+
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
751
|
+
return 'unknown';
|
|
752
|
+
}
|
|
488
753
|
|
|
489
|
-
return
|
|
754
|
+
return findRevokingSession(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined)
|
|
755
|
+
? 'revoked'
|
|
756
|
+
: 'active';
|
|
490
757
|
}
|
|
491
758
|
|
|
492
759
|
// -----------------------------------------------------------------------------
|