@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.
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveNextAuthSecret = resolveNextAuthSecret;
4
+ /**
5
+ * resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
9
+ * ---------------------------------------------------------------------------
10
+ * The NextAuth-era broker secret resolver was DELETED in
11
+ * 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
12
+ * and it is intentionally NOT coming back as first-class API: this package is
13
+ * Better Auth now, and new apps must not resolve a NextAuth secret at all.
14
+ *
15
+ * It is restored here ONLY because ONE consumer is still mid-migration and cannot
16
+ * take the 4.4.x bump without it:
17
+ *
18
+ * CONSUMER THAT NEEDS THIS: the customer portal
19
+ * (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
20
+ * @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
21
+ * `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
22
+ * import `resolveNextAuthSecret` from here. Without this export, bumping the
23
+ * portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
24
+ * shim rather than force the portal's full NextAuth->BetterAuth migration
25
+ * tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
26
+ * THIS FILE.
27
+ *
28
+ * DO NOT add new consumers. If you are starting an app, use Better Auth's own
29
+ * secret handling; you do not need this.
30
+ *
31
+ * ---------------------------------------------------------------------------
32
+ * BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
33
+ * ---------------------------------------------------------------------------
34
+ * Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
35
+ * signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
36
+ *
37
+ * DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
38
+ * `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
39
+ * renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
40
+ * `validateAuthSecret` - same predicate, current name - so there is no second
41
+ * copy of the rule. The validation semantics are unchanged.
42
+ *
43
+ * SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
44
+ */
45
+ require("server-only");
46
+ const node_crypto_1 = require("node:crypto");
47
+ const secret_validation_1 = require("./secret-validation");
48
+ let cachedSecret = null;
49
+ let lastFetchedAt = 0;
50
+ const CACHE_TTL_MS = 5 * 60 * 1000;
51
+ /**
52
+ * Resolve the NextAuth secret (server-only).
53
+ *
54
+ * Priority:
55
+ * 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
56
+ * 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
57
+ * 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
58
+ */
59
+ async function resolveNextAuthSecret() {
60
+ // Check if already in environment
61
+ if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
62
+ return process.env.NEXTAUTH_SECRET;
63
+ }
64
+ // Check if cached and fresh (within 5 minutes)
65
+ if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
66
+ return cachedSecret;
67
+ }
68
+ // Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
69
+ const base = process.env.IDP_URL;
70
+ if (!base)
71
+ throw new Error('IDP_URL environment variable is required');
72
+ const clientIdStr = process.env.CLIENT_ID;
73
+ if (!clientIdStr || clientIdStr.trim() === '') {
74
+ throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
75
+ }
76
+ // Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
77
+ const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
78
+ const signingResp = await fetch(signingUrl, {
79
+ method: 'POST',
80
+ headers: {
81
+ Accept: 'application/json',
82
+ 'Content-Type': 'application/json',
83
+ 'X-Client-Id': clientIdStr,
84
+ 'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
85
+ },
86
+ body: JSON.stringify({
87
+ issuer: clientIdStr,
88
+ subject: clientIdStr,
89
+ audience: 'urn:payez:externalauth:nextauthsecret',
90
+ expires_in: 60,
91
+ }),
92
+ cache: 'no-store',
93
+ });
94
+ if (!signingResp.ok) {
95
+ const txt = await signingResp.text().catch(() => 'Unknown error');
96
+ throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
97
+ }
98
+ const signingBody = (await signingResp.json().catch(() => ({})));
99
+ const clientAssertion = signingBody?.data?.client_assertion ??
100
+ signingBody?.data?.clientAssertion ??
101
+ signingBody?.client_assertion ??
102
+ signingBody?.clientAssertion ??
103
+ signingBody?.data?.ClientAssertion ??
104
+ signingBody?.ClientAssertion;
105
+ if (!clientAssertion || typeof clientAssertion !== 'string') {
106
+ throw new Error('IDP did not return a valid signed client assertion');
107
+ }
108
+ // Step 2: use the signed assertion to fetch the NextAuth secret
109
+ const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
110
+ const proxyResp = await fetch(proxyUrl, {
111
+ method: 'POST',
112
+ headers: {
113
+ Accept: 'application/json',
114
+ 'Content-Type': 'application/json',
115
+ 'X-Client-Id': clientIdStr,
116
+ 'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
117
+ },
118
+ body: JSON.stringify({ client_assertion: clientAssertion }),
119
+ cache: 'no-store',
120
+ });
121
+ if (!proxyResp.ok) {
122
+ const txt = await proxyResp.text().catch(() => 'Unknown error');
123
+ throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
124
+ }
125
+ const proxyBody = (await proxyResp.json().catch(() => ({})));
126
+ const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
127
+ // `configuration` is available from the IDP but deliberately not logged.
128
+ if (!secret || typeof secret !== 'string') {
129
+ throw new Error('Proxy did not return a valid NextAuth secret');
130
+ }
131
+ // Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
132
+ const validation = (0, secret_validation_1.validateAuthSecret)(secret);
133
+ if (!validation.valid) {
134
+ throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
135
+ }
136
+ cachedSecret = secret;
137
+ lastFetchedAt = Date.now();
138
+ process.env.NEXTAUTH_SECRET = secret;
139
+ console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
140
+ return secret;
141
+ }
@@ -65,3 +65,11 @@ export declare function projectResetTokenInvalid(json: unknown): boolean;
65
65
  * (the caller must be able to tell a refused password from a dead recovery session).
66
66
  */
67
67
  export declare function projectPasswordPolicyRules(json: unknown): string[] | undefined;
68
+ /**
69
+ * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
70
+ * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
71
+ * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
72
+ * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
73
+ * only the code, never IdP text.
74
+ */
75
+ export declare function projectPasswordBreached(json: unknown): boolean;
@@ -21,6 +21,7 @@ exports.projectPasswordPolicyDetails = projectPasswordPolicyDetails;
21
21
  exports.idpErrorEnvelope = idpErrorEnvelope;
22
22
  exports.projectResetTokenInvalid = projectResetTokenInvalid;
23
23
  exports.projectPasswordPolicyRules = projectPasswordPolicyRules;
24
+ exports.projectPasswordBreached = projectPasswordBreached;
24
25
  const password_rules_1 = require("./password-rules");
25
26
  const str = (v) => (typeof v === 'string' ? v : undefined);
26
27
  /** Log an exception with a fresh correlationId and return the id for the response body. */
@@ -128,3 +129,29 @@ function projectPasswordPolicyRules(json) {
128
129
  return undefined;
129
130
  return (0, password_rules_1.knownPasswordRules)(details.rules);
130
131
  }
132
+ /**
133
+ * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
134
+ * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
135
+ * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
136
+ * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
137
+ * only the code, never IdP text.
138
+ */
139
+ function projectPasswordBreached(json) {
140
+ if (!json || typeof json !== 'object')
141
+ return false;
142
+ const top = json;
143
+ const err = top.error && typeof top.error === 'object' ? top.error : {};
144
+ const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
145
+ if (direct?.toLowerCase() === 'password_breached')
146
+ return true;
147
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
148
+ if (str(details?.code)?.toLowerCase() === 'password_breached')
149
+ return true;
150
+ // THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
151
+ // { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
152
+ // The breach is a member of rules[], not a code - the house envelope carries every password rule
153
+ // that way. Reading only a code would MISS the real refusal.
154
+ if (Array.isArray(details?.rules) && details.rules.some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached'))
155
+ return true;
156
+ return Array.isArray(details?.value) && details.value.some((v) => !!v && typeof v === 'object' && str(v.code)?.toLowerCase() === 'password_breached');
157
+ }
@@ -99,7 +99,48 @@ export interface MvpMiddlewareOptions {
99
99
  bypassPaths?: string[];
100
100
  /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
101
101
  rbacExemptPaths?: string[];
102
+ /**
103
+ * PAY-1882 step 4 - how this app treats a producer that reports the session's
104
+ * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
105
+ * throw - the status could not be determined).
106
+ *
107
+ * 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
108
+ * session is force-invalidated and the user re-auths.
109
+ * This is Jon's PAY-1880 ruling (option A, fail closed),
110
+ * 2026-09-27, and BAPert 66316/66320 restates it for
111
+ * step 4. Consequence, accepted explicitly: a Vibe
112
+ * outage signs this app's users out. 'not_configured'
113
+ * also fails closed here - both live consumers ARE
114
+ * configured (BAPert 66356), so for them it means a
115
+ * misconfiguration, not "no revocation source".
116
+ * 'open' - an unknown OR not-configured status stays viable
117
+ * (availability over fail-closed). Per-app opt-out for a
118
+ * deployment with no revocation source, or the two live
119
+ * consumers while Jon decides.
120
+ *
121
+ * Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
122
+ * `revocationStatus` (an older producer - unchanged behaviour, so no consumer
123
+ * is signed out for not implementing the check).
124
+ */
125
+ revocationFailMode?: 'closed' | 'open';
102
126
  }
103
127
  /** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
104
128
  export declare const TWO_FACTOR_TIMEOUT_ERROR = "TwoFactorTimeout";
105
129
  export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
130
+ /**
131
+ * The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
132
+ *
133
+ * Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
134
+ * that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
135
+ * producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
136
+ *
137
+ * RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
138
+ * decision is made from it plus failMode, and the folded booleans are ignored for
139
+ * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
140
+ * absent (idealresume.online's older boolean-only route).
141
+ */
142
+ export declare function decideForceInvalid(data: {
143
+ revocationStatus?: unknown;
144
+ forceInvalid?: unknown;
145
+ revoked?: unknown;
146
+ }, revocationFailMode: 'closed' | 'open'): boolean;
@@ -20,6 +20,7 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.TWO_FACTOR_TIMEOUT_ERROR = void 0;
22
22
  exports.createMvpMiddleware = createMvpMiddleware;
23
+ exports.decideForceInvalid = decideForceInvalid;
23
24
  const server_1 = require("next/server");
24
25
  const route_config_1 = require("../auth/route-config");
25
26
  const safe_callback_1 = require("../auth/safe-callback");
@@ -317,6 +318,8 @@ function createMvpMiddleware(options = {}) {
317
318
  const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
318
319
  const bypassPaths = options.bypassPaths || [];
319
320
  const rbacExemptPaths = options.rbacExemptPaths || [];
321
+ // PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
322
+ const revocationFailMode = options.revocationFailMode || 'closed';
320
323
  return async function middleware(request) {
321
324
  const { pathname, searchParams } = request.nextUrl;
322
325
  // =========================================================================
@@ -347,7 +350,7 @@ function createMvpMiddleware(options = {}) {
347
350
  // Has cookie - continue to viability check for 2FA enforcement
348
351
  }
349
352
  // --- Fetch session viability from internal API ---
350
- const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log);
353
+ const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log, revocationFailMode);
351
354
  // --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
352
355
  //
353
356
  // *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
@@ -409,8 +412,37 @@ function createMvpMiddleware(options = {}) {
409
412
  });
410
413
  };
411
414
  }
415
+ /**
416
+ * The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
417
+ *
418
+ * Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
419
+ * that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
420
+ * producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
421
+ *
422
+ * RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
423
+ * decision is made from it plus failMode, and the folded booleans are ignored for
424
+ * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
425
+ * absent (idealresume.online's older boolean-only route).
426
+ */
427
+ function decideForceInvalid(data, revocationFailMode) {
428
+ const status = data.revocationStatus;
429
+ if (typeof status === 'string') {
430
+ if (status === 'revoked')
431
+ return true;
432
+ if (status === 'active')
433
+ return false;
434
+ // S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
435
+ // typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
436
+ // applies), NEVER as 'active'. Returning false here would let the first
437
+ // producer that emits an unexpected state silently switch revocation OFF -
438
+ // the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
439
+ return revocationFailMode === 'closed';
440
+ }
441
+ // No revocationStatus: an older boolean producer. Absence of both stays false.
442
+ return data.forceInvalid === true || data.revoked === true;
443
+ }
412
444
  /** Check session viability via internal API */
413
- async function checkViability(request, endpoint, log) {
445
+ async function checkViability(request, endpoint, log, revocationFailMode) {
414
446
  const sessionStatus = {
415
447
  exists: false,
416
448
  forceInvalid: false,
@@ -443,6 +475,46 @@ async function checkViability(request, endpoint, log) {
443
475
  sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
444
476
  sessionStatus.requires2FA = data.requires2FA || false;
445
477
  sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
478
+ // --- Session revocation (PAY-1882 Step 4) ---
479
+ // Two producer spellings are honoured, because both exist on the wire:
480
+ // - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
481
+ // - the three-state `revocationStatus` the package's own viability
482
+ // handlers now emit (PAY-1882 step 4).
483
+ //
484
+ // RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
485
+ // (a FAILED Vibe read) fails closed by default and is the ONLY state that
486
+ // the per-app `revocationFailMode` option can relax to viable.
487
+ //
488
+ // Four producer states (BAPert 66320), with the fail-closed ruling for
489
+ // 'not_configured' added by BAPert 66356:
490
+ // 'active' - Vibe read OK, not revoked -> viable
491
+ // 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
492
+ // 'unknown' - read FAILED -> force-invalid if 'closed'
493
+ // 'not_configured' - no revocation source -> force-invalid if 'closed'
494
+ //
495
+ // WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
496
+ // this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
497
+ // DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
498
+ // their committed manifests + the graft pipeline's own secret check), so for
499
+ // them 'not_configured' can only mean a MISCONFIGURATION - revocation
500
+ // silently switched off is exactly the PAY-1880 fail-open bug. An app with
501
+ // no revocation source that genuinely wants availability sets
502
+ // revocationFailMode 'open'.
503
+ //
504
+ // A MISSING revocationStatus (an older producer) keeps today's behaviour,
505
+ // i.e. NOT force-invalid: absence must never sign out a population that
506
+ // never opted into the check.
507
+ //
508
+ // *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
509
+ // The package's own producers emit `forceInvalid: revocationStatus ===
510
+ // 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
511
+ // FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
512
+ // was consulted, so `'open'` could never keep an 'unknown' session viable -
513
+ // the opt-out lever would be DEAD. So: when a producer supplies a
514
+ // revocations status, decide from it + failMode and ignore the folded
515
+ // booleans for unknown/not_configured. The booleans are read ONLY when
516
+ // revocationStatus is ABSENT (idealresume.online's older boolean route).
517
+ sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
446
518
  sessionPointer.exists = sessionStatus.exists ?? false;
447
519
  sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
448
520
  sessionPointer.expired = data.accessTokenExpired || false;
@@ -14,6 +14,7 @@
14
14
  * @since auth-ready-v2
15
15
  */
16
16
  import { NextRequest, NextResponse } from 'next/server';
17
+ import { type RevocationStatus } from '../../vibe/sessions';
17
18
  /**
18
19
  * GET /api/session/viability - Check if session is viable
19
20
  *
@@ -34,6 +35,9 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
34
35
  hasRefreshToken: boolean;
35
36
  authenticated: boolean;
36
37
  sessionTokenId: string | null;
38
+ revocationStatus: RevocationStatus;
39
+ forceInvalid: boolean;
40
+ revoked: boolean;
37
41
  tenantRequiresTwoFactor: boolean;
38
42
  userHasCompletedTenantTwoFactorRequirements: boolean;
39
43
  userStillNeedsTwoFactor: boolean;
@@ -49,6 +53,7 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
49
53
  viable: boolean;
50
54
  needsRefresh: boolean;
51
55
  authenticated: boolean;
56
+ revocationStatus: RevocationStatus;
52
57
  error: string;
53
58
  code: string;
54
59
  correlationId: string;
@@ -22,6 +22,7 @@ const session_store_1 = require("../../lib/session-store");
22
22
  const idp_client_config_1 = require("../../lib/idp-client-config");
23
23
  const safe_error_response_1 = require("../../lib/safe-error-response");
24
24
  const token_log_id_1 = require("../../lib/token-log-id");
25
+ const sessions_1 = require("../../vibe/sessions");
25
26
  /**
26
27
  * Get tenant-wide 2FA requirement from cached client config (from broker handshake)
27
28
  */
@@ -248,6 +249,41 @@ async function GET(req) {
248
249
  const oauthSessionNeedsTwoFactor = session?.mfaSignalPresent === true &&
249
250
  session?.mfaVerified === false &&
250
251
  !(session.idpRefreshToken || session?.refreshToken);
252
+ // --- Session revocation status (PAY-1882 step 4 producer half) ---
253
+ /*
254
+ * Resolve the four-state revocation status for this user (BAPert 66320).
255
+ * Read it HERE, above every authenticated return, so all three responses
256
+ * carry the SAME value - the same discipline as nextStep above.
257
+ *
258
+ * The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
259
+ * and always on 'revoked'; 'active' and 'not_configured' stay viable. A
260
+ * consumer that does NOT read revocationStatus is unaffected - absent is not
261
+ * revoked - so shipping this changes nothing for them until they opt in.
262
+ */
263
+ let revocationStatus = 'not_configured';
264
+ if (session) {
265
+ // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
266
+ // the token's own `sub` claim FIRST (session.userId can be an email).
267
+ const revocationUserId = (0, sessions_1.resolveNumericIdpUserId)(session, session.idpAccessToken);
268
+ if (revocationUserId > 0) {
269
+ try {
270
+ revocationStatus = await (0, sessions_1.getRevocationStatus)(revocationUserId, sessionToken);
271
+ }
272
+ catch {
273
+ // getRevocationStatus does not throw by contract, but never let a
274
+ // revocation probe take down the viability answer: fail closed.
275
+ revocationStatus = 'unknown';
276
+ }
277
+ }
278
+ else {
279
+ // N1 (NightHawk 66392): a session exists but carries no usable numeric
280
+ // IdP id, so the check could not run -> 'unknown' (could not determine),
281
+ // NOT 'not_configured' (no revocation source).
282
+ // BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
283
+ // id surfaces as a log line, not an unexplained sign-out.
284
+ revocationStatus = (0, sessions_1.warnNonNumericUserId)(sessionToken, session.userId);
285
+ }
286
+ }
251
287
  // Check access token expiry
252
288
  const now = Math.floor(Date.now() / 1000);
253
289
  // 240654 addendum 2: fall back to the Redis session's own expiry.
@@ -292,6 +328,11 @@ async function GET(req) {
292
328
  needsRefresh: true,
293
329
  authenticated: true,
294
330
  sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
331
+ // Session revocation (PAY-1882 step 4): the four-state status, plus the
332
+ // boolean the middleware's existing force-invalid branch already reads.
333
+ revocationStatus,
334
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
335
+ revoked: revocationStatus === 'revoked',
295
336
  // Clear names for middleware decision-making
296
337
  tenantRequiresTwoFactor,
297
338
  userHasCompletedTenantTwoFactorRequirements,
@@ -363,6 +404,10 @@ async function GET(req) {
363
404
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
364
405
  accessTokenExpired: true,
365
406
  reason: 'Token expired',
407
+ // Session revocation (PAY-1882 step 4) - same value on every return.
408
+ revocationStatus,
409
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
410
+ revoked: revocationStatus === 'revoked',
366
411
  // RBAC fields
367
412
  roles: session?.roles || [],
368
413
  clientId: resolveClientId(session?.idpClientId),
@@ -375,6 +420,10 @@ async function GET(req) {
375
420
  hasRefreshToken,
376
421
  authenticated: true,
377
422
  sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
423
+ // Session revocation (PAY-1882 step 4) - same value on every return.
424
+ revocationStatus,
425
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
426
+ revoked: revocationStatus === 'revoked',
378
427
  // Clear names
379
428
  tenantRequiresTwoFactor,
380
429
  userHasCompletedTenantTwoFactorRequirements,
@@ -398,6 +447,10 @@ async function GET(req) {
398
447
  viable: false,
399
448
  needsRefresh: false,
400
449
  authenticated: false,
450
+ // PAY-1882 step 4: an exception here means the status could not be read.
451
+ // Report 'unknown' (not a bare 500 with no revocation signal) so the
452
+ // middleware's fail-closed default applies rather than reading it as viable.
453
+ revocationStatus: 'unknown',
401
454
  error: 'Failed to check session',
402
455
  code: 'INTERNAL_ERROR',
403
456
  correlationId
@@ -33,6 +33,12 @@ export declare function apiFetch<T>(url: string, options?: ApiOptions): Promise<
33
33
  */
34
34
  export declare const RECOVERY_EXPIRED_TEXT = "This reset link has expired or was already used. Start password recovery again.";
35
35
  export declare const RESET_NEUTRAL_TEXT = "We couldn't set that password. Check it meets the requirements shown and try again.";
36
+ /**
37
+ * PAY-2103 (AUTH-009): the IdP refuses a password found in a breach corpus; the route projects a
38
+ * `password_breached: true` boolean, and the user reads this. Distinct from the policy sentence,
39
+ * which would wrongly tell them to re-read the requirements.
40
+ */
41
+ export declare const RESET_BREACHED_TEXT = "This password appears in a known data breach. Choose a different one.";
36
42
  /**
37
43
  * Three tiers for a 400/422 from reset (rigpert 60822, QAPert 60820), decided ONLY on codes the IdP actually sends:
38
44
  * policy details.code password_policy (projected by the handler as password_policy) -> the rule sentences
package/dist/utils/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.accountApi = exports.AccountApi = exports.RESET_NEUTRAL_TEXT = exports.RECOVERY_EXPIRED_TEXT = exports.ApiError = void 0;
3
+ exports.accountApi = exports.AccountApi = exports.RESET_BREACHED_TEXT = exports.RESET_NEUTRAL_TEXT = exports.RECOVERY_EXPIRED_TEXT = exports.ApiError = void 0;
4
4
  exports.apiFetch = apiFetch;
5
5
  exports.resetFailureText = resetFailureText;
6
6
  const standardized_client_api_1 = require("../lib/standardized-client-api");
@@ -84,6 +84,12 @@ async function apiFetch(url, options = {}) {
84
84
  */
85
85
  exports.RECOVERY_EXPIRED_TEXT = 'This reset link has expired or was already used. Start password recovery again.';
86
86
  exports.RESET_NEUTRAL_TEXT = "We couldn't set that password. Check it meets the requirements shown and try again.";
87
+ /**
88
+ * PAY-2103 (AUTH-009): the IdP refuses a password found in a breach corpus; the route projects a
89
+ * `password_breached: true` boolean, and the user reads this. Distinct from the policy sentence,
90
+ * which would wrongly tell them to re-read the requirements.
91
+ */
92
+ exports.RESET_BREACHED_TEXT = 'This password appears in a known data breach. Choose a different one.';
87
93
  /**
88
94
  * Three tiers for a 400/422 from reset (rigpert 60822, QAPert 60820), decided ONLY on codes the IdP actually sends:
89
95
  * policy details.code password_policy (projected by the handler as password_policy) -> the rule sentences
@@ -196,7 +202,9 @@ class AccountApi {
196
202
  const policy = !!body?.password_policy;
197
203
  const rules = (0, password_rules_1.knownPasswordRules)(body?.password_policy?.rules);
198
204
  const tokenInvalid = body?.reset_token_invalid === true;
199
- const err = new Error(resetFailureText(res.status, rules, policy, tokenInvalid));
205
+ // PAY-2103: a breached password is its own outcome, not a policy refusal.
206
+ const breached = body?.password_breached === true;
207
+ const err = new Error(breached ? exports.RESET_BREACHED_TEXT : resetFailureText(res.status, rules, policy, tokenInvalid));
200
208
  if (rules.length)
201
209
  err.rules = rules;
202
210
  if ((res.status === 400 || res.status === 422) && tokenInvalid && !policy)
@@ -144,8 +144,95 @@ export declare function isSessionValid(sessionId: number, accessToken: string):
144
144
  * @param accessToken - Access token for auth
145
145
  * @param sessionTokenHash - Optional token hash to check specific session
146
146
  * @returns Revoked session info if enforcement needed, null otherwise
147
+ *
148
+ * @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
149
+ * same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
150
+ * a failed read ('unknown') from 'active' instead of throwing.
151
+ */
152
+ export declare function checkSessionRevocation(idpUserId: number, _accessToken: string, sessionTokenHash?: string): Promise<IVibeLoginSession | null>;
153
+ /** The four states a viability producer can report (BAPert 66320). */
154
+ export type RevocationStatus = 'active' | 'revoked' | 'unknown' | 'not_configured';
155
+ /**
156
+ * Hash a session token the same way record-login stores it.
157
+ * SHA256 hex, 64 chars. This must stay byte-identical to
158
+ * `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
159
+ * or per-session matching silently falls back to the user-wide path.
160
+ */
161
+ export declare function hashSessionToken(token: string): string;
162
+ /**
163
+ * BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
164
+ * resolve the NUMERIC IdP user id the Vibe query filters on.
165
+ *
166
+ * *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
167
+ * USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
168
+ * and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
169
+ * an email, so reading `sub` yields 0 for the common case - and for a username that
170
+ * happens to be all digits it yields the WRONG id, which getRevocationStatus answers
171
+ * 'active' for a user with no rows: a fail-OPEN.
172
+ *
173
+ * Resolution order:
174
+ * 1. the token's `user_id` claim, when it is all digits;
175
+ * 2. a digits-only session.userId / session.user.id;
176
+ * 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
177
+ *
178
+ * session.userId is not a safe source on its own either: session-store.ts:143 falls
179
+ * back to user.email, and better-auth.ts stringifies whichever id it finds.
180
+ */
181
+ export declare function resolveNumericIdpUserId(session: unknown, accessToken?: string): number;
182
+ /**
183
+ * Public entry point for the twins: call this when a session exists but its
184
+ * userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
185
+ * emits the once-per-session WARN. Shape only, never the value.
186
+ */
187
+ export declare function warnNonNumericUserId(sessionToken: string | undefined, rawUserId: unknown): 'unknown';
188
+ /**
189
+ * HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
190
+ *
191
+ * *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
192
+ * It is deliberately separate and deliberately sends NO Authorization header.
193
+ *
194
+ * WHY: the twin this must match is idealresume.online's checkSessionRevocation
195
+ * read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
196
+ * paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
197
+ * :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
198
+ * The comment at vibe-client.ts:188-206 there records why: a bearer on the
199
+ * collections path caused USER_REQUIRED and broke signups. getUserSessions()
200
+ * here ALWAYS sends the bearer, so it cannot stand in for this read without
201
+ * changing the credential the two twins are supposed to share.
202
+ *
203
+ * BAPert 66344 (option a): match the twin, HMAC-only.
204
+ *
205
+ * Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
206
+ * collapses a proxy outage and "user has no sessions" to `[]`, which is
207
+ * fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
208
+ * caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
209
+ */
210
+ export declare function readSessionRevocationHmac(idpUserId: number): Promise<{
211
+ ok: boolean;
212
+ sessions: IVibeLoginSession[];
213
+ }>;
214
+ /**
215
+ * Determine whether a session is revoked, with an explicit UNKNOWN state
216
+ * (PAY-1882 step 4 producer half).
217
+ *
218
+ * States:
219
+ * 'not_configured' - this deployment has no revocation source (IDP_URL /
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).
147
234
  */
148
- export declare function checkSessionRevocation(idpUserId: number, accessToken: string, sessionTokenHash?: string): Promise<IVibeLoginSession | null>;
235
+ export declare function getRevocationStatus(idpUserId: number, sessionToken?: string): Promise<RevocationStatus>;
149
236
  /**
150
237
  * Get session statistics for admin dashboard.
151
238
  *