@payez/next-mvp 4.1.3 → 4.1.4

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.
@@ -56,7 +56,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
56
56
  };
57
57
  session: {
58
58
  cookieCache: {
59
- enabled: true;
59
+ enabled: false;
60
60
  maxAge: number;
61
61
  refreshCache: false;
62
62
  };
@@ -152,8 +152,25 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
152
152
  },
153
153
  },
154
154
  session: {
155
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
156
+ //
157
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
158
+ // a decoded session in process memory for 5 minutes, bypassing Redis
159
+ // reads during the window. Consequence: if the canonical app session
160
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
161
+ // Better Auth's in-memory copy stayed alive — and callers got
162
+ // contradictory answers depending on whether they consulted Better
163
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
164
+ // visible in production traces: viability 200 + idp-token 200 +
165
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
166
+ //
167
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
168
+ // read on the secondary-storage path. Latency cost is acceptable
169
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
170
+ // workarounds in every consumer app — already shipped in idealvibe.online
171
+ // at b6a91f6.
155
172
  cookieCache: {
156
- enabled: true,
173
+ enabled: false,
157
174
  maxAge: 300,
158
175
  refreshCache: false,
159
176
  },
@@ -30,7 +30,7 @@ export declare function getAuthInstance(): Promise<import("better-auth/types").A
30
30
  };
31
31
  session: {
32
32
  cookieCache: {
33
- enabled: true;
33
+ enabled: false;
34
34
  maxAge: number;
35
35
  refreshCache: false;
36
36
  };
@@ -154,9 +154,22 @@ export declare function getAuthInstance(): Promise<import("better-auth/types").A
154
154
  }>>;
155
155
  /**
156
156
  * Get the current session from a request.
157
- * Replaces getToken() and getServerSession().
158
157
  *
159
- * Returns the session object or null if not authenticated.
158
+ * Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
159
+ * cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
160
+ * secret and the canonical cookie parse) but does NOT decide whether a session
161
+ * is alive. If Better Auth's primary path returns null or a partial session
162
+ * (cookie cache miss, secondary storage eviction, token rotation), we fall
163
+ * back to manually extracting the session-token claim from the cookie and
164
+ * querying the canonical Redis store directly.
165
+ *
166
+ * This closes the asymmetric early-exit that caused contradictory answers
167
+ * within milliseconds in production traces:
168
+ * GET /api/session/viability → 200 (Redis: alive)
169
+ * GET /api/session/idp-token → 200 (Redis: alive)
170
+ * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
171
+ *
172
+ * Returns the session object or null if not authenticated per Redis.
160
173
  */
161
174
  export declare function getSession(request?: Request): Promise<any>;
162
175
  /**
@@ -49,6 +49,7 @@ require("server-only");
49
49
  const better_auth_1 = require("../auth/better-auth");
50
50
  const idp_client_config_1 = require("../lib/idp-client-config");
51
51
  const session_store_1 = require("../lib/session-store");
52
+ const app_slug_1 = require("../lib/app-slug");
52
53
  let authInstance = null;
53
54
  let authInitPromise = null;
54
55
  function buildSessionDataFromAuthSession(session) {
@@ -116,40 +117,146 @@ async function getAuthInstance() {
116
117
  }
117
118
  return authInitPromise;
118
119
  }
120
+ /**
121
+ * JWT body-decode helper — base64-decode only, NO signature verification.
122
+ * Safe because the value is then used as a Redis lookup key: Redis is the
123
+ * liveness gate, so an attacker forging a JWT body with a guessed
124
+ * sessionToken claim still has to land on a real Redis session (infeasible
125
+ * against high-entropy UUIDs). Used by the canonical-fallback path in
126
+ * `getSession` to extract the session token when Better Auth's primary
127
+ * path returns null (cookie cache miss / secondary storage eviction).
128
+ */
129
+ function decodeJwtBody(jwt) {
130
+ try {
131
+ const parts = jwt.split('.');
132
+ if (parts.length !== 3)
133
+ return null;
134
+ const decoded = Buffer.from(parts[1], 'base64').toString('utf-8');
135
+ return JSON.parse(decoded);
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /** Extract the session-token claim from the Better Auth session cookie
142
+ * WITHOUT going through Better Auth's `auth.api.getSession()`. Handles
143
+ * both single-cookie and chunked-cookie cases (Better Auth chunks long
144
+ * JWTs across `{name}.0`, `{name}.1`, …). Returns the raw `sessionToken`
145
+ * claim suitable for use as a Redis key. Returns null if no cookie is
146
+ * present or the JWT can't be parsed. */
147
+ function extractSessionTokenFromCookie(request) {
148
+ const cookieHeader = request.headers.get('cookie');
149
+ if (!cookieHeader)
150
+ return null;
151
+ const cookieName = (0, app_slug_1.getSessionCookieName)();
152
+ let rawJwt = null;
153
+ // Direct cookie first.
154
+ const chunks = [];
155
+ for (const part of cookieHeader.split(';')) {
156
+ const trimmed = part.trim();
157
+ const eq = trimmed.indexOf('=');
158
+ if (eq === -1)
159
+ continue;
160
+ const k = trimmed.slice(0, eq);
161
+ const v = trimmed.slice(eq + 1);
162
+ if (k === cookieName) {
163
+ rawJwt = decodeURIComponent(v);
164
+ break;
165
+ }
166
+ if (k.startsWith(`${cookieName}.`)) {
167
+ const idx = parseInt(k.split('.').pop() || '0', 10);
168
+ if (Number.isFinite(idx))
169
+ chunks.push({ idx, value: decodeURIComponent(v) });
170
+ }
171
+ }
172
+ // Chunked cookies fallback (Better Auth splits long JWTs across .0/.1/.2 …).
173
+ if (!rawJwt && chunks.length > 0) {
174
+ chunks.sort((a, b) => a.idx - b.idx);
175
+ rawJwt = chunks.map(c => c.value).join('');
176
+ }
177
+ if (!rawJwt)
178
+ return null;
179
+ const decoded = decodeJwtBody(rawJwt);
180
+ if (decoded && typeof decoded === 'object' && typeof decoded.sessionToken === 'string') {
181
+ return decoded.sessionToken;
182
+ }
183
+ return null;
184
+ }
119
185
  /**
120
186
  * Get the current session from a request.
121
- * Replaces getToken() and getServerSession().
122
187
  *
123
- * Returns the session object or null if not authenticated.
188
+ * Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
189
+ * cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
190
+ * secret and the canonical cookie parse) but does NOT decide whether a session
191
+ * is alive. If Better Auth's primary path returns null or a partial session
192
+ * (cookie cache miss, secondary storage eviction, token rotation), we fall
193
+ * back to manually extracting the session-token claim from the cookie and
194
+ * querying the canonical Redis store directly.
195
+ *
196
+ * This closes the asymmetric early-exit that caused contradictory answers
197
+ * within milliseconds in production traces:
198
+ * GET /api/session/viability → 200 (Redis: alive)
199
+ * GET /api/session/idp-token → 200 (Redis: alive)
200
+ * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
201
+ *
202
+ * Returns the session object or null if not authenticated per Redis.
124
203
  */
125
204
  async function getSession(request) {
126
205
  const auth = await getAuthInstance();
127
206
  if (!request)
128
207
  return null;
208
+ // Path A — Better Auth primary path (cookie cache → secondary storage).
209
+ // Fast happy-path when both Better Auth and Redis agree.
210
+ let betterAuthSession = null;
129
211
  try {
130
- const session = await auth.api.getSession({ headers: request.headers });
131
- if (!session?.session?.token || !session?.user)
132
- return session;
133
- const sessionToken = session.session.token;
212
+ betterAuthSession = await auth.api.getSession({ headers: request.headers });
213
+ }
214
+ catch { /* fall through to canonical Redis path */ }
215
+ if (betterAuthSession?.session?.token && betterAuthSession?.user) {
216
+ const sessionToken = betterAuthSession.session.token;
134
217
  let sessionData = null;
135
- // Prefer the app's normalized Redis session. Fall back to Better Auth's
136
- // secondary storage record, then finally to whatever Better Auth already
137
- // put on the request session object.
138
218
  try {
139
219
  sessionData = await (0, session_store_1.getSession)(sessionToken);
140
- if (!sessionData) {
220
+ if (!sessionData)
141
221
  sessionData = await (0, session_store_1.getBetterAuthSession)(sessionToken);
142
- }
143
222
  }
144
223
  catch { /* Redis unavailable */ }
145
- if (!sessionData) {
146
- sessionData = buildSessionDataFromAuthSession(session);
147
- }
148
- return attachSessionData(session, sessionData, sessionToken);
224
+ if (!sessionData)
225
+ sessionData = buildSessionDataFromAuthSession(betterAuthSession);
226
+ return attachSessionData(betterAuthSession, sessionData, sessionToken);
149
227
  }
150
- catch {
228
+ // Path B — Better Auth said null/incomplete. Fall back to manual cookie
229
+ // decode + Redis-canonical liveness check. This catches cases where the
230
+ // canonical app session at `{slug}:{token}` is still alive but Better
231
+ // Auth's `ba:{slug}:{token}` secondary record was evicted or the
232
+ // in-memory cookie cache returned stale-null. No JWT signature check —
233
+ // Redis lookup is the validation: an attacker forging a session-token
234
+ // claim hits NO_SESSION because Redis has no matching entry.
235
+ const sessionToken = extractSessionTokenFromCookie(request);
236
+ if (!sessionToken)
151
237
  return null;
238
+ let sessionData = null;
239
+ try {
240
+ sessionData = await (0, session_store_1.getSession)(sessionToken);
241
+ if (!sessionData)
242
+ sessionData = await (0, session_store_1.getBetterAuthSession)(sessionToken);
152
243
  }
244
+ catch { /* Redis unavailable */ }
245
+ if (!sessionData)
246
+ return null;
247
+ // Synthesize a minimal session shape compatible with downstream callers.
248
+ // The `session.token` field is what `getFreshIdpToken` and other callers
249
+ // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
250
+ // populated here even though Better Auth didn't give us a full session.
251
+ const synthetic = {
252
+ session: { token: sessionToken },
253
+ user: {
254
+ id: sessionData.userId,
255
+ email: sessionData.email,
256
+ roles: sessionData.roles,
257
+ },
258
+ };
259
+ return attachSessionData(synthetic, sessionData, sessionToken);
153
260
  }
154
261
  /**
155
262
  * Get normalized session data for the current request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.3",
3
+ "version": "4.1.4",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -145,8 +145,25 @@ export function createBetterAuthInstance(
145
145
  },
146
146
 
147
147
  session: {
148
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
149
+ //
150
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
151
+ // a decoded session in process memory for 5 minutes, bypassing Redis
152
+ // reads during the window. Consequence: if the canonical app session
153
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
154
+ // Better Auth's in-memory copy stayed alive — and callers got
155
+ // contradictory answers depending on whether they consulted Better
156
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
157
+ // visible in production traces: viability 200 + idp-token 200 +
158
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
159
+ //
160
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
161
+ // read on the secondary-storage path. Latency cost is acceptable
162
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
163
+ // workarounds in every consumer app — already shipped in idealvibe.online
164
+ // at b6a91f6.
148
165
  cookieCache: {
149
- enabled: true,
166
+ enabled: false,
150
167
  maxAge: 300,
151
168
  refreshCache: false,
152
169
  },
@@ -13,6 +13,7 @@ import {
13
13
  getBetterAuthSession as getBetterAuthRedisSession,
14
14
  type SessionData,
15
15
  } from '../lib/session-store';
16
+ import { getSessionCookieName } from '../lib/app-slug';
16
17
 
17
18
  let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
18
19
  let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
@@ -94,41 +95,141 @@ export async function getAuthInstance() {
94
95
  return authInitPromise;
95
96
  }
96
97
 
98
+ /**
99
+ * JWT body-decode helper — base64-decode only, NO signature verification.
100
+ * Safe because the value is then used as a Redis lookup key: Redis is the
101
+ * liveness gate, so an attacker forging a JWT body with a guessed
102
+ * sessionToken claim still has to land on a real Redis session (infeasible
103
+ * against high-entropy UUIDs). Used by the canonical-fallback path in
104
+ * `getSession` to extract the session token when Better Auth's primary
105
+ * path returns null (cookie cache miss / secondary storage eviction).
106
+ */
107
+ function decodeJwtBody(jwt: string): any {
108
+ try {
109
+ const parts = jwt.split('.');
110
+ if (parts.length !== 3) return null;
111
+ const decoded = Buffer.from(parts[1], 'base64').toString('utf-8');
112
+ return JSON.parse(decoded);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Extract the session-token claim from the Better Auth session cookie
119
+ * WITHOUT going through Better Auth's `auth.api.getSession()`. Handles
120
+ * both single-cookie and chunked-cookie cases (Better Auth chunks long
121
+ * JWTs across `{name}.0`, `{name}.1`, …). Returns the raw `sessionToken`
122
+ * claim suitable for use as a Redis key. Returns null if no cookie is
123
+ * present or the JWT can't be parsed. */
124
+ function extractSessionTokenFromCookie(request: Request): string | null {
125
+ const cookieHeader = request.headers.get('cookie');
126
+ if (!cookieHeader) return null;
127
+
128
+ const cookieName = getSessionCookieName();
129
+ let rawJwt: string | null = null;
130
+
131
+ // Direct cookie first.
132
+ const chunks: Array<{ idx: number; value: string }> = [];
133
+ for (const part of cookieHeader.split(';')) {
134
+ const trimmed = part.trim();
135
+ const eq = trimmed.indexOf('=');
136
+ if (eq === -1) continue;
137
+ const k = trimmed.slice(0, eq);
138
+ const v = trimmed.slice(eq + 1);
139
+ if (k === cookieName) {
140
+ rawJwt = decodeURIComponent(v);
141
+ break;
142
+ }
143
+ if (k.startsWith(`${cookieName}.`)) {
144
+ const idx = parseInt(k.split('.').pop() || '0', 10);
145
+ if (Number.isFinite(idx)) chunks.push({ idx, value: decodeURIComponent(v) });
146
+ }
147
+ }
148
+ // Chunked cookies fallback (Better Auth splits long JWTs across .0/.1/.2 …).
149
+ if (!rawJwt && chunks.length > 0) {
150
+ chunks.sort((a, b) => a.idx - b.idx);
151
+ rawJwt = chunks.map(c => c.value).join('');
152
+ }
153
+ if (!rawJwt) return null;
154
+
155
+ const decoded = decodeJwtBody(rawJwt);
156
+ if (decoded && typeof decoded === 'object' && typeof decoded.sessionToken === 'string') {
157
+ return decoded.sessionToken;
158
+ }
159
+ return null;
160
+ }
161
+
97
162
  /**
98
163
  * Get the current session from a request.
99
- * Replaces getToken() and getServerSession().
100
164
  *
101
- * Returns the session object or null if not authenticated.
165
+ * Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
166
+ * cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
167
+ * secret and the canonical cookie parse) but does NOT decide whether a session
168
+ * is alive. If Better Auth's primary path returns null or a partial session
169
+ * (cookie cache miss, secondary storage eviction, token rotation), we fall
170
+ * back to manually extracting the session-token claim from the cookie and
171
+ * querying the canonical Redis store directly.
172
+ *
173
+ * This closes the asymmetric early-exit that caused contradictory answers
174
+ * within milliseconds in production traces:
175
+ * GET /api/session/viability → 200 (Redis: alive)
176
+ * GET /api/session/idp-token → 200 (Redis: alive)
177
+ * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
178
+ *
179
+ * Returns the session object or null if not authenticated per Redis.
102
180
  */
103
181
  export async function getSession(request?: Request): Promise<any> {
104
182
  const auth = await getAuthInstance();
105
183
  if (!request) return null;
106
184
 
185
+ // Path A — Better Auth primary path (cookie cache → secondary storage).
186
+ // Fast happy-path when both Better Auth and Redis agree.
187
+ let betterAuthSession: any = null;
107
188
  try {
108
- const session = await auth.api.getSession({ headers: request.headers });
109
- if (!session?.session?.token || !session?.user) return session;
189
+ betterAuthSession = await auth.api.getSession({ headers: request.headers });
190
+ } catch { /* fall through to canonical Redis path */ }
110
191
 
111
- const sessionToken = session.session.token as string;
192
+ if (betterAuthSession?.session?.token && betterAuthSession?.user) {
193
+ const sessionToken = betterAuthSession.session.token as string;
112
194
  let sessionData: SessionData | null = null;
113
-
114
- // Prefer the app's normalized Redis session. Fall back to Better Auth's
115
- // secondary storage record, then finally to whatever Better Auth already
116
- // put on the request session object.
117
195
  try {
118
196
  sessionData = await getRedisSession(sessionToken);
119
- if (!sessionData) {
120
- sessionData = await getBetterAuthRedisSession(sessionToken);
121
- }
197
+ if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
122
198
  } catch { /* Redis unavailable */ }
123
-
124
- if (!sessionData) {
125
- sessionData = buildSessionDataFromAuthSession(session);
126
- }
127
-
128
- return attachSessionData(session, sessionData, sessionToken);
129
- } catch {
130
- return null;
199
+ if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
200
+ return attachSessionData(betterAuthSession, sessionData, sessionToken);
131
201
  }
202
+
203
+ // Path B — Better Auth said null/incomplete. Fall back to manual cookie
204
+ // decode + Redis-canonical liveness check. This catches cases where the
205
+ // canonical app session at `{slug}:{token}` is still alive but Better
206
+ // Auth's `ba:{slug}:{token}` secondary record was evicted or the
207
+ // in-memory cookie cache returned stale-null. No JWT signature check —
208
+ // Redis lookup is the validation: an attacker forging a session-token
209
+ // claim hits NO_SESSION because Redis has no matching entry.
210
+ const sessionToken = extractSessionTokenFromCookie(request);
211
+ if (!sessionToken) return null;
212
+
213
+ let sessionData: SessionData | null = null;
214
+ try {
215
+ sessionData = await getRedisSession(sessionToken);
216
+ if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
217
+ } catch { /* Redis unavailable */ }
218
+ if (!sessionData) return null;
219
+
220
+ // Synthesize a minimal session shape compatible with downstream callers.
221
+ // The `session.token` field is what `getFreshIdpToken` and other callers
222
+ // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
223
+ // populated here even though Better Auth didn't give us a full session.
224
+ const synthetic = {
225
+ session: { token: sessionToken },
226
+ user: {
227
+ id: sessionData.userId,
228
+ email: sessionData.email,
229
+ roles: sessionData.roles,
230
+ },
231
+ };
232
+ return attachSessionData(synthetic, sessionData, sessionToken);
132
233
  }
133
234
 
134
235
  /**