@payez/next-mvp 4.1.21 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +120 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Name the missing variable instead of letting undici say "Failed to parse URL".
3
+ *
4
+ * Every caller builds its target as `${ENV_CONFIG.IDP_URL}/api/...`. IDP_URL is a
5
+ * BARE PASSTHROUGH in src/config/env.ts - no fallback, no validation - so when it
6
+ * is missing the interpolation yields a url that is not absolute. BOTH spellings
7
+ * of "missing" are caught here, which is why this tests for an absolute url
8
+ * rather than for a leading slash:
9
+ * IDP_URL= (empty, as the quickstart now ships) -> "/api/Account/profile"
10
+ * IDP_URL unset (absent from the env entirely) -> "undefined/api/Account/profile"
11
+ * A leading-slash test would have caught only the first.
12
+ *
13
+ * This already fails CLOSED: undici rejects a relative url, so the guard changes
14
+ * no outcome - it only makes the existing failure self-describing. It does NOT
15
+ * silently retarget the app's own origin. (QAPert tested that hypothesis and
16
+ * reported it disproved, mail 48139.)
17
+ *
18
+ * WHY THIS IS A LEAF MODULE WITH NO IMPORTS, AND NOT A THROW IN env.ts:
19
+ * - env.ts is the wrong home: ENV_CONFIG is imported by 26 modules and
20
+ * re-exported from the package index, so a module-load throw would crash
21
+ * consumers that only render components and never call the IDP. QAPert
22
+ * proposed exactly that (48139); it is the one shape this cannot take.
23
+ * - idp-fetch.ts is the wrong home for the SHARED copy: importing it pulls
24
+ * server-only, the session store and the logger, so the three direct-fetch
25
+ * callers would drag that whole chain in for one regex - and the predicate
26
+ * could not be unit-tested without a live server environment.
27
+ * Keep this file dependency-free so both stay true.
28
+ */
29
+ export function assertAbsoluteIdpUrl(targetUrl: string): void {
30
+ if (!/^https?:\/\//i.test(targetUrl)) {
31
+ throw new Error(
32
+ `IDP_URL is not set. Refusing to call the IDP with the non-absolute URL "${targetUrl}". ` +
33
+ `Set IDP_URL in your environment (dev: http://10.0.0.93:32785). ` +
34
+ `Never set it to a production host in a development worktree.`
35
+ );
36
+ }
37
+ }
package/src/lib/redis.ts CHANGED
@@ -3,16 +3,94 @@ import Redis, { RedisOptions } from 'ioredis';
3
3
 
4
4
  let client: Redis | null = null;
5
5
 
6
+ // ── 183496: fail FAST and NAMED, never a 20s spinner ───────────────────────
7
+ // The DEFAULTS were the defect (Nextpert-Scout's killable-proxy measurement,
8
+ // BAPert 22626): no commandTimeout, so a command against a cut Redis path
9
+ // hung 20s+, and maxRetriesPerRequest defaults to 20, so every command was
10
+ // retried through every reconnect attempt. In the room that reads as "the
11
+ // app hung", not "Redis blipped" - and self-heal was instant all along, so
12
+ // the failure MODE was the whole defect.
13
+ //
14
+ // commandTimeout covers OFFLINE-QUEUED commands too (ioredis 5.8.2 Redis.js:
15
+ // command.setTimeout runs BEFORE the queue/writable check), which makes it
16
+ // the bounded offline queue the card asks for: no command, queued or sent,
17
+ // outlives 3s. The queue itself stays ENABLED - a transient blip still
18
+ // self-heals inside the window; only the failure mode changes. The throw
19
+ // lands in with-auth's catch and comes out a fast 5xx, not a spinner.
20
+ const REDIS_OPTIONS: RedisOptions = {
21
+ commandTimeout: 3000,
22
+ maxRetriesPerRequest: 1,
23
+ };
24
+
6
25
  function createClient(): Redis {
7
26
  const url = process.env.REDIS_URL;
8
27
 
9
- if (url && url.trim() !== '') {
10
- // Use a standard configuration for better Docker compatibility
11
- return new Redis(url);
28
+ // ── THE COMMENT THAT USED TO BE HERE WAS FALSE (Nextpert-Scout 35159). ──
29
+ //
30
+ // It said: "No REDIS_URL set, create a client that will fail fast."
31
+ // IT DOES NOT FAIL. Measured, not read:
32
+ //
33
+ // new Redis({ commandTimeout, maxRetriesPerRequest, lazyConnect: true })
34
+ // -> options.host === 'localhost', options.port === 6379
35
+ // (ioredis DEFAULT_REDIS_OPTIONS: port 6379, host "localhost")
36
+ //
37
+ // `lazyConnect` only DEFERS the connection to the first command; it does not
38
+ // prevent one. So with REDIS_URL absent this client silently targets
39
+ // localhost:6379. Whether that is a loud failure or a SILENT CONNECTION TO
40
+ // THE WRONG STORE depends entirely on what happens to be listening on the
41
+ // local loopback - which is not a property this module controls, and is
42
+ // exactly the kind of thing that differs between a container and a dev box.
43
+ //
44
+ // This mattered operationally: I told the team that deleting REDIS_URL from a
45
+ // configmap would be fail-closed, and recommended it as a hardening step for
46
+ // the 249808 remediation. That advice was wrong and rested on this comment.
47
+ // A loopback-exempt firewall rule also does not stop a client that has
48
+ // fallen back to loopback.
49
+ //
50
+ // Corrected rather than deleted, because the comment's INTENT was right and
51
+ // is worth preserving as a stated goal: an absent REDIS_URL SHOULD fail
52
+ // loudly. Making it actually do so means changing behaviour at module load
53
+ // (`const redis = getRedis()` at the bottom of this file runs on import), so
54
+ // it is carded rather than changed at speed - throwing on import would break
55
+ // every consumer that imports this module without needing Redis.
56
+ //
57
+ // What IS done here: the misconfiguration is now ANNOUNCED, so it can never
58
+ // again be silent even though it is not yet fatal.
59
+ if (!url || url.trim() === '') {
60
+ console.error(
61
+ '[redis] MISCONFIGURED: REDIS_URL is unset or empty. ' +
62
+ 'This client will fall back to ioredis defaults (localhost:6379) and may ' +
63
+ 'connect to whatever is listening there, or fail on first command. ' +
64
+ 'It does NOT fail fast. Set REDIS_URL explicitly.',
65
+ );
12
66
  }
13
67
 
14
- // No REDIS_URL set, create a client that will fail fast.
15
- return new Redis({ lazyConnect: true });
68
+ const instance = url && url.trim() !== ''
69
+ // Use a standard configuration for better Docker compatibility
70
+ ? new Redis(url, REDIS_OPTIONS)
71
+ // NOTE: this does NOT fail fast - see the block above. It targets
72
+ // localhost:6379 by ioredis default.
73
+ : new Redis({ ...REDIS_OPTIONS, lazyConnect: true });
74
+
75
+ // Loud AND load-bearing: an ioredis 'error' event with NO listener is an
76
+ // unhandled EventEmitter error, which can take the whole PROCESS down -
77
+ // a Redis blip becoming a dead server is worse than the spinner was.
78
+ instance.on('error', (err) => {
79
+ console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${err instanceof Error ? err.message : String(err)}`);
80
+ });
81
+
82
+ // 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
83
+ // was listened for. A reconnect or a clean end is silent in the artifact,
84
+ // so a session-death window could not be correlated with a redis event.
85
+ // One line per event; a recurrence now says whether redis moved at all.
86
+ instance.on('reconnecting', (delayMs: number) => {
87
+ console.warn(`[redis] RECONNECTING ${new Date().toISOString()}: next attempt in ${delayMs}ms`);
88
+ });
89
+ instance.on('end', () => {
90
+ console.error(`[redis] CONNECTION ENDED ${new Date().toISOString()}: no further reconnects — every session read now fails or misses`);
91
+ });
92
+
93
+ return instance;
16
94
  }
17
95
 
18
96
  export function getRedis(): Redis {
@@ -24,4 +102,4 @@ export function getRedis(): Redis {
24
102
 
25
103
  const redis = getRedis();
26
104
  export { redis };
27
- export default redis;
105
+ export default redis;
@@ -0,0 +1,184 @@
1
+ import {
2
+ getJwtCookieName,
3
+ getSecureSessionCookieName,
4
+ getSessionCookieName,
5
+ } from './app-slug';
6
+
7
+ /**
8
+ * Session-cookie SHAPE detection — shared by every fallback path that must
9
+ * recover a Redis session token from a raw session-cookie value without
10
+ * going through Better Auth's `auth.api.getSession()`.
11
+ *
12
+ * WHY THIS EXISTS (183047 post-mortem, BAPert/QAPert/Scout, 2026-08-08):
13
+ *
14
+ * Two cookie shapes exist in this codebase's history, and until now no
15
+ * fallback path recognized both:
16
+ *
17
+ * - JWS (3 dot-separated segments): the legacy NextAuth-era format. The
18
+ * session token lives in the payload's `sessionToken` claim, base64-JSON
19
+ * encoded in the middle segment.
20
+ * - BA-hmac (2 segments — exactly ONE dot): Better Auth's own hand-rolled
21
+ * cookie, `{token}.{base64(hmac)}` (see better-auth.ts's
22
+ * CreateBetterAuthInstanceOptions.plugins doc comment). The token here
23
+ * IS the first segment verbatim — there is no claim to decode.
24
+ *
25
+ * Every fallback that existed before this file assumed the JWS shape
26
+ * unconditionally:
27
+ * - decode-session.ts's legacy path called jose's `jwtVerify()` directly on
28
+ * the cookie value. `jwtVerify` requires a genuine compact JWS and throws
29
+ * "Invalid Compact JWS" on a 2-segment value — secret-independent, before
30
+ * any signature is even checked.
31
+ * - auth.ts's `decodeJwtBody()` required `parts.length === 3` and returned
32
+ * null otherwise.
33
+ *
34
+ * Since Better Auth is the only session minter in production, EVERY
35
+ * BA-shaped cookie hit both fallbacks' shape gate and failed structurally.
36
+ * That meant any single BA-primary miss (secondary-storage eviction,
37
+ * in-flight secret divergence, cookie-cache disabled) had no working
38
+ * fallback at all — a single cookie's failure read as total session death
39
+ * instead of degrading to one clean Redis-canonical lookup (or a genuine
40
+ * 401 if the record really is gone).
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.
47
+ */
48
+
49
+ export type SessionCookieShape = 'jws' | 'ba-hmac';
50
+
51
+ export interface ExtractedSessionToken {
52
+ token: string;
53
+ shape: SessionCookieShape;
54
+ }
55
+
56
+ /**
57
+ * Extract a candidate session token from a raw session-cookie value,
58
+ * regardless of which minter produced it.
59
+ *
60
+ * Returns null if the value doesn't match either known shape, or (for the
61
+ * JWS shape) if the payload segment doesn't decode to a `sessionToken`
62
+ * claim.
63
+ */
64
+ export function extractSessionTokenFromCookieValue(rawValue: string | null | undefined): ExtractedSessionToken | null {
65
+ if (!rawValue) return null;
66
+
67
+ const segments = rawValue.split('.');
68
+
69
+ if (segments.length === 3) {
70
+ // JWS shape: base64-decode the payload segment (part 1), read the
71
+ // sessionToken claim. No signature check — see file header.
72
+ try {
73
+ const decoded = Buffer.from(segments[1], 'base64').toString('utf-8');
74
+ const payload = JSON.parse(decoded);
75
+ if (payload && typeof payload === 'object' && typeof payload.sessionToken === 'string') {
76
+ return { token: payload.sessionToken, shape: 'jws' };
77
+ }
78
+ } catch {
79
+ // fall through to null
80
+ }
81
+ return null;
82
+ }
83
+
84
+ if (segments.length === 2 && segments[0] && segments[1]) {
85
+ // BA-hmac shape: `{token}.{base64(hmac)}` — the token is the first
86
+ // segment verbatim, no decoding required.
87
+ return { token: segments[0], shape: 'ba-hmac' };
88
+ }
89
+
90
+ return null;
91
+ }
92
+
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 {
107
+ const cookieHeader = request.headers.get('cookie');
108
+ if (!cookieHeader) return null;
109
+
110
+ // 240654: read EVERY name this session cookie is minted under, not just the
111
+ // bare one.
112
+ //
113
+ // getSessionCookieName() is ALWAYS `{slug}.session-token`. But the package's
114
+ // own read contract is getJwtCookieName() — "the cookie name that getToken()
115
+ // should use to READ" — and in production that resolves to the
116
+ // `__Secure-`-prefixed name. The admin-login door (238225) mints the legacy
117
+ // JWS session under the __Secure- name in prod, exactly per that contract.
118
+ //
119
+ // So this resolver — the Path-B fallback, and the ONLY resolver for legacy
120
+ // JWS sessions — could not see a production session at all. Measured live on
121
+ // portal.keelauth.com: the session was minted and present in Redis at
122
+ // `idp_admin_ui_graft:sess:{token}`, yet invisible to viability and
123
+ // middleware, so every page bounced to /sign-in. Dev survived because in
124
+ // development both helpers return the SAME string, which is precisely why
125
+ // the dev-93 walks passed while prod died.
126
+ //
127
+ // Checking BOTH names rather than just swapping to getJwtCookieName(): the
128
+ // graft currently double-writes both cookie names as an app-local mitigation
129
+ // (graft 8a5ac21), and a rollout necessarily spans sessions minted under
130
+ // either name. Reading both is correct before, during and after that
131
+ // double-write is retired, and costs one extra map lookup.
132
+ const candidateNames = Array.from(
133
+ new Set([getJwtCookieName(), getSecureSessionCookieName(), getSessionCookieName()]),
134
+ );
135
+
136
+ // Parse the header ONCE, then resolve per candidate. Chunked cookies are
137
+ // kept per-name: Better Auth splits long values across `{name}.0/.1/.2…`,
138
+ // and chunks from different cookie names must never be concatenated.
139
+ const direct = new Map<string, string>();
140
+ const chunksByName = new Map<string, Array<{ idx: number; value: string }>>();
141
+
142
+ for (const part of cookieHeader.split(';')) {
143
+ const trimmed = part.trim();
144
+ const eq = trimmed.indexOf('=');
145
+ if (eq === -1) continue;
146
+ const k = trimmed.slice(0, eq);
147
+ const v = trimmed.slice(eq + 1);
148
+
149
+ for (const name of candidateNames) {
150
+ if (k === name) {
151
+ if (!direct.has(name)) direct.set(name, decodeURIComponent(v));
152
+ break;
153
+ }
154
+ if (k.startsWith(`${name}.`)) {
155
+ const idx = parseInt(k.split('.').pop() || '0', 10);
156
+ if (Number.isFinite(idx)) {
157
+ const list = chunksByName.get(name) ?? [];
158
+ list.push({ idx, value: decodeURIComponent(v) });
159
+ chunksByName.set(name, list);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ let rawJwt: string | null = null;
167
+ for (const name of candidateNames) {
168
+ const whole = direct.get(name);
169
+ if (whole) {
170
+ rawJwt = whole;
171
+ break;
172
+ }
173
+ const parts = chunksByName.get(name);
174
+ if (parts && parts.length > 0) {
175
+ parts.sort((a, b) => a.idx - b.idx);
176
+ rawJwt = parts.map(c => c.value).join('');
177
+ break;
178
+ }
179
+ }
180
+
181
+ if (!rawJwt) return null;
182
+
183
+ return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
184
+ }
@@ -11,8 +11,8 @@
11
11
  import redis from './redis';
12
12
  import { randomBytes } from 'crypto';
13
13
  import { SessionData } from '../models/SessionModel';
14
- import { getSessionPrefix, getRefreshLockPrefix } from './app-slug';
15
- import { extractKidFromToken } from '../auth/utils/token-utils';
14
+ import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
15
+ import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
16
16
 
17
17
  // Re-export SessionData for consumers
18
18
  export type { SessionData } from '../models/SessionModel';
@@ -98,15 +98,37 @@ export async function getSession(sessionToken: string): Promise<SessionData | nu
98
98
  * Better Auth uses key format: ba:{appSlug}:{token}
99
99
  *
100
100
  * @param sessionToken The session token to look up.
101
- * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
101
+ * @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
102
+ * writes under - so reader and writer cannot disagree. Pass explicitly
103
+ * only if a consumer sets createBetterAuthInstance's clientSlug to
104
+ * something other than getAppSlug().
102
105
  * @returns The session data, or null if not found.
103
106
  */
104
107
  export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
105
108
  if (!sessionToken) {
106
109
  return null;
107
110
  }
108
- // Try to get appSlug from env if not provided
109
- const slug = appSlug || process.env.CLIENT_ID || 'idealvibe_online';
111
+ // 241875 / 213416 (partially-applied fix, completed here): this line used to be
112
+ // appSlug || process.env.CLIENT_ID || 'idealvibe_online'
113
+ // which is a THIRD slug resolution, disagreeing with the two canonical ones in
114
+ // this same file - getSessionKey() uses getSessionPrefix() (i.e. getAppSlug()),
115
+ // and deleteSessionEverywhere() uses `appSlug || getAppSlug()`.
116
+ //
117
+ // WRITER vs READER, same keyspace, different answers. Better Auth WRITES
118
+ // ba:{getAppSlug()}:{token} - deleteSessionEverywhere's own docstring records
119
+ // that it mirrors createBetterAuthInstance's `config.clientSlug || getAppSlug()`.
120
+ // This function READ ba:{CLIENT_ID or 'idealvibe_online'}:{token}.
121
+ //
122
+ // MEASURED IN PRODUCTION (keelauth-portal-config, 2026-08-24): APP_SLUG is
123
+ // declared and CLIENT_ID is NOT. So the old chain fell through to the literal and
124
+ // every one of this function's 14 call sites looked under a key Better Auth never
125
+ // writes. It could not match, and it returned null silently rather than failing -
126
+ // so the BA-session fallback documented in 4.1.3 (viability falling back to BA
127
+ // Redis when the canonical store misses) has been a safety net with a hole in it.
128
+ //
129
+ // 213416 fixed this exact writer/reader split at deleteSessionEverywhere and did
130
+ // not reach here. Using getAppSlug() makes the two agree by construction.
131
+ const slug = appSlug || getAppSlug();
110
132
  const key = getBetterAuthSessionKey(sessionToken, slug);
111
133
  const json = await redis.get(key);
112
134
  if (!json) {
@@ -125,6 +147,11 @@ export async function getBetterAuthSession(sessionToken: string, appSlug?: strin
125
147
  idpRefreshToken: data.idpTokens?.idpRefreshToken,
126
148
  idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
127
149
  mfaVerified: data.idpTokens?.mfaVerified ?? false,
150
+ // R807 (362550): the `?? false` above is CORRECT and stays - fail-closed for
151
+ // every security consumer. This records the bit it destroys, for viability
152
+ // only. Note `!== undefined`, not a truthiness test: an explicit `false` IS
153
+ // a signal, and is precisely the case viability needs to see.
154
+ mfaSignalPresent: data.idpTokens?.mfaVerified !== undefined,
128
155
  // Cards 141688/141741 — the IDP's routing decision, carried not derived.
129
156
  // `null` is a real value here (IDP said nothing is outstanding), so it
130
157
  // must stay distinguishable from `undefined` (we never captured it —
@@ -226,6 +253,31 @@ export async function deleteSession(sessionToken: string): Promise<void> {
226
253
  await redis.del(key, versionKey);
227
254
  }
228
255
 
256
+ /**
257
+ * Deletes a session from EVERY store it can live in (181922): the canonical
258
+ * {slug}:sess:{token} + version key AND the Better Auth secondary
259
+ * ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
260
+ * session — getSession's Path B reads the ba: record when the canonical is
261
+ * gone, so a replayed cookie still authenticated after "logout" (QAPert-
262
+ * NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
263
+ * not deleteSession.
264
+ *
265
+ * The ba: key slug mirrors createBetterAuthInstance's
266
+ * `config.clientSlug || getAppSlug()` — on every current consumer those are
267
+ * the same value (the client-id aliases make it so). If a consumer ever sets
268
+ * them differently, pass appSlug explicitly.
269
+ */
270
+ export async function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void> {
271
+ if (!sessionToken) {
272
+ return;
273
+ }
274
+ await redis.del(
275
+ getSessionKey(sessionToken),
276
+ getSessionVersionKey(sessionToken),
277
+ getBetterAuthSessionKey(sessionToken, appSlug || getAppSlug()),
278
+ );
279
+ }
280
+
229
281
  /**
230
282
  * Sets a session directly (for testing or migrations).
231
283
  * @param sessionToken The session token.
@@ -292,6 +344,56 @@ export async function updateSession(
292
344
  return updatedSession;
293
345
  }
294
346
 
347
+ /**
348
+ * Derives the MFA lifetime fields from an IDP access token.
349
+ *
350
+ * *** CARD 339563. THE FLAG AND THE EXPIRY ARE ONE UNIT - WRITE THEM TOGETHER. ***
351
+ *
352
+ * The 2FA-completion writers below used to set `mfaVerified: true` and never
353
+ * write `mfaExpiresAt`, while every consumer reads the expiry as
354
+ * const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
355
+ * const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
356
+ * The `> 0` guard makes a MISSING expiry unfalsifiable, so `mfaExpired` is
357
+ * forced false forever and the session's MFA state never ages out. The IDP is
358
+ * NOT at fault: IdentityBearerTokenService emits mfa_time + mfa_expires +
359
+ * mfa_validity_hours together inside one `if` block, so the claims arrive as an
360
+ * atomic set or not at all. We were dropping them on the floor.
361
+ *
362
+ * *** THIS RETURNS `{}` WHEN THE TOKEN CARRIES NO mfa_expires, AND THAT IS
363
+ * *** DELIBERATE - DO NOT DEFAULT IT TO `Date.now()` OR TO A CONSTANT. ***
364
+ * The step-up policy omits mfa_time on purpose when the only factor was a
365
+ * primary credential (e.g. passwordless email login): inventing a timestamp
366
+ * because one was missing is forging the evidence. A lane with no claim is a
367
+ * lane where we do not KNOW the expiry, and the honest fix for that case is to
368
+ * make the consumers fail CLOSED on a missing expiry - a separate change, on
369
+ * this same card, which MUST land after this one (the card's option (b)).
370
+ *
371
+ * Mirrors ensure-fresh-access-token.ts and api-handlers/auth/refresh.ts, which
372
+ * already derive these three fields off the token on every exchange (170007
373
+ * rule 2: re-derive from the token, never carry forward).
374
+ */
375
+ function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData> {
376
+ if (!accessToken) return {};
377
+
378
+ const decoded = decodeIdpAccessToken(accessToken);
379
+ if (!decoded) return {};
380
+
381
+ const fields: Partial<SessionData> = {};
382
+
383
+ // 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;
386
+ }
387
+ if (decoded.mfa_time !== undefined && decoded.mfa_time !== null) {
388
+ fields.mfaCompletedAt = Number(decoded.mfa_time) * 1000;
389
+ }
390
+ if (decoded.mfa_validity_hours !== undefined && decoded.mfa_validity_hours !== null) {
391
+ fields.mfaValidityHours = Number(decoded.mfa_validity_hours);
392
+ }
393
+
394
+ return fields;
395
+ }
396
+
295
397
  /**
296
398
  * Transitions a session to a MFA-completed state.
297
399
  * @param sessionToken The session token to update.
@@ -344,6 +446,9 @@ export async function transitionTo2FASession(
344
446
  mfaMethod: mfaMethod,
345
447
  // Update bearerKeyId if extracted from new token
346
448
  ...(bearerKeyId && { bearerKeyId }),
449
+ // 339563: write the expiry in the SAME update that sets the flag. We already
450
+ // hold the stepped-up token here and already decode its header above.
451
+ ...mfaLifetimeFromAccessToken(newAccessToken),
347
452
  };
348
453
 
349
454
  const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
@@ -356,6 +461,12 @@ export async function transitionTo2FASession(
356
461
  twoFactorComplete: (updates as any).twoFactorComplete,
357
462
  mfaMethod: updates.mfaMethod,
358
463
  hasIdpAccessToken: !!updates.idpAccessToken,
464
+ // 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
465
+ // the passwordless/primary-credential lane (token carried no mfa_expires),
466
+ // NOT a bug - but it IS the case the consumers currently fail OPEN on, so
467
+ // it needs to be visible in the log rather than inferred from its absence.
468
+ mfaExpiresAt: updates.mfaExpiresAt,
469
+ mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
359
470
  });
360
471
 
361
472
  const result = await updateSession(sessionToken, updates);
@@ -394,12 +505,47 @@ export async function updateTokens(
394
505
 
395
506
  /**
396
507
  * Marks a session as having completed MFA.
508
+ *
509
+ * *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
510
+ * Without it this writes a `mfaVerified: true` that no consumer can ever expire
511
+ * (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
512
+ * that this published package keeps its existing signature for external
513
+ * callers; every in-tree caller supplies it, and omitting it re-opens the
514
+ * fail-open for that call site.
515
+ *
516
+ * It is NOT defaulted to the session's stored token on purpose. Reading it back
517
+ * would work today only because the sole caller happens to run `updateTokens()`
518
+ * first, and depending on undocumented call ORDERING is how this class of bug
519
+ * gets reintroduced. The token is an input, so take it as one.
520
+ *
397
521
  * @param sessionToken The session token to update.
522
+ * @param accessToken The access token minted AFTER MFA completion, carrying the
523
+ * mfa_time / mfa_expires / mfa_validity_hours claim set.
398
524
  * @returns The updated session data.
399
525
  */
400
- export async function mark2FAComplete(sessionToken: string): Promise<SessionData | null> {
526
+ export async function mark2FAComplete(
527
+ sessionToken: string,
528
+ accessToken?: string
529
+ ): Promise<SessionData | null> {
530
+ const mfaLifetime = mfaLifetimeFromAccessToken(accessToken);
531
+
532
+ if (!accessToken) {
533
+ console.warn(
534
+ '[mark2FAComplete] 339563: called with NO access token, so no mfaExpiresAt ' +
535
+ 'can be derived. This session\'s MFA state will not expire until its next ' +
536
+ 'token refresh repairs it. Pass the stepped-up access token.'
537
+ );
538
+ } else if (mfaLifetime.mfaExpiresAt === undefined) {
539
+ console.warn(
540
+ '[mark2FAComplete] 339563: access token carries NO mfa_expires claim, so ' +
541
+ 'this session has no MFA expiry. Expected on a primary-credential lane ' +
542
+ '(e.g. passwordless email); unexpected on an ordinary 2FA completion.'
543
+ );
544
+ }
545
+
401
546
  return updateSession(sessionToken, {
402
547
  mfaVerified: true,
548
+ ...mfaLifetime,
403
549
  });
404
550
  }
405
551
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Single-flight memoisation that does NOT memoise failure — card 244549.
3
+ *
4
+ * The bug this exists to prevent: caching an in-flight promise to de-duplicate
5
+ * concurrent callers is correct, but if the cached promise REJECTS and is never
6
+ * cleared, the rejection is memoised for the life of the process. Every later
7
+ * caller re-awaits the same settled rejection and re-throws the same stale
8
+ * error, long after the underlying dependency has recovered. Recovery becomes
9
+ * restart-only, and — because the error text is identical every time — it looks
10
+ * exactly like the dependency is still down.
11
+ *
12
+ * Measured instance: @payez/next-mvp getAuthInstance() memoised a failed IDP
13
+ * config load. Re-injecting correct config into three running rigs on
14
+ * 2026-08-23 did not heal them; the processes were replaying a cached
15
+ * rejection rather than retrying.
16
+ *
17
+ * Contract:
18
+ * - concurrent callers during a successful load share ONE underlying call
19
+ * - a rejection is re-thrown to every caller awaiting that attempt (failures
20
+ * stay LOUD — this never converts an error into a silent retry)
21
+ * - the NEXT call after a failure starts a fresh attempt
22
+ * - a slow failure never clears a newer in-flight attempt
23
+ *
24
+ * Zero imports on purpose: this must be unit-testable without loading the
25
+ * server auth stack, which is why the bug it fixes went untested for so long.
26
+ */
27
+ export interface SingleFlight<T> {
28
+ /** Run (or join) the in-flight attempt. Rejects if the attempt rejects. */
29
+ get(): Promise<T>;
30
+ /** Test/diagnostic seam: is an attempt currently cached? */
31
+ isPending(): boolean;
32
+ /** Drop any cached attempt. */
33
+ reset(): void;
34
+ }
35
+
36
+ export function createSingleFlight<T>(factory: () => Promise<T>): SingleFlight<T> {
37
+ let pending: Promise<T> | null = null;
38
+
39
+ return {
40
+ get(): Promise<T> {
41
+ if (pending) return pending;
42
+
43
+ const attempt = factory();
44
+
45
+ // Cleanup runs on a SIDE branch so the promise handed to callers is the
46
+ // original one and still rejects. The identity guard means only the
47
+ // attempt that actually failed clears itself: without it, a slow failure
48
+ // could evict a newer attempt a later caller had already installed, and
49
+ // that caller would be de-duplicated against a promise nobody owns.
50
+ attempt.catch(() => {
51
+ if (pending === attempt) pending = null;
52
+ });
53
+
54
+ pending = attempt;
55
+ return pending;
56
+ },
57
+ isPending(): boolean {
58
+ return pending !== null;
59
+ },
60
+ reset(): void {
61
+ pending = null;
62
+ },
63
+ };
64
+ }
@@ -351,6 +351,10 @@ export async function ensureFreshToken(
351
351
  * `mfaVerified || ... || false`).
352
352
  */
353
353
  mfaVerified: idpTokens?.mfaVerified ?? false,
354
+ // R807 (362550): the fail-closed default above stays exactly as documented;
355
+ // this records whether it was a measurement or a default. `!== undefined`
356
+ // deliberately - an explicit `false` is a signal.
357
+ mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
354
358
  oauthProvider: idpTokens?.oauthProvider ?? baSession.oauthProvider ?? undefined,
355
359
  idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
356
360
  merchantId: idpTokens?.merchantId ?? baSession.merchantId,
@@ -377,6 +381,14 @@ export async function ensureFreshToken(
377
381
  * UNSET is the only honest provider. Both are fail-closed.
378
382
  */
379
383
  mfaVerified: false,
384
+ /**
385
+ * R807 (362550) - FIFTH construction site, not in the ruling's list of four.
386
+ * This path has NO IDP TOKENS AT ALL, so there is no producer statement to
387
+ * record: the `false` above is a DEFAULT, never a measurement. `false` is
388
+ * therefore the honest signal value, and it is the conservative one for
389
+ * viability (absent/false -> do not force a re-challenge).
390
+ */
391
+ mfaSignalPresent: false,
380
392
  oauthProvider: undefined,
381
393
  } as SessionData;
382
394
  }
@@ -292,6 +292,17 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
292
292
  if (sessionPointer.hasRefreshToken) {
293
293
  return { type: 'refresh_needed' };
294
294
  }
295
+ // 307290: this is the TERMINAL arm of the expired branch - refresh is not
296
+ // possible here, so the only outcomes are "redirect" or "allow". Every other
297
+ // terminal branch in this function (:242 no_session, :259 force_invalidated,
298
+ // :271 stale_session, :318 2FA-incomplete) already exempts /api/ so the route
299
+ // can answer with a real 401 JSON; this one did not, and redirecting an API
300
+ // request to an HTML login page makes the failure INVISIBLE - fetch() follows
301
+ // the 307, gets 200 HTML, resolves successfully and parses garbage.
302
+ // MUST stay BELOW the hasRefreshToken check above: placed any higher it would
303
+ // intercept 'refresh_needed' and disable middleware token refresh for the
304
+ // entire API surface.
305
+ if (pathname.startsWith('/api/')) return { type: 'allow' }; // Let API return proper 401 JSON
295
306
  return {
296
307
  type: 'redirect',
297
308
  location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=SessionExpired`,