@payez/next-mvp 4.4.8 → 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.
@@ -9,12 +9,44 @@ interface Props {
9
9
  isLoading?: boolean;
10
10
  }
11
11
 
12
- const providerIcons: Record<FederatedProvider, { label: string; svg: React.ReactNode; color: string }> = {
12
+ /**
13
+ * PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
14
+ *
15
+ * Apple and Facebook both publish binding rules for their sign-in buttons and
16
+ * neither is satisfied by the generic "Continue with X" control this section
17
+ * used to render for every provider:
18
+ * - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
19
+ * button MUST carry the Apple logo and one of Apple's own wordings. The
20
+ * approved primary wording is "Sign in with Apple" ("Continue with Apple"
21
+ * is the permitted alternative). "Continue with Apple" with no logo, which
22
+ * is what the generic path produced, is non-compliant and is what App Store
23
+ * review rejects. Black or white only.
24
+ * - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
25
+ * Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
26
+ * are the sanctioned treatments.
27
+ * Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
28
+ * and inventing brand colours for them would be a change nobody asked for.
29
+ *
30
+ * The mark inherits `currentColor` so it is legible on both the white neutral
31
+ * buttons and the black/blue branded ones without a second SVG per provider.
32
+ */
33
+ const PROVIDER_BRAND: Record<
34
+ FederatedProvider,
35
+ { label: string; background: string; color: string; border: string }
36
+ > = {
37
+ google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
38
+ microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
39
+ github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
40
+ // Meta brand blue; white mark on blue, per the brand guidelines.
41
+ facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
42
+ // Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
43
+ apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
44
+ };
45
+
46
+ const providerIcons: Record<FederatedProvider, { svg: React.ReactNode }> = {
13
47
  google: {
14
- label: 'Google',
15
- color: '#4285F4',
16
48
  svg: (
17
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
49
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
18
50
  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
19
51
  <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
20
52
  <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
@@ -23,10 +55,8 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
23
55
  ),
24
56
  },
25
57
  microsoft: {
26
- label: 'Microsoft',
27
- color: '#0078D4',
28
58
  svg: (
29
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
59
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
30
60
  <rect x="1" y="1" width="9" height="9" fill="#F25022"/>
31
61
  <rect x="12" y="1" width="9" height="9" fill="#7FBA00"/>
32
62
  <rect x="1" y="12" width="9" height="9" fill="#00A4EF"/>
@@ -35,28 +65,25 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
35
65
  ),
36
66
  },
37
67
  facebook: {
38
- label: 'Facebook',
39
- color: '#1877F2',
68
+ // currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
69
+ // #1877F2 field is an invisible mark — the exact way a branded button ships
70
+ // looking like a blank one.
40
71
  svg: (
41
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2">
72
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
42
73
  <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
43
74
  </svg>
44
75
  ),
45
76
  },
46
77
  github: {
47
- label: 'GitHub',
48
- color: '#000000',
49
78
  svg: (
50
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
79
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
51
80
  <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
52
81
  </svg>
53
82
  ),
54
83
  },
55
84
  apple: {
56
- label: 'Apple',
57
- color: '#000000',
58
85
  svg: (
59
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
86
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
60
87
  <path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
61
88
  </svg>
62
89
  ),
@@ -71,22 +98,29 @@ export function FederatedAuthSection({ providers, onProviderClick, isLoading }:
71
98
  return (
72
99
  <div className="space-y-3">
73
100
  {providers.map((provider) => {
74
- const config = providerIcons[provider];
101
+ const icon = providerIcons[provider];
102
+ const brand = PROVIDER_BRAND[provider] ?? {
103
+ label: `Continue with ${provider}`,
104
+ background: 'var(--bg-default, #ffffff)',
105
+ color: 'var(--text-primary, #111827)',
106
+ border: 'var(--border-default, #e5e7eb)',
107
+ };
75
108
  return (
76
109
  <button
77
110
  key={provider}
78
111
  type="button"
79
112
  onClick={() => onProviderClick(provider)}
80
113
  disabled={isLoading}
114
+ data-provider={provider}
81
115
  className={`w-full px-4 py-3 rounded-lg border-2 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`}
82
116
  style={{
83
- border: '1px solid var(--border-default)',
84
- color: 'var(--text-primary)',
85
- background: 'var(--bg-default)',
117
+ border: `1px solid ${brand.border}`,
118
+ color: brand.color,
119
+ background: brand.background,
86
120
  }}
87
121
  >
88
- {config.svg}
89
- Continue with {config.label}
122
+ {icon.svg}
123
+ {brand.label}
90
124
  </button>
91
125
  );
92
126
  })}
@@ -62,35 +62,58 @@ export function useAvailableProviders(): UseAvailableProvidersResult {
62
62
 
63
63
  async function fetchProviders() {
64
64
  try {
65
- // Fetch available providers from Better Auth
66
- // Better Auth doesn't have a getProviders equivalent, so we use a static list
67
- // based on configured social providers
68
- const result: Record<string, { id: string; name: string }> = {
69
- google: { id: 'google', name: 'Google' },
70
- };
65
+ // PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
66
+ //
67
+ // const result = { google: { id: 'google', name: 'Google' } };
68
+ //
69
+ // under a comment claiming providers were "loaded dynamically". It
70
+ // returned Google for EVERY client whatever the tenant had enabled -
71
+ // a config-shaped constant, so Apple/Facebook (PAY-2104) could never
72
+ // appear and a client that had DISABLED Google would still see its
73
+ // button. The real answer is served by /api/auth/settings (the same
74
+ // route routes/auth/settings.ts builds from the IDP's oauthProviders).
75
+ const response = await fetch('/api/auth/settings', { cache: 'no-store' });
71
76
 
72
77
  if (!mounted) return;
73
78
 
74
- setRawProviders(result);
75
-
76
- if (result) {
77
- // Filter to OAuth providers only (exclude credentials)
78
- // Map to our FederatedProvider type
79
- const oauthProviders = Object.keys(result)
80
- .filter(id => OAUTH_PROVIDERS.includes(id))
81
- .map(id => PROVIDER_MAP[id])
82
- .filter((p): p is FederatedProvider => p !== undefined);
79
+ if (!response.ok) {
80
+ // FAIL CLOSED. An unreadable policy is not an empty provider list
81
+ // that we may then pad with a guess - it is "we do not know". See
82
+ // the PAY-1243 rule: never fabricate a provider list.
83
+ throw new Error(`Failed to fetch auth settings: ${response.status}`);
84
+ }
83
85
 
84
- setProviders(oauthProviders);
85
- } else {
86
- setProviders([]);
86
+ const body = await response.json().catch(() => null);
87
+ const settings = body?.data ?? body;
88
+ if (body?.success === false || !settings) {
89
+ throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
87
90
  }
88
91
 
92
+ // Keep the raw provider rows (id -> the settings row) for callers that
93
+ // want more than the mapped union type, exactly as before.
94
+ const enabledIds: string[] = Array.isArray(settings.enabledProviders)
95
+ ? settings.enabledProviders.filter((p: unknown): p is string => typeof p === 'string')
96
+ : [];
97
+ const raw: Record<string, { id: string; name: string }> = {};
98
+ for (const id of enabledIds) raw[id] = { id, name: id };
99
+
100
+ if (!mounted) return;
101
+ setRawProviders(raw);
102
+
103
+ // Filter to OAuth providers only (exclude credentials) and map to our
104
+ // FederatedProvider union, in the IDP's order.
105
+ const oauthProviders = enabledIds
106
+ .filter(id => OAUTH_PROVIDERS.includes(id))
107
+ .map(id => PROVIDER_MAP[id])
108
+ .filter((p): p is FederatedProvider => p !== undefined);
109
+
110
+ setProviders(oauthProviders);
89
111
  setError(null);
90
112
  } catch (err) {
91
113
  if (!mounted) return;
92
114
  setError(err instanceof Error ? err : new Error(String(err)));
93
115
  setProviders([]);
116
+ setRawProviders(null); // never leave a stale list behind a failure
94
117
  } finally {
95
118
  if (mounted) {
96
119
  setIsLoading(false);
@@ -124,3 +124,26 @@ export function projectPasswordPolicyRules(json: unknown): string[] | undefined
124
124
  if (!details || details.code !== 'password_policy') return undefined;
125
125
  return knownPasswordRules(details.rules);
126
126
  }
127
+
128
+ /**
129
+ * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
130
+ * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
131
+ * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
132
+ * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
133
+ * only the code, never IdP text.
134
+ */
135
+ export function projectPasswordBreached(json: unknown): boolean {
136
+ if (!json || typeof json !== 'object') return false;
137
+ const top = json as Record<string, unknown>;
138
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
139
+ const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
140
+ if (direct?.toLowerCase() === 'password_breached') return true;
141
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
142
+ if (str(details?.code)?.toLowerCase() === 'password_breached') return true;
143
+ // THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
144
+ // { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
145
+ // The breach is a member of rules[], not a code - the house envelope carries every password rule
146
+ // that way. Reading only a code would MISS the real refusal.
147
+ if (Array.isArray(details?.rules) && (details!.rules as unknown[]).some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached')) return true;
148
+ return Array.isArray(details?.value) && (details!.value as unknown[]).some((v) => !!v && typeof v === 'object' && str((v as Record<string, unknown>).code)?.toLowerCase() === 'password_breached');
149
+ }
@@ -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(request, viabilityEndpoint, log);
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
- const err = new Error(resetFailureText(res.status, rules, policy, tokenInvalid)) as Error & { rules?: string[]; code?: string };
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;