@payez/next-mvp 4.1.21 → 4.4.0

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 (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +120 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -30,16 +30,41 @@ function decodeJwtPayload(token: string): any {
30
30
  }
31
31
 
32
32
  /**
33
- * Session Inspector Page
33
+ * ⚠️ DEBUG PAGE THAT DISPLAYS AND COPIES LIVE CREDENTIALS — DO NOT MOUNT
34
+ * IN PRODUCTION. ⚠️
34
35
  *
35
- * Debug page for inspecting session data from Redis.
36
- * Shows user info, roles, 2FA status, and tokens.
36
+ * Session Inspector Page. Debug page for inspecting session data from Redis:
37
+ * shows user info, roles, 2FA status, and TOKENS. It offers
38
+ * COPY-TO-CLIPBOARD buttons for the live SESSION TOKEN and ACCESS TOKEN, and
39
+ * reads them from the consuming app's `/api/internal/session-data` route.
37
40
  *
38
- * Usage in consuming app:
41
+ * ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000 / 248991) ──────────────────────────
42
+ * THIS PAGE HAS NO ENV GATE OF ITS OWN. Unlike the force-expire handler
43
+ * (default-denied behind ENABLE_TEST_ROUTES) or the idp-token route (behind
44
+ * ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as the
45
+ * consuming app's own middleware allows. MOUNTING IT IS THE GATE. There is no
46
+ * second line of defence behind it.
47
+ *
48
+ * AND THE CREDENTIAL IT HANDS OVER IS THE PRECONDITION FOR A REAL ATTACK ON
49
+ * THIS SAME PACKAGE. The 246007 finding reduces to "a caller who KNOWS A
50
+ * SESSION TOKEN can act on a session it does not own". The purpose of this
51
+ * page is to put a live session token on the clipboard of whoever loads it. A
52
+ * screen-share, a shoulder-surfed screenshot or a support call is enough.
53
+ * Treat the rendered value as a live credential, because that is what it is.
54
+ *
55
+ * NOTE FOR MAINTAINERS: this warning must stay on THIS docstring, the one
56
+ * attached to the exported declaration. An identical block placed higher in
57
+ * the file does NOT reach the emitted .d.ts — measured, after I made exactly
58
+ * that mistake — so it would warn no consumer at all.
59
+ *
60
+ * ────────────────────────── IF YOU MOUNT IT, MOUNT IT LIKE THIS ──────────────────────────
39
61
  * ```typescript
40
- * // app/test-env/jwt-inspect/page.tsx
62
+ * // app/test-env/jwt-inspect/page.tsx — TEST/DEV ENVIRONMENTS ONLY
41
63
  * export { JwtInspectPage as default } from '@payez/next-mvp/pages/test-env';
42
64
  * ```
65
+ * Gate the whole /test-env segment in your middleware, per environment. If you
66
+ * are not actively debugging token contents, DO NOT MOUNT THIS AT ALL — the
67
+ * safest configuration is the absence of the route.
43
68
  */
44
69
  export function JwtInspectPage() {
45
70
  const { data: sessionData, isPending } = authClient.useSession();
@@ -84,6 +109,111 @@ export function JwtInspectPage() {
84
109
  }
85
110
  }, [serverSession, session]);
86
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
+
87
217
  const copyToClipboard = (text: string, label: string) => {
88
218
  navigator.clipboard.writeText(text);
89
219
  setCopied(label);
@@ -242,15 +372,28 @@ export function JwtInspectPage() {
242
372
  <div className="flex items-center justify-between mb-2">
243
373
  <span className={labelClass}>Session Token (Redis Key):</span>
244
374
  {displayExt.sessionToken && (
245
- <CopyButton
246
- onClick={() => copyToClipboard(displayExt.sessionToken, 'session')}
247
- copied={copied === 'session'}
248
- isDarkMode={isDarkMode}
249
- />
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>
250
389
  )}
251
390
  </div>
252
391
  <code className={`block p-2 rounded text-xs break-all ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
253
- {displayExt.sessionToken || 'N/A'}
392
+ {displayExt.sessionToken
393
+ ? revealed.session
394
+ ? displayExt.sessionToken
395
+ : maskToken(displayExt.sessionToken)
396
+ : 'N/A'}
254
397
  </code>
255
398
  </div>
256
399
 
@@ -258,15 +401,28 @@ export function JwtInspectPage() {
258
401
  <div className="flex items-center justify-between mb-2">
259
402
  <span className={labelClass}>Access Token (IDP):</span>
260
403
  {displayExt.accessToken && (
261
- <CopyButton
262
- onClick={() => copyToClipboard(displayExt.accessToken, 'access')}
263
- copied={copied === 'access'}
264
- isDarkMode={isDarkMode}
265
- />
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>
266
418
  )}
267
419
  </div>
268
420
  <code className={`block p-2 rounded text-xs break-all max-h-24 overflow-auto ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
269
- {displayExt.accessToken || 'N/A'}
421
+ {displayExt.accessToken
422
+ ? revealed.access
423
+ ? displayExt.accessToken
424
+ : maskToken(displayExt.accessToken)
425
+ : 'N/A'}
270
426
  </code>
271
427
  </div>
272
428
 
@@ -370,11 +526,31 @@ export function JwtInspectPage() {
370
526
  </div>
371
527
  </div>
372
528
 
373
- {/* Raw Session Data */}
529
+ {/* Raw Session Data - credentials redacted by default, see redactCredentials above */}
374
530
  <details className={`p-4 rounded-lg ${cardClass}`}>
375
- <summary className="font-semibold cursor-pointer">Raw Session Data (Click to expand)</summary>
531
+ <summary className="font-semibold cursor-pointer">
532
+ Raw Session Data &mdash; credentials redacted (Click to expand)
533
+ </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
+ />
540
+ <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.'}
544
+ </span>
545
+ </div>
376
546
  <pre className={`mt-4 text-xs overflow-auto p-3 rounded ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
377
- {JSON.stringify({ clientSession: session, serverSession: serverSession }, null, 2)}
547
+ {JSON.stringify(
548
+ revealed.raw
549
+ ? { clientSession: session, serverSession: serverSession }
550
+ : redactCredentials({ clientSession: session, serverSession: serverSession }),
551
+ null,
552
+ 2,
553
+ )}
378
554
  </pre>
379
555
  </details>
380
556
  </div>
@@ -453,4 +629,43 @@ function CopyButton({
453
629
  );
454
630
  }
455
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
+
456
671
  export default JwtInspectPage;
@@ -3,17 +3,33 @@ import React, { useState, useEffect } from "react";
3
3
  import { authClient } from '../../client/better-auth-client';
4
4
 
5
5
  /**
6
- * Refresh Token Test Page
6
+ * ⚠️ DEBUG PAGE THAT DRIVES A DESTRUCTIVE ENDPOINT — DO NOT MOUNT IN
7
+ * PRODUCTION. ⚠️
7
8
  *
8
- * Debug page for testing OAuth refresh token flow.
9
- * Shows current session state, allows manual refresh trigger,
10
- * and can force-expire tokens for testing.
9
+ * Refresh Token Test Page. Shows current session state, allows a manual
10
+ * refresh trigger, and FORCE-EXPIRES the access token via a button that POSTs
11
+ * `/api/test/force-expire`. That endpoint MUTATES SESSION STATE.
11
12
  *
12
- * Usage:
13
+ * ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000) ──────────────────────────
14
+ * The force-expire HANDLER was fixed (244877: default-deny behind
15
+ * ENABLE_TEST_ROUTES) and its docstring corrected to say so — but that fix
16
+ * covered the ROUTE AND NOT THE PAGE THAT DRIVES IT. This page is a separate
17
+ * public export carrying its own mount instructions, so a consumer can follow
18
+ * these lines and never once read the handler's warning.
19
+ *
20
+ * THIS PAGE HAS NO ENV GATE OF ITS OWN. Mounting it is the gate. The two are
21
+ * independent: mounting this page while ENABLE_TEST_ROUTES is unset gives you
22
+ * a button that 404s — confusing, but safe. The unsafe configuration is
23
+ * setting that flag anywhere this page is also reachable.
24
+ *
25
+ * ────────────────────────── IF YOU MOUNT IT, MOUNT IT LIKE THIS ──────────────────────────
13
26
  * ```typescript
14
- * // app/test-env/refresh-token/page.tsx
27
+ * // app/test-env/refresh-token/page.tsx — TEST/DEV ENVIRONMENTS ONLY
15
28
  * export { RefreshTokenPage as default } from '@payez/next-mvp/pages/test-env';
16
29
  * ```
30
+ * Requires ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY for the button to
31
+ * do anything. Never set it in production. If you do not need to exercise the
32
+ * refresh flow, DO NOT MOUNT THIS AT ALL.
17
33
  */
18
34
  export function RefreshTokenPage() {
19
35
  const { data: session } = authClient.useSession();
@@ -5,13 +5,25 @@ import { useState, useEffect } from 'react';
5
5
  import Link from 'next/link';
6
6
 
7
7
  /**
8
- * Test Environment Index Page
8
+ * ⚠️ DEBUG INDEX — DO NOT MOUNT IN PRODUCTION. ⚠️
9
9
  *
10
- * Debug tools index showing session status and links to debug pages.
10
+ * Test Environment Index Page. Shows session status and links to the other
11
+ * debug pages.
11
12
  *
12
- * Usage in consuming app:
13
+ * ────────────────────────── WHY THE INDEX IS ITS OWN FINDING (QAPert 35000 sweep) ──────────────────────────
14
+ * This page is DISCOVERY. On its own it only reads session status, so in
15
+ * isolation it looks like the harmless member of the set — but it
16
+ * ADVERTISES the sibling routes to anyone who reaches it, including the JWT
17
+ * inspector that puts a live session token on the clipboard. Mounting this
18
+ * turns four URLs somebody would otherwise have to guess into one linked
19
+ * index.
20
+ *
21
+ * If you mount the /test-env segment at all, gate the WHOLE SEGMENT in your
22
+ * middleware, per environment — not page by page.
23
+ *
24
+ * ────────────────────────── IF YOU MOUNT IT, MOUNT IT LIKE THIS ──────────────────────────
13
25
  * ```typescript
14
- * // app/test-env/page.tsx
26
+ * // app/test-env/page.tsx — TEST/DEV ENVIRONMENTS ONLY
15
27
  * export { TestEnvPage as default } from '@payez/next-mvp/pages/test-env';
16
28
  * ```
17
29
  */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * SERVER-COMPONENT GUARDS for the test-env debug pages (card 249259, BAPert
3
+ * ruling 35321). These are what `@payez/next-mvp/pages/test-env` exports. The
4
+ * raw client components are NOT exported.
5
+ *
6
+ * ── WHAT THIS IS, AND WHAT IT IS EMPHATICALLY NOT ───────────────────────────
7
+ * MOUNTING IS STILL THE PRIMARY GATE. This wrapper is a backstop for a mount
8
+ * that should not have happened. It is NOT a licence to mount these freely:
9
+ * setting ENABLE_TEST_ROUTES=true in an environment where these pages are
10
+ * mounted gives you exactly the exposure this package spent 248991 documenting
11
+ * - a live session token and a copy button. If you are not actively debugging,
12
+ * DO NOT MOUNT THESE AT ALL. The safest configuration remains their absence.
13
+ *
14
+ * ── WHY A SERVER COMPONENT, AND WHY THE PAGES THEMSELVES CANNOT DO THIS ─────
15
+ * 248991 item 2 asked for a per-page env gate and it was correctly REFUSED:
16
+ * the pages are `'use client'`, and non-`NEXT_PUBLIC_` variables are not
17
+ * available in the browser (Next.js docs, environment-variables: "Non-
18
+ * NEXT_PUBLIC_ environment variables are only available in the Node.js
19
+ * environment"). A check written inside those files would read `undefined` on
20
+ * the client and deny ALWAYS - killing the tool in dev too - while reading the
21
+ * REAL value during SSR, producing a hydration mismatch. It would look like a
22
+ * gate, satisfy a grep for one, and protect nothing.
23
+ *
24
+ * A SERVER component can read the variable for real. That is the whole reason
25
+ * this file exists as a separate layer instead of a few lines in each page.
26
+ *
27
+ * ── FAIL CLOSED ─────────────────────────────────────────────────────────────
28
+ * The test is `=== 'true'`, not truthiness. Unset, empty, "false", "0", "TRUE"
29
+ * and every typo all deny. The UNSAFE state requires a deliberate, exact act;
30
+ * the SAFE state is what you get by doing nothing, including by misconfiguring
31
+ * it. A gate whose default is "open" is not a gate.
32
+ *
33
+ * `notFound()` is deliberate and is not a 403: a real 404 is indistinguishable
34
+ * from a route that was never registered, whereas a 403 CONFIRMS the page
35
+ * exists to anyone who probes for it. That is the same disclosure axis card
36
+ * 245920 was raised about, so shipping the confirming variant here would
37
+ * reintroduce next door the thing being fixed there.
38
+ *
39
+ * notFound() throws (NEXT_HTTP_ERROR_FALLBACK;404) and terminates rendering of
40
+ * the segment, so nothing below it runs and the client component is never sent
41
+ * to the browser. It does not need `return` - it is typed `never`.
42
+ */
43
+ import { notFound } from 'next/navigation';
44
+
45
+ import { TestEnvPage as TestEnvPageUnguarded } from './TestEnvPage';
46
+ import { JwtInspectPage as JwtInspectPageUnguarded } from './JwtInspectPage';
47
+ import { RefreshTokenPage as RefreshTokenPageUnguarded } from './RefreshTokenPage';
48
+ import { EmergencyLogoutPage as EmergencyLogoutPageUnguarded } from './EmergencyLogoutPage';
49
+
50
+ /**
51
+ * The single gate. Exact-match on purpose - see FAIL CLOSED above.
52
+ *
53
+ * Read at REQUEST time inside each page rather than once at module scope: a
54
+ * module-scope constant would be captured at build time, so a consumer who
55
+ * changed the variable would keep whatever value was baked into the bundle and
56
+ * would have no way to tell. Reading per render costs an object property
57
+ * lookup and removes an entire class of "I turned it off and it stayed on".
58
+ */
59
+ function testRoutesEnabled(): boolean {
60
+ return process.env.ENABLE_TEST_ROUTES === 'true';
61
+ }
62
+
63
+ export function TestEnvPage() {
64
+ if (!testRoutesEnabled()) notFound();
65
+ return <TestEnvPageUnguarded />;
66
+ }
67
+
68
+ export function JwtInspectPage() {
69
+ if (!testRoutesEnabled()) notFound();
70
+ return <JwtInspectPageUnguarded />;
71
+ }
72
+
73
+ export function RefreshTokenPage() {
74
+ if (!testRoutesEnabled()) notFound();
75
+ return <RefreshTokenPageUnguarded />;
76
+ }
77
+
78
+ export function EmergencyLogoutPage() {
79
+ if (!testRoutesEnabled()) notFound();
80
+ return <EmergencyLogoutPageUnguarded />;
81
+ }
@@ -1,9 +1,55 @@
1
1
  /**
2
- * Test Environment Pages
2
+ * ⚠️ TEST-ENVIRONMENT DEBUG PAGES — NONE OF THESE BELONG IN PRODUCTION. ⚠️
3
3
  *
4
- * Export debug pages for MVP consumers.
4
+ * ────────────────────────── READ BEFORE COPYING ANYTHING BELOW (QAPert 35000) ──────────────────────────
5
+ * This barrel is the SPREAD MECHANISM. It ships compiled into dist and into
6
+ * the .d.ts, so it reaches every consuming developer's editor as hover text,
7
+ * and the block below is a four-line copy-paste that mounts every debug page
8
+ * this package has. It previously carried no warning at all.
5
9
  *
6
- * Usage:
10
+ * NONE of these pages has an env gate of its own. Unlike the force-expire
11
+ * handler (ENABLE_TEST_ROUTES) or the idp-token route
12
+ * (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
13
+ * middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
14
+ * segment, per environment, not page by page.
15
+ *
16
+ * They are not equally dangerous. In descending order:
17
+ * JwtInspectPage WORST. Copy-to-clipboard for the LIVE SESSION TOKEN
18
+ * and ACCESS TOKEN. A session token is exactly the
19
+ * precondition the 246007 finding needs.
20
+ * TestEnvPage The index. Harmless alone, but it ADVERTISES the
21
+ * other three to anyone who reaches it.
22
+ * RefreshTokenPage Drives the destructive /api/test/force-expire.
23
+ * EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
24
+ *
25
+ * ── THESE PAGES ARE NOW ENV-GATED, FAIL-CLOSED (249259, BAPert ruling 35321) ──
26
+ * SUPERSEDES the previous note here, which said a gate was impossible and that
27
+ * mounting was the only control. That was correct about the PAGES and wrong as
28
+ * a conclusion: it CANNOT be done inside these `'use client'` files, but it CAN
29
+ * be done in a server component wrapping them. That wrapper now ships, and it
30
+ * is what the four names below resolve to — see ./guarded.tsx.
31
+ *
32
+ * ENABLE_TEST_ROUTES === 'true' -> the page renders
33
+ * anything else, including unset -> notFound()
34
+ *
35
+ * It is the DEFAULT, not an opt-in sibling: BAPert's ruling was explicit that
36
+ * "an opt-in safety gate is one nobody opts into". The raw ungated components
37
+ * are NOT exported. Same import path, same component names, same signatures —
38
+ * consumers add one env var to their dev environments and change no code.
39
+ *
40
+ * ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
41
+ * Do not read the env var as making mounting safe. The wrapper is a backstop
42
+ * for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
43
+ * environment where these are mounted you have exactly the exposure 248991
44
+ * documented: a live session token, one click from the clipboard. If you are
45
+ * not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
46
+ * is still their absence, and the env var does not change that.
47
+ *
48
+ * Each page's own docstring carries the detail. If you are not actively
49
+ * debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
50
+ * absence.
51
+ *
52
+ * Usage — TEST/DEV ENVIRONMENTS ONLY:
7
53
  * ```typescript
8
54
  * // app/test-env/page.tsx
9
55
  * export { TestEnvPage as default } from '@payez/next-mvp/pages/test-env';
@@ -19,7 +65,13 @@
19
65
  * ```
20
66
  */
21
67
 
22
- export { TestEnvPage, TestEnvPage as default } from './TestEnvPage';
23
- export { JwtInspectPage } from './JwtInspectPage';
24
- export { RefreshTokenPage } from './RefreshTokenPage';
25
- export { EmergencyLogoutPage } from './EmergencyLogoutPage';
68
+ // GUARDED wrappers only. Exporting the raw components from './TestEnvPage'
69
+ // etc. would reintroduce an ungated path to the same pages under a different
70
+ // import, which is the "opt-in gate nobody opts into" the ruling rejected.
71
+ export {
72
+ TestEnvPage,
73
+ TestEnvPage as default,
74
+ JwtInspectPage,
75
+ RefreshTokenPage,
76
+ EmergencyLogoutPage,
77
+ } from './guarded';
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { NextRequest, NextResponse } from 'next/server';
18
18
  import { getSession } from '../../server/auth';
19
- import { deleteSession } from '../../lib/session-store';
19
+ import { deleteSessionEverywhere } from '../../lib/session-store';
20
20
  import {
21
21
  getSessionCookieName,
22
22
  getSecureSessionCookieName,
@@ -48,10 +48,11 @@ export async function POST(req: NextRequest) {
48
48
 
49
49
  const sessionId = session.session?.token;
50
50
 
51
- // Delete session from store (this also removes the refresh token)
51
+ // Delete session from EVERY store — canonical + BA secondary (181922).
52
+ // Canonical-only deletion let the ba: record resurrect the session.
52
53
  if (sessionId) {
53
54
  try {
54
- await deleteSession(sessionId);
55
+ await deleteSessionEverywhere(sessionId);
55
56
  console.info('[LOGOUT_ROUTE] Session deleted from store');
56
57
  } catch (error) {
57
58
  console.warn('[LOGOUT_ROUTE] Failed to delete session:', error);
@@ -1,13 +1,13 @@
1
- /**
2
- * Ready-to-Use Passkey Sign-In Route (ANONYMOUS ceremony)
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkey/sign-in/options/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/auth/passkey-signin';
8
- * // (optionsPOST is exported as POST; mount verify at its own path:)
9
- * // app/api/passkey/sign-in/verify/route.ts
10
- * export { verifyPOST as POST } from '@payez/next-mvp/routes/auth/passkey-signin';
11
- * ```
12
- */
13
- export { optionsPOST, verifyPOST } from '../../api-handlers/auth/passkey-signin';
1
+ /**
2
+ * Ready-to-Use Passkey Sign-In Route (ANONYMOUS ceremony)
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkey/sign-in/options/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/auth/passkey-signin';
8
+ * // (optionsPOST is exported as POST; mount verify at its own path:)
9
+ * // app/api/passkey/sign-in/verify/route.ts
10
+ * export { verifyPOST as POST } from '@payez/next-mvp/routes/auth/passkey-signin';
11
+ * ```
12
+ */
13
+ export { optionsPOST, verifyPOST } from '../../api-handlers/auth/passkey-signin';
@@ -16,15 +16,44 @@
16
16
 
17
17
  import { createRefreshHandler } from '../../api-handlers/auth/refresh';
18
18
 
19
+ /**
20
+ * Resolve the client id from environment variables.
21
+ *
22
+ * Was: `process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID || ''`
23
+ * That fallback to empty string made "we do not know the client" indistinguishable from
24
+ * "the client id is blank". Callers cannot tell absence from a known-empty value.
25
+ *
26
+ * Card 221076 / 223542: absence is now `null`, which is representable and still falsy,
27
+ * so existing `if (!clientId)` guards behave exactly as before.
28
+ * (Ported verbatim from qapert-nighthawk/221076, where this fix already existed;
29
+ * the qa/222363 line carried viability's half but never got refresh's.)
30
+ */
31
+ function resolveClientId(): string | null {
32
+ const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID;
33
+ if (clientId) {
34
+ return clientId;
35
+ }
36
+ return null;
37
+ }
38
+
19
39
  // Configuration is read at runtime from environment.
20
40
  function getConfig() {
21
41
  const idpBaseUrl = process.env.IDP_URL;
22
42
  if (!idpBaseUrl) {
23
43
  throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
24
44
  }
45
+ const clientId = resolveClientId();
46
+ if (!clientId) {
47
+ // Card 221076 / 223542: absence must be detectable, not collapsed to a default.
48
+ console.warn(
49
+ '[REFRESH_ROUTE] No CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID configured. ' +
50
+ 'Token refresh will be attempted, but the server cannot identify which client ' +
51
+ 'initiated the request. This may fail if the IDP requires a client ID.'
52
+ );
53
+ }
25
54
  return {
26
55
  idpBaseUrl,
27
- clientId: process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID || '',
56
+ clientId: clientId || 'payez_default_client', // Handler-safe fallback
28
57
  refreshEndpoint: process.env.REFRESH_ENDPOINT || '/api/ExternalAuth/refresh',
29
58
  };
30
59
  }