@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.
@@ -72,7 +72,9 @@ async function POST(req) {
72
72
  if (!upstream.ok) {
73
73
  // PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
74
74
  const rules = (0, safe_error_response_1.projectPasswordPolicyRules)(data);
75
- return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}) }, { status: upstream.status });
75
+ // PAY-2103: a breached-password refusal is a distinct 400; project a BOOLEAN so the anonymous
76
+ // route carries no code text and the page picks its own sentence.
77
+ return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}), ...((0, safe_error_response_1.projectPasswordBreached)(data) ? { password_breached: true } : {}) }, { status: upstream.status });
76
78
  }
77
79
  return server_1.NextResponse.json(data, { status: upstream.status });
78
80
  }
@@ -12,6 +12,7 @@ const auth_1 = require("../../server/auth");
12
12
  const startup_init_1 = require("../../lib/startup-init");
13
13
  const idp_client_config_1 = require("../../lib/idp-client-config");
14
14
  const token_log_id_1 = require("../../lib/token-log-id");
15
+ const sessions_1 = require("../../vibe/sessions");
15
16
  async function GET(req) {
16
17
  try {
17
18
  // Ensure initialization is complete (auth signing secret resolved from IDP)
@@ -124,12 +125,43 @@ async function GET(req) {
124
125
  hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
125
126
  });
126
127
  }
128
+ // --- Session revocation status (PAY-1882 step 4 producer half) ---
129
+ // The four-state status (BAPert 66320). The middleware fails CLOSED by
130
+ // default on 'unknown' (a failed Vibe read) and always on 'revoked';
131
+ // 'active' and 'not_configured' stay viable. A consumer that does not
132
+ // read revocationStatus is unaffected - absent is not revoked.
133
+ let revocationStatus = 'not_configured';
134
+ // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
135
+ // the token's own `sub` claim FIRST (session.userId can be an email).
136
+ const revocationUserId = (0, sessions_1.resolveNumericIdpUserId)(sessionData, sessionData.idpAccessToken);
137
+ if (revocationUserId > 0) {
138
+ try {
139
+ revocationStatus = await (0, sessions_1.getRevocationStatus)(revocationUserId, sessionToken);
140
+ }
141
+ catch {
142
+ // getRevocationStatus does not throw by contract; fail closed if it ever does.
143
+ revocationStatus = 'unknown';
144
+ }
145
+ }
146
+ else {
147
+ // N1 (NightHawk 66392): a session EXISTS but its userId is not a usable
148
+ // numeric IdP id, so the check could not be run. That is 'unknown'
149
+ // (could not determine), NOT 'not_configured' (no revocation source) -
150
+ // the two must not be conflated.
151
+ // BAPert 66480 condition 1: emit ONE WARN (shape only) per session.
152
+ revocationStatus = (0, sessions_1.warnNonNumericUserId)(sessionToken, sessionData.userId);
153
+ }
127
154
  const response = {
128
155
  authenticated: true,
129
156
  sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionToken), // PAY-1481: correlation id for middleware tracking, never the token
130
157
  // 2FA fields - critical for middleware redirect logic
131
158
  requires2FA, // From cached client config (client-wide setting)
132
159
  twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
160
+ // Session revocation (PAY-1882 step 4): the four-state status, plus the
161
+ // boolean the middleware's existing force-invalid branch already reads.
162
+ revocationStatus,
163
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
164
+ revoked: revocationStatus === 'revoked',
133
165
  // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
134
166
  //
135
167
  // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
@@ -3,31 +3,54 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.FederatedAuthSection = FederatedAuthSection;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
+ /**
7
+ * PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
8
+ *
9
+ * Apple and Facebook both publish binding rules for their sign-in buttons and
10
+ * neither is satisfied by the generic "Continue with X" control this section
11
+ * used to render for every provider:
12
+ * - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
13
+ * button MUST carry the Apple logo and one of Apple's own wordings. The
14
+ * approved primary wording is "Sign in with Apple" ("Continue with Apple"
15
+ * is the permitted alternative). "Continue with Apple" with no logo, which
16
+ * is what the generic path produced, is non-compliant and is what App Store
17
+ * review rejects. Black or white only.
18
+ * - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
19
+ * Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
20
+ * are the sanctioned treatments.
21
+ * Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
22
+ * and inventing brand colours for them would be a change nobody asked for.
23
+ *
24
+ * The mark inherits `currentColor` so it is legible on both the white neutral
25
+ * buttons and the black/blue branded ones without a second SVG per provider.
26
+ */
27
+ const PROVIDER_BRAND = {
28
+ google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
29
+ microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
30
+ github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
31
+ // Meta brand blue; white mark on blue, per the brand guidelines.
32
+ facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
33
+ // Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
34
+ apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
35
+ };
6
36
  const providerIcons = {
7
37
  google: {
8
- label: 'Google',
9
- color: '#4285F4',
10
- svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })),
38
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })),
11
39
  },
12
40
  microsoft: {
13
- label: 'Microsoft',
14
- color: '#0078D4',
15
- svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
41
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
16
42
  },
17
43
  facebook: {
18
- label: 'Facebook',
19
- color: '#1877F2',
20
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "#1877F2", children: (0, jsx_runtime_1.jsx)("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" }) })),
44
+ // currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
45
+ // #1877F2 field is an invisible mark — the exact way a branded button ships
46
+ // looking like a blank one.
47
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
21
48
  },
22
49
  github: {
23
- label: 'GitHub',
24
- color: '#000000',
25
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("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" }) })),
50
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
26
51
  },
27
52
  apple: {
28
- label: 'Apple',
29
- color: '#000000',
30
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("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" }) })),
53
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
31
54
  },
32
55
  };
33
56
  function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
@@ -35,11 +58,17 @@ function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
35
58
  return null;
36
59
  }
37
60
  return ((0, jsx_runtime_1.jsx)("div", { className: "space-y-3", children: providers.map((provider) => {
38
- const config = providerIcons[provider];
39
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, 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`, style: {
40
- border: '1px solid var(--border-default)',
41
- color: 'var(--text-primary)',
42
- background: 'var(--bg-default)',
43
- }, children: [config.svg, "Continue with ", config.label] }, provider));
61
+ const icon = providerIcons[provider];
62
+ const brand = PROVIDER_BRAND[provider] ?? {
63
+ label: `Continue with ${provider}`,
64
+ background: 'var(--bg-default, #ffffff)',
65
+ color: 'var(--text-primary, #111827)',
66
+ border: 'var(--border-default, #e5e7eb)',
67
+ };
68
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, "data-provider": provider, 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`, style: {
69
+ border: `1px solid ${brand.border}`,
70
+ color: brand.color,
71
+ background: brand.background,
72
+ }, children: [icon.svg, brand.label] }, provider));
44
73
  }) }));
45
74
  }
@@ -57,27 +57,48 @@ function useAvailableProviders() {
57
57
  let mounted = true;
58
58
  async function fetchProviders() {
59
59
  try {
60
- // Fetch available providers from Better Auth
61
- // Better Auth doesn't have a getProviders equivalent, so we use a static list
62
- // based on configured social providers
63
- const result = {
64
- google: { id: 'google', name: 'Google' },
65
- };
60
+ // PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
61
+ //
62
+ // const result = { google: { id: 'google', name: 'Google' } };
63
+ //
64
+ // under a comment claiming providers were "loaded dynamically". It
65
+ // returned Google for EVERY client whatever the tenant had enabled -
66
+ // a config-shaped constant, so Apple/Facebook (PAY-2104) could never
67
+ // appear and a client that had DISABLED Google would still see its
68
+ // button. The real answer is served by /api/auth/settings (the same
69
+ // route routes/auth/settings.ts builds from the IDP's oauthProviders).
70
+ const response = await fetch('/api/auth/settings', { cache: 'no-store' });
66
71
  if (!mounted)
67
72
  return;
68
- setRawProviders(result);
69
- if (result) {
70
- // Filter to OAuth providers only (exclude credentials)
71
- // Map to our FederatedProvider type
72
- const oauthProviders = Object.keys(result)
73
- .filter(id => OAUTH_PROVIDERS.includes(id))
74
- .map(id => PROVIDER_MAP[id])
75
- .filter((p) => p !== undefined);
76
- setProviders(oauthProviders);
73
+ if (!response.ok) {
74
+ // FAIL CLOSED. An unreadable policy is not an empty provider list
75
+ // that we may then pad with a guess - it is "we do not know". See
76
+ // the PAY-1243 rule: never fabricate a provider list.
77
+ throw new Error(`Failed to fetch auth settings: ${response.status}`);
77
78
  }
78
- else {
79
- setProviders([]);
79
+ const body = await response.json().catch(() => null);
80
+ const settings = body?.data ?? body;
81
+ if (body?.success === false || !settings) {
82
+ throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
80
83
  }
84
+ // Keep the raw provider rows (id -> the settings row) for callers that
85
+ // want more than the mapped union type, exactly as before.
86
+ const enabledIds = Array.isArray(settings.enabledProviders)
87
+ ? settings.enabledProviders.filter((p) => typeof p === 'string')
88
+ : [];
89
+ const raw = {};
90
+ for (const id of enabledIds)
91
+ raw[id] = { id, name: id };
92
+ if (!mounted)
93
+ return;
94
+ setRawProviders(raw);
95
+ // Filter to OAuth providers only (exclude credentials) and map to our
96
+ // FederatedProvider union, in the IDP's order.
97
+ const oauthProviders = enabledIds
98
+ .filter(id => OAUTH_PROVIDERS.includes(id))
99
+ .map(id => PROVIDER_MAP[id])
100
+ .filter((p) => p !== undefined);
101
+ setProviders(oauthProviders);
81
102
  setError(null);
82
103
  }
83
104
  catch (err) {
@@ -85,6 +106,7 @@ function useAvailableProviders() {
85
106
  return;
86
107
  setError(err instanceof Error ? err : new Error(String(err)));
87
108
  setProviders([]);
109
+ setRawProviders(null); // never leave a stale list behind a failure
88
110
  }
89
111
  finally {
90
112
  if (mounted) {
@@ -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)