@payez/next-mvp 4.4.5 → 4.4.7

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 (219) 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 +33 -8
  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 +58 -14
  57. package/dist/auth/better-auth.js +125 -54
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/client/better-auth-client.d.ts +101 -956
  60. package/dist/components/admin/DataBrowserTab.js +4 -1
  61. package/dist/components/admin/SessionsTab.js +9 -3
  62. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  63. package/dist/components/recovery/RequestedStep.js +14 -0
  64. package/dist/components/recovery/SetPasswordStep.js +10 -1
  65. package/dist/config/env.d.ts +0 -2
  66. package/dist/config/env.js +0 -2
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +8 -1
  69. package/dist/lib/app-slug.d.ts +21 -0
  70. package/dist/lib/app-slug.js +51 -0
  71. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  72. package/dist/lib/ensure-fresh-access-token.js +105 -4
  73. package/dist/lib/idp-client-config.d.ts +20 -0
  74. package/dist/lib/idp-client-config.js +73 -45
  75. package/dist/lib/idp-fetch.js +1 -1
  76. package/dist/lib/never-in-response.d.ts +30 -0
  77. package/dist/lib/never-in-response.js +71 -0
  78. package/dist/lib/password-rules.d.ts +22 -0
  79. package/dist/lib/password-rules.js +43 -0
  80. package/dist/lib/safe-error-response.d.ts +67 -0
  81. package/dist/lib/safe-error-response.js +130 -0
  82. package/dist/lib/session-cookie-shape.d.ts +22 -19
  83. package/dist/lib/session-cookie-shape.js +14 -18
  84. package/dist/lib/session-store.d.ts +8 -1
  85. package/dist/lib/session-store.js +170 -24
  86. package/dist/lib/startup-init.d.ts +6 -0
  87. package/dist/lib/startup-init.js +30 -4
  88. package/dist/lib/token-lifecycle.js +2 -1
  89. package/dist/lib/token-log-id.d.ts +18 -0
  90. package/dist/lib/token-log-id.js +84 -0
  91. package/dist/lib/two-factor-verify-failure.d.ts +78 -0
  92. package/dist/lib/two-factor-verify-failure.js +220 -0
  93. package/dist/middleware/create-middleware.d.ts +4 -1
  94. package/dist/middleware/create-middleware.js +79 -8
  95. package/dist/pages/login/page.js +5 -0
  96. package/dist/pages/recovery/page.js +14 -4
  97. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  98. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  99. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  100. package/dist/pages/test-env/redact-credentials.js +101 -0
  101. package/dist/pages/verify-code/page.js +6 -3
  102. package/dist/routes/auth/logout.d.ts +2 -1
  103. package/dist/routes/auth/logout.js +4 -2
  104. package/dist/routes/auth/session.d.ts +0 -3
  105. package/dist/routes/auth/session.js +16 -10
  106. package/dist/routes/auth/settings.js +4 -1
  107. package/dist/routes/auth/viability.d.ts +3 -2
  108. package/dist/routes/auth/viability.js +11 -7
  109. package/dist/server/auth.d.ts +36 -294
  110. package/dist/server/auth.js +32 -31
  111. package/dist/server/decode-session.js +15 -26
  112. package/dist/server/verified-session-token.d.ts +13 -0
  113. package/dist/server/verified-session-token.js +103 -0
  114. package/dist/stores/authStore.js +14 -3
  115. package/dist/types/recovery.d.ts +1 -1
  116. package/dist/utils/api.d.ts +18 -0
  117. package/dist/utils/api.js +51 -9
  118. package/dist/vibe/client.d.ts +132 -16
  119. package/dist/vibe/client.js +288 -82
  120. package/dist/vibe/document-unwrap.d.ts +50 -0
  121. package/dist/vibe/document-unwrap.js +78 -0
  122. package/dist/vibe/errors.d.ts +17 -0
  123. package/dist/vibe/errors.js +21 -1
  124. package/dist/vibe/generic.d.ts +2 -49
  125. package/dist/vibe/generic.js +17 -78
  126. package/dist/vibe/index.d.ts +1 -1
  127. package/dist/vibe/index.js +2 -1
  128. package/dist/vibe/sessions.js +2 -1
  129. package/package.json +13 -2
  130. package/scripts/verify-release-provenance.mjs +410 -0
  131. package/src/api/auth-handler.ts +5 -3
  132. package/src/api-handlers/account/change-password.ts +36 -8
  133. package/src/api-handlers/account/masked-info.ts +2 -1
  134. package/src/api-handlers/account/recovery/forward.ts +117 -0
  135. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  136. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  137. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  138. package/src/api-handlers/account/reset-password.ts +30 -1
  139. package/src/api-handlers/account/send-code.ts +3 -9
  140. package/src/api-handlers/account/update-phone.ts +6 -1
  141. package/src/api-handlers/account/validate-password.ts +3 -1
  142. package/src/api-handlers/account/verify-email.ts +4 -9
  143. package/src/api-handlers/account/verify-sms.ts +4 -9
  144. package/src/api-handlers/admin/analytics.ts +13 -5
  145. package/src/api-handlers/admin/audit.ts +13 -5
  146. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  147. package/src/api-handlers/admin/sessions.ts +15 -7
  148. package/src/api-handlers/admin/site-logs.ts +39 -16
  149. package/src/api-handlers/admin/stats.ts +15 -5
  150. package/src/api-handlers/admin/users.ts +15 -7
  151. package/src/api-handlers/admin/vibe-data.ts +20 -12
  152. package/src/api-handlers/auth/jwks.ts +2 -1
  153. package/src/api-handlers/auth/login.ts +13 -4
  154. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  155. package/src/api-handlers/auth/refresh.ts +38 -11
  156. package/src/api-handlers/auth/signout.ts +2 -1
  157. package/src/api-handlers/auth/status.ts +4 -1
  158. package/src/api-handlers/auth/update-session.ts +45 -11
  159. package/src/api-handlers/auth/validate.ts +4 -2
  160. package/src/api-handlers/auth/verify-code.ts +3 -2
  161. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  162. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  163. package/src/api-handlers/mfa/methods.ts +2 -1
  164. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  165. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  166. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  167. package/src/api-handlers/passkeys/list.ts +2 -1
  168. package/src/api-handlers/passkeys/manage.ts +2 -1
  169. package/src/api-handlers/passkeys/register-options.ts +2 -1
  170. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  171. package/src/api-handlers/session/refresh-viability.ts +10 -7
  172. package/src/api-handlers/session/viability.ts +4 -3
  173. package/src/api-handlers/test/force-expire.ts +5 -3
  174. package/src/auth/auth-decision.ts +2 -1
  175. package/src/auth/better-auth.ts +128 -61
  176. package/src/auth/utils/idp-client.ts +2 -1
  177. package/src/components/admin/DataBrowserTab.tsx +4 -1
  178. package/src/components/admin/SessionsTab.tsx +10 -3
  179. package/src/components/recovery/RequestedStep.tsx +30 -0
  180. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  181. package/src/config/env.ts +0 -2
  182. package/src/hooks/useViabilitySession.ts +2 -1
  183. package/src/index.ts +4 -0
  184. package/src/lib/app-slug.ts +50 -0
  185. package/src/lib/ensure-fresh-access-token.ts +133 -4
  186. package/src/lib/idp-client-config.ts +98 -45
  187. package/src/lib/idp-fetch.ts +1 -1
  188. package/src/lib/never-in-response.ts +64 -0
  189. package/src/lib/password-rules.ts +50 -0
  190. package/src/lib/safe-error-response.ts +126 -0
  191. package/src/lib/session-cookie-shape.ts +23 -22
  192. package/src/lib/session-store.ts +167 -24
  193. package/src/lib/startup-init.ts +29 -4
  194. package/src/lib/token-lifecycle.ts +2 -1
  195. package/src/lib/token-log-id.ts +69 -0
  196. package/src/lib/two-factor-verify-failure.ts +248 -0
  197. package/src/middleware/create-middleware.ts +97 -9
  198. package/src/pages/login/page.tsx +4 -0
  199. package/src/pages/recovery/page.tsx +21 -4
  200. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  201. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  202. package/src/pages/test-env/redact-credentials.ts +96 -0
  203. package/src/pages/verify-code/page.tsx +6 -3
  204. package/src/routes/auth/logout.ts +4 -2
  205. package/src/routes/auth/session.ts +16 -10
  206. package/src/routes/auth/settings.ts +5 -1
  207. package/src/routes/auth/viability.ts +11 -7
  208. package/src/server/auth.ts +33 -35
  209. package/src/server/decode-session.ts +16 -26
  210. package/src/server/verified-session-token.ts +103 -0
  211. package/src/stores/authStore.ts +16 -5
  212. package/src/types/recovery.ts +1 -1
  213. package/src/utils/api.ts +47 -8
  214. package/src/vibe/client.ts +378 -91
  215. package/src/vibe/document-unwrap.ts +103 -0
  216. package/src/vibe/errors.ts +24 -0
  217. package/src/vibe/generic.ts +8 -100
  218. package/src/vibe/index.ts +1 -0
  219. package/src/vibe/sessions.ts +2 -1
@@ -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
  }
@@ -6,17 +6,13 @@
6
6
  */
7
7
 
8
8
  import 'server-only';
9
- import { createBetterAuthInstance, secretFingerprint } from '../auth/better-auth';
10
- import { getIDPClientConfig } from '../lib/idp-client-config';
9
+ import { getBetterAuthInstance } from '../auth/better-auth';
11
10
  import {
12
11
  getSession as getRedisSession,
13
12
  getBetterAuthSession as getBetterAuthRedisSession,
14
13
  type SessionData,
15
14
  } from '../lib/session-store';
16
- import { extractSessionTokenFromCookie } from '../lib/session-cookie-shape';
17
- import { createSingleFlight } from '../lib/single-flight';
18
-
19
- let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
15
+ import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
20
16
 
21
17
  export type IdpTokenResult =
22
18
  | { success: true; accessToken: string; sessionData: SessionData }
@@ -102,32 +98,30 @@ function attachSessionData(session: any, sessionData: SessionData | null, sessio
102
98
  * test/single-flight-retry.test.cjs, which imports dist/ and therefore
103
99
  * exercises the artefact that actually ships.
104
100
  *
105
- * Semantics, all asserted by that suite: concurrent callers share ONE
106
- * underlying init; a rejection reaches every caller awaiting that attempt
107
- * (failures stay loud -- getIDPClientConfig is fail-loud by design and callers
108
- * depend on it); the NEXT call after a failure starts a fresh attempt; and a
109
- * slow failure never evicts a newer in-flight attempt.
101
+ * PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
102
+ * INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
103
+ * it carried NONE of the app-supplied plugins registered by the host app at
104
+ * startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
105
+ * `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
106
+ * instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
107
+ * of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
108
+ * 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
109
+ * instance, `getBetterAuthInstance`, which is why this file must now resolve the
110
+ * same one). It also silently created a SECOND, divergent instance beside the
111
+ * one every /api/auth/* route uses — two instances, two secrets, one process.
112
+ *
113
+ * It now DELEGATES to that configured funnel, which is the single source of
114
+ * truth for the process's Better Auth instance:
115
+ * - it carries `state.configuredOpts` (better-auth.ts:930), so the app
116
+ * plugins are present;
117
+ * - its init is already single-flighted with the retry semantics described in
118
+ * lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
119
+ * this function keeps the same no-latch recovery contract;
120
+ * - there is now ONE instance, so the double-init / divergent-secret hazard
121
+ * this path used to create is gone by construction.
110
122
  */
111
- const authInit = createSingleFlight(async () => {
112
- const config = await getIDPClientConfig(true);
113
- authInstance = createBetterAuthInstance(config);
114
- // 185121 line 1, path 2: this module-scope instance had NO creation log
115
- // line — first-called during a config transition window it pinned a
116
- // divergent secret INVISIBLY. Name the path and the secret fingerprint
117
- // (the createBetterAuthInstance line already logged the same hash;
118
- // this line is what attributes it to THIS init path).
119
- // Guard matches createBetterAuthInstance's: getIDPClientConfig currently
120
- // throws FATAL on a missing authSecret (QAPert 27328 verified), but that
121
- // guarantee is one refactor away — log ABSENT rather than ever throw here.
122
- console.log(
123
- `[AUTH] getAuthInstance: created BA instance via server/auth.ts — authSecret ${config.authSecret ? `sha256:${secretFingerprint(config.authSecret as string)}` : 'ABSENT'}`
124
- );
125
- return authInstance;
126
- });
127
-
128
123
  export async function getAuthInstance() {
129
- if (authInstance) return authInstance;
130
- return authInit.get();
124
+ return getBetterAuthInstance();
131
125
  }
132
126
 
133
127
 
@@ -172,14 +166,18 @@ export async function getSession(request?: Request): Promise<any> {
172
166
  return attachSessionData(betterAuthSession, sessionData, sessionToken);
173
167
  }
174
168
 
175
- // Path B — Better Auth said null/incomplete. Fall back to manual cookie
169
+ // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
176
170
  // decode + Redis-canonical liveness check. This catches cases where the
177
171
  // canonical app session at `{slug}:{token}` is still alive but Better
178
172
  // 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);
173
+ // in-memory cookie cache returned stale-null.
174
+ //
175
+ // PAY-1476: this path used to take the token with NO signature check, on the
176
+ // theory that "Redis lookup is the validation". It is not: a leaked token has
177
+ // a live record, so the token string alone authenticated, with any garbage in
178
+ // the signature position, and a forged JWS too. The token now comes only from
179
+ // the shared verified resolver (HMAC or JWS signature checked first).
180
+ const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
183
181
  if (!sessionToken) return null;
184
182
 
185
183
  let sessionData: SessionData | null = null;
@@ -7,12 +7,14 @@
7
7
 
8
8
  import 'server-only';
9
9
  import { cookies } from 'next/headers';
10
- import { jwtVerify, type JWTPayload } from 'jose';
10
+ import { type JWTPayload } from 'jose';
11
11
  import { getSession, type SessionData } from '../lib/session-store';
12
12
  import { getIDPClientConfig } from '../lib/idp-client-config';
13
13
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
14
14
  import { ensureInitialized } from '../lib/startup-init';
15
15
  import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
16
+ import { resolveVerifiedSessionCookie } from './verified-session-token';
17
+ import { tokenLogId } from '../lib/token-log-id';
16
18
 
17
19
  export interface DecodedSession {
18
20
  sessionData: SessionData;
@@ -137,7 +139,7 @@ async function tryBetterAuthSession(
137
139
  if (!idpTokens?.roles?.length) {
138
140
  console.warn(
139
141
  '[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
140
- result.session.token.substring(0, 10),
142
+ tokenLogId(result.session.token),
141
143
  '— falling through to the sess: leg'
142
144
  );
143
145
  return null;
@@ -252,32 +254,20 @@ export async function decodeSession(
252
254
  // death, because the ONLY fallback was structurally blind to the only
253
255
  // shape it would ever be handed.
254
256
  //
255
- // Check the shape FIRST. A BA-hmac cookie skips jose entirely and goes
256
- // straight to the Redis-canonical liveness check below — same posture
257
- // as the JWS path always had (no signature re-check here either; Redis
258
- // presence is the authority). A JWS-shaped cookie (legacy NextAuth-era,
259
- // if any still linger) keeps the original jwtVerify path unchanged.
257
+ // PAY-1476: the shape still decides HOW to verify, but BOTH shapes are now
258
+ // verified, by the shared resolver. This branch used to take a BA-hmac
259
+ // cookie's token with no signature check ("Redis presence is the
260
+ // authority"), so `{leaked token}.{anything}` authenticated here - including
261
+ // behind the portal's admin API gate. The resolver checks the HMAC with
262
+ // timingSafeEqual (ba-hmac) or runs jwtVerify (jws) against the same
263
+ // env-first auth secret, and returns null on any failure.
260
264
  const shaped = extractSessionTokenFromCookieValue(cookieValue);
261
-
262
- let sessionToken: string | undefined;
263
- let payload: JWTPayload | undefined;
264
-
265
- if (shaped?.shape === 'ba-hmac') {
266
- sessionToken = shaped.token;
267
- } else {
268
- const secretKey = new TextEncoder().encode(secret);
269
- try {
270
- const result = await jwtVerify(cookieValue, secretKey);
271
- payload = result.payload;
272
- } catch (jwtError) {
273
- console.warn('[DECODE-SESSION] JWT verification failed:', jwtError instanceof Error ? jwtError.message : String(jwtError));
274
- return null;
275
- }
276
- sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
277
- }
265
+ const verified = await resolveVerifiedSessionCookie(cookieValue);
266
+ const sessionToken = verified?.token;
267
+ const payload: JWTPayload | undefined = verified?.payload;
278
268
 
279
269
  if (!sessionToken) {
280
- console.warn('[DECODE-SESSION] No sessionToken resolved from cookie (shape:', shaped?.shape ?? 'unrecognized', ')');
270
+ console.warn('[DECODE-SESSION] Session cookie did not verify (shape:', shaped?.shape ?? 'unrecognized', ')');
281
271
  return null;
282
272
  }
283
273
 
@@ -292,7 +282,7 @@ export async function decodeSession(
292
282
  // fixing a silent path.
293
283
  console.warn(
294
284
  '[DECODE-SESSION] No sess: record for token',
295
- sessionToken.substring(0, 10),
285
+ tokenLogId(sessionToken),
296
286
  '— no fallback available, session will be signed out'
297
287
  );
298
288
  return null;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * PAY-1476 / card 372275 (R941 s1b) - THE ONE SHARED VERIFIED RESOLVER for a session token taken from a cookie.
3
+ *
4
+ * THE DEFECT (4.4.5, measured at the deployed portal by QAPert-NightHawk, mail 59030):
5
+ * Module A server/decode-session.ts the ba-hmac branch took `{token}.{anything}` and used segment 0 unread.
6
+ * Module B server/auth.ts Path B took the token from ANY recognised shape and never verified anything;
7
+ * a forged JWS resolved there too.
8
+ * Both then treated "a Redis record exists" as the validation. So a leaked session token string was a bearer
9
+ * credential with no signature needed, including on the portal's admin API gate (api-admin-gate -> decodeSession).
10
+ *
11
+ * THE RULE THIS MODULE ENFORCES: a cookie yields a session token ONLY after its signature checks out against
12
+ * the auth secret. Every caller that turns a cookie into a token for the session store awaits this function.
13
+ * lib/session-cookie-shape.ts stays classification-only and secret-free (it cannot verify, by construction).
14
+ *
15
+ * ba-hmac `{token}.{sig}` as Better Auth mints it (better-call signCookieValue): sig =
16
+ * base64(HMAC-SHA256(secret, token)), standard padding, 44 characters. Compared with
17
+ * crypto.timingSafeEqual over equal-length buffers.
18
+ * jws legacy NextAuth-era cookie: jose jwtVerify with the same secret, then the sessionToken
19
+ * (or redisSessionId) claim.
20
+ * anything else, a missing secret, or a failed check -> null. Never throws.
21
+ *
22
+ * THE SECRET is the auth secret from getIDPClientConfig(), which resolves env-first (BETTER_AUTH_SECRET,
23
+ * NEXTAUTH_SECRET, then IdP config; PAY-1460). The cookie HMAC key IS the auth secret, so the resolver and the
24
+ * config loader cannot disagree about which secret signs sessions (BAPert 59034).
25
+ */
26
+ import { createHmac, timingSafeEqual } from 'crypto';
27
+ import { jwtVerify, type JWTPayload } from 'jose';
28
+ import { getIDPClientConfig } from '../lib/idp-client-config';
29
+ import { extractSessionCookieValue, extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
30
+
31
+ /** Better Auth's signature: base64 of a 32-byte HMAC-SHA256, padded = 44 chars ending in '='. */
32
+ const BA_SIGNATURE_LENGTH = 44;
33
+
34
+ async function authSecret(): Promise<string | null> {
35
+ try {
36
+ const config = await getIDPClientConfig();
37
+ return config?.authSecret ? String(config.authSecret) : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function verifyBaHmac(rawValue: string, secret: string): string | null {
44
+ const dot = rawValue.lastIndexOf('.');
45
+ if (dot < 1) return null;
46
+ const token = rawValue.slice(0, dot);
47
+ let signature = rawValue.slice(dot + 1);
48
+ if (signature.includes('%')) {
49
+ try {
50
+ signature = decodeURIComponent(signature);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ if (signature.length !== BA_SIGNATURE_LENGTH || !signature.endsWith('=')) return null;
56
+ const expected = createHmac('sha256', secret).update(token, 'utf8').digest('base64');
57
+ const a = Buffer.from(signature, 'utf8');
58
+ const b = Buffer.from(expected, 'utf8');
59
+ if (a.length !== b.length) return null;
60
+ return timingSafeEqual(a, b) ? token : null;
61
+ }
62
+
63
+ export interface VerifiedSessionCookie {
64
+ token: string;
65
+ shape: 'ba-hmac' | 'jws';
66
+ /** The verified JWS payload (jws shape only). */
67
+ payload?: JWTPayload;
68
+ }
69
+
70
+ async function verifyJws(rawValue: string, secret: string): Promise<VerifiedSessionCookie | null> {
71
+ try {
72
+ const { payload } = await jwtVerify(rawValue, new TextEncoder().encode(secret));
73
+ const claim = (payload as Record<string, unknown>).sessionToken ?? (payload as Record<string, unknown>).redisSessionId;
74
+ return typeof claim === 'string' && claim ? { token: claim, shape: 'jws', payload } : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** The verified session cookie (token, shape, JWS payload), or null unless its signature verifies. */
81
+ export async function resolveVerifiedSessionCookie(rawValue: string | null | undefined): Promise<VerifiedSessionCookie | null> {
82
+ if (!rawValue) return null;
83
+ const shaped = extractSessionTokenFromCookieValue(rawValue);
84
+ if (!shaped) return null;
85
+ const secret = await authSecret();
86
+ if (!secret) return null;
87
+ if (shaped.shape === 'ba-hmac') {
88
+ const token = verifyBaHmac(rawValue, secret);
89
+ return token ? { token, shape: 'ba-hmac' } : null;
90
+ }
91
+ return verifyJws(rawValue, secret);
92
+ }
93
+
94
+ /** The session token carried by a raw session-cookie value, or null unless its signature verifies. */
95
+ export async function resolveVerifiedSessionToken(rawValue: string | null | undefined): Promise<string | null> {
96
+ const verified = await resolveVerifiedSessionCookie(rawValue);
97
+ return verified ? verified.token : null;
98
+ }
99
+
100
+ /** Same, reading the session cookie (every name it is minted under, chunks joined) from a Request. */
101
+ export async function resolveVerifiedSessionTokenFromRequest(request: Request): Promise<string | null> {
102
+ return resolveVerifiedSessionToken(extractSessionCookieValue(request));
103
+ }
@@ -26,6 +26,7 @@ import {
26
26
  getCsrfCookieName,
27
27
  getSecureCsrfCookieName
28
28
  } from '../lib/app-slug';
29
+ import { tokenLogId } from '../lib/token-log-id';
29
30
 
30
31
  // ===============================
31
32
  // INTERFACES & TYPES
@@ -484,19 +485,28 @@ export const useAuthStore = create<AuthStore>()(
484
485
 
485
486
  forceLogoutAndRedirect: async (reason: string) => {
486
487
  const state = get();
487
-
488
+ // Same truncation convention as the server-side session logs (session-store.ts,
489
+ // refresh.ts): a session token is a bearer credential, and this line goes to the
490
+ // browser console (and whatever aggregates authLogger client-side) - full value
491
+ // was leftover debug logging (DotNetPert-Scout, mail 58678).
492
+ // PAY-1505: the ternary that used to stand here fell back to the RAW token on its false arm.
493
+ // Safe only by the unenforced property that the arm is reached when the token is falsy -
494
+ // exactly the shape the source guard exists to refuse. tokenLogId already answers 'none'
495
+ // for an absent token, so the fallback is not needed.
496
+ const sessionTokenPrefix = tokenLogId(state.session?.sessionToken);
497
+
488
498
  // AGGRESSIVE LOGGING TO TRACK EXECUTION
489
499
  console.error('🚨 FORCE LOGOUT INITIATED 🚨', {
490
500
  reason,
491
501
  userId: state.user?.id,
492
- sessionToken: state.session?.sessionToken,
502
+ sessionToken: sessionTokenPrefix,
493
503
  timestamp: new Date().toISOString()
494
504
  });
495
-
505
+
496
506
  authLogger.error('[AuthStore] Force logout initiated', {
497
507
  reason,
498
508
  userId: state.user?.id,
499
- sessionToken: state.session?.sessionToken
509
+ sessionToken: sessionTokenPrefix
500
510
  });
501
511
 
502
512
  try {
@@ -1870,7 +1880,8 @@ export const useAuthStore = create<AuthStore>()(
1870
1880
 
1871
1881
  // If this event is about the current user, update their state
1872
1882
  if (data.userId === state.user?.id && state.session) {
1873
- authLogger.info('[AuthStore] Updating current user state from SignalR:', data);
1883
+ // PAY-1427: the event is user state; log its shape, not its contents.
1884
+ authLogger.info('[AuthStore] Updating current user state from SignalR:', { keys: Object.keys(data || {}) });
1874
1885
  // ... user state update logic would go here
1875
1886
  }
1876
1887
  },
@@ -1,4 +1,4 @@
1
- export type RecoveryStep = 'initiate' | 'select-method' | 'verify-code' | 'set-password' | 'complete';
1
+ export type RecoveryStep = 'initiate' | 'select-method' | 'verify-code' | 'set-password' | 'requested' | 'complete';
2
2
 
3
3
  export interface RecoverySession {
4
4
  recoveryToken: string;
package/src/utils/api.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ENV_CONFIG, API_ENDPOINTS } from '../config/env';
2
2
  import { TwoFactorMethod } from '../types/security';
3
3
  import { standardizedApi, isApiSuccess, isApiError, extractApiData } from '../lib/standardized-client-api';
4
+ import { knownPasswordRules, passwordRuleSentences } from '../lib/password-rules';
4
5
 
5
6
  export class ApiError extends Error {
6
7
  type: string;
@@ -96,6 +97,35 @@ export async function apiFetch<T>(url: string, options: ApiOptions = {}): Promis
96
97
  // DELETED: apiFetchAuth - Use clientApi from @/lib/client-api instead for proper token refresh handling
97
98
 
98
99
  // Account API for masked info and 2FA
100
+ /**
101
+ * PAY-1591: what the user reads when the LAST recovery step fails. Four outcomes, told apart only by codes the IdP really sends
102
+ * (see resetFailureText): a password-policy refusal names the failed rules; an explicit expired/used token says so and links back
103
+ * to recovery; any other 400/422 gets a neutral 'check the requirements and try again' (the user's recovery session is kept); and
104
+ * a server or transport failure gets a retry sentence. None of them is the old fixed "Failed to reset password".
105
+ */
106
+ export const RECOVERY_EXPIRED_TEXT = 'This reset link has expired or was already used. Start password recovery again.';
107
+
108
+ export const RESET_NEUTRAL_TEXT = "We couldn't set that password. Check it meets the requirements shown and try again.";
109
+
110
+ /**
111
+ * Three tiers for a 400/422 from reset (rigpert 60822, QAPert 60820), decided ONLY on codes the IdP actually sends:
112
+ * policy details.code password_policy (projected by the handler as password_policy) -> the rule sentences
113
+ * tokenInvalid an EXPLICIT expired/used signal (token_expired / token_used / Identity InvalidToken, projected as
114
+ * reset_token_invalid) -> 'link expired, start again'
115
+ * anything else -> NEUTRAL, which must NOT tell the user their link is dead: the served IdP sends a weak-password refusal with
116
+ * no code, and 'expired' there loops them after they spent their emailed code. The recovery session is kept, so
117
+ * they can retry.
118
+ */
119
+ export function resetFailureText(status: number, rules: string[] = [], policy = false, tokenInvalid = false): string {
120
+ if (status === 400 || status === 422) {
121
+ if (tokenInvalid && !policy) return RECOVERY_EXPIRED_TEXT;
122
+ if (!policy) return RESET_NEUTRAL_TEXT;
123
+ const base = "That password wasn't accepted.";
124
+ return rules.length ? `${base} ${passwordRuleSentences(rules)}` : `${base} Try a different one; the rules are shown above.`;
125
+ }
126
+ return "We couldn't reset your password just now. Please try again.";
127
+ }
128
+
99
129
  export class AccountApi {
100
130
  /**
101
131
  * Get masked user info (email, phone, authenticator status)
@@ -116,18 +146,16 @@ export class AccountApi {
116
146
  // Empty body: user info comes from JWT token
117
147
  const result = await standardizedApi.post<any>('/api/account/masked-info', {}, accessToken);
118
148
 
149
+ // PAY-1427: masked email/phone are still user data - log the response shape, never its values.
119
150
  console.log('[DEBUG] getMaskedInfo result:', {
120
151
  success: isApiSuccess(result),
121
152
  hasData: isApiSuccess(result) && !!result.data,
122
- rawData: isApiSuccess(result) ? result.data : null,
123
153
  dataFields: isApiSuccess(result) && result.data ? Object.keys(result.data) : []
124
154
  });
125
155
 
126
- console.log('[DEBUG] getMaskedInfo raw data structure:', JSON.stringify(isApiSuccess(result) ? result.data : result, null, 2));
127
-
128
156
  if (!isApiSuccess(result)) {
129
157
  const errorMessage = isApiError(result) ? result.message : 'Failed to get masked info';
130
- console.error('[DEBUG] getMaskedInfo failed:', result);
158
+ console.error('[DEBUG] getMaskedInfo failed:', { keys: result && typeof result === 'object' ? Object.keys(result) : typeof result });
131
159
  throw new ApiError(errorMessage, result);
132
160
  }
133
161
  // Use only snake_case fields from backend response
@@ -149,6 +177,8 @@ export class AccountApi {
149
177
  headers: { 'Content-Type': 'application/json' },
150
178
  body: JSON.stringify({ email }),
151
179
  });
180
+ // PAY-1588: a throttled caller (429) must look exactly like an unknown one - no session, no distinguishing error.
181
+ if (res.status === 429) return { success: false, data: {} } as any;
152
182
  if (!res.ok) throw new Error('Failed to initiate recovery');
153
183
  return res.json();
154
184
  }
@@ -193,12 +223,21 @@ export class AccountApi {
193
223
  headers: { 'Content-Type': 'application/json' },
194
224
  body: JSON.stringify({
195
225
  email,
196
- password_reset_token: resetToken,
226
+ token: resetToken,
197
227
  new_password: newPassword,
198
228
  confirm_password: confirmPassword,
199
229
  }),
200
230
  });
201
- if (!res.ok) throw new Error('Failed to reset password');
231
+ if (!res.ok) {
232
+ const body = await res.json().catch(() => null);
233
+ const policy = !!body?.password_policy;
234
+ const rules = knownPasswordRules(body?.password_policy?.rules);
235
+ const tokenInvalid = body?.reset_token_invalid === true;
236
+ const err = new Error(resetFailureText(res.status, rules, policy, tokenInvalid)) as Error & { rules?: string[]; code?: string };
237
+ if (rules.length) err.rules = rules;
238
+ if ((res.status === 400 || res.status === 422) && tokenInvalid && !policy) err.code = 'RECOVERY_EXPIRED';
239
+ throw err;
240
+ }
202
241
  return res.json();
203
242
  }
204
243
 
@@ -210,8 +249,8 @@ export class AccountApi {
210
249
  body: JSON.stringify({
211
250
  email: data.email,
212
251
  token: data.token,
213
- password: data.password,
214
- password_confirmation: data.password_confirmation
252
+ new_password: data.password,
253
+ confirm_password: data.password_confirmation
215
254
  })
216
255
  })
217
256
  const result = await res.json().catch(() => ({}))