@payez/next-mvp 4.1.11 → 4.1.12

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 (91) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.js +12 -33
  3. package/dist/client/better-auth-client.d.ts +14 -2
  4. package/dist/lib/ensure-fresh-access-token.js +8 -42
  5. package/dist/lib/session-store.d.ts +0 -1
  6. package/dist/lib/session-store.js +29 -33
  7. package/dist/lib/startup-init.js +19 -19
  8. package/dist/lib/token-lifecycle.d.ts +0 -2
  9. package/dist/lib/token-lifecycle.js +3 -21
  10. package/package.json +4 -3
  11. package/scripts/check-internal-url-usage.sh +73 -73
  12. package/src/api/index.ts +17 -17
  13. package/src/api-handlers/account/masked-info.ts +45 -45
  14. package/src/api-handlers/account/profile.ts +80 -80
  15. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  16. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  17. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  18. package/src/api-handlers/account/reset-password.ts +23 -23
  19. package/src/api-handlers/account/send-code.ts +76 -76
  20. package/src/api-handlers/account/validate-password.ts +118 -118
  21. package/src/api-handlers/admin/stats.ts +249 -249
  22. package/src/api-handlers/anon/preferences.ts +123 -123
  23. package/src/api-handlers/auth/jwks.ts +20 -20
  24. package/src/api-handlers/auth/login.ts +240 -240
  25. package/src/api-handlers/auth/refresh.ts +15 -38
  26. package/src/api-handlers/auth/validate.ts +43 -43
  27. package/src/api-handlers/session/viability.ts +142 -142
  28. package/src/auth/better-auth.ts +450 -467
  29. package/src/auth/unauthenticated-routes.ts +2 -2
  30. package/src/client/better-auth-client.ts +70 -70
  31. package/src/client/fetchWithSession.ts +21 -21
  32. package/src/client/index.ts +13 -13
  33. package/src/client/useAnonSession.ts +131 -131
  34. package/src/components/admin/VibeAdminContext.tsx +87 -87
  35. package/src/config/logging-config.ts +82 -82
  36. package/src/config/unauthenticated-routes.ts +19 -19
  37. package/src/edge/internal-api-url.ts +65 -65
  38. package/src/edge/middleware.ts +42 -42
  39. package/src/hooks/useProfile.ts +75 -75
  40. package/src/lib/anon-session.ts +213 -213
  41. package/src/lib/auth-secret.ts +137 -137
  42. package/src/lib/demo-mode.ts +17 -17
  43. package/src/lib/ensure-fresh-access-token.ts +8 -42
  44. package/src/lib/idp-client-config.ts +559 -559
  45. package/src/lib/idp-fetch.ts +126 -126
  46. package/src/lib/internal-api.ts +171 -171
  47. package/src/lib/jwt-decode-client.ts +45 -45
  48. package/src/lib/rate-limit-service.ts +9 -9
  49. package/src/lib/redis.ts +26 -26
  50. package/src/lib/refresh-token-validator.ts +64 -64
  51. package/src/lib/secret-validation.ts +8 -8
  52. package/src/lib/session-store.ts +692 -695
  53. package/src/lib/session.ts +34 -34
  54. package/src/lib/startup-init.ts +251 -251
  55. package/src/lib/token-expiry.ts +40 -40
  56. package/src/lib/token-lifecycle.ts +470 -497
  57. package/src/lib/types/api-responses.ts +336 -336
  58. package/src/middleware/auth-decision.ts +43 -43
  59. package/src/models/DecodedAccessToken.ts +16 -16
  60. package/src/models/SessionModel.ts +264 -264
  61. package/src/pages/admin-roles/index.ts +9 -9
  62. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  63. package/src/routes/account/index.ts +31 -31
  64. package/src/routes/account/masked-info.ts +36 -36
  65. package/src/routes/account/send-code.ts +39 -39
  66. package/src/routes/account/verify-email.ts +40 -40
  67. package/src/routes/account/verify-sms.ts +40 -40
  68. package/src/routes/auth/index.ts +22 -22
  69. package/src/routes/auth/refresh.ts +48 -48
  70. package/src/routes/auth/session.ts +166 -166
  71. package/src/routes/index.ts +17 -17
  72. package/src/routes/session/index.ts +7 -7
  73. package/src/routes/session/refresh-viability.ts +17 -17
  74. package/src/server/auth.ts +373 -373
  75. package/src/server/decode-session.ts +202 -202
  76. package/src/services/signalrActivityService.ts +258 -258
  77. package/src/theme/ThemeProvider.tsx +39 -39
  78. package/src/theme/default.ts +33 -33
  79. package/src/theme/index.ts +31 -31
  80. package/src/theme/types.ts +69 -69
  81. package/src/theme/useTheme.ts +57 -57
  82. package/src/theme/utils.ts +40 -40
  83. package/src/types/api.ts +12 -12
  84. package/src/types/auth.d.ts +23 -23
  85. package/src/types/auth.ts +22 -22
  86. package/src/types/logging.ts +11 -11
  87. package/src/types/recovery.ts +54 -54
  88. package/src/utils/api.ts +353 -353
  89. package/src/utils/circuitBreaker.ts +40 -40
  90. package/src/utils/error-message.ts +108 -108
  91. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,373 +1,373 @@
1
- /**
2
- * Server-side auth utilities for Better Auth.
3
- *
4
- * All server-side auth flows go through the Better Auth instance returned by
5
- * getAuthInstance(); use getSession(req) for the request-scoped session.
6
- */
7
-
8
- import 'server-only';
9
- import { createBetterAuthInstance } from '../auth/better-auth';
10
- import { getIDPClientConfig } from '../lib/idp-client-config';
11
- import {
12
- getSession as getRedisSession,
13
- getBetterAuthSession as getBetterAuthRedisSession,
14
- type SessionData,
15
- } from '../lib/session-store';
16
- import { getSessionCookieName } from '../lib/app-slug';
17
-
18
- let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
19
- let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
20
-
21
- export type IdpTokenResult =
22
- | { success: true; accessToken: string; sessionData: SessionData }
23
- | { success: false; error: 'NO_SESSION' | 'NO_TOKEN'; terminal: true };
24
-
25
- function buildSessionDataFromAuthSession(session: any): SessionData | null {
26
- const user = session?.user;
27
- if (!user?.id && !user?.email) {
28
- return null;
29
- }
30
-
31
- const expiresAt = session?.session?.expiresAt
32
- ? new Date(session.session.expiresAt).getTime()
33
- : Date.now() + 24 * 60 * 60 * 1000;
34
-
35
- return {
36
- userId: user.userId || user.id || '',
37
- email: user.email || '',
38
- name: user.name || undefined,
39
- image: user.image || undefined,
40
- roles: Array.isArray(user.roles) ? user.roles : [],
41
- idpAccessToken: user.idpAccessToken,
42
- idpRefreshToken: user.idpRefreshToken,
43
- idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
44
- mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
45
- oauthProvider: user.oauthProvider,
46
- idpClientId: user.idpClientId,
47
- merchantId: user.merchantId,
48
- };
49
- }
50
-
51
- function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
52
- if (!sessionData) {
53
- return session;
54
- }
55
-
56
- const enrichedSessionData = {
57
- ...sessionData,
58
- ...(sessionToken ? { sessionToken } : {}),
59
- };
60
-
61
- (session as any).sessionData = enrichedSessionData;
62
-
63
- if (session?.user) {
64
- const user = session.user as any;
65
- user.userId = enrichedSessionData.userId || user.userId;
66
- user.email = enrichedSessionData.email || user.email;
67
- user.name = enrichedSessionData.name || user.name;
68
- user.image = enrichedSessionData.image || user.image;
69
- user.roles = enrichedSessionData.roles || user.roles || [];
70
- user.idpAccessToken = enrichedSessionData.idpAccessToken;
71
- user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
72
- user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
73
- user.mfaVerified = enrichedSessionData.mfaVerified;
74
- user.twoFactorSessionVerified =
75
- enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
76
- user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
77
- user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
78
- user.merchantId = enrichedSessionData.merchantId || user.merchantId;
79
- }
80
-
81
- return session;
82
- }
83
-
84
- /**
85
- * Get the initialized Better Auth instance (singleton).
86
- */
87
- export async function getAuthInstance() {
88
- if (authInstance) return authInstance;
89
- if (!authInitPromise) {
90
- authInitPromise = getIDPClientConfig(true).then(config => {
91
- authInstance = createBetterAuthInstance(config);
92
- return authInstance;
93
- });
94
- }
95
- return authInitPromise;
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
-
162
- /**
163
- * Get the current session from a request.
164
- *
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.
180
- */
181
- export async function getSession(request?: Request): Promise<any> {
182
- const auth = await getAuthInstance();
183
- if (!request) return null;
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;
188
- try {
189
- betterAuthSession = await auth.api.getSession({ headers: request.headers });
190
- } catch { /* fall through to canonical Redis path */ }
191
-
192
- if (betterAuthSession?.session?.token && betterAuthSession?.user) {
193
- const sessionToken = betterAuthSession.session.token as string;
194
- let sessionData: SessionData | null = null;
195
- try {
196
- sessionData = await getRedisSession(sessionToken);
197
- if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
198
- } catch { /* Redis unavailable */ }
199
- if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
200
- return attachSessionData(betterAuthSession, sessionData, sessionToken);
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);
233
- }
234
-
235
- /**
236
- * Get normalized session data for the current request.
237
- *
238
- * This prefers the app's Redis session because it carries the canonical
239
- * IDP token, roles, and tenant-specific user identity used by app routes.
240
- */
241
- export async function getSessionData(request?: Request): Promise<SessionData | null> {
242
- const session = await getSession(request);
243
- const sessionData =
244
- ((session as any)?.sessionData as SessionData | undefined) ||
245
- buildSessionDataFromAuthSession(session);
246
-
247
- if (!sessionData) {
248
- return null;
249
- }
250
-
251
- const sessionToken = session?.session?.token as string | undefined;
252
- return sessionToken
253
- ? { ...sessionData, sessionToken }
254
- : sessionData;
255
- }
256
-
257
- /**
258
- * Get the current request's IDP access token without triggering a refresh.
259
- *
260
- * Use this for routes that only need the currently-issued bearer token and
261
- * should fail closed instead of performing token lifecycle work. For backend
262
- * proxy routes that forward the token to a downstream API, prefer
263
- * `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
264
- * the proxy never sends a credential it already knows is invalid.
265
- */
266
- export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
267
- const sessionData = await getSessionData(request);
268
- if (!sessionData) {
269
- return { success: false, error: 'NO_SESSION', terminal: true };
270
- }
271
-
272
- const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
273
- if (!accessToken) {
274
- return { success: false, error: 'NO_TOKEN', terminal: true };
275
- }
276
-
277
- return {
278
- success: true,
279
- accessToken,
280
- sessionData,
281
- };
282
- }
283
-
284
- export type FreshIdpTokenResult =
285
- | { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
286
- | {
287
- success: false;
288
- error: string;
289
- status: number;
290
- terminal?: boolean;
291
- discardToken?: boolean;
292
- retryable?: boolean;
293
- resolution?: string;
294
- };
295
-
296
- export interface FreshIdpTokenConfig {
297
- idpBaseUrl: string;
298
- clientId: string;
299
- refreshEndpoint?: string;
300
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
301
- safetyWindowMs?: number;
302
- }
303
-
304
- /**
305
- * Get the current request's IDP access token, preflight-refreshing if it is
306
- * expired or within the safety window. Single-flight via Redis lock, so
307
- * concurrent calls on the same session share one IDP round-trip and one
308
- * single-use refresh-token consumption.
309
- *
310
- * Use this in proxy routes. The returned `accessToken` is safe to forward to
311
- * a downstream API without expecting a 401. If `success` is false, surface a
312
- * 401/redirect — there is no recoverable token for this session.
313
- */
314
- export async function getFreshIdpToken(
315
- request: Request | undefined,
316
- config: FreshIdpTokenConfig
317
- ): Promise<FreshIdpTokenResult> {
318
- const session = await getSession(request);
319
- const sessionToken = session?.session?.token as string | undefined;
320
- if (!sessionToken) {
321
- return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
322
- }
323
-
324
- const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
325
- const result = await ensureFreshAccessToken(
326
- sessionToken,
327
- {
328
- idpBaseUrl: config.idpBaseUrl,
329
- clientId: config.clientId,
330
- refreshEndpoint: config.refreshEndpoint,
331
- },
332
- {
333
- safetyWindowMs: config.safetyWindowMs,
334
- requestId: request?.headers?.get('x-request-id') ?? undefined,
335
- }
336
- );
337
-
338
- if (!result.ok) {
339
- return {
340
- success: false,
341
- error: result.code,
342
- status: result.status,
343
- terminal: result.terminal,
344
- discardToken: result.discardToken,
345
- retryable: result.retryable,
346
- resolution: result.resolution,
347
- };
348
- }
349
-
350
- const sessionData = await getRedisSession(sessionToken);
351
- if (!sessionData) {
352
- return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
353
- }
354
-
355
- return {
356
- success: true,
357
- accessToken: result.accessToken,
358
- sessionData,
359
- refreshed: result.refreshed,
360
- };
361
- }
362
-
363
- /**
364
- * Get the current session, throwing if not authenticated.
365
- * Use in API handlers that require auth.
366
- */
367
- export async function requireSession(request: Request) {
368
- const session = await getSession(request);
369
- if (!session) {
370
- throw new Error('Unauthorized');
371
- }
372
- return session;
373
- }
1
+ /**
2
+ * Server-side auth utilities for Better Auth.
3
+ *
4
+ * All server-side auth flows go through the Better Auth instance returned by
5
+ * getAuthInstance(); use getSession(req) for the request-scoped session.
6
+ */
7
+
8
+ import 'server-only';
9
+ import { createBetterAuthInstance } from '../auth/better-auth';
10
+ import { getIDPClientConfig } from '../lib/idp-client-config';
11
+ import {
12
+ getSession as getRedisSession,
13
+ getBetterAuthSession as getBetterAuthRedisSession,
14
+ type SessionData,
15
+ } from '../lib/session-store';
16
+ import { getSessionCookieName } from '../lib/app-slug';
17
+
18
+ let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
19
+ let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
20
+
21
+ export type IdpTokenResult =
22
+ | { success: true; accessToken: string; sessionData: SessionData }
23
+ | { success: false; error: 'NO_SESSION' | 'NO_TOKEN'; terminal: true };
24
+
25
+ function buildSessionDataFromAuthSession(session: any): SessionData | null {
26
+ const user = session?.user;
27
+ if (!user?.id && !user?.email) {
28
+ return null;
29
+ }
30
+
31
+ const expiresAt = session?.session?.expiresAt
32
+ ? new Date(session.session.expiresAt).getTime()
33
+ : Date.now() + 24 * 60 * 60 * 1000;
34
+
35
+ return {
36
+ userId: user.userId || user.id || '',
37
+ email: user.email || '',
38
+ name: user.name || undefined,
39
+ image: user.image || undefined,
40
+ roles: Array.isArray(user.roles) ? user.roles : [],
41
+ idpAccessToken: user.idpAccessToken,
42
+ idpRefreshToken: user.idpRefreshToken,
43
+ idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
44
+ mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
45
+ oauthProvider: user.oauthProvider,
46
+ idpClientId: user.idpClientId,
47
+ merchantId: user.merchantId,
48
+ };
49
+ }
50
+
51
+ function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
52
+ if (!sessionData) {
53
+ return session;
54
+ }
55
+
56
+ const enrichedSessionData = {
57
+ ...sessionData,
58
+ ...(sessionToken ? { sessionToken } : {}),
59
+ };
60
+
61
+ (session as any).sessionData = enrichedSessionData;
62
+
63
+ if (session?.user) {
64
+ const user = session.user as any;
65
+ user.userId = enrichedSessionData.userId || user.userId;
66
+ user.email = enrichedSessionData.email || user.email;
67
+ user.name = enrichedSessionData.name || user.name;
68
+ user.image = enrichedSessionData.image || user.image;
69
+ user.roles = enrichedSessionData.roles || user.roles || [];
70
+ user.idpAccessToken = enrichedSessionData.idpAccessToken;
71
+ user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
72
+ user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
73
+ user.mfaVerified = enrichedSessionData.mfaVerified;
74
+ user.twoFactorSessionVerified =
75
+ enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
76
+ user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
77
+ user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
78
+ user.merchantId = enrichedSessionData.merchantId || user.merchantId;
79
+ }
80
+
81
+ return session;
82
+ }
83
+
84
+ /**
85
+ * Get the initialized Better Auth instance (singleton).
86
+ */
87
+ export async function getAuthInstance() {
88
+ if (authInstance) return authInstance;
89
+ if (!authInitPromise) {
90
+ authInitPromise = getIDPClientConfig(true).then(config => {
91
+ authInstance = createBetterAuthInstance(config);
92
+ return authInstance;
93
+ });
94
+ }
95
+ return authInitPromise;
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
+
162
+ /**
163
+ * Get the current session from a request.
164
+ *
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.
180
+ */
181
+ export async function getSession(request?: Request): Promise<any> {
182
+ const auth = await getAuthInstance();
183
+ if (!request) return null;
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;
188
+ try {
189
+ betterAuthSession = await auth.api.getSession({ headers: request.headers });
190
+ } catch { /* fall through to canonical Redis path */ }
191
+
192
+ if (betterAuthSession?.session?.token && betterAuthSession?.user) {
193
+ const sessionToken = betterAuthSession.session.token as string;
194
+ let sessionData: SessionData | null = null;
195
+ try {
196
+ sessionData = await getRedisSession(sessionToken);
197
+ if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
198
+ } catch { /* Redis unavailable */ }
199
+ if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
200
+ return attachSessionData(betterAuthSession, sessionData, sessionToken);
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);
233
+ }
234
+
235
+ /**
236
+ * Get normalized session data for the current request.
237
+ *
238
+ * This prefers the app's Redis session because it carries the canonical
239
+ * IDP token, roles, and tenant-specific user identity used by app routes.
240
+ */
241
+ export async function getSessionData(request?: Request): Promise<SessionData | null> {
242
+ const session = await getSession(request);
243
+ const sessionData =
244
+ ((session as any)?.sessionData as SessionData | undefined) ||
245
+ buildSessionDataFromAuthSession(session);
246
+
247
+ if (!sessionData) {
248
+ return null;
249
+ }
250
+
251
+ const sessionToken = session?.session?.token as string | undefined;
252
+ return sessionToken
253
+ ? { ...sessionData, sessionToken }
254
+ : sessionData;
255
+ }
256
+
257
+ /**
258
+ * Get the current request's IDP access token without triggering a refresh.
259
+ *
260
+ * Use this for routes that only need the currently-issued bearer token and
261
+ * should fail closed instead of performing token lifecycle work. For backend
262
+ * proxy routes that forward the token to a downstream API, prefer
263
+ * `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
264
+ * the proxy never sends a credential it already knows is invalid.
265
+ */
266
+ export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
267
+ const sessionData = await getSessionData(request);
268
+ if (!sessionData) {
269
+ return { success: false, error: 'NO_SESSION', terminal: true };
270
+ }
271
+
272
+ const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
273
+ if (!accessToken) {
274
+ return { success: false, error: 'NO_TOKEN', terminal: true };
275
+ }
276
+
277
+ return {
278
+ success: true,
279
+ accessToken,
280
+ sessionData,
281
+ };
282
+ }
283
+
284
+ export type FreshIdpTokenResult =
285
+ | { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
286
+ | {
287
+ success: false;
288
+ error: string;
289
+ status: number;
290
+ terminal?: boolean;
291
+ discardToken?: boolean;
292
+ retryable?: boolean;
293
+ resolution?: string;
294
+ };
295
+
296
+ export interface FreshIdpTokenConfig {
297
+ idpBaseUrl: string;
298
+ clientId: string;
299
+ refreshEndpoint?: string;
300
+ /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
301
+ safetyWindowMs?: number;
302
+ }
303
+
304
+ /**
305
+ * Get the current request's IDP access token, preflight-refreshing if it is
306
+ * expired or within the safety window. Single-flight via Redis lock, so
307
+ * concurrent calls on the same session share one IDP round-trip and one
308
+ * single-use refresh-token consumption.
309
+ *
310
+ * Use this in proxy routes. The returned `accessToken` is safe to forward to
311
+ * a downstream API without expecting a 401. If `success` is false, surface a
312
+ * 401/redirect — there is no recoverable token for this session.
313
+ */
314
+ export async function getFreshIdpToken(
315
+ request: Request | undefined,
316
+ config: FreshIdpTokenConfig
317
+ ): Promise<FreshIdpTokenResult> {
318
+ const session = await getSession(request);
319
+ const sessionToken = session?.session?.token as string | undefined;
320
+ if (!sessionToken) {
321
+ return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
322
+ }
323
+
324
+ const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
325
+ const result = await ensureFreshAccessToken(
326
+ sessionToken,
327
+ {
328
+ idpBaseUrl: config.idpBaseUrl,
329
+ clientId: config.clientId,
330
+ refreshEndpoint: config.refreshEndpoint,
331
+ },
332
+ {
333
+ safetyWindowMs: config.safetyWindowMs,
334
+ requestId: request?.headers?.get('x-request-id') ?? undefined,
335
+ }
336
+ );
337
+
338
+ if (!result.ok) {
339
+ return {
340
+ success: false,
341
+ error: result.code,
342
+ status: result.status,
343
+ terminal: result.terminal,
344
+ discardToken: result.discardToken,
345
+ retryable: result.retryable,
346
+ resolution: result.resolution,
347
+ };
348
+ }
349
+
350
+ const sessionData = await getRedisSession(sessionToken);
351
+ if (!sessionData) {
352
+ return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
353
+ }
354
+
355
+ return {
356
+ success: true,
357
+ accessToken: result.accessToken,
358
+ sessionData,
359
+ refreshed: result.refreshed,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Get the current session, throwing if not authenticated.
365
+ * Use in API handlers that require auth.
366
+ */
367
+ export async function requireSession(request: Request) {
368
+ const session = await getSession(request);
369
+ if (!session) {
370
+ throw new Error('Unauthorized');
371
+ }
372
+ return session;
373
+ }