@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
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PAY-1591: the one sentence that states the password rules on the recovery set-password step.
3
+ *
4
+ * Built ONLY from the policy the IdP reported (usePasswordValidation.policy). A rule that is off is not stated, and with
5
+ * no policy at all we state no number: telling a user "at least 8 characters" when the client is configured for 12 is
6
+ * the same defect as saying nothing, one step later.
7
+ */
8
+ export interface PasswordPolicyLike {
9
+ min_length?: number;
10
+ require_uppercase?: boolean;
11
+ require_lowercase?: boolean;
12
+ require_digit?: boolean;
13
+ require_special?: boolean;
14
+ }
15
+
16
+ export function passwordRulesText(policy: PasswordPolicyLike | undefined | null): string {
17
+ if (!policy || typeof policy.min_length !== 'number') {
18
+ return "Use a password that meets this site's rules. We'll show what is missing as you type.";
19
+ }
20
+ const parts = [`at least ${policy.min_length} characters`];
21
+ if (policy.require_uppercase) parts.push('an upper-case letter');
22
+ if (policy.require_lowercase) parts.push('a lower-case letter');
23
+ if (policy.require_digit) parts.push('a number');
24
+ if (policy.require_special) parts.push('a symbol');
25
+ const [first, ...rest] = parts;
26
+ return rest.length ? `Your password needs ${first}, with ${rest.join(', ')}.` : `Your password needs ${first}.`;
27
+ }
28
+
29
+ /**
30
+ * PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
31
+ * portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
32
+ */
33
+ export const PASSWORD_RULE_SENTENCES: Readonly<Record<string, string>> = {
34
+ min_length: 'Use a longer password.',
35
+ uppercase: 'Add an upper-case letter.',
36
+ lowercase: 'Add a lower-case letter.',
37
+ digit: 'Add a number.',
38
+ special: 'Add a symbol.',
39
+ unique_chars: 'Use more different characters.',
40
+ recent_password: "Choose a password you haven't used recently.",
41
+ };
42
+
43
+ export function knownPasswordRules(rules: unknown): string[] {
44
+ if (!Array.isArray(rules)) return [];
45
+ return rules.filter((r): r is string => typeof r === 'string' && Object.prototype.hasOwnProperty.call(PASSWORD_RULE_SENTENCES, r));
46
+ }
47
+
48
+ export function passwordRuleSentences(rules: string[]): string {
49
+ return rules.map((r) => PASSWORD_RULE_SENTENCES[r]).join(' ');
50
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * PAY-1438: what a USER-FACING handler may put in an error response.
3
+ *
4
+ * Several of these routes are reachable before authentication (session check, viability probe,
5
+ * logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
6
+ *
7
+ * Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
8
+ * JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
9
+ * a code and a correlationId; the log line carries the exception and the same id.
10
+ *
11
+ * Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
12
+ * never passed through whole. Only the display contract is projected: code, message, resolution,
13
+ * request_id - each only when it is a string. `details` (free-form, may carry validation internals
14
+ * or exception data) is dropped, except for the password-policy messages change-password's UI reads.
15
+ */
16
+
17
+ import { knownPasswordRules } from './password-rules';
18
+
19
+ export interface IdpErrorProjection {
20
+ code?: string;
21
+ message?: string;
22
+ resolution?: string;
23
+ request_id?: string;
24
+ }
25
+
26
+ const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
27
+
28
+ /** Log an exception with a fresh correlationId and return the id for the response body. */
29
+ export function logExceptionWithCorrelation(tag: string, err: unknown): string {
30
+ const correlationId = globalThis.crypto.randomUUID();
31
+ console.error(tag, { correlationId, error: err });
32
+ return correlationId;
33
+ }
34
+
35
+ /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
36
+ export function projectIdpError(json: unknown): IdpErrorProjection {
37
+ if (!json || typeof json !== 'object') return {};
38
+ const top = json as Record<string, unknown>;
39
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
40
+ const support = err.support && typeof err.support === 'object' ? (err.support as Record<string, unknown>) : {};
41
+ const out: IdpErrorProjection = {};
42
+ const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
43
+ const message = str(err.message) ?? str(top.message);
44
+ const resolution = str(err.resolution) ?? str(top.resolution);
45
+ const requestId = str(support.request_id) ?? str(top.request_id);
46
+ if (code !== undefined) out.code = code;
47
+ if (message !== undefined) out.message = message;
48
+ if (resolution !== undefined) out.resolution = resolution;
49
+ if (requestId !== undefined) out.request_id = requestId;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * change-password's `details`: the security pages show `details.value[0].message` (else
55
+ * `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
56
+ * strings; drop everything else.
57
+ */
58
+ export function projectPasswordPolicyDetails(details: unknown): { message?: string; value?: Array<{ message: string }> } | undefined {
59
+ if (!details || typeof details !== 'object') return undefined;
60
+ const d = details as Record<string, unknown>;
61
+ const out: { message?: string; value?: Array<{ message: string }> } = {};
62
+ if (Array.isArray(d.value)) {
63
+ const value = d.value
64
+ .map((v) => (v && typeof v === 'object' ? str((v as Record<string, unknown>).message) : undefined))
65
+ .filter((m): m is string => m !== undefined)
66
+ .map((message) => ({ message }));
67
+ if (value.length) out.value = value;
68
+ }
69
+ const message = str(d.message);
70
+ if (message !== undefined) out.message = message;
71
+ return out.value || out.message !== undefined ? out : undefined;
72
+ }
73
+
74
+ /**
75
+ * For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
76
+ * reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
77
+ * SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
78
+ * whitelisted projection. The success path keeps its passthrough.
79
+ */
80
+ export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request failed'): {
81
+ success: false;
82
+ message: string;
83
+ error: { code?: string; message: string; resolution?: string };
84
+ request_id?: string;
85
+ } {
86
+ const p = projectIdpError(json);
87
+ const message = p.message ?? fallbackMessage;
88
+ const error: { code?: string; message: string; resolution?: string } = { message };
89
+ if (p.code !== undefined) error.code = p.code;
90
+ if (p.resolution !== undefined) error.resolution = p.resolution;
91
+ const out: { success: false; message: string; error: typeof error; request_id?: string } = { success: false, message, error };
92
+ if (p.request_id !== undefined) out.request_id = p.request_id;
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
98
+ * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
99
+ * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
100
+ * (the caller must be able to tell a refused password from a dead recovery session).
101
+ */
102
+ /**
103
+ * True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
104
+ * ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
105
+ * expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
106
+ * Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
107
+ * no code at all, and calling that 'link expired' loops the user.
108
+ */
109
+ export function projectResetTokenInvalid(json: unknown): boolean {
110
+ if (!json || typeof json !== 'object') return false;
111
+ const top = json as Record<string, unknown>;
112
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
113
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
114
+ if (!details) return false;
115
+ if (details.code === 'token_expired' || details.code === 'token_used') return true;
116
+ return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
117
+ }
118
+
119
+ export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
120
+ if (!json || typeof json !== 'object') return undefined;
121
+ const top = json as Record<string, unknown>;
122
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
123
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
124
+ if (!details || details.code !== 'password_policy') return undefined;
125
+ return knownPasswordRules(details.rules);
126
+ }
@@ -39,11 +39,16 @@ import {
39
39
  * instead of degrading to one clean Redis-canonical lookup (or a genuine
40
40
  * 401 if the record really is gone).
41
41
  *
42
- * No signature is verified for the BA-hmac shape here — same posture the
43
- * pre-existing JWS fallback already took (see auth.ts's `getSession` Path B
44
- * comment): Redis is the liveness authority downstream, so a forged token
45
- * still has to land on a live session record to do anything. This function
46
- * only recovers the CANDIDATE token; callers must still look it up.
42
+ * No signature is verified here, and none CAN be: this module is secret-free
43
+ * and synchronous. It only CLASSIFIES and recovers the candidate token.
44
+ *
45
+ * PAY-1476: the earlier claim that "Redis is the liveness authority, so a
46
+ * forged token still has to land on a live session record" was the defect.
47
+ * A LEAKED token lands on a live record by definition, so without a signature
48
+ * check the token string alone was a bearer credential. The candidate token
49
+ * returned here must NEVER be handed to the session store directly: go through
50
+ * server/verified-session-token.ts resolveVerifiedSessionToken, which verifies
51
+ * the HMAC (ba-hmac) or the JWS signature first.
47
52
  */
48
53
 
49
54
  export type SessionCookieShape = 'jws' | 'ba-hmac';
@@ -90,20 +95,18 @@ export function extractSessionTokenFromCookieValue(rawValue: string | null | und
90
95
  return null;
91
96
  }
92
97
 
93
- /** Extract the session token from the Better Auth session cookie WITHOUT
94
- * going through Better Auth's `auth.api.getSession()`. Handles both
95
- * single-cookie and chunked-cookie cases (Better Auth chunks long values
96
- * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
97
- * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
98
- * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
99
- * the shape-blind version of this function is what turned a single
100
- * BA-primary miss into total, secret-independent fallback failure for
101
- * every current cookie, since Better Auth never mints the JWS shape this
102
- * used to require). No signature check — Redis is the liveness gate, so
103
- * an attacker forging a token still has to land on a real Redis session
104
- * (infeasible against high-entropy UUIDs). Returns null if no cookie is
105
- * present or neither known shape matches. */
106
- export function extractSessionTokenFromCookie(request: Request): string | null {
98
+ /** The RAW session-cookie value from a Request, WITHOUT going through Better
99
+ * Auth's `auth.api.getSession()`. Handles single and chunked cookies (Better
100
+ * Auth chunks long values across `{name}.0`, `{name}.1`, …) and every name the
101
+ * cookie is minted under (240654).
102
+ *
103
+ * PAY-1476: this used to be `extractSessionTokenFromCookie`, which returned the
104
+ * TOKEN with "No signature check — Redis is the liveness gate". That turned any
105
+ * leaked token into a bearer credential. It now returns the raw value only, so
106
+ * the one way to get a token out of a request cookie is
107
+ * server/verified-session-token.ts resolveVerifiedSessionTokenFromRequest.
108
+ * Returns null if no session cookie is present. */
109
+ export function extractSessionCookieValue(request: Request): string | null {
107
110
  const cookieHeader = request.headers.get('cookie');
108
111
  if (!cookieHeader) return null;
109
112
 
@@ -178,7 +181,5 @@ export function extractSessionTokenFromCookie(request: Request): string | null {
178
181
  }
179
182
  }
180
183
 
181
- if (!rawJwt) return null;
182
-
183
- return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
184
+ return rawJwt || null;
184
185
  }
@@ -13,6 +13,7 @@ import { randomBytes } from 'crypto';
13
13
  import { SessionData } from '../models/SessionModel';
14
14
  import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
15
15
  import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
16
+ import { tokenLogId } from './token-log-id';
16
17
 
17
18
  // Re-export SessionData for consumers
18
19
  export type { SessionData } from '../models/SessionModel';
@@ -293,11 +294,29 @@ export async function setSession(sessionToken: string, data: SessionData): Promi
293
294
  .exec();
294
295
  }
295
296
 
297
+ /**
298
+ * Describe a credential-bearing value for a log line WITHOUT the value: its kind only.
299
+ * Deliberately no length or fingerprint either - both narrow the value down.
300
+ */
301
+ function describeTokenValueForLog(v: unknown): string {
302
+ if (v === undefined) return 'undefined';
303
+ if (v === null) return 'null';
304
+ if (typeof v === 'string') return v.length === 0 ? 'empty string' : 'non-empty string (value withheld)';
305
+ return `${typeof v} (value withheld)`;
306
+ }
307
+
296
308
  /**
297
309
  * Updates tokens within an existing session.
310
+ *
311
+ * CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
312
+ * row was absent and EVERY field in `updates` was discarded — it is a dropped
313
+ * write, not a no-op on unchanged data. Ignoring it is how a token refresh came
314
+ * to report success while persisting nothing.
315
+ *
298
316
  * @param sessionToken The session token to update.
299
317
  * @param updates Partial session data to update.
300
- * @returns The updated session data, or null if the session was not found.
318
+ * @returns The updated session data, or null if the session was not found — in
319
+ * which case NOTHING WAS WRITTEN.
301
320
  */
302
321
  export async function updateSession(
303
322
  sessionToken: string,
@@ -309,6 +328,74 @@ export async function updateSession(
309
328
  // Get current session to merge with updates
310
329
  const currentSession = await getSession(sessionToken);
311
330
  if (!currentSession) {
331
+ /**
332
+ * 320072: THIS IS A DROPPED WRITE, AND IT USED TO LEAVE NO TRACE.
333
+ *
334
+ * Returning null here is correct — there is no row to merge into — but it
335
+ * was previously done in total silence: no throw, no log. Every caller that
336
+ * ignores the return value therefore believed it had persisted, and the
337
+ * most damaging caller did exactly that (ensure-fresh-access-token).
338
+ *
339
+ * The canonical row can legitimately be absent: better-auth.ts writes the
340
+ * `ba:` record and the canonical row in two steps, and the second is
341
+ * wrapped in a try/catch that only console.warn()s — a deliberate,
342
+ * documented degradation that leaves the BA session correct and this row
343
+ * missing. That decision is fine. What was NOT fine is that the READ side
344
+ * reported success anyway, so nothing ever surfaced it.
345
+ *
346
+ * Logged at error, not warn: the caller's data is gone. Callers that can
347
+ * tolerate a missing row should check the null return — which is now the
348
+ * documented contract — rather than rely on this being quiet.
349
+ */
350
+ /**
351
+ * DO NOT LOG `key` HERE. `getSessionKey()` (line 21) is
352
+ * `${getSessionPrefix()}${token}` — the prefix plus the WHOLE session
353
+ * token — so logging it writes a live, replayable credential into
354
+ * console.error, and logs travel further than the process that wrote them.
355
+ * The redacting idiom in this file is `substring(0, 8) + '...'`.
356
+ *
357
+ * NOTE ON WHAT THIS COMMENT NO LONGER CLAIMS. It used to say this was the
358
+ * single unredacted departure in the file, and gave a site count. Both were
359
+ * wrong: a RAW refresh token was being emitted at the
360
+ * REFRESH_TOKEN_BEING_CLEARED site below, and the count was stale. QAPert found that
361
+ * leak while reviewing PR 96 — he looked *because* this comment asserted
362
+ * there was nothing to find, which is the worst thing a comment can do.
363
+ * It is fixed in the same commit as this rewrite.
364
+ *
365
+ * So the counts are gone deliberately. A tally in prose decays silently on
366
+ * the next edit and then actively misleads; that decay is the defect being
367
+ * repaired here, and reproducing it in the fix would be absurd. If you need
368
+ * a number, measure it — do not trust this paragraph for one.
369
+ *
370
+ * What replaces the prose is enforcement. The invariant is
371
+ * NO RAW CREDENTIAL IS EVER EMITTED FROM THIS FUNCTION, and it is pinned by
372
+ * executable assertions, not by this text:
373
+ * - Case 4 (session token, this log)
374
+ * - Case 5 (refresh token, the REFRESH_TOKEN_BEING_CLEARED log below)
375
+ * in test/ensure-fresh-persist-failure.test.cjs. Both were proven to fail
376
+ * when their fix is mutated out. Delete a redaction and a test goes red;
377
+ * delete this comment and nothing does. Trust the tests.
378
+ *
379
+ * Caught by QAPert on 320072 and made binding by BAPert (R1043 s1).
380
+ *
381
+ * The prefix is logged SEPARATELY and deliberately, because it is the field
382
+ * that makes this line diagnostic rather than decorative: a "no canonical
383
+ * row" miss has two very different causes, and only the prefix tells them
384
+ * apart —
385
+ * wrong keyspace (slug/prefix disagreement — the 241875 / 213416 defect
386
+ * documented at line 111, where a writer and a reader
387
+ * resolved different slugs and every lookup silently
388
+ * missed), versus
389
+ * absent row (the documented better-auth.ts two-step degradation).
390
+ * The prefix is an app slug, not a secret. Redacting it too would leave a
391
+ * log that says only "something was missing".
392
+ */
393
+ console.error('[SESSION-STORE] UPDATE_DROPPED_NO_CANONICAL_ROW', {
394
+ keyPrefix: getSessionPrefix(),
395
+ sessionToken: tokenLogId(sessionToken),
396
+ updatedFields: Object.keys(updates),
397
+ note: 'updateSession found no session row; ALL updates were discarded and null was returned',
398
+ });
312
399
  return null;
313
400
  }
314
401
 
@@ -322,10 +409,15 @@ export async function updateSession(
322
409
 
323
410
  if (hadRefreshToken && !willHaveRefreshToken) {
324
411
  console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
325
- sessionToken: sessionToken.substring(0, 8) + '...',
412
+ sessionToken: tokenLogId(sessionToken),
326
413
  userId: currentSession.userId,
327
414
  updateKeys: Object.keys(updates),
328
- refreshTokenInUpdate: (updates as any).idpRefreshToken || (updates as any).refreshToken,
415
+ // NEVER the value: a refresh token is a long-lived credential and this line ships to the
416
+ // log sink. The old `idpRefreshToken || refreshToken` printed a live legacy token raw when
417
+ // idpRefreshToken was null/"" (which clears) and refreshToken still held one.
418
+ // Test: test/session-store-refresh-token-not-logged.test.cjs
419
+ idpRefreshTokenInUpdate: describeTokenValueForLog((updates as any).idpRefreshToken),
420
+ legacyRefreshTokenInUpdate: describeTokenValueForLog((updates as any).refreshToken),
329
421
  refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
330
422
  stack: new Error().stack
331
423
  });
@@ -380,15 +472,66 @@ function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData>
380
472
 
381
473
  const fields: Partial<SessionData> = {};
382
474
 
475
+ /**
476
+ * *** TEST THE CONVERTED VALUE, NOT THE CLAIM'S PRESENCE. ***
477
+ *
478
+ * This guard previously branched on `claim !== undefined && claim !== null`
479
+ * and then wrote `Number(claim) * 1000`. Those are two different questions,
480
+ * and for a non-numeric claim they disagree: the claim IS present, so the
481
+ * branch is taken, and the value written is NaN. That single NaN defeats both
482
+ * guards in this system at once, in opposite directions:
483
+ *
484
+ * - `mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined` is TRUE
485
+ * for NaN, so R908(e) certifies a ceremony it never observed and GRANTS
486
+ * mfaVerified.
487
+ * - consumers read `mfaExpiresAt || 0`, and NaN is FALSY, so that collapses
488
+ * to 0, `0 > 0` is false, and the session NEVER expires.
489
+ *
490
+ * Net: `mfaVerified: true` that nothing can ever expire — the exact
491
+ * pre-339563 fail-open described at the top of this function, restored
492
+ * through a malformed claim, and silent (neither warning below fires on NaN).
493
+ *
494
+ * So a claim that will not convert is treated as NO CLAIM: the key is OMITTED
495
+ * and R908(e) withholds the grant, identical to the absent-claim lane. Absent
496
+ * and unreadable are the same epistemic state — we do not know the expiry —
497
+ * and they must not produce opposite security outcomes.
498
+ *
499
+ * Omitted, never set-to-undefined: updateSession() merges with
500
+ * `{ ...current, ...updates }`, so a present-but-undefined key would CLOBBER
501
+ * a good stored value. Pinned by test/mfa-expiry-malformed-claim.test.cjs.
502
+ *
503
+ * *** `Number.isFinite` ALONE IS NOT ENOUGH — IT MUST ALSO BE POSITIVE. ***
504
+ * `Number([])` is 0, and 0 is perfectly finite. But the consumer guard reads
505
+ * `mfaExpiresAt || 0`, and 0 is FALSY, so a stored zero is indistinguishable
506
+ * from a missing value and fails open in precisely the same way NaN does —
507
+ * while still passing `!== undefined` and so still earning the R908(e) grant.
508
+ * The honest invariant is not "is it a number" but "can this value ever
509
+ * expire a session", and only a positive one can. A zero/negative
510
+ * `mfa_expires` is meaningless anyway (epoch 1970 or earlier).
511
+ *
512
+ * The `typeof` gate rejects objects and arrays outright rather than letting
513
+ * JS coercion invent a number for them: `[]` -> 0 and `[5]` -> 5 are accidents
514
+ * of coercion, not claims the IDP made.
515
+ */
516
+ const claimToNumber = (claim: unknown): number | undefined => {
517
+ if (typeof claim !== 'number' && typeof claim !== 'string') return undefined;
518
+ if (claim === '') return undefined;
519
+ const n = Number(claim);
520
+ return Number.isFinite(n) && n > 0 ? n : undefined;
521
+ };
522
+
383
523
  // Claims are Unix SECONDS; SessionData stores milliseconds.
384
- if (decoded.mfa_expires !== undefined && decoded.mfa_expires !== null) {
385
- fields.mfaExpiresAt = Number(decoded.mfa_expires) * 1000;
524
+ const mfaExpires = claimToNumber(decoded.mfa_expires);
525
+ if (mfaExpires !== undefined) {
526
+ fields.mfaExpiresAt = mfaExpires * 1000;
386
527
  }
387
- if (decoded.mfa_time !== undefined && decoded.mfa_time !== null) {
388
- fields.mfaCompletedAt = Number(decoded.mfa_time) * 1000;
528
+ const mfaTime = claimToNumber(decoded.mfa_time);
529
+ if (mfaTime !== undefined) {
530
+ fields.mfaCompletedAt = mfaTime * 1000;
389
531
  }
390
- if (decoded.mfa_validity_hours !== undefined && decoded.mfa_validity_hours !== null) {
391
- fields.mfaValidityHours = Number(decoded.mfa_validity_hours);
532
+ const mfaValidityHours = claimToNumber(decoded.mfa_validity_hours);
533
+ if (mfaValidityHours !== undefined) {
534
+ fields.mfaValidityHours = mfaValidityHours;
392
535
  }
393
536
 
394
537
  return fields;
@@ -419,7 +562,7 @@ export async function transitionTo2FASession(
419
562
  const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
420
563
 
421
564
  console.log('[transitionTo2FASession] Called with:', {
422
- sessionToken: sessionToken?.substring(0, 8) + '...',
565
+ sessionToken: tokenLogId(sessionToken),
423
566
  mfaMethod,
424
567
  hasAccessToken: !!newAccessToken,
425
568
  hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
@@ -483,7 +626,7 @@ export async function transitionTo2FASession(
483
626
  'If this fires for a lane that DID complete a real ceremony, the defect is ' +
484
627
  'on the emit side (R908(b)) - do not "fix" it by minting the flag here.',
485
628
  {
486
- sessionToken: sessionToken?.substring(0, 8) + '...',
629
+ sessionToken: tokenLogId(sessionToken),
487
630
  mfaMethod,
488
631
  hasAccessToken: !!newAccessToken,
489
632
  }
@@ -699,7 +842,7 @@ export async function acquireRefreshLock(
699
842
 
700
843
  if (result === 'OK') {
701
844
  console.debug('[SESSION-STORE] Refresh lock acquired', {
702
- sessionToken: sessionToken.substring(0, 8) + '...',
845
+ sessionToken: tokenLogId(sessionToken),
703
846
  requestId,
704
847
  lockVersion
705
848
  });
@@ -709,7 +852,7 @@ export async function acquireRefreshLock(
709
852
  // Lock already exists, check if we should wait
710
853
  if (maxWaitMs > 0) {
711
854
  console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
712
- sessionToken: sessionToken.substring(0, 8) + '...',
855
+ sessionToken: tokenLogId(sessionToken),
713
856
  requestId,
714
857
  maxWaitMs
715
858
  });
@@ -718,7 +861,7 @@ export async function acquireRefreshLock(
718
861
  }
719
862
 
720
863
  console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
721
- sessionToken: sessionToken.substring(0, 8) + '...',
864
+ sessionToken: tokenLogId(sessionToken),
722
865
  requestId
723
866
  });
724
867
 
@@ -726,7 +869,7 @@ export async function acquireRefreshLock(
726
869
  }
727
870
  } catch (error) {
728
871
  console.error('[SESSION-STORE] Failed to acquire refresh lock', {
729
- sessionToken: sessionToken.substring(0, 8) + '...',
872
+ sessionToken: tokenLogId(sessionToken),
730
873
  requestId,
731
874
  error: error instanceof Error ? error.message : String(error)
732
875
  });
@@ -760,7 +903,7 @@ async function waitForRefreshLockRelease(
760
903
  await new Promise(resolve => setTimeout(resolve, pollInterval));
761
904
  } catch (error) {
762
905
  console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
763
- sessionToken: sessionToken.substring(0, 8) + '...',
906
+ sessionToken: tokenLogId(sessionToken),
764
907
  requestId,
765
908
  error: error instanceof Error ? error.message : String(error)
766
909
  });
@@ -769,7 +912,7 @@ async function waitForRefreshLockRelease(
769
912
  }
770
913
 
771
914
  console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
772
- sessionToken: sessionToken.substring(0, 8) + '...',
915
+ sessionToken: tokenLogId(sessionToken),
773
916
  requestId,
774
917
  waitedMs: Date.now() - startTime
775
918
  });
@@ -823,33 +966,33 @@ export async function releaseRefreshLock(
823
966
 
824
967
  if (result === 1) {
825
968
  console.debug('[SESSION-STORE] Refresh lock released successfully', {
826
- sessionToken: sessionToken.substring(0, 8) + '...',
969
+ sessionToken: tokenLogId(sessionToken),
827
970
  requestId,
828
971
  lockVersion
829
972
  });
830
973
  return true;
831
974
  } else if (result === 0) {
832
975
  console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
833
- sessionToken: sessionToken.substring(0, 8) + '...',
976
+ sessionToken: tokenLogId(sessionToken),
834
977
  requestId
835
978
  });
836
979
  return false;
837
980
  } else if (result === -1) {
838
981
  console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
839
- sessionToken: sessionToken.substring(0, 8) + '...',
982
+ sessionToken: tokenLogId(sessionToken),
840
983
  requestId
841
984
  });
842
985
  return false;
843
986
  } else if (result === -2) {
844
987
  console.error('[SESSION-STORE] Lock version mismatch during release', {
845
- sessionToken: sessionToken.substring(0, 8) + '...',
988
+ sessionToken: tokenLogId(sessionToken),
846
989
  requestId,
847
990
  lockVersion
848
991
  });
849
992
  return false;
850
993
  } else {
851
994
  console.error('[SESSION-STORE] Unexpected result from lock release script', {
852
- sessionToken: sessionToken.substring(0, 8) + '...',
995
+ sessionToken: tokenLogId(sessionToken),
853
996
  requestId,
854
997
  result
855
998
  });
@@ -857,7 +1000,7 @@ export async function releaseRefreshLock(
857
1000
  }
858
1001
  } catch (error) {
859
1002
  console.error('[SESSION-STORE] Failed to release refresh lock', {
860
- sessionToken: sessionToken.substring(0, 8) + '...',
1003
+ sessionToken: tokenLogId(sessionToken),
861
1004
  requestId,
862
1005
  error: error instanceof Error ? error.message : String(error)
863
1006
  });
@@ -882,7 +1025,7 @@ export async function checkRefreshLock(sessionToken: string): Promise<RefreshLoc
882
1025
  return JSON.parse(lockData) as RefreshLockInfo;
883
1026
  } catch (error) {
884
1027
  console.error('[SESSION-STORE] Failed to check refresh lock', {
885
- sessionToken: sessionToken.substring(0, 8) + '...',
1028
+ sessionToken: tokenLogId(sessionToken),
886
1029
  error: error instanceof Error ? error.message : String(error)
887
1030
  });
888
1031
 
@@ -12,6 +12,7 @@
12
12
 
13
13
  import 'server-only';
14
14
  import { getIDPClientConfig, clearConfigRedisCache, type IDPClientConfig } from './idp-client-config';
15
+ import { slugStateReason } from './app-slug';
15
16
 
16
17
  let initializationStarted = false;
17
18
  let initializationComplete = false;
@@ -59,9 +60,21 @@ export async function ensureInitialized(): Promise<void> {
59
60
  // Mark as started
60
61
  initializationStarted = true;
61
62
 
62
- // Start initialization
63
- initializationPromise = performInitialization();
64
- await initializationPromise;
63
+ // Start initialization.
64
+ //
65
+ // PAY-642: a FAILED attempt must not stay installed. This used to be
66
+ // `initializationPromise = performInitialization(); await initializationPromise;` with no reset, and the
67
+ // in-progress check above runs BEFORE the backoff check, so a pod that booted while the IdP was down replayed
68
+ // its first rejection to every caller forever: the 30 s backoff could never lead to a retry. Same latch, same
69
+ // fix, as createSingleFlight (lib/single-flight.ts): callers of the failing attempt still see the rejection
70
+ // (loud), then the attempt clears itself, identity-guarded so a slow failure never evicts a newer attempt.
71
+ // After a failure the backoff check above governs when the next attempt may start.
72
+ const attempt = performInitialization();
73
+ initializationPromise = attempt;
74
+ attempt.catch(() => {
75
+ if (initializationPromise === attempt) initializationPromise = null;
76
+ });
77
+ await attempt;
65
78
  }
66
79
 
67
80
  /**
@@ -168,8 +181,11 @@ async function performInitialization(): Promise<void> {
168
181
  // Step 3: Validate cookie name consistency
169
182
  // This catches bugs where getJwtCookieName() returns a different name than
170
183
  // what auth-options.ts configures, which causes sessions to fail in production
171
- const { validateCookieNameConsistency, getSessionCookieName } = await import('./app-slug');
184
+ const { validateCookieNameConsistency, getSessionCookieName, assertAppSlugMatchesIdp, assertIdpSlugKnown } = await import('./app-slug');
172
185
  validateCookieNameConsistency();
186
+ // rigpert 60743: refuse to run with two cookie-name sources (IdP clientSlug vs APP_SLUG/CLIENT_ID).
187
+ assertIdpSlugKnown(cachedIDPConfig?.clientSlug);
188
+ assertAppSlugMatchesIdp(cachedIDPConfig?.clientSlug);
173
189
  console.log('[STARTUP] Cookie name consistency validated:', getSessionCookieName());
174
190
 
175
191
  // All done
@@ -225,6 +241,15 @@ ${connectionLine}║ Possible causes:
225
241
  * Get the cached IDP config after initialization.
226
242
  * Returns null if not yet initialized.
227
243
  */
244
+ /**
245
+ * For health endpoints (rigpert 60765): a guard that does not stop the process is an empty red unless the mismatch is visible
246
+ * where operators and promote-fe look. Returns `SLUG_MISMATCH: cookie=<idp slug> gate=<app slug>` while the drift holds, else null;
247
+ * a health route should answer non-200 with this as the reason. Null until the IdP config has been fetched.
248
+ */
249
+ export function getSlugMismatchReason(): string | null {
250
+ return slugStateReason(cachedIDPConfig?.clientSlug, initializationComplete || initializationFailed);
251
+ }
252
+
228
253
  export function getStartupIDPConfig(): IDPClientConfig | null {
229
254
  return cachedIDPConfig;
230
255
  }
@@ -25,6 +25,7 @@ import { getSession as getRedisSession, SessionData } from './session-store';
25
25
  import { getSession as getBetterAuthSession } from '../server/auth';
26
26
  import { getRedis } from './redis';
27
27
  import { getAppSlug } from './app-slug';
28
+ import { tokenLogId } from './token-log-id';
28
29
 
29
30
  // 5 minute threshold for "needs refresh" - matches refresh handler pattern
30
31
  const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
@@ -437,7 +438,7 @@ export async function ensureFreshToken(
437
438
  }
438
439
 
439
440
  console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
440
- sessionToken: sessionToken.substring(0, 8) + '...',
441
+ sessionToken: tokenLogId(sessionToken),
441
442
  accessTokenExpires: tokenExpiresStr,
442
443
  now: new Date().toISOString(),
443
444
  needsRefresh: needsRefreshNow,