@payez/next-mvp 4.4.4 → 4.4.6

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.
Files changed (189) hide show
  1. package/README.md +10 -3
  2. package/dist/api/auth-handler.js +5 -3
  3. package/dist/api-handlers/account/change-password.js +6 -3
  4. package/dist/api-handlers/account/masked-info.js +2 -1
  5. package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
  6. package/dist/api-handlers/account/recovery/forward.js +105 -0
  7. package/dist/api-handlers/account/recovery/initiate.js +4 -0
  8. package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
  9. package/dist/api-handlers/account/recovery/send-code.js +11 -84
  10. package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
  11. package/dist/api-handlers/account/recovery/verify-code.js +10 -84
  12. package/dist/api-handlers/account/reset-password.js +24 -1
  13. package/dist/api-handlers/account/send-code.d.ts +1 -15
  14. package/dist/api-handlers/account/send-code.js +3 -6
  15. package/dist/api-handlers/account/update-phone.js +6 -1
  16. package/dist/api-handlers/account/validate-password.js +3 -1
  17. package/dist/api-handlers/account/verify-email.d.ts +1 -17
  18. package/dist/api-handlers/account/verify-email.js +4 -6
  19. package/dist/api-handlers/account/verify-sms.d.ts +1 -17
  20. package/dist/api-handlers/account/verify-sms.js +4 -6
  21. package/dist/api-handlers/admin/analytics.js +12 -4
  22. package/dist/api-handlers/admin/audit.js +12 -4
  23. package/dist/api-handlers/admin/redis-sessions.js +16 -8
  24. package/dist/api-handlers/admin/sessions.js +14 -6
  25. package/dist/api-handlers/admin/site-logs.js +39 -16
  26. package/dist/api-handlers/admin/stats.js +14 -4
  27. package/dist/api-handlers/admin/users.js +14 -6
  28. package/dist/api-handlers/admin/vibe-data.js +19 -11
  29. package/dist/api-handlers/auth/jwks.js +2 -1
  30. package/dist/api-handlers/auth/login.js +13 -4
  31. package/dist/api-handlers/auth/passkey-signin.js +6 -0
  32. package/dist/api-handlers/auth/refresh.js +38 -11
  33. package/dist/api-handlers/auth/signout.js +2 -1
  34. package/dist/api-handlers/auth/status.js +4 -1
  35. package/dist/api-handlers/auth/update-session.d.ts +25 -4
  36. package/dist/api-handlers/auth/update-session.js +38 -11
  37. package/dist/api-handlers/auth/validate.js +4 -2
  38. package/dist/api-handlers/auth/verify-code.js +3 -2
  39. package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
  40. package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
  41. package/dist/api-handlers/mfa/backup-codes.js +2 -1
  42. package/dist/api-handlers/mfa/methods.js +2 -1
  43. package/dist/api-handlers/mfa/totp-confirm.js +2 -1
  44. package/dist/api-handlers/mfa/totp-enroll.js +2 -1
  45. package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
  46. package/dist/api-handlers/mfa/totp-verify.js +4 -6
  47. package/dist/api-handlers/passkeys/list.js +2 -1
  48. package/dist/api-handlers/passkeys/manage.js +2 -1
  49. package/dist/api-handlers/passkeys/register-options.js +2 -1
  50. package/dist/api-handlers/passkeys/register-verify.js +2 -1
  51. package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
  52. package/dist/api-handlers/session/refresh-viability.js +10 -7
  53. package/dist/api-handlers/session/viability.js +4 -3
  54. package/dist/api-handlers/test/force-expire.js +5 -3
  55. package/dist/auth/auth-decision.js +2 -1
  56. package/dist/auth/better-auth.d.ts +14 -0
  57. package/dist/auth/better-auth.js +33 -13
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/components/admin/DataBrowserTab.js +4 -1
  60. package/dist/components/admin/SessionsTab.js +9 -3
  61. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  62. package/dist/components/recovery/RequestedStep.js +14 -0
  63. package/dist/components/recovery/SetPasswordStep.js +10 -1
  64. package/dist/lib/app-slug.d.ts +21 -0
  65. package/dist/lib/app-slug.js +51 -0
  66. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  67. package/dist/lib/ensure-fresh-access-token.js +105 -4
  68. package/dist/lib/idp-client-config.d.ts +20 -0
  69. package/dist/lib/idp-client-config.js +65 -14
  70. package/dist/lib/idp-fetch.js +1 -1
  71. package/dist/lib/password-rules.d.ts +22 -0
  72. package/dist/lib/password-rules.js +43 -0
  73. package/dist/lib/safe-error-response.d.ts +67 -0
  74. package/dist/lib/safe-error-response.js +130 -0
  75. package/dist/lib/session-cookie-shape.d.ts +22 -19
  76. package/dist/lib/session-cookie-shape.js +14 -18
  77. package/dist/lib/session-store.d.ts +8 -1
  78. package/dist/lib/session-store.js +170 -24
  79. package/dist/lib/startup-init.d.ts +6 -0
  80. package/dist/lib/startup-init.js +30 -4
  81. package/dist/lib/token-lifecycle.js +2 -1
  82. package/dist/lib/token-log-id.d.ts +18 -0
  83. package/dist/lib/token-log-id.js +84 -0
  84. package/dist/lib/two-factor-verify-failure.d.ts +45 -0
  85. package/dist/lib/two-factor-verify-failure.js +143 -0
  86. package/dist/middleware/create-middleware.d.ts +2 -1
  87. package/dist/middleware/create-middleware.js +43 -8
  88. package/dist/pages/recovery/page.js +14 -4
  89. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  90. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  91. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  92. package/dist/pages/test-env/redact-credentials.js +101 -0
  93. package/dist/pages/verify-code/page.js +2 -1
  94. package/dist/routes/auth/logout.d.ts +2 -1
  95. package/dist/routes/auth/logout.js +4 -2
  96. package/dist/routes/auth/session.d.ts +0 -3
  97. package/dist/routes/auth/session.js +16 -10
  98. package/dist/routes/auth/viability.d.ts +3 -2
  99. package/dist/routes/auth/viability.js +11 -7
  100. package/dist/server/auth.js +10 -6
  101. package/dist/server/decode-session.js +15 -26
  102. package/dist/server/verified-session-token.d.ts +13 -0
  103. package/dist/server/verified-session-token.js +103 -0
  104. package/dist/stores/authStore.js +14 -3
  105. package/dist/types/recovery.d.ts +1 -1
  106. package/dist/utils/api.d.ts +18 -0
  107. package/dist/utils/api.js +51 -9
  108. package/dist/vibe/sessions.js +2 -1
  109. package/package.json +2 -1
  110. package/scripts/verify-release-provenance.mjs +410 -0
  111. package/src/api/auth-handler.ts +5 -3
  112. package/src/api-handlers/account/change-password.ts +6 -3
  113. package/src/api-handlers/account/masked-info.ts +2 -1
  114. package/src/api-handlers/account/recovery/forward.ts +117 -0
  115. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  116. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  117. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  118. package/src/api-handlers/account/reset-password.ts +30 -1
  119. package/src/api-handlers/account/send-code.ts +3 -9
  120. package/src/api-handlers/account/update-phone.ts +6 -1
  121. package/src/api-handlers/account/validate-password.ts +3 -1
  122. package/src/api-handlers/account/verify-email.ts +4 -9
  123. package/src/api-handlers/account/verify-sms.ts +4 -9
  124. package/src/api-handlers/admin/analytics.ts +13 -5
  125. package/src/api-handlers/admin/audit.ts +13 -5
  126. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  127. package/src/api-handlers/admin/sessions.ts +15 -7
  128. package/src/api-handlers/admin/site-logs.ts +39 -16
  129. package/src/api-handlers/admin/stats.ts +15 -5
  130. package/src/api-handlers/admin/users.ts +15 -7
  131. package/src/api-handlers/admin/vibe-data.ts +20 -12
  132. package/src/api-handlers/auth/jwks.ts +2 -1
  133. package/src/api-handlers/auth/login.ts +13 -4
  134. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  135. package/src/api-handlers/auth/refresh.ts +38 -11
  136. package/src/api-handlers/auth/signout.ts +2 -1
  137. package/src/api-handlers/auth/status.ts +4 -1
  138. package/src/api-handlers/auth/update-session.ts +45 -11
  139. package/src/api-handlers/auth/validate.ts +4 -2
  140. package/src/api-handlers/auth/verify-code.ts +3 -2
  141. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  142. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  143. package/src/api-handlers/mfa/methods.ts +2 -1
  144. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  145. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  146. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  147. package/src/api-handlers/passkeys/list.ts +2 -1
  148. package/src/api-handlers/passkeys/manage.ts +2 -1
  149. package/src/api-handlers/passkeys/register-options.ts +2 -1
  150. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  151. package/src/api-handlers/session/refresh-viability.ts +10 -7
  152. package/src/api-handlers/session/viability.ts +4 -3
  153. package/src/api-handlers/test/force-expire.ts +5 -3
  154. package/src/auth/auth-decision.ts +2 -1
  155. package/src/auth/better-auth.ts +35 -10
  156. package/src/auth/utils/idp-client.ts +2 -1
  157. package/src/components/admin/DataBrowserTab.tsx +4 -1
  158. package/src/components/admin/SessionsTab.tsx +10 -3
  159. package/src/components/recovery/RequestedStep.tsx +30 -0
  160. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  161. package/src/hooks/useViabilitySession.ts +2 -1
  162. package/src/lib/app-slug.ts +50 -0
  163. package/src/lib/ensure-fresh-access-token.ts +133 -4
  164. package/src/lib/idp-client-config.ts +90 -14
  165. package/src/lib/idp-fetch.ts +1 -1
  166. package/src/lib/password-rules.ts +50 -0
  167. package/src/lib/safe-error-response.ts +126 -0
  168. package/src/lib/session-cookie-shape.ts +23 -22
  169. package/src/lib/session-store.ts +167 -24
  170. package/src/lib/startup-init.ts +29 -4
  171. package/src/lib/token-lifecycle.ts +2 -1
  172. package/src/lib/token-log-id.ts +69 -0
  173. package/src/lib/two-factor-verify-failure.ts +167 -0
  174. package/src/middleware/create-middleware.ts +55 -9
  175. package/src/pages/recovery/page.tsx +21 -4
  176. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  177. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  178. package/src/pages/test-env/redact-credentials.ts +96 -0
  179. package/src/pages/verify-code/page.tsx +2 -1
  180. package/src/routes/auth/logout.ts +4 -2
  181. package/src/routes/auth/session.ts +16 -10
  182. package/src/routes/auth/viability.ts +11 -7
  183. package/src/server/auth.ts +10 -6
  184. package/src/server/decode-session.ts +16 -26
  185. package/src/server/verified-session-token.ts +103 -0
  186. package/src/stores/authStore.ts +16 -5
  187. package/src/types/recovery.ts +1 -1
  188. package/src/utils/api.ts +47 -8
  189. package/src/vibe/sessions.ts +2 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { authClient } from '../../client/better-auth-client';
4
4
  import { useState, useEffect } from 'react';
5
+ import { maskToken, redactCredentials } from './redact-credentials';
5
6
 
6
7
  // Decode JWT header (contains kid, alg, typ)
7
8
  function decodeJwtHeader(token: string): any {
@@ -70,7 +71,6 @@ export function JwtInspectPage() {
70
71
  const { data: sessionData, isPending } = authClient.useSession();
71
72
  const session = sessionData;
72
73
  const status = isPending ? 'loading' : session ? 'authenticated' : 'unauthenticated';
73
- const [copied, setCopied] = useState<string | null>(null);
74
74
  const [isDarkMode, setIsDarkMode] = useState(false);
75
75
  const [jwtHeader, setJwtHeader] = useState<any>(null);
76
76
  const [jwtPayload, setJwtPayload] = useState<any>(null);
@@ -109,116 +109,10 @@ export function JwtInspectPage() {
109
109
  }
110
110
  }, [serverSession, session]);
111
111
 
112
- // 248991 item 3 (QAPert 35021) - CREDENTIALS ARE REDACTED UNTIL DELIBERATELY
113
- // REVEALED.
114
- //
115
- // Before this, loading the page put a live session token on screen and one
116
- // click from the clipboard. That is the precondition the 246007 finding needs
117
- // ("a caller who knows a session token can act on a session it does not own"),
118
- // so this page was the supply side of the attack the same package was being
119
- // hardened against. A screen-share, a screenshot or a support call was enough.
120
- //
121
- // The fix is deliberately NOT removing the tokens or the copy button: replaying
122
- // a bearer in curl is the entire reason this tool exists, and a debug tool that
123
- // cannot show you the thing you are debugging just gets replaced by a worse
124
- // ad-hoc one. What changes is that exposure now requires an INTENTIONAL ACT per
125
- // token, so the default state of a loaded page - the state a bystander, a
126
- // screen-share or a screenshot captures - carries no usable credential.
127
- //
128
- // NOTE: this is a UI affordance, not an access control. Anyone who can load
129
- // this page can still click reveal, and the values are in the network response
130
- // either way. It shrinks accidental disclosure; it does not gate the route.
131
- // The gate is mounting - see this file's docstring and index.ts.
132
- const [revealed, setRevealed] = useState<Record<string, boolean>>({});
133
-
134
- const maskToken = (value: string): string => {
135
- if (value.length <= 12) return '\u2022'.repeat(Math.max(value.length, 8));
136
- return `${value.slice(0, 6)}${'\u2022'.repeat(12)}${value.slice(-4)} (${value.length} chars, hidden)`;
137
- };
138
-
139
- // Keys whose STRING values are live credentials. Anchored at the END of the key
140
- // name on purpose: it catches sessionToken / accessToken / refreshToken /
141
- // idpRefreshToken, while deliberately NOT catching the metadata beside them -
142
- // accessTokenExpires, idpAccessTokenExpires, tokenType - which are safe and are
143
- // the fields you actually want when debugging an expiry problem.
144
- const CREDENTIAL_KEY = /(token|secret|password|apikey|credential)$/i;
145
-
146
- // VALUE-SHAPE checks, because a key-name sweep has a blind spot that this page
147
- // is squarely in: a credential under an innocuous key. Scout hit the same class
148
- // elsewhere today (35297) - a postgres URL whose only key-name hit was a
149
- // POINTER to the secret, while the actual password sat in the URL value and the
150
- // sweep scored the file SAFE. Here the equivalents are a raw JWT parked under
151
- // `jwt` / `bearer` / `authorization` / `cookie` - none of which end in `token`.
152
- //
153
- // JWT: three base64url segments. The length floors matter - without them
154
- // "4.1.42" matches the three-segment shape and the package version would render
155
- // as a redacted credential, which teaches people the redactor is broken.
156
- const looksLikeJwt = (v: string): boolean => {
157
- if (v.length < 40) return false;
158
- const parts = v.split('.');
159
- return parts.length === 3 && parts.every((p) => p.length >= 8 && /^[A-Za-z0-9_-]+$/.test(p));
160
- };
161
-
162
- // scheme://user:password@host - redact ONLY the password and keep the rest, so
163
- // the URL stays usable for debugging. Masking the whole value would hide which
164
- // host you are pointed at, which is usually the thing you opened this page for.
165
- const URL_WITH_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/i;
166
-
167
- // Third value-shape check, on DotNetPert-Scout's 251951 rule: "a content-pattern
168
- // sweep CANNOT find a credential that has no pattern." A PEM block defeats BOTH
169
- // checks above and the key-name check as well - it has no key=value pair, its key
170
- // name is usually something innocuous like `cert`, `pem`, `chain` or `data`, it is
171
- // not three base64url segments so looksLikeJwt rejects it, and it is not a URL.
172
- // He found a live wildcard TLS private key that had sat committed for 215 days for
173
- // exactly this reason: it was never in anyone's search space, missed by
174
- // construction rather than by carelessness.
175
- //
176
- // HONEST SCOPE: this is defence-in-depth, not a measured exposure. I have not seen
177
- // PEM material in a session object and would not expect to. It is here because this
178
- // dump renders WHATEVER the session carries, and the entire lesson of the two
179
- // previous hardenings on this function is that the next credential arrives in a
180
- // shape the current checks were not written for. Matching the armour band rather
181
- // than the payload costs one regex.
182
- // Matches the PEM ARMOUR BAND itself rather than enumerating key types. A first
183
- // draft listed PRIVATE KEY / CERTIFICATE / RSA / EC / OPENSSH / PGP and a test
184
- // caught it missing plain "-----BEGIN CERTIFICATE-----", because that header has
185
- // no space before the closing dashes while "-----BEGIN RSA PRIVATE KEY-----" does.
186
- // Enumerating the payload types is the same mistake as enumerating credential key
187
- // names, one level down: it only ever covers the shapes already thought of.
188
- // Requiring an alphanumeric after "BEGIN " keeps prose containing the word, and a
189
- // bare "-----BEGIN-----", from matching.
190
- const PEM_BLOCK = /-----BEGIN [A-Z0-9][A-Z0-9 ]*-----/;
191
-
192
- // 248991 item 3, SECOND HALF. The masked fields above were only half the fix:
193
- // the Raw Session Data block below re-rendered the SAME tokens in plain text via
194
- // JSON.stringify, one click away behind a neutral "Click to expand" summary - and
195
- // with them idpRefreshToken, which has NO other raw surface on this page (it is
196
- // rendered as a boolean at the Refresh Token row precisely because it is the
197
- // longest-lived credential of the three). Redacting per-field while dumping the
198
- // whole object verbatim is not a partial fix, it is the appearance of one.
199
- const redactCredentials = (value: unknown, keyName = ''): unknown => {
200
- if (Array.isArray(value)) return value.map((v) => redactCredentials(v));
201
- if (value && typeof value === 'object') {
202
- return Object.fromEntries(
203
- Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactCredentials(v, k)]),
204
- );
205
- }
206
- if (typeof value !== 'string' || value.length === 0) return value;
207
- if (CREDENTIAL_KEY.test(keyName)) return maskToken(value);
208
- // Key-name matching alone is not enough (DotNetPert-Scout 35297): a credential
209
- // can sit under a key that is not credential-shaped at all. Two value-shape
210
- // checks close the cases that actually reach a session object:
211
- if (looksLikeJwt(value)) return maskToken(value);
212
- if (PEM_BLOCK.test(value)) return '••• PEM block redacted (' + value.length + ' chars) •••';
213
- if (URL_WITH_USERINFO.test(value)) return value.replace(URL_WITH_USERINFO, '$1$2:•••••@');
214
- return value;
215
- };
216
-
217
- const copyToClipboard = (text: string, label: string) => {
218
- navigator.clipboard.writeText(text);
219
- setCopied(label);
220
- setTimeout(() => setCopied(null), 2000);
221
- };
112
+ // PAY-1470 (BAPert rulings 59022, 59040): credentials are shown as tokenLogId + length ONLY. The Reveal toggles
113
+ // (per token and for the raw session JSON) and the Copy buttons are gone: "never renders token material"
114
+ // includes click-to-reveal, because a full bearer one click away on a prod-reachable page is the same defect.
115
+ // The redaction rules (key names, JWT / PEM / URL-password shapes) live in ./redact-credentials.
222
116
 
223
117
  if (status === 'loading') {
224
118
  return (
@@ -371,58 +265,18 @@ export function JwtInspectPage() {
371
265
  <div>
372
266
  <div className="flex items-center justify-between mb-2">
373
267
  <span className={labelClass}>Session Token (Redis Key):</span>
374
- {displayExt.sessionToken && (
375
- <div className="flex items-center gap-2">
376
- <RevealButton
377
- revealed={!!revealed.session}
378
- onClick={() => setRevealed((r) => ({ ...r, session: !r.session }))}
379
- isDarkMode={isDarkMode}
380
- />
381
- {revealed.session && (
382
- <CopyButton
383
- onClick={() => copyToClipboard(displayExt.sessionToken, 'session')}
384
- copied={copied === 'session'}
385
- isDarkMode={isDarkMode}
386
- />
387
- )}
388
- </div>
389
- )}
390
268
  </div>
391
269
  <code className={`block p-2 rounded text-xs break-all ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
392
- {displayExt.sessionToken
393
- ? revealed.session
394
- ? displayExt.sessionToken
395
- : maskToken(displayExt.sessionToken)
396
- : 'N/A'}
270
+ {displayExt.sessionToken ? maskToken(displayExt.sessionToken) : 'N/A'}
397
271
  </code>
398
272
  </div>
399
273
 
400
274
  <div>
401
275
  <div className="flex items-center justify-between mb-2">
402
276
  <span className={labelClass}>Access Token (IDP):</span>
403
- {displayExt.accessToken && (
404
- <div className="flex items-center gap-2">
405
- <RevealButton
406
- revealed={!!revealed.access}
407
- onClick={() => setRevealed((r) => ({ ...r, access: !r.access }))}
408
- isDarkMode={isDarkMode}
409
- />
410
- {revealed.access && (
411
- <CopyButton
412
- onClick={() => copyToClipboard(displayExt.accessToken, 'access')}
413
- copied={copied === 'access'}
414
- isDarkMode={isDarkMode}
415
- />
416
- )}
417
- </div>
418
- )}
419
277
  </div>
420
278
  <code className={`block p-2 rounded text-xs break-all max-h-24 overflow-auto ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
421
- {displayExt.accessToken
422
- ? revealed.access
423
- ? displayExt.accessToken
424
- : maskToken(displayExt.accessToken)
425
- : 'N/A'}
279
+ {displayExt.accessToken ? maskToken(displayExt.accessToken) : 'N/A'}
426
280
  </code>
427
281
  </div>
428
282
 
@@ -465,7 +319,7 @@ export function JwtInspectPage() {
465
319
  <InfoRow
466
320
  key={key}
467
321
  label={key}
468
- value={typeof value === 'object' ? JSON.stringify(value) : String(value)}
322
+ value={typeof value === 'object' ? JSON.stringify(redactCredentials(value)) : String(redactCredentials(value, key))}
469
323
  labelClass={labelClass}
470
324
  valueClass={valueClass}
471
325
  />
@@ -531,26 +385,13 @@ export function JwtInspectPage() {
531
385
  <summary className="font-semibold cursor-pointer">
532
386
  Raw Session Data &mdash; credentials redacted (Click to expand)
533
387
  </summary>
534
- <div className="mt-3 flex items-center gap-3">
535
- <RevealButton
536
- revealed={!!revealed.raw}
537
- onClick={() => setRevealed((r) => ({ ...r, raw: !r.raw }))}
538
- isDarkMode={isDarkMode}
539
- />
388
+ <div className="mt-3">
540
389
  <span className={`text-xs ${labelClass}`}>
541
- {revealed.raw
542
- ? 'Showing LIVE credentials in plain text, including the refresh token.'
543
- : 'Token values are masked. Structure, claims and expiry fields are unchanged.'}
390
+ Token values are masked. Structure, claims and expiry fields are unchanged.
544
391
  </span>
545
392
  </div>
546
393
  <pre className={`mt-4 text-xs overflow-auto p-3 rounded ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
547
- {JSON.stringify(
548
- revealed.raw
549
- ? { clientSession: session, serverSession: serverSession }
550
- : redactCredentials({ clientSession: session, serverSession: serverSession }),
551
- null,
552
- 2,
553
- )}
394
+ {JSON.stringify(redactCredentials({ clientSession: session, serverSession: serverSession }), null, 2)}
554
395
  </pre>
555
396
  </details>
556
397
  </div>
@@ -604,68 +445,5 @@ function StatusBadge({
604
445
  );
605
446
  }
606
447
 
607
- function CopyButton({
608
- onClick,
609
- copied,
610
- isDarkMode,
611
- }: {
612
- onClick: () => void;
613
- copied: boolean;
614
- isDarkMode: boolean;
615
- }) {
616
- return (
617
- <button
618
- onClick={onClick}
619
- className={`px-2 py-1 rounded text-xs ${
620
- copied
621
- ? 'bg-green-600 text-white'
622
- : isDarkMode
623
- ? 'bg-slate-700 hover:bg-slate-600 text-white'
624
- : 'bg-gray-200 hover:bg-gray-300 text-gray-700'
625
- }`}
626
- >
627
- {copied ? 'Copied!' : 'Copy'}
628
- </button>
629
- );
630
- }
631
-
632
- /**
633
- * Reveal toggle for a redacted credential (248991 item 3).
634
- *
635
- * Deliberately labelled "Reveal token" rather than something neutral like
636
- * "Show": the label is the last chance to tell whoever is about to click it,
637
- * possibly while screen-sharing, that a live credential is about to appear.
638
- */
639
- function RevealButton({
640
- revealed,
641
- onClick,
642
- isDarkMode,
643
- }: {
644
- revealed: boolean;
645
- onClick: () => void;
646
- isDarkMode: boolean;
647
- }) {
648
- return (
649
- <button
650
- onClick={onClick}
651
- title={
652
- revealed
653
- ? 'Hide this credential again'
654
- : 'Reveals a LIVE credential in plain text. Not while screen-sharing.'
655
- }
656
- className={`px-2 py-1 rounded text-xs ${
657
- revealed
658
- ? isDarkMode
659
- ? 'bg-amber-700 hover:bg-amber-600 text-white'
660
- : 'bg-amber-200 hover:bg-amber-300 text-amber-900'
661
- : isDarkMode
662
- ? 'bg-slate-700 hover:bg-slate-600 text-white'
663
- : 'bg-gray-200 hover:bg-gray-300 text-gray-700'
664
- }`}
665
- >
666
- {revealed ? 'Hide' : 'Reveal token'}
667
- </button>
668
- );
669
- }
670
448
 
671
449
  export default JwtInspectPage;
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
  import React, { useState, useEffect } from "react";
3
3
  import { authClient } from '../../client/better-auth-client';
4
+ // PAY-1470 (BAPert 59022): never render characters of a token, whatever the ENABLE_TEST_ROUTES gate says.
5
+ // idealresume production mounted this page on 4.1.14 (pre-gate) showing 40 chars of both tokens.
6
+ import { redactCredentials } from './redact-credentials';
4
7
 
5
8
  /**
6
9
  * ⚠️ DEBUG PAGE THAT DRIVES A DESTRUCTIVE ENDPOINT — DO NOT MOUNT IN
@@ -112,11 +115,12 @@ export function RefreshTokenPage() {
112
115
  <div><span className="text-gray-400">2FA Complete:</span> {String((session?.user as any)?.twoFactorSessionVerified ?? "unknown")}</div>
113
116
  <div>
114
117
  <span className="text-gray-400">Access Token:</span>{" "}
115
- {sessionDetails?.accessToken ? `${sessionDetails.accessToken.substring(0, 40)}...` : "N/A"}
118
+ {/* PAY-1481: the session route returns presence only. */}
119
+ {sessionDetails ? (sessionDetails.hasAccessToken ? "present (server-side only)" : "absent") : "N/A"}
116
120
  </div>
117
121
  <div>
118
122
  <span className="text-gray-400">Refresh Token:</span>{" "}
119
- {sessionDetails?.refreshToken ? `${sessionDetails.refreshToken.substring(0, 40)}...` : "N/A"}
123
+ {sessionDetails ? (sessionDetails.hasRefreshToken ? "present (server-side only)" : "absent") : "N/A"}
120
124
  </div>
121
125
  <div>
122
126
  <span className="text-gray-400">Access Expires:</span>{" "}
@@ -154,7 +158,7 @@ export function RefreshTokenPage() {
154
158
  <div className="rounded border border-gray-600 bg-gray-800 p-4">
155
159
  <h3 className="font-semibold mb-2 text-gray-300">Result:</h3>
156
160
  <pre className="text-xs overflow-x-auto whitespace-pre-wrap text-green-400">
157
- {JSON.stringify(result, null, 2)}
161
+ {JSON.stringify(redactCredentials(result), null, 2)}
158
162
  </pre>
159
163
  </div>
160
164
  )}
@@ -163,7 +167,8 @@ export function RefreshTokenPage() {
163
167
  <details className="mt-4">
164
168
  <summary className="cursor-pointer text-gray-400 hover:text-white">Raw Session Details</summary>
165
169
  <pre className="mt-2 text-xs bg-gray-800 p-2 rounded overflow-x-auto text-gray-300">
166
- {JSON.stringify(sessionDetails, null, 2)}
170
+ {/* PAY-1470: this dumped the access AND refresh token in full (Nextpert-Scout 59060). */}
171
+ {JSON.stringify(redactCredentials(sessionDetails), null, 2)}
167
172
  </pre>
168
173
  </details>
169
174
  </div>
@@ -0,0 +1,96 @@
1
+ /**
2
+ * redact-credentials - what the test-env debug pages may show of a credential: a stable hash and a length, never
3
+ * characters of the value (PAY-1470, BAPert rulings 59022 and 59040).
4
+ *
5
+ * PAY-1481 item 2 (QAPert, PR 186 review 59083): this was a DENYLIST (keys ending in token/secret/password/...).
6
+ * Opaque credentials under `tokens.access`, `redisSessionId`, `sid`, `authorization`, `cookie`, `backupCodes[]` and
7
+ * `code` passed unmasked. It is now an ALLOWLIST: a string is shown only when its key is a known DISPLAYABLE field
8
+ * (identity, roles, timestamps, provider and flag metadata, standard JWT claims). Every other string - an unknown
9
+ * key, an array item under an unknown key, a top-level string - becomes maskToken. A new field is hidden until
10
+ * someone decides it is displayable, which is the safe direction for a page reachable in production builds.
11
+ *
12
+ * Kept from the denylist era, and applied EVEN under a displayable key, because a credential can sit under an
13
+ * innocent name: a JWT (three base64url segments, length floors so "4.1.42" is not one), a PEM armour band, and the
14
+ * password in scheme://user:password@host. Numbers, booleans and null are never credentials and stay readable.
15
+ *
16
+ * There is no Reveal and no copy. "Never renders token material" includes click-to-reveal (59040).
17
+ */
18
+ import { tokenLogId } from '../../lib/token-log-id';
19
+
20
+ /** A stable, non-reversible stand-in for a credential: `h:<12 hex> (<n> chars, hidden)`. */
21
+ export function maskToken(value: string): string {
22
+ return `${tokenLogId(value)} (${value.length} chars, hidden)`;
23
+ }
24
+
25
+ /**
26
+ * Keys whose string values are displayable. Exact names (case-insensitive), plus timestamp / flag suffix patterns.
27
+ * An exact listed key wins (tokenType is metadata); otherwise a key naming a credential (CREDENTIAL_NAME) is refused.
28
+ */
29
+ const DISPLAYABLE_KEYS = new Set(
30
+ [
31
+ // identity
32
+ 'id', 'userId', 'user_id', 'sub', 'email', 'name', 'given_name', 'family_name', 'preferred_username', 'image',
33
+ 'picture', 'locale', 'phone_number_verified',
34
+ // authorisation metadata
35
+ 'role', 'roles', 'scope', 'scopes', 'permissions', 'authenticationMethods', 'authenticationLevel', 'amr', 'acr',
36
+ 'oauthProvider', 'provider', 'providerId', 'tokenType', 'token_type', 'type', 'typ', 'alg', 'kid', 'iss', 'aud',
37
+ 'azp', 'jti',
38
+ // client / tenant
39
+ 'clientId', 'client_id', 'idpClientId', 'clientSlug', 'slug', 'tenant', 'tenantId', 'merchantId', 'appSlug',
40
+ // result / status fields of the test-env routes
41
+ 'status', 'statusText', 'success', 'action', 'message', 'error', 'reason', 'outcome', 'source', 'shape', 'mode',
42
+ ].map((k) => k.toLowerCase()),
43
+ );
44
+ /** Timestamps, durations and booleans-as-strings by suffix: expires, accessTokenExpires, mfaCompletedAt, iat... */
45
+ const DISPLAYABLE_SUFFIX = /(expires|_at|iat|exp|nbf|hours|seconds|verified|required|enabled|count|version)$/i;
46
+ /** camelCase timestamps (createdAt, mfaCompletedAt): tested on the ORIGINAL key so `format` / `chat` do not match. */
47
+ const CAMEL_AT = /[a-z0-9]At$/;
48
+ /** A key that names a credential is never displayable, whatever suffix it has (e.g. refreshTokenCreatedAt stays OK
49
+ * because it names a TIME; `authorization`, `cookie`, `sid`, `code` are credentials). */
50
+ const CREDENTIAL_NAME = /(token|secret|password|passwd|apikey|api_key|credential|authorization|cookie|^sid$|session_?id$|^code$|codes?$|otp|pin$|signature|private)/i;
51
+
52
+ const URL_WITH_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/i;
53
+ const PEM_BLOCK = /-----BEGIN [A-Z0-9][A-Z0-9 ]*-----/;
54
+
55
+ function looksLikeJwt(v: string): boolean {
56
+ if (v.length < 40) return false;
57
+ const parts = v.split('.');
58
+ return parts.length === 3 && parts.every((p) => p.length >= 8 && /^[A-Za-z0-9_-]+$/.test(p));
59
+ }
60
+
61
+ /** Is a string under this key displayable? Credential names lose even when a timestamp suffix matches. */
62
+ export function isDisplayableKey(keyName: string): boolean {
63
+ if (!keyName) return false;
64
+ const k = keyName.toLowerCase();
65
+ // 1. An exact, deliberately listed field (tokenType is metadata even though it says "token").
66
+ if (DISPLAYABLE_KEYS.has(k)) return true;
67
+ // 2. A key naming a credential is refused - unless what it names is a TIME about it (accessTokenExpires).
68
+ const namesTime = /(expires|_at|iat|exp|nbf|hours|seconds)$/i.test(k) || CAMEL_AT.test(keyName);
69
+ if (CREDENTIAL_NAME.test(k) && !namesTime) return false;
70
+ // 3. Metadata by suffix: timestamps, durations, flags, counts, versions, URLs (userinfo is still masked).
71
+ return DISPLAYABLE_SUFFIX.test(k) || CAMEL_AT.test(keyName) || /url$/i.test(k);
72
+ }
73
+
74
+ /** A deep copy of `value` in which every string not under a displayable key is replaced by maskToken. */
75
+ export function redactCredentials(value: unknown, keyName = ''): unknown {
76
+ // Array items inherit the array's key: backupCodes[] items are credentials, roles[] items are not.
77
+ if (Array.isArray(value)) return value.map((v) => redactCredentials(v, keyName));
78
+ if (value && typeof value === 'object') {
79
+ return Object.fromEntries(
80
+ Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactCredentials(v, k)]),
81
+ );
82
+ }
83
+ // NightHawk (PR 204 review): a numeric OTP / code under a credential-named key is a credential too.
84
+ if (typeof value === 'number' && keyName && !isDisplayableKey(keyName) && CREDENTIAL_NAME.test(keyName)) {
85
+ return maskToken(String(value));
86
+ }
87
+ if (typeof value !== 'string' || value.length === 0) return value;
88
+ if (looksLikeJwt(value)) return maskToken(value);
89
+ if (PEM_BLOCK.test(value)) return '••• PEM block redacted (' + value.length + ' chars) •••';
90
+ if (!isDisplayableKey(keyName)) return maskToken(value);
91
+ let shown = value;
92
+ if (URL_WITH_USERINFO.test(shown)) shown = shown.replace(URL_WITH_USERINFO, '$1$2:•••••@');
93
+ // NightHawk (PR 204 review): a URL's query and fragment carry tokens (?token=, #access_token=). Names stay, values go.
94
+ shown = shown.replace(/([?&#][^=&#\s]+=)[^&#\s]*/g, '$1•••');
95
+ return shown;
96
+ }
@@ -458,7 +458,8 @@ function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
458
458
  if (!updateRes.ok) {
459
459
  console.warn('[2FA] update-session returned non-OK status:', updateRes.status);
460
460
  const errorData = await updateRes.json();
461
- console.warn('[2FA] update-session error:', errorData);
461
+ // PAY-1427: the request carried access_token/refresh_token; log the error envelope's shape, never its contents.
462
+ console.warn('[2FA] update-session error:', { keys: errorData && typeof errorData === 'object' ? Object.keys(errorData) : typeof errorData, code: errorData?.error?.code ?? errorData?.code });
462
463
  } else {
463
464
  console.log('[2FA] Session updated successfully with MFA tokens');
464
465
  }
@@ -25,6 +25,7 @@ import {
25
25
  getCallbackUrlCookieName,
26
26
  } from '../../lib/app-slug';
27
27
  import { siteEvents, getClientIp } from '../../lib/site-logger';
28
+ import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
28
29
 
29
30
  /**
30
31
  * POST /api/auth/logout - Sign out and clean up session
@@ -101,10 +102,11 @@ export async function POST(req: NextRequest) {
101
102
 
102
103
  return response;
103
104
  } catch (error) {
104
- console.error('[LOGOUT_ROUTE] Error during logout:', error);
105
+ const correlationId = logExceptionWithCorrelation('[LOGOUT_ROUTE] Error during logout:', error);
105
106
  return NextResponse.json({
106
107
  error: 'Failed to logout',
107
- details: error instanceof Error ? error.message : 'Unknown error'
108
+ code: 'INTERNAL_ERROR',
109
+ correlationId,
108
110
  }, { status: 500 });
109
111
  }
110
112
  }
@@ -16,6 +16,8 @@
16
16
  import { NextRequest, NextResponse } from 'next/server';
17
17
  import { getSession as getBetterAuthSession } from '../../server/auth';
18
18
  import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
19
+ import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
20
+ import { tokenLogId } from '../../lib/token-log-id';
19
21
 
20
22
  /**
21
23
  * GET /api/auth/session - Check current session status
@@ -42,7 +44,7 @@ export async function GET(req: NextRequest) {
42
44
  userId: authSession.user?.id,
43
45
  email: authSession.user?.email,
44
46
  name: authSession.user?.name,
45
- redisSessionId: redisSessionId ? redisSessionId.substring(0, 8) + '...' : 'MISSING',
47
+ redisSessionId: redisSessionId ? tokenLogId(redisSessionId) : 'MISSING',
46
48
  });
47
49
 
48
50
  // Fetch full session data from Redis
@@ -70,7 +72,7 @@ export async function GET(req: NextRequest) {
70
72
  if (!session) {
71
73
  console.error('[SESSION_ROUTE] Redis session MISS — refusing to report authenticated', {
72
74
  userId: authSession.user?.id,
73
- redisSessionId: redisSessionId ? redisSessionId.substring(0, 8) + '...' : 'MISSING',
75
+ redisSessionId: redisSessionId ? tokenLogId(redisSessionId) : 'MISSING',
74
76
  reason: redisSessionId ? 'session expired or evicted' : 'no session token on auth session',
75
77
  });
76
78
  return NextResponse.json({});
@@ -98,20 +100,23 @@ export async function GET(req: NextRequest) {
98
100
  idpClientId: session?.idpClientId,
99
101
  merchantId: session?.merchantId,
100
102
  },
101
- // Session tokens
102
- sessionToken: redisSessionId,
103
- accessToken: session?.idpAccessToken,
104
- refreshToken: session?.idpRefreshToken,
103
+ // PAY-1481 item 1: NO token material. This body is read by browser JS, so returning the session token and the
104
+ // IdP access/refresh tokens handed them to any script on the page (the refresh token outlives the session).
105
+ // Server routes read tokens from the session store; the UI only needs presence and expiry.
106
+ hasAccessToken: !!session?.idpAccessToken,
107
+ hasRefreshToken: !!session?.idpRefreshToken,
105
108
  accessTokenExpires: session?.idpAccessTokenExpires,
106
109
  expires: authSession.session?.expiresAt
107
110
  ? new Date(authSession.session.expiresAt).toISOString()
108
111
  : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
109
112
  });
110
113
  } catch (error) {
111
- console.error('[SESSION_ROUTE] Error checking session:', error);
114
+ // PAY-1438: pre-auth route - the exception text stays in the log, keyed by correlationId.
115
+ const correlationId = logExceptionWithCorrelation('[SESSION_ROUTE] Error checking session:', error);
112
116
  return NextResponse.json({
113
117
  error: 'Failed to check session',
114
- details: error instanceof Error ? error.message : 'Unknown error'
118
+ code: 'INTERNAL_ERROR',
119
+ correlationId,
115
120
  }, { status: 500 });
116
121
  }
117
122
  }
@@ -247,10 +252,11 @@ export async function POST(req: NextRequest) {
247
252
  message: 'Session updated successfully'
248
253
  });
249
254
  } catch (error) {
250
- console.error('[SESSION_ROUTE] Error updating session:', error);
255
+ const correlationId = logExceptionWithCorrelation('[SESSION_ROUTE] Error updating session:', error);
251
256
  return NextResponse.json({
252
257
  error: 'Failed to update session',
253
- details: error instanceof Error ? error.message : 'Unknown error'
258
+ code: 'INTERNAL_ERROR',
259
+ correlationId,
254
260
  }, { status: 500 });
255
261
  }
256
262
  }
@@ -21,6 +21,8 @@ import {
21
21
  getBetterAuthSession as getBetterAuthRedisSession,
22
22
  } from '../../lib/session-store';
23
23
  import { getIDPClientConfig } from '../../lib/idp-client-config';
24
+ import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
25
+ import { tokenLogId } from '../../lib/token-log-id';
24
26
 
25
27
  /**
26
28
  * Get tenant-wide 2FA requirement from cached client config (from broker handshake)
@@ -175,7 +177,7 @@ export async function GET(req: NextRequest) {
175
177
  viable: false,
176
178
  needsRefresh: false,
177
179
  authenticated: false,
178
- sessionToken, // Return sessionToken so middleware can detect and clear stale cookie
180
+ sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
179
181
  reason: 'Stale session - cookie exists but session not found in Redis'
180
182
  });
181
183
  }
@@ -309,7 +311,7 @@ export async function GET(req: NextRequest) {
309
311
  viable: true,
310
312
  needsRefresh: true,
311
313
  authenticated: true,
312
- sessionToken,
314
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
313
315
  // Clear names for middleware decision-making
314
316
  tenantRequiresTwoFactor,
315
317
  userHasCompletedTenantTwoFactorRequirements,
@@ -351,7 +353,7 @@ export async function GET(req: NextRequest) {
351
353
 
352
354
  // DEBUG: Log what we're reading from the session
353
355
  console.log('[VIABILITY] Session 2FA state:', {
354
- sessionToken: sessionToken?.substring(0, 8) + '...',
356
+ sessionToken: tokenLogId(sessionToken),
355
357
  'session.mfaVerified': session?.mfaVerified,
356
358
  'session.twoFactorComplete': (session as any)?.twoFactorComplete,
357
359
  mfaVerifiedInSession,
@@ -379,7 +381,7 @@ export async function GET(req: NextRequest) {
379
381
  expiresIn: 0,
380
382
  hasRefreshToken,
381
383
  authenticated: true,
382
- sessionToken,
384
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
383
385
  // Clear names
384
386
  tenantRequiresTwoFactor,
385
387
  userHasCompletedTenantTwoFactorRequirements,
@@ -403,7 +405,7 @@ export async function GET(req: NextRequest) {
403
405
  expiresIn,
404
406
  hasRefreshToken,
405
407
  authenticated: true,
406
- sessionToken,
408
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
407
409
  // Clear names
408
410
  tenantRequiresTwoFactor,
409
411
  userHasCompletedTenantTwoFactorRequirements,
@@ -420,13 +422,15 @@ export async function GET(req: NextRequest) {
420
422
  clientId: resolveClientId(session?.idpClientId),
421
423
  });
422
424
  } catch (error) {
423
- console.error('[VIABILITY_ROUTE] Error checking session viability:', error);
425
+ // PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
426
+ const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
424
427
  return NextResponse.json({
425
428
  viable: false,
426
429
  needsRefresh: false,
427
430
  authenticated: false,
428
431
  error: 'Failed to check session',
429
- details: error instanceof Error ? error.message : 'Unknown error'
432
+ code: 'INTERNAL_ERROR',
433
+ correlationId
430
434
  }, { status: 500 });
431
435
  }
432
436
  }
@@ -13,7 +13,7 @@ import {
13
13
  getBetterAuthSession as getBetterAuthRedisSession,
14
14
  type SessionData,
15
15
  } from '../lib/session-store';
16
- import { extractSessionTokenFromCookie } from '../lib/session-cookie-shape';
16
+ import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
17
17
  import { createSingleFlight } from '../lib/single-flight';
18
18
 
19
19
  let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
@@ -172,14 +172,18 @@ export async function getSession(request?: Request): Promise<any> {
172
172
  return attachSessionData(betterAuthSession, sessionData, sessionToken);
173
173
  }
174
174
 
175
- // Path B — Better Auth said null/incomplete. Fall back to manual cookie
175
+ // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
176
176
  // decode + Redis-canonical liveness check. This catches cases where the
177
177
  // canonical app session at `{slug}:{token}` is still alive but Better
178
178
  // Auth's `ba:{slug}:{token}` secondary record was evicted or the
179
- // in-memory cookie cache returned stale-null. No JWT signature check —
180
- // Redis lookup is the validation: an attacker forging a session-token
181
- // claim hits NO_SESSION because Redis has no matching entry.
182
- const sessionToken = extractSessionTokenFromCookie(request);
179
+ // in-memory cookie cache returned stale-null.
180
+ //
181
+ // PAY-1476: this path used to take the token with NO signature check, on the
182
+ // theory that "Redis lookup is the validation". It is not: a leaked token has
183
+ // a live record, so the token string alone authenticated, with any garbage in
184
+ // the signature position, and a forged JWS too. The token now comes only from
185
+ // the shared verified resolver (HMAC or JWS signature checked first).
186
+ const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
183
187
  if (!sessionToken) return null;
184
188
 
185
189
  let sessionData: SessionData | null = null;