@payez/next-mvp 4.4.5 → 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 +35 -49
  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 +66 -45
  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 +37 -55
  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 +91 -45
  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,130 @@
1
+ "use strict";
2
+ /**
3
+ * PAY-1438: what a USER-FACING handler may put in an error response.
4
+ *
5
+ * Several of these routes are reachable before authentication (session check, viability probe,
6
+ * logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
7
+ *
8
+ * Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
9
+ * JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
10
+ * a code and a correlationId; the log line carries the exception and the same id.
11
+ *
12
+ * Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
13
+ * never passed through whole. Only the display contract is projected: code, message, resolution,
14
+ * request_id - each only when it is a string. `details` (free-form, may carry validation internals
15
+ * or exception data) is dropped, except for the password-policy messages change-password's UI reads.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.logExceptionWithCorrelation = logExceptionWithCorrelation;
19
+ exports.projectIdpError = projectIdpError;
20
+ exports.projectPasswordPolicyDetails = projectPasswordPolicyDetails;
21
+ exports.idpErrorEnvelope = idpErrorEnvelope;
22
+ exports.projectResetTokenInvalid = projectResetTokenInvalid;
23
+ exports.projectPasswordPolicyRules = projectPasswordPolicyRules;
24
+ const password_rules_1 = require("./password-rules");
25
+ const str = (v) => (typeof v === 'string' ? v : undefined);
26
+ /** Log an exception with a fresh correlationId and return the id for the response body. */
27
+ function logExceptionWithCorrelation(tag, err) {
28
+ const correlationId = globalThis.crypto.randomUUID();
29
+ console.error(tag, { correlationId, error: err });
30
+ return correlationId;
31
+ }
32
+ /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
33
+ function projectIdpError(json) {
34
+ if (!json || typeof json !== 'object')
35
+ return {};
36
+ const top = json;
37
+ const err = top.error && typeof top.error === 'object' ? top.error : {};
38
+ const support = err.support && typeof err.support === 'object' ? err.support : {};
39
+ const out = {};
40
+ const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
41
+ const message = str(err.message) ?? str(top.message);
42
+ const resolution = str(err.resolution) ?? str(top.resolution);
43
+ const requestId = str(support.request_id) ?? str(top.request_id);
44
+ if (code !== undefined)
45
+ out.code = code;
46
+ if (message !== undefined)
47
+ out.message = message;
48
+ if (resolution !== undefined)
49
+ out.resolution = resolution;
50
+ if (requestId !== undefined)
51
+ out.request_id = requestId;
52
+ return out;
53
+ }
54
+ /**
55
+ * change-password's `details`: the security pages show `details.value[0].message` (else
56
+ * `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
57
+ * strings; drop everything else.
58
+ */
59
+ function projectPasswordPolicyDetails(details) {
60
+ if (!details || typeof details !== 'object')
61
+ return undefined;
62
+ const d = details;
63
+ const out = {};
64
+ if (Array.isArray(d.value)) {
65
+ const value = d.value
66
+ .map((v) => (v && typeof v === 'object' ? str(v.message) : undefined))
67
+ .filter((m) => m !== undefined)
68
+ .map((message) => ({ message }));
69
+ if (value.length)
70
+ out.value = value;
71
+ }
72
+ const message = str(d.message);
73
+ if (message !== undefined)
74
+ out.message = message;
75
+ return out.value || out.message !== undefined ? out : undefined;
76
+ }
77
+ /**
78
+ * For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
79
+ * reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
80
+ * SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
81
+ * whitelisted projection. The success path keeps its passthrough.
82
+ */
83
+ function idpErrorEnvelope(json, fallbackMessage = 'Request failed') {
84
+ const p = projectIdpError(json);
85
+ const message = p.message ?? fallbackMessage;
86
+ const error = { message };
87
+ if (p.code !== undefined)
88
+ error.code = p.code;
89
+ if (p.resolution !== undefined)
90
+ error.resolution = p.resolution;
91
+ const out = { success: false, message, error };
92
+ if (p.request_id !== undefined)
93
+ out.request_id = p.request_id;
94
+ return out;
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
+ function projectResetTokenInvalid(json) {
110
+ if (!json || typeof json !== 'object')
111
+ return false;
112
+ const top = json;
113
+ const err = top.error && typeof top.error === 'object' ? top.error : {};
114
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
115
+ if (!details)
116
+ return false;
117
+ if (details.code === 'token_expired' || details.code === 'token_used')
118
+ return true;
119
+ return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && v.code === 'InvalidToken');
120
+ }
121
+ function projectPasswordPolicyRules(json) {
122
+ if (!json || typeof json !== 'object')
123
+ return undefined;
124
+ const top = json;
125
+ const err = top.error && typeof top.error === 'object' ? top.error : {};
126
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
127
+ if (!details || details.code !== 'password_policy')
128
+ return undefined;
129
+ return (0, password_rules_1.knownPasswordRules)(details.rules);
130
+ }
@@ -33,11 +33,16 @@
33
33
  * instead of degrading to one clean Redis-canonical lookup (or a genuine
34
34
  * 401 if the record really is gone).
35
35
  *
36
- * No signature is verified for the BA-hmac shape here — same posture the
37
- * pre-existing JWS fallback already took (see auth.ts's `getSession` Path B
38
- * comment): Redis is the liveness authority downstream, so a forged token
39
- * still has to land on a live session record to do anything. This function
40
- * only recovers the CANDIDATE token; callers must still look it up.
36
+ * No signature is verified here, and none CAN be: this module is secret-free
37
+ * and synchronous. It only CLASSIFIES and recovers the candidate token.
38
+ *
39
+ * PAY-1476: the earlier claim that "Redis is the liveness authority, so a
40
+ * forged token still has to land on a live session record" was the defect.
41
+ * A LEAKED token lands on a live record by definition, so without a signature
42
+ * check the token string alone was a bearer credential. The candidate token
43
+ * returned here must NEVER be handed to the session store directly: go through
44
+ * server/verified-session-token.ts resolveVerifiedSessionToken, which verifies
45
+ * the HMAC (ba-hmac) or the JWS signature first.
41
46
  */
42
47
  export type SessionCookieShape = 'jws' | 'ba-hmac';
43
48
  export interface ExtractedSessionToken {
@@ -53,17 +58,15 @@ export interface ExtractedSessionToken {
53
58
  * claim.
54
59
  */
55
60
  export declare function extractSessionTokenFromCookieValue(rawValue: string | null | undefined): ExtractedSessionToken | null;
56
- /** Extract the session token from the Better Auth session cookie WITHOUT
57
- * going through Better Auth's `auth.api.getSession()`. Handles both
58
- * single-cookie and chunked-cookie cases (Better Auth chunks long values
59
- * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
60
- * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
61
- * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
62
- * the shape-blind version of this function is what turned a single
63
- * BA-primary miss into total, secret-independent fallback failure for
64
- * every current cookie, since Better Auth never mints the JWS shape this
65
- * used to require). No signature check — Redis is the liveness gate, so
66
- * an attacker forging a token still has to land on a real Redis session
67
- * (infeasible against high-entropy UUIDs). Returns null if no cookie is
68
- * present or neither known shape matches. */
69
- export declare function extractSessionTokenFromCookie(request: Request): string | null;
61
+ /** The RAW session-cookie value from a Request, WITHOUT going through Better
62
+ * Auth's `auth.api.getSession()`. Handles single and chunked cookies (Better
63
+ * Auth chunks long values across `{name}.0`, `{name}.1`, …) and every name the
64
+ * cookie is minted under (240654).
65
+ *
66
+ * PAY-1476: this used to be `extractSessionTokenFromCookie`, which returned the
67
+ * TOKEN with "No signature check — Redis is the liveness gate". That turned any
68
+ * leaked token into a bearer credential. It now returns the raw value only, so
69
+ * the one way to get a token out of a request cookie is
70
+ * server/verified-session-token.ts resolveVerifiedSessionTokenFromRequest.
71
+ * Returns null if no session cookie is present. */
72
+ export declare function extractSessionCookieValue(request: Request): string | null;
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractSessionTokenFromCookieValue = extractSessionTokenFromCookieValue;
4
- exports.extractSessionTokenFromCookie = extractSessionTokenFromCookie;
4
+ exports.extractSessionCookieValue = extractSessionCookieValue;
5
5
  const app_slug_1 = require("./app-slug");
6
6
  /**
7
7
  * Extract a candidate session token from a raw session-cookie value,
@@ -37,20 +37,18 @@ function extractSessionTokenFromCookieValue(rawValue) {
37
37
  }
38
38
  return null;
39
39
  }
40
- /** Extract the session token from the Better Auth session cookie WITHOUT
41
- * going through Better Auth's `auth.api.getSession()`. Handles both
42
- * single-cookie and chunked-cookie cases (Better Auth chunks long values
43
- * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
44
- * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
45
- * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
46
- * the shape-blind version of this function is what turned a single
47
- * BA-primary miss into total, secret-independent fallback failure for
48
- * every current cookie, since Better Auth never mints the JWS shape this
49
- * used to require). No signature check — Redis is the liveness gate, so
50
- * an attacker forging a token still has to land on a real Redis session
51
- * (infeasible against high-entropy UUIDs). Returns null if no cookie is
52
- * present or neither known shape matches. */
53
- function extractSessionTokenFromCookie(request) {
40
+ /** The RAW session-cookie value from a Request, WITHOUT going through Better
41
+ * Auth's `auth.api.getSession()`. Handles single and chunked cookies (Better
42
+ * Auth chunks long values across `{name}.0`, `{name}.1`, …) and every name the
43
+ * cookie is minted under (240654).
44
+ *
45
+ * PAY-1476: this used to be `extractSessionTokenFromCookie`, which returned the
46
+ * TOKEN with "No signature check — Redis is the liveness gate". That turned any
47
+ * leaked token into a bearer credential. It now returns the raw value only, so
48
+ * the one way to get a token out of a request cookie is
49
+ * server/verified-session-token.ts resolveVerifiedSessionTokenFromRequest.
50
+ * Returns null if no session cookie is present. */
51
+ function extractSessionCookieValue(request) {
54
52
  const cookieHeader = request.headers.get('cookie');
55
53
  if (!cookieHeader)
56
54
  return null;
@@ -120,7 +118,5 @@ function extractSessionTokenFromCookie(request) {
120
118
  break;
121
119
  }
122
120
  }
123
- if (!rawJwt)
124
- return null;
125
- return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
121
+ return rawJwt || null;
126
122
  }
@@ -101,9 +101,16 @@ export declare function deleteSessionEverywhere(sessionToken: string, appSlug?:
101
101
  export declare function setSession(sessionToken: string, data: SessionData): Promise<void>;
102
102
  /**
103
103
  * Updates tokens within an existing session.
104
+ *
105
+ * CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
106
+ * row was absent and EVERY field in `updates` was discarded — it is a dropped
107
+ * write, not a no-op on unchanged data. Ignoring it is how a token refresh came
108
+ * to report success while persisting nothing.
109
+ *
104
110
  * @param sessionToken The session token to update.
105
111
  * @param updates Partial session data to update.
106
- * @returns The updated session data, or null if the session was not found.
112
+ * @returns The updated session data, or null if the session was not found — in
113
+ * which case NOTHING WAS WRITTEN.
107
114
  */
108
115
  export declare function updateSession(sessionToken: string, updates: Partial<SessionData>): Promise<SessionData | null>;
109
116
  /**
@@ -41,6 +41,7 @@ const redis_1 = __importDefault(require("./redis"));
41
41
  const crypto_1 = require("crypto");
42
42
  const app_slug_1 = require("./app-slug");
43
43
  const token_utils_1 = require("../auth/utils/token-utils");
44
+ const token_log_id_1 = require("./token-log-id");
44
45
  // Use app-slug prefixes for multi-app isolation
45
46
  const getSessionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}${token}`;
46
47
  const getRefreshLockKey = (token) => `${(0, app_slug_1.getRefreshLockPrefix)()}${token}`;
@@ -278,11 +279,31 @@ async function setSession(sessionToken, data) {
278
279
  .expire(versionKey, exports.SESSION_TTL)
279
280
  .exec();
280
281
  }
282
+ /**
283
+ * Describe a credential-bearing value for a log line WITHOUT the value: its kind only.
284
+ * Deliberately no length or fingerprint either - both narrow the value down.
285
+ */
286
+ function describeTokenValueForLog(v) {
287
+ if (v === undefined)
288
+ return 'undefined';
289
+ if (v === null)
290
+ return 'null';
291
+ if (typeof v === 'string')
292
+ return v.length === 0 ? 'empty string' : 'non-empty string (value withheld)';
293
+ return `${typeof v} (value withheld)`;
294
+ }
281
295
  /**
282
296
  * Updates tokens within an existing session.
297
+ *
298
+ * CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
299
+ * row was absent and EVERY field in `updates` was discarded — it is a dropped
300
+ * write, not a no-op on unchanged data. Ignoring it is how a token refresh came
301
+ * to report success while persisting nothing.
302
+ *
283
303
  * @param sessionToken The session token to update.
284
304
  * @param updates Partial session data to update.
285
- * @returns The updated session data, or null if the session was not found.
305
+ * @returns The updated session data, or null if the session was not found — in
306
+ * which case NOTHING WAS WRITTEN.
286
307
  */
287
308
  async function updateSession(sessionToken, updates) {
288
309
  const key = getSessionKey(sessionToken);
@@ -290,6 +311,74 @@ async function updateSession(sessionToken, updates) {
290
311
  // Get current session to merge with updates
291
312
  const currentSession = await getSession(sessionToken);
292
313
  if (!currentSession) {
314
+ /**
315
+ * 320072: THIS IS A DROPPED WRITE, AND IT USED TO LEAVE NO TRACE.
316
+ *
317
+ * Returning null here is correct — there is no row to merge into — but it
318
+ * was previously done in total silence: no throw, no log. Every caller that
319
+ * ignores the return value therefore believed it had persisted, and the
320
+ * most damaging caller did exactly that (ensure-fresh-access-token).
321
+ *
322
+ * The canonical row can legitimately be absent: better-auth.ts writes the
323
+ * `ba:` record and the canonical row in two steps, and the second is
324
+ * wrapped in a try/catch that only console.warn()s — a deliberate,
325
+ * documented degradation that leaves the BA session correct and this row
326
+ * missing. That decision is fine. What was NOT fine is that the READ side
327
+ * reported success anyway, so nothing ever surfaced it.
328
+ *
329
+ * Logged at error, not warn: the caller's data is gone. Callers that can
330
+ * tolerate a missing row should check the null return — which is now the
331
+ * documented contract — rather than rely on this being quiet.
332
+ */
333
+ /**
334
+ * DO NOT LOG `key` HERE. `getSessionKey()` (line 21) is
335
+ * `${getSessionPrefix()}${token}` — the prefix plus the WHOLE session
336
+ * token — so logging it writes a live, replayable credential into
337
+ * console.error, and logs travel further than the process that wrote them.
338
+ * The redacting idiom in this file is `substring(0, 8) + '...'`.
339
+ *
340
+ * NOTE ON WHAT THIS COMMENT NO LONGER CLAIMS. It used to say this was the
341
+ * single unredacted departure in the file, and gave a site count. Both were
342
+ * wrong: a RAW refresh token was being emitted at the
343
+ * REFRESH_TOKEN_BEING_CLEARED site below, and the count was stale. QAPert found that
344
+ * leak while reviewing PR 96 — he looked *because* this comment asserted
345
+ * there was nothing to find, which is the worst thing a comment can do.
346
+ * It is fixed in the same commit as this rewrite.
347
+ *
348
+ * So the counts are gone deliberately. A tally in prose decays silently on
349
+ * the next edit and then actively misleads; that decay is the defect being
350
+ * repaired here, and reproducing it in the fix would be absurd. If you need
351
+ * a number, measure it — do not trust this paragraph for one.
352
+ *
353
+ * What replaces the prose is enforcement. The invariant is
354
+ * NO RAW CREDENTIAL IS EVER EMITTED FROM THIS FUNCTION, and it is pinned by
355
+ * executable assertions, not by this text:
356
+ * - Case 4 (session token, this log)
357
+ * - Case 5 (refresh token, the REFRESH_TOKEN_BEING_CLEARED log below)
358
+ * in test/ensure-fresh-persist-failure.test.cjs. Both were proven to fail
359
+ * when their fix is mutated out. Delete a redaction and a test goes red;
360
+ * delete this comment and nothing does. Trust the tests.
361
+ *
362
+ * Caught by QAPert on 320072 and made binding by BAPert (R1043 s1).
363
+ *
364
+ * The prefix is logged SEPARATELY and deliberately, because it is the field
365
+ * that makes this line diagnostic rather than decorative: a "no canonical
366
+ * row" miss has two very different causes, and only the prefix tells them
367
+ * apart —
368
+ * wrong keyspace (slug/prefix disagreement — the 241875 / 213416 defect
369
+ * documented at line 111, where a writer and a reader
370
+ * resolved different slugs and every lookup silently
371
+ * missed), versus
372
+ * absent row (the documented better-auth.ts two-step degradation).
373
+ * The prefix is an app slug, not a secret. Redacting it too would leave a
374
+ * log that says only "something was missing".
375
+ */
376
+ console.error('[SESSION-STORE] UPDATE_DROPPED_NO_CANONICAL_ROW', {
377
+ keyPrefix: (0, app_slug_1.getSessionPrefix)(),
378
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
379
+ updatedFields: Object.keys(updates),
380
+ note: 'updateSession found no session row; ALL updates were discarded and null was returned',
381
+ });
293
382
  return null;
294
383
  }
295
384
  // CRITICAL: Track any refresh token changes (check both old and new field names)
@@ -301,10 +390,15 @@ async function updateSession(sessionToken, updates) {
301
390
  : hadRefreshToken;
302
391
  if (hadRefreshToken && !willHaveRefreshToken) {
303
392
  console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
304
- sessionToken: sessionToken.substring(0, 8) + '...',
393
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
305
394
  userId: currentSession.userId,
306
395
  updateKeys: Object.keys(updates),
307
- refreshTokenInUpdate: updates.idpRefreshToken || updates.refreshToken,
396
+ // NEVER the value: a refresh token is a long-lived credential and this line ships to the
397
+ // log sink. The old `idpRefreshToken || refreshToken` printed a live legacy token raw when
398
+ // idpRefreshToken was null/"" (which clears) and refreshToken still held one.
399
+ // Test: test/session-store-refresh-token-not-logged.test.cjs
400
+ idpRefreshTokenInUpdate: describeTokenValueForLog(updates.idpRefreshToken),
401
+ legacyRefreshTokenInUpdate: describeTokenValueForLog(updates.refreshToken),
308
402
  refreshTokenClearedReason: updates.refreshTokenClearedReason || 'UNKNOWN',
309
403
  stack: new Error().stack
310
404
  });
@@ -354,15 +448,67 @@ function mfaLifetimeFromAccessToken(accessToken) {
354
448
  if (!decoded)
355
449
  return {};
356
450
  const fields = {};
451
+ /**
452
+ * *** TEST THE CONVERTED VALUE, NOT THE CLAIM'S PRESENCE. ***
453
+ *
454
+ * This guard previously branched on `claim !== undefined && claim !== null`
455
+ * and then wrote `Number(claim) * 1000`. Those are two different questions,
456
+ * and for a non-numeric claim they disagree: the claim IS present, so the
457
+ * branch is taken, and the value written is NaN. That single NaN defeats both
458
+ * guards in this system at once, in opposite directions:
459
+ *
460
+ * - `mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined` is TRUE
461
+ * for NaN, so R908(e) certifies a ceremony it never observed and GRANTS
462
+ * mfaVerified.
463
+ * - consumers read `mfaExpiresAt || 0`, and NaN is FALSY, so that collapses
464
+ * to 0, `0 > 0` is false, and the session NEVER expires.
465
+ *
466
+ * Net: `mfaVerified: true` that nothing can ever expire — the exact
467
+ * pre-339563 fail-open described at the top of this function, restored
468
+ * through a malformed claim, and silent (neither warning below fires on NaN).
469
+ *
470
+ * So a claim that will not convert is treated as NO CLAIM: the key is OMITTED
471
+ * and R908(e) withholds the grant, identical to the absent-claim lane. Absent
472
+ * and unreadable are the same epistemic state — we do not know the expiry —
473
+ * and they must not produce opposite security outcomes.
474
+ *
475
+ * Omitted, never set-to-undefined: updateSession() merges with
476
+ * `{ ...current, ...updates }`, so a present-but-undefined key would CLOBBER
477
+ * a good stored value. Pinned by test/mfa-expiry-malformed-claim.test.cjs.
478
+ *
479
+ * *** `Number.isFinite` ALONE IS NOT ENOUGH — IT MUST ALSO BE POSITIVE. ***
480
+ * `Number([])` is 0, and 0 is perfectly finite. But the consumer guard reads
481
+ * `mfaExpiresAt || 0`, and 0 is FALSY, so a stored zero is indistinguishable
482
+ * from a missing value and fails open in precisely the same way NaN does —
483
+ * while still passing `!== undefined` and so still earning the R908(e) grant.
484
+ * The honest invariant is not "is it a number" but "can this value ever
485
+ * expire a session", and only a positive one can. A zero/negative
486
+ * `mfa_expires` is meaningless anyway (epoch 1970 or earlier).
487
+ *
488
+ * The `typeof` gate rejects objects and arrays outright rather than letting
489
+ * JS coercion invent a number for them: `[]` -> 0 and `[5]` -> 5 are accidents
490
+ * of coercion, not claims the IDP made.
491
+ */
492
+ const claimToNumber = (claim) => {
493
+ if (typeof claim !== 'number' && typeof claim !== 'string')
494
+ return undefined;
495
+ if (claim === '')
496
+ return undefined;
497
+ const n = Number(claim);
498
+ return Number.isFinite(n) && n > 0 ? n : undefined;
499
+ };
357
500
  // Claims are Unix SECONDS; SessionData stores milliseconds.
358
- if (decoded.mfa_expires !== undefined && decoded.mfa_expires !== null) {
359
- fields.mfaExpiresAt = Number(decoded.mfa_expires) * 1000;
501
+ const mfaExpires = claimToNumber(decoded.mfa_expires);
502
+ if (mfaExpires !== undefined) {
503
+ fields.mfaExpiresAt = mfaExpires * 1000;
360
504
  }
361
- if (decoded.mfa_time !== undefined && decoded.mfa_time !== null) {
362
- fields.mfaCompletedAt = Number(decoded.mfa_time) * 1000;
505
+ const mfaTime = claimToNumber(decoded.mfa_time);
506
+ if (mfaTime !== undefined) {
507
+ fields.mfaCompletedAt = mfaTime * 1000;
363
508
  }
364
- if (decoded.mfa_validity_hours !== undefined && decoded.mfa_validity_hours !== null) {
365
- fields.mfaValidityHours = Number(decoded.mfa_validity_hours);
509
+ const mfaValidityHours = claimToNumber(decoded.mfa_validity_hours);
510
+ if (mfaValidityHours !== undefined) {
511
+ fields.mfaValidityHours = mfaValidityHours;
366
512
  }
367
513
  return fields;
368
514
  }
@@ -376,7 +522,7 @@ function mfaLifetimeFromAccessToken(accessToken) {
376
522
  async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
377
523
  const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
378
524
  console.log('[transitionTo2FASession] Called with:', {
379
- sessionToken: sessionToken?.substring(0, 8) + '...',
525
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
380
526
  mfaMethod,
381
527
  hasAccessToken: !!newAccessToken,
382
528
  hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
@@ -433,7 +579,7 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
433
579
  'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
434
580
  'If this fires for a lane that DID complete a real ceremony, the defect is ' +
435
581
  'on the emit side (R908(b)) - do not "fix" it by minting the flag here.', {
436
- sessionToken: sessionToken?.substring(0, 8) + '...',
582
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
437
583
  mfaMethod,
438
584
  hasAccessToken: !!newAccessToken,
439
585
  });
@@ -603,7 +749,7 @@ async function acquireRefreshLock(sessionToken, requestId, maxWaitMs = 5000) {
603
749
  const result = await redis_1.default.set(lockKey, JSON.stringify(lockInfo), 'PX', REFRESH_LOCK_TTL * 1000, 'NX');
604
750
  if (result === 'OK') {
605
751
  console.debug('[SESSION-STORE] Refresh lock acquired', {
606
- sessionToken: sessionToken.substring(0, 8) + '...',
752
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
607
753
  requestId,
608
754
  lockVersion
609
755
  });
@@ -613,14 +759,14 @@ async function acquireRefreshLock(sessionToken, requestId, maxWaitMs = 5000) {
613
759
  // Lock already exists, check if we should wait
614
760
  if (maxWaitMs > 0) {
615
761
  console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
616
- sessionToken: sessionToken.substring(0, 8) + '...',
762
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
617
763
  requestId,
618
764
  maxWaitMs
619
765
  });
620
766
  return await waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs);
621
767
  }
622
768
  console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
623
- sessionToken: sessionToken.substring(0, 8) + '...',
769
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
624
770
  requestId
625
771
  });
626
772
  return { acquired: false };
@@ -628,7 +774,7 @@ async function acquireRefreshLock(sessionToken, requestId, maxWaitMs = 5000) {
628
774
  }
629
775
  catch (error) {
630
776
  console.error('[SESSION-STORE] Failed to acquire refresh lock', {
631
- sessionToken: sessionToken.substring(0, 8) + '...',
777
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
632
778
  requestId,
633
779
  error: error instanceof Error ? error.message : String(error)
634
780
  });
@@ -654,7 +800,7 @@ async function waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs) {
654
800
  }
655
801
  catch (error) {
656
802
  console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
657
- sessionToken: sessionToken.substring(0, 8) + '...',
803
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
658
804
  requestId,
659
805
  error: error instanceof Error ? error.message : String(error)
660
806
  });
@@ -662,7 +808,7 @@ async function waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs) {
662
808
  }
663
809
  }
664
810
  console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
665
- sessionToken: sessionToken.substring(0, 8) + '...',
811
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
666
812
  requestId,
667
813
  waitedMs: Date.now() - startTime
668
814
  });
@@ -701,7 +847,7 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
701
847
  const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
702
848
  if (result === 1) {
703
849
  console.debug('[SESSION-STORE] Refresh lock released successfully', {
704
- sessionToken: sessionToken.substring(0, 8) + '...',
850
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
705
851
  requestId,
706
852
  lockVersion
707
853
  });
@@ -709,21 +855,21 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
709
855
  }
710
856
  else if (result === 0) {
711
857
  console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
712
- sessionToken: sessionToken.substring(0, 8) + '...',
858
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
713
859
  requestId
714
860
  });
715
861
  return false;
716
862
  }
717
863
  else if (result === -1) {
718
864
  console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
719
- sessionToken: sessionToken.substring(0, 8) + '...',
865
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
720
866
  requestId
721
867
  });
722
868
  return false;
723
869
  }
724
870
  else if (result === -2) {
725
871
  console.error('[SESSION-STORE] Lock version mismatch during release', {
726
- sessionToken: sessionToken.substring(0, 8) + '...',
872
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
727
873
  requestId,
728
874
  lockVersion
729
875
  });
@@ -731,7 +877,7 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
731
877
  }
732
878
  else {
733
879
  console.error('[SESSION-STORE] Unexpected result from lock release script', {
734
- sessionToken: sessionToken.substring(0, 8) + '...',
880
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
735
881
  requestId,
736
882
  result
737
883
  });
@@ -740,7 +886,7 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
740
886
  }
741
887
  catch (error) {
742
888
  console.error('[SESSION-STORE] Failed to release refresh lock', {
743
- sessionToken: sessionToken.substring(0, 8) + '...',
889
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
744
890
  requestId,
745
891
  error: error instanceof Error ? error.message : String(error)
746
892
  });
@@ -761,7 +907,7 @@ async function checkRefreshLock(sessionToken) {
761
907
  }
762
908
  catch (error) {
763
909
  console.error('[SESSION-STORE] Failed to check refresh lock', {
764
- sessionToken: sessionToken.substring(0, 8) + '...',
910
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
765
911
  error: error instanceof Error ? error.message : String(error)
766
912
  });
767
913
  return null;
@@ -25,6 +25,12 @@ export declare function logStartupStatus(): void;
25
25
  * Get the cached IDP config after initialization.
26
26
  * Returns null if not yet initialized.
27
27
  */
28
+ /**
29
+ * For health endpoints (rigpert 60765): a guard that does not stop the process is an empty red unless the mismatch is visible
30
+ * where operators and promote-fe look. Returns `SLUG_MISMATCH: cookie=<idp slug> gate=<app slug>` while the drift holds, else null;
31
+ * a health route should answer non-200 with this as the reason. Null until the IdP config has been fetched.
32
+ */
33
+ export declare function getSlugMismatchReason(): string | null;
28
34
  export declare function getStartupIDPConfig(): IDPClientConfig | null;
29
35
  /**
30
36
  * Check if initialization failed (auth signing secret couldn't be retrieved)