@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.
- package/dist/auth/better-auth.d.ts +1 -1
- package/dist/auth/better-auth.js +18 -1
- package/dist/server/auth.d.ts +16 -3
- package/dist/server/auth.js +123 -16
- package/package.json +1 -1
- package/src/auth/better-auth.ts +18 -1
- package/src/server/auth.ts +121 -20
package/dist/auth/better-auth.js
CHANGED
|
@@ -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:
|
|
173
|
+
enabled: false,
|
|
157
174
|
maxAge: 300,
|
|
158
175
|
refreshCache: false,
|
|
159
176
|
},
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export declare function getAuthInstance(): Promise<import("better-auth/types").A
|
|
|
30
30
|
};
|
|
31
31
|
session: {
|
|
32
32
|
cookieCache: {
|
|
33
|
-
enabled:
|
|
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
|
-
*
|
|
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
|
/**
|
package/dist/server/auth.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(
|
|
147
|
-
|
|
148
|
-
return attachSessionData(session, sessionData, sessionToken);
|
|
224
|
+
if (!sessionData)
|
|
225
|
+
sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
226
|
+
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
149
227
|
}
|
|
150
|
-
|
|
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
package/src/auth/better-auth.ts
CHANGED
|
@@ -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:
|
|
166
|
+
enabled: false,
|
|
150
167
|
maxAge: 300,
|
|
151
168
|
refreshCache: false,
|
|
152
169
|
},
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
189
|
+
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
190
|
+
} catch { /* fall through to canonical Redis path */ }
|
|
110
191
|
|
|
111
|
-
|
|
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
|
-
|
|
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
|
/**
|