@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
@@ -45,6 +45,13 @@ export interface SessionsTabProps {
45
45
  // COMPONENT
46
46
  // -----------------------------------------------------------------------------
47
47
 
48
+ // PAY-1429: admin handlers return a fixed message plus a correlationId (never upstream or
49
+ // exception text). Surface the id so an admin can quote it to an operator.
50
+ function errorWithRef(data: any, fallback: string): string {
51
+ const msg = typeof data?.error === 'string' ? data.error : fallback;
52
+ return data?.correlationId ? `${msg} (ref ${data.correlationId})` : msg;
53
+ }
54
+
48
55
  export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }: SessionsTabProps) {
49
56
  const [sessions, setSessions] = useState<LoginSession[]>([]);
50
57
  const [isLoading, setIsLoading] = useState(false);
@@ -80,7 +87,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
80
87
  setSessions(data.sessions || []);
81
88
  setLastRefresh(new Date());
82
89
  } else {
83
- setError(data.error || 'Failed to fetch sessions');
90
+ setError(errorWithRef(data, 'Failed to fetch sessions'));
84
91
  }
85
92
  } catch (err) {
86
93
  setError('Network error. Please try again.');
@@ -132,7 +139,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
132
139
  await fetchSessions();
133
140
  await fetchStats();
134
141
  } else {
135
- alert(data.error || 'Failed to revoke session');
142
+ alert(errorWithRef(data, 'Failed to revoke session'));
136
143
  }
137
144
  } catch (err) {
138
145
  alert('Network error. Please try again.');
@@ -162,7 +169,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
162
169
  await fetchSessions();
163
170
  await fetchStats();
164
171
  } else {
165
- alert(data.error || 'Failed to revoke sessions');
172
+ alert(errorWithRef(data, 'Failed to revoke sessions'));
166
173
  }
167
174
  } catch (err) {
168
175
  alert('Network error. Please try again.');
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ interface Props {
6
+ onGoToLogin: () => void;
7
+ }
8
+
9
+ /**
10
+ * PAY-1588 (rigpert 60711): what a caller sees when the recovery request produced NO recovery session - an unknown
11
+ * address, a locked account, a throttled caller. ONE sentence, identical for all of them, so the screen cannot be used
12
+ * to tell which. It must not claim a reset happened (that is CompleteStep, reached only after a successful reset) and it
13
+ * must not name a cause.
14
+ */
15
+ export function RequestedStep({ onGoToLogin }: Props) {
16
+ return (
17
+ <div className="bg-white border border-gray-300 rounded p-8 text-center">
18
+ <h2 className="text-2xl font-semibold text-gray-900 mb-2">Check your email</h2>
19
+
20
+ <p className="text-gray-600 mb-6">If an account exists for that email, we've sent instructions to reset your password.</p>
21
+
22
+ <button
23
+ onClick={onGoToLogin}
24
+ className="w-full py-2 px-4 border border-gray-300 rounded text-gray-900 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
25
+ >
26
+ Back to sign in
27
+ </button>
28
+ </div>
29
+ );
30
+ }
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useEffect, useState } from 'react';
4
+ import { usePasswordValidation } from '../../hooks/usePasswordValidation';
5
+ import { passwordRulesText } from '../../lib/password-rules';
4
6
 
5
7
  interface Props {
6
8
  onSubmit: (password: string, confirmPassword: string) => void;
@@ -15,6 +17,14 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
15
17
 
16
18
  const passwordsMatch = password === confirmPassword && password !== '';
17
19
 
20
+ // PAY-1591: ADVISORY only. It shows the rules and what is missing; it never disables submit, because the validator
21
+ // may be down and the IdP is the authority (a dead validator must not trap a user who already spent their code).
22
+ const { setPassword: validateAsYouType, policy, failedRequirements } = usePasswordValidation();
23
+ // The policy only comes back with a validation, so ask once on mount with a one-character probe (it never leaves the
24
+ // policy question; the answer is what the rules line is built from). Until it lands the line says the rules are coming.
25
+ useEffect(() => { validateAsYouType('a'); }, [validateAsYouType]);
26
+ const missing = failedRequirements.filter((r) => !/^(Unable to validate|Password validation)/i.test(r));
27
+
18
28
  const handleSubmit = (e: React.FormEvent) => {
19
29
  e.preventDefault();
20
30
  if (passwordsMatch) {
@@ -25,9 +35,12 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
25
35
  return (
26
36
  <div className="bg-white border border-gray-300 rounded p-8">
27
37
  <h2 className="text-xl font-semibold text-gray-900 mb-2">Set New Password</h2>
28
- <p className="text-sm text-gray-600 mb-6">
38
+ <p className="text-sm text-gray-600 mb-2">
29
39
  Choose a strong password for your account.
30
40
  </p>
41
+ <p data-testid="password-rules" className="text-sm text-gray-700 mb-6">
42
+ {passwordRulesText(policy)}
43
+ </p>
31
44
 
32
45
  <form onSubmit={handleSubmit} className="space-y-4">
33
46
  <div>
@@ -39,7 +52,7 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
39
52
  type={showPassword ? 'text' : 'password'}
40
53
  id="password"
41
54
  value={password}
42
- onChange={(e) => setPassword(e.target.value)}
55
+ onChange={(e) => { setPassword(e.target.value); validateAsYouType(e.target.value); }}
43
56
  required
44
57
  disabled={loading}
45
58
  className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10"
@@ -78,6 +91,14 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
78
91
  </div>
79
92
  </div>
80
93
 
94
+ {password && missing.length > 0 && (
95
+ <ul data-testid="password-missing" className="text-sm text-gray-700 list-disc pl-5">
96
+ {missing.map((r) => (
97
+ <li key={r}>{r}</li>
98
+ ))}
99
+ </ul>
100
+ )}
101
+
81
102
  {!passwordsMatch && confirmPassword && (
82
103
  <div className="bg-red-50 border border-red-200 rounded p-3">
83
104
  <p className="text-red-700 text-sm">Passwords do not match</p>
@@ -43,7 +43,8 @@ export interface ViabilityState {
43
43
 
44
44
  interface ViabilityResponse {
45
45
  authenticated: boolean;
46
- sessionToken?: string;
46
+ /** PAY-1481: tokenLogId of the session token (correlation only); the token itself is never returned. */
47
+ sessionTokenId?: string | null;
47
48
  requires2FA?: boolean;
48
49
  twoFactorComplete?: boolean;
49
50
  accessTokenExpired?: boolean;
@@ -27,6 +27,56 @@ export function getAppSlug(): string {
27
27
  return cachedSlug;
28
28
  }
29
29
 
30
+ /**
31
+ * The session cookie name has TWO producers: better-auth mints `${idpConfig.clientSlug || getAppSlug()}.session-token`, while
32
+ * the gate and every reader use getSessionCookieName() = getAppSlug() = APP_SLUG || CLIENT_ID. When the IdP's slug is known and
33
+ * differs, the gate looks for a cookie that was never set and every better-auth (Google) session bounces to
34
+ * /sign-in?error=SessionInvalid straight after a successful 2FA (rigpert 60743, live on :4797 with CLIENT_ID=idp-admin-ui-graft
35
+ * against clientSlug idp_admin_ui_graft). FAIL CLOSED: refuse rather than run with two names. No IdP slug -> nothing to compare.
36
+ */
37
+ export function slugMismatchReason(idpClientSlug: string | null | undefined): string | null {
38
+ if (!idpClientSlug) return null;
39
+ const gateSlug = getAppSlug();
40
+ return idpClientSlug !== gateSlug ? `SLUG_MISMATCH: cookie=${idpClientSlug} gate=${gateSlug}` : null;
41
+ }
42
+
43
+ /**
44
+ * rigpert 60813: when the IdP returns NO client slug for this app (an unknown client: the config fetch failed or came back
45
+ * without one), startup must fail LOUD. Silently defaulting would run with a cookie name nobody verified against the IdP. The
46
+ * startup path swallows a failed config fetch when BETTER_AUTH_SECRET is supplied by env, so without this an unregistered
47
+ * CLIENT_ID (e.g. payez_idp_customer_web, absent on dev-93) would boot 'fine'.
48
+ */
49
+ export function assertIdpSlugKnown(idpClientSlug: string | null | undefined): void {
50
+ if (idpClientSlug) return;
51
+ throw new Error(
52
+ `[FATAL] The IdP returned no client slug for this app (gate slug "${getAppSlug()}"). Either CLIENT_ID/APP_SLUG names a client ` +
53
+ `the IdP does not know, or its config could not be fetched. Refusing to start with an unverified session cookie name.`,
54
+ );
55
+ }
56
+
57
+ /**
58
+ * The health-endpoint view: a mismatch is always reportable; an UNKNOWN slug is reported only once startup has actually been
59
+ * attempted (before that nothing is known yet and health must not go red on a pod that has not started initialising).
60
+ */
61
+ export function slugStateReason(idpClientSlug: string | null | undefined, startupAttempted: boolean): string | null {
62
+ const mismatch = slugMismatchReason(idpClientSlug);
63
+ if (mismatch) return mismatch;
64
+ if (startupAttempted && !idpClientSlug) return `SLUG_UNKNOWN: the IdP returned no client slug for this app gate=${getAppSlug()}`;
65
+ return null;
66
+ }
67
+
68
+ export function assertAppSlugMatchesIdp(idpClientSlug: string | null | undefined): void {
69
+ if (!idpClientSlug) return;
70
+ const gateSlug = getAppSlug();
71
+ if (slugMismatchReason(idpClientSlug) !== null) {
72
+ throw new Error(
73
+ `[FATAL] Session cookie name would have two sources: the IdP client slug is "${idpClientSlug}" but this app's slug ` +
74
+ `(APP_SLUG, else CLIENT_ID) is "${gateSlug}". better-auth would set "${idpClientSlug}.session-token" while the gate reads ` +
75
+ `"${gateSlug}.session-token", so every better-auth session would be rejected. Set APP_SLUG (or CLIENT_ID) to "${idpClientSlug}".`,
76
+ );
77
+ }
78
+ }
79
+
30
80
  /**
31
81
  * Clears the cached slug (useful for testing).
32
82
  */
@@ -46,6 +46,32 @@ export type EnsureFreshResult =
46
46
  accessTokenExpires: number;
47
47
  /** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
48
48
  refreshed: boolean;
49
+ /**
50
+ * CARD 320072. Is the token in THIS result actually in the session store?
51
+ *
52
+ * ON THIS BRANCH OF THE UNION IT IS ALWAYS `true`, AND THAT IS THE POINT.
53
+ * Every `ok: true` return either read the token back from the store or
54
+ * wrote it and confirmed the write. An unpersisted refresh does not reach
55
+ * an `ok: true` result at all — it returns `ok:false, SESSION_PERSIST_FAILED`
56
+ * from the other branch below.
57
+ *
58
+ * READ THIS BEFORE YOU DELETE THE FIELD. It is declared `boolean` rather
59
+ * than the literal `true` on purpose: it is the assertable witness that the
60
+ * escalation is still wired. If someone later re-weakens the persist site
61
+ * back to reporting success, this field is where that regression becomes
62
+ * visible to a test instead of invisible to everyone. Cases 2 and 7 in
63
+ * test/ensure-fresh-persist-failure.test.cjs assert on it.
64
+ *
65
+ * WHAT THIS COMMENT USED TO SAY, AND WHY IT IS GONE. It documented `false`
66
+ * as a live value on an ok:true result — "we hold a valid token that was
67
+ * not persisted" — because that WAS the contract before this merge, when
68
+ * the dropped write was reported as a success with a flag on it. Blending
69
+ * in the escalation made that sentence describe a state the code can no
70
+ * longer produce. A docblock asserting an impossible semantic is exactly
71
+ * the defect f5ac6dc3 was written to fix, one file over; reproducing it
72
+ * here while merging that fix would be absurd.
73
+ */
74
+ persisted: boolean;
49
75
  }
50
76
  | {
51
77
  ok: false;
@@ -113,7 +139,8 @@ export async function ensureFreshAccessToken(
113
139
  const msUntilExpiry = effectiveExpires - now;
114
140
 
115
141
  if (msUntilExpiry > safetyWindowMs) {
116
- return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false };
142
+ // Durable by construction: this token came OUT of the store and we wrote nothing.
143
+ return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
117
144
  }
118
145
 
119
146
  if (!session.idpRefreshToken) {
@@ -152,6 +179,8 @@ export async function ensureFreshAccessToken(
152
179
  accessToken: after.idpAccessToken,
153
180
  accessTokenExpires: after.idpAccessTokenExpires,
154
181
  refreshed: true,
182
+ // Read back from the store after the winner's refresh - durable.
183
+ persisted: true,
155
184
  };
156
185
  }
157
186
  }
@@ -188,6 +217,8 @@ export async function ensureFreshAccessToken(
188
217
  accessToken: latest.idpAccessToken,
189
218
  accessTokenExpires: latest.idpAccessTokenExpires,
190
219
  refreshed: true,
220
+ // Post-lock re-read: another caller already refreshed and stored this.
221
+ persisted: true,
191
222
  };
192
223
  }
193
224
  }
@@ -276,12 +307,37 @@ export async function ensureFreshAccessToken(
276
307
  const tokenUnchanged =
277
308
  !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
278
309
  if (tokenUnchanged) {
279
- await updateSession(sessionToken, {
310
+ /**
311
+ * 320072: this is the OTHER updateSession call in this file, and it
312
+ * does NOT escalate on a null return the way the persist site below
313
+ * does. Checked, not overlooked: reaching here requires
314
+ * `tokenUnchanged`, which requires `postRefresh?.idpRefreshToken` to
315
+ * equal the token we sent — so the CAS read three lines up already
316
+ * proved the row exists. A missing row makes tokenUnchanged false and
317
+ * routes to the else branch instead, so the dropped-write path is
318
+ * very nearly unreachable here.
319
+ *
320
+ * "Very nearly" is why the return IS still captured. The residual both
321
+ * halves of this merge identified is real: the row can expire between
322
+ * that CAS read and this write, in which case the dead refresh token is
323
+ * NOT cleared and the "we discarded it" claim in the error below is
324
+ * false. That costs nothing to observe and cannot be inferred from any
325
+ * other line, so it is warned rather than assumed — see `if (!cleared)`
326
+ * immediately below. The RESULT is unchanged either way: the session is
327
+ * unusable and we return the same non-ok payload.
328
+ */
329
+ const cleared = await updateSession(sessionToken, {
280
330
  idpRefreshToken: '',
281
331
  idpRefreshTokenExpires: undefined,
282
332
  refreshTokenClearedAt: Date.now(),
283
333
  refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
284
334
  });
335
+ if (!cleared) {
336
+ console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
337
+ code,
338
+ requestId,
339
+ });
340
+ }
285
341
  } else {
286
342
  // Concurrent refresh already rotated the token — our 401 is stale. Return the
287
343
  // freshly-rotated token directly so the loser succeeds without a retry round-trip
@@ -293,6 +349,8 @@ export async function ensureFreshAccessToken(
293
349
  accessToken: postRefresh.idpAccessToken,
294
350
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
295
351
  refreshed: true,
352
+ // `postRefresh` is a store read, so this token is by definition stored.
353
+ persisted: true,
296
354
  };
297
355
  }
298
356
  }
@@ -351,7 +409,14 @@ export async function ensureFreshAccessToken(
351
409
  const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
352
410
  const newKid = extractKidFromToken(newAccess);
353
411
 
354
- await updateSession(sessionToken, {
412
+ // *** CARD 320072. CAPTURE THIS RETURN. ***
413
+ // `updateSession` returns null - and writes nothing - when the canonical
414
+ // session row is missing, which happens whenever the BA fallback above is what
415
+ // resolved this session. Discarding the return is what let this function
416
+ // report `ok:true, refreshed:true` for a refresh it had just thrown away.
417
+ // The full rationale for what we DO about it is at the `if (!persisted)`
418
+ // block below; this line only makes the answer available.
419
+ const persisted = await updateSession(sessionToken, {
355
420
  ...session,
356
421
  idpAccessToken: newAccess,
357
422
  idpAccessTokenExpires: accessTokenExpires,
@@ -371,7 +436,71 @@ export async function ensureFreshAccessToken(
371
436
  mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
372
437
  });
373
438
 
374
- return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true };
439
+ /**
440
+ * 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
441
+ *
442
+ * `updateSession` returns null when the canonical session row is absent,
443
+ * which silently throws away EVERYTHING above — the new access token, the
444
+ * rotated refresh token, decodedAccessToken, authenticationMethods,
445
+ * mfaVerified, mfaCompletedAt, mfaExpiresAt. This return value was
446
+ * previously ignored, and the line below then reported
447
+ * `ok: true, refreshed: true`.
448
+ *
449
+ * WHY THAT IS WORSE THAN A PLAIN LOST WRITE. By this point the exchange at
450
+ * the IDP has ALREADY HAPPENED, so if refresh tokens are single-use the
451
+ * token we sent is now spent and its replacement is the thing we just
452
+ * dropped. The stored credential is therefore dead, and every later attempt
453
+ * re-sends that same dead token and fails. The user is bounced to LOGIN —
454
+ * not to verify-code — with nothing in the logs naming the cause.
455
+ *
456
+ * It did not even surface as a wrong answer to our one caller: server/auth.ts
457
+ * calls getRedisSession immediately after us, which is this same `getSession`,
458
+ * so it finds the same missing row and returns a generic NO_SESSION one frame
459
+ * later. The false `ok: true` was thus invisible AND the real cause — a
460
+ * dropped persist — was reported as an unrelated missing session.
461
+ *
462
+ * Naming it here is the point: the failure is reported at its true cause,
463
+ * with a code that says the refresh succeeded and the PERSIST is what
464
+ * failed. Not retryable — retrying burns another refresh token the same way.
465
+ *
466
+ * This is the card's option (b), deliberately. Option (a) — teaching
467
+ * updateSession to write through to whichever store holds the session,
468
+ * mirroring the read fallback above — is the better end state but changes
469
+ * write-path storage semantics for every caller, which is not this card.
470
+ *
471
+ * AND IT IS ALSO LOGGED, WHICH IS NOT THE SAME THING (BAPert R1134 §7,
472
+ * re-affirmed in R1145 §2c). Returning a named code tells the CALLER. §7
473
+ * requires that `persisted:false` reach a log A HUMAN READS, because the one
474
+ * caller — server/auth.ts — converts every non-ok result into the same
475
+ * generic 401 and keeps no record of which code produced it. Without this
476
+ * line the distinction we just bought at the type level is erased one frame
477
+ * later and no operator ever sees the word PERSIST. The escalation is the
478
+ * contract; the warn is the evidence.
479
+ */
480
+ if (!persisted) {
481
+ console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
482
+ requestId,
483
+ refreshTokenRotated: hasNewRefresh,
484
+ persisted: false,
485
+ });
486
+ return {
487
+ ok: false,
488
+ code: 'SESSION_PERSIST_FAILED',
489
+ message:
490
+ 'Token refresh succeeded at the IDP but the session could not be persisted; the rotated tokens were discarded.',
491
+ status: 401,
492
+ terminal: true,
493
+ retryable: false,
494
+ discardToken: true,
495
+ resolution: 'REAUTHENTICATE',
496
+ };
497
+ }
498
+
499
+ // `persisted: true` is not decoration and not a leftover: the `if` above is
500
+ // the only way out for a dropped write, so by construction anything reaching
501
+ // this line WAS written. Keeping the field keeps the ok:true branch of
502
+ // EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
503
+ return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
375
504
  } finally {
376
505
  if (weHoldLock) {
377
506
  await releaseRefreshLock(sessionToken, requestId, releaseVersion);
@@ -166,6 +166,75 @@ const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
166
166
  const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
167
167
  let operatorOverrideLogged = false;
168
168
 
169
+ // ============================================================================
170
+ // PAY-1460 - SECRETS ARE ENV-FIRST
171
+ // ============================================================================
172
+ /**
173
+ * The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
174
+ * caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
175
+ * them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
176
+ *
177
+ * So both are now resolved ENV FIRST, config second:
178
+ * signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
179
+ * Uses the module-load snapshot above: this module publishes the config value into
180
+ * process.env, so a live read would mistake the IdP's value for the operator's.
181
+ * provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
182
+ * (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
183
+ *
184
+ * The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
185
+ * environment and the config are empty.
186
+ *
187
+ * Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
188
+ * written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
189
+ */
190
+ export type SecretSource = string; // 'env:<VAR>' | 'idp-config' | 'none'
191
+
192
+ export function providerSecretEnvVar(provider: string): string {
193
+ return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
194
+ }
195
+
196
+ const loggedSecretSources = new Map<string, SecretSource>();
197
+ function logSecretSource(what: string, source: SecretSource): void {
198
+ if (loggedSecretSources.get(what) === source) return;
199
+ loggedSecretSources.set(what, source);
200
+ console.log(`[IDP_CONFIG] ${what} source: ${source}`);
201
+ }
202
+
203
+ function resolveAuthSecret(fromConfig: string | undefined): { value: string; source: SecretSource } {
204
+ if (OPERATOR_AUTH_SECRET) return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
205
+ if (OPERATOR_NEXTAUTH_SECRET) return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
206
+ if (fromConfig) return { value: fromConfig, source: 'idp-config' };
207
+ return { value: '', source: 'none' };
208
+ }
209
+
210
+ /** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
211
+ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
212
+ const auth = resolveAuthSecret(config.authSecret);
213
+ logSecretSource('authSecret', auth.source);
214
+ return {
215
+ ...config,
216
+ authSecret: auth.value,
217
+ oauthProviders: (config.oauthProviders ?? []).map((p) => {
218
+ const envVar = providerSecretEnvVar(p.provider);
219
+ const fromEnv = process.env[envVar];
220
+ const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
221
+ logSecretSource(`${p.provider} clientSecret`, source);
222
+ return { ...p, clientSecret: fromEnv || p.clientSecret };
223
+ }),
224
+ };
225
+ }
226
+
227
+ /** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
228
+ function stripEnvSecrets(config: IDPClientConfig): IDPClientConfig {
229
+ return {
230
+ ...config,
231
+ authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
232
+ oauthProviders: (config.oauthProviders ?? []).map((p) =>
233
+ process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p
234
+ ),
235
+ };
236
+ }
237
+
169
238
  /** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
170
239
  function publishAuthSecret(value: string, source: string): void {
171
240
  if (OPERATOR_AUTH_SECRET) {
@@ -294,7 +363,8 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
294
363
  const key = getRedisConfigKey();
295
364
  const ttlSeconds = config.configCacheTtlSeconds || 300;
296
365
  const data: RedisCachedConfig = {
297
- config,
366
+ // PAY-1460: never persist a secret the environment supplied.
367
+ config: stripEnvSecrets(config),
298
368
  expiresAt: Date.now() + (ttlSeconds * 1000)
299
369
  };
300
370
  // Store with TTL slightly longer than the logical expiry to allow for clock skew
@@ -355,7 +425,10 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
355
425
 
356
426
  // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
357
427
  if (!forceRefresh) {
358
- const redisConfig = await getConfigFromRedis();
428
+ // PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
429
+ // environment is re-applied on read, BEFORE the emptiness test below.
430
+ const cachedRaw = await getConfigFromRedis();
431
+ const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
359
432
  // A cached entry is only USABLE if it carries a secret — see the note
360
433
  // below. `redisConfig && !authSecret` must behave like a cache MISS, so
361
434
  // the emptiness test belongs here, in the condition that gates the
@@ -505,7 +578,7 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
505
578
  // This throw is what stops that. It is also what turned the
506
579
  // 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
507
580
  // of a silently forgeable app.
508
- throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
581
+ throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
509
582
  }
510
583
 
511
584
  // Validated above — only now is it safe to cache or publish.
@@ -588,7 +661,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
588
661
 
589
662
  if (!resp.ok) {
590
663
  const txt = await resp.text().catch(() => 'Unknown error');
591
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} - ${txt}`);
664
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
592
665
  }
593
666
 
594
667
  const body: any = await resp.json().catch(() => null);
@@ -603,7 +676,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
603
676
  throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
604
677
  }
605
678
 
606
- const config: IDPClientConfig = {
679
+ const config: IDPClientConfig = applyEnvSecrets({
607
680
  clientId: String(rawClientId),
608
681
  clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
609
682
  // Wire compatibility: accept new authSecret first, fall back to legacy
@@ -669,13 +742,13 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
669
742
  logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
670
743
  },
671
744
  baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
672
- };
745
+ });
673
746
 
674
747
  if (!config.authSecret) {
675
748
  // LOAD-BEARING — see the note on the external path's equivalent throw.
676
749
  // An empty secret reaching betterAuth() becomes the PUBLIC default, not an
677
750
  // error. Never replace this with a fallback.
678
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
751
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
679
752
  }
680
753
 
681
754
  console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
@@ -751,7 +824,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
751
824
 
752
825
  if (!signingResp.ok) {
753
826
  const txt = await signingResp.text().catch(() => 'Unknown error');
754
- throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} - ${txt}`);
827
+ // PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
828
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
755
829
  }
756
830
 
757
831
  const signingBody: any = await signingResp.json().catch(() => null);
@@ -768,7 +842,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
768
842
  ) as string | undefined;
769
843
 
770
844
  if (!client_assertion) {
771
- console.error('[IDP_CONFIG] FATAL: Full response body:', JSON.stringify(signingBody, null, 2));
845
+ // PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
846
+ console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
772
847
  throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
773
848
  }
774
849
 
@@ -789,7 +864,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
789
864
 
790
865
  if (!configResp.ok) {
791
866
  const txt = await configResp.text().catch(() => 'Unknown error');
792
- throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} - ${txt}`);
867
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
793
868
  }
794
869
 
795
870
  const configBody: any = await configResp.json().catch(() => null);
@@ -802,7 +877,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
802
877
  const configData = configBody?.data;
803
878
 
804
879
  if (!configData || typeof configData !== 'object') {
805
- console.error('[IDP_CONFIG] FATAL: Full config response body:', JSON.stringify(configBody, null, 2));
880
+ // PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
881
+ console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
806
882
  throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
807
883
  }
808
884
 
@@ -815,7 +891,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
815
891
  // Map response to our interface (IDP returns camelCase or snake_case).
816
892
  // Wire compatibility: accept new authSecret first, fall back to legacy
817
893
  // nextAuthSecret/next_auth_secret while IDP rename rolls out.
818
- const config: IDPClientConfig = {
894
+ const config: IDPClientConfig = applyEnvSecrets({
819
895
  clientId: String(rawClientId),
820
896
  clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
821
897
  authSecret:
@@ -889,7 +965,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
889
965
  logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
890
966
  },
891
967
  baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
892
- };
968
+ });
893
969
 
894
970
  // Debug: log what we got for baseClientUrl
895
971
  console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
@@ -901,7 +977,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
901
977
  if (!config.authSecret) {
902
978
  // LOAD-BEARING — empty must fail here, never downstream. betterAuth()
903
979
  // silently substitutes a public constant for an empty secret.
904
- throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
980
+ throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
905
981
  }
906
982
 
907
983
  // Success - reset failure tracking
@@ -58,7 +58,7 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
58
58
  const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
59
59
  console.log('[IDP_FETCH] ensureFreshAccessToken:', {
60
60
  hasToken: !!token,
61
- sessionToken: sessionToken?.substring(0, 20) + '...',
61
+ hasSessionToken: !!sessionToken, // PAY-1427 / BAPert 58829: no token material in logs, not even a stub (was a 20-char prefix)
62
62
  pathname: req.nextUrl.pathname
63
63
  });
64
64
  if (!sessionToken) {