@payez/next-mvp 4.1.9 → 4.1.11

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 (164) hide show
  1. package/README.md +792 -792
  2. package/dist/api-handlers/auth/refresh.js +38 -15
  3. package/dist/auth/better-auth.js +4 -3
  4. package/dist/components/account/MobileNavDrawer.js +18 -18
  5. package/dist/components/account/UserAvatarMenu.js +1 -1
  6. package/dist/components/admin/AlertSettingsTab.js +40 -40
  7. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  8. package/dist/lib/ensure-fresh-access-token.js +42 -8
  9. package/dist/lib/idp-fetch.js +25 -9
  10. package/dist/lib/session-store.d.ts +1 -0
  11. package/dist/lib/session-store.js +12 -8
  12. package/dist/lib/token-lifecycle.d.ts +2 -0
  13. package/dist/lib/token-lifecycle.js +21 -3
  14. package/dist/pages/login/page.js +28 -28
  15. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  16. package/package.json +893 -893
  17. package/scripts/dev-broker.ps1 +35 -35
  18. package/scripts/dev-local.ps1 +45 -45
  19. package/src/api/auth-handler.ts +548 -548
  20. package/src/api-handlers/account/change-password.ts +142 -142
  21. package/src/api-handlers/account/update-phone.ts +79 -79
  22. package/src/api-handlers/account/verify-email.ts +125 -125
  23. package/src/api-handlers/account/verify-sms.ts +125 -125
  24. package/src/api-handlers/admin/analytics.ts +443 -443
  25. package/src/api-handlers/admin/audit.ts +223 -223
  26. package/src/api-handlers/admin/index.ts +63 -63
  27. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  28. package/src/api-handlers/admin/sessions.ts +318 -318
  29. package/src/api-handlers/admin/site-logs.ts +365 -365
  30. package/src/api-handlers/admin/users.ts +242 -242
  31. package/src/api-handlers/admin/vibe-data.ts +324 -324
  32. package/src/api-handlers/auth/refresh.ts +705 -682
  33. package/src/api-handlers/auth/signout.ts +199 -199
  34. package/src/api-handlers/auth/status.ts +20 -20
  35. package/src/api-handlers/auth/update-session.ts +110 -110
  36. package/src/api-handlers/auth/verify-code.ts +112 -112
  37. package/src/api-handlers/session/refresh-viability.ts +36 -36
  38. package/src/api-handlers/test/force-expire.ts +60 -60
  39. package/src/auth/auth-decision.ts +229 -229
  40. package/src/auth/better-auth.ts +467 -466
  41. package/src/auth/route-config.ts +219 -219
  42. package/src/auth/types/auth-types.ts +555 -555
  43. package/src/auth/types/index.ts +7 -7
  44. package/src/auth/utils/idp-client.ts +445 -445
  45. package/src/auth/utils/index.ts +6 -6
  46. package/src/auth/utils/token-utils.ts +243 -243
  47. package/src/client/AuthContext.tsx +143 -143
  48. package/src/client/fetch-with-auth.ts +47 -47
  49. package/src/components/SessionSync.tsx +138 -138
  50. package/src/components/SignalRHealthCheck.tsx +131 -131
  51. package/src/components/account/MobileNavDrawer.tsx +305 -305
  52. package/src/components/account/UserAvatarMenu.tsx +266 -266
  53. package/src/components/account/index.ts +13 -13
  54. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  55. package/src/components/admin/AnalyticsTab.tsx +703 -703
  56. package/src/components/admin/DataBrowserTab.tsx +505 -505
  57. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  58. package/src/components/admin/SessionsTab.tsx +414 -414
  59. package/src/components/admin/StatsTab.tsx +379 -379
  60. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  61. package/src/components/admin/index.ts +59 -59
  62. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  63. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  64. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  65. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  66. package/src/components/recovery/CompleteStep.tsx +36 -36
  67. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  68. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  69. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  70. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  71. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  72. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  73. package/src/components/ui/BetaBadge.tsx +58 -58
  74. package/src/components/ui/Footer.tsx +93 -93
  75. package/src/config/env.ts +57 -57
  76. package/src/config/logger.ts +62 -62
  77. package/src/config/vibe-log-transport.ts +264 -264
  78. package/src/hooks/useAuth.ts +117 -117
  79. package/src/hooks/useAuthSettings.ts +97 -97
  80. package/src/hooks/useAvailableProviders.ts +120 -120
  81. package/src/hooks/usePasswordValidation.ts +127 -127
  82. package/src/hooks/usePublicAuthSettings.ts +149 -149
  83. package/src/hooks/useSessionExpiration.ts +101 -101
  84. package/src/hooks/useViabilitySession.ts +336 -336
  85. package/src/index.ts +60 -60
  86. package/src/lib/api-handler.ts +643 -643
  87. package/src/lib/app-slug.ts +178 -178
  88. package/src/lib/ensure-fresh-access-token.ts +369 -335
  89. package/src/lib/geolocation.ts +265 -265
  90. package/src/lib/idp-fetch.ts +26 -10
  91. package/src/lib/jwt-decode.ts +82 -82
  92. package/src/lib/roles.ts +177 -177
  93. package/src/lib/session-store.ts +4 -1
  94. package/src/lib/site-logger.ts +245 -245
  95. package/src/lib/standardized-client-api.ts +901 -901
  96. package/src/lib/test-aware-get-token.ts +41 -41
  97. package/src/lib/token-lifecycle.ts +30 -3
  98. package/src/lib/user-agent-parser.ts +252 -252
  99. package/src/logging/api/admin-analytics.ts +51 -51
  100. package/src/logging/api/audit-log.ts +53 -53
  101. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  102. package/src/logging/components/AuditLogViewer.tsx +125 -125
  103. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  104. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  105. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  106. package/src/logging/hooks/useAuditLog.ts +24 -24
  107. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  108. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  109. package/src/logging/index.ts +18 -18
  110. package/src/logging/types/analytics.ts +81 -81
  111. package/src/logging/types/audit.ts +31 -31
  112. package/src/logging/types/index.ts +3 -3
  113. package/src/middleware/create-middleware.ts +663 -663
  114. package/src/middleware/rbac-check.ts +279 -279
  115. package/src/middleware/twofa-presets.ts +224 -224
  116. package/src/pages/admin-login/page.tsx +221 -221
  117. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  118. package/src/pages/admin-page-permissions/index.ts +7 -7
  119. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  120. package/src/pages/admin-roles/modals.tsx +469 -469
  121. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  122. package/src/pages/client-admin/index.ts +33 -33
  123. package/src/pages/coming-soon/page.tsx +83 -83
  124. package/src/pages/login/page.tsx +456 -456
  125. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  126. package/src/pages/profile/index.ts +9 -9
  127. package/src/pages/profile/page.tsx +166 -166
  128. package/src/pages/recovery/page.tsx +234 -234
  129. package/src/pages/roles/MyRolesPage.tsx +211 -211
  130. package/src/pages/roles/components.tsx +294 -294
  131. package/src/pages/roles/index.ts +17 -17
  132. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  133. package/src/pages/security/index.ts +9 -9
  134. package/src/pages/security/page.tsx +507 -507
  135. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  136. package/src/pages/settings/index.ts +9 -9
  137. package/src/pages/settings/page.tsx +47 -47
  138. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  139. package/src/pages/showcase/index.ts +13 -13
  140. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  141. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  142. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  143. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  144. package/src/pages/test-env/index.ts +25 -25
  145. package/src/pages/verify-code/page.tsx +652 -652
  146. package/src/routes/account/update-phone.ts +13 -13
  147. package/src/routes/auth/logout.ts +108 -108
  148. package/src/routes/auth/nextauth.ts +45 -45
  149. package/src/routes/auth/settings.ts +75 -75
  150. package/src/routes/auth/viability.ts +224 -224
  151. package/src/server/auth-guard.ts +170 -170
  152. package/src/server/slim-middleware.ts +117 -117
  153. package/src/server/with-auth.ts +93 -93
  154. package/src/stores/authStore.ts +1899 -1899
  155. package/src/templates/instrumentation.ts +41 -41
  156. package/src/utils/logout.ts +30 -30
  157. package/src/vibe/client.ts +590 -590
  158. package/src/vibe/enterprise-auth.ts +208 -208
  159. package/src/vibe/errors.ts +185 -185
  160. package/src/vibe/generic.ts +431 -431
  161. package/src/vibe/hooks/index.ts +367 -367
  162. package/src/vibe/index.ts +132 -132
  163. package/src/vibe/sessions.ts +551 -551
  164. package/src/vibe/types.ts +577 -577
@@ -1,466 +1,467 @@
1
- /**
2
- * Better Auth Configuration
3
- *
4
- * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
- *
6
- * Architecture: No database adapter — Better Auth runs in stateless mode
7
- * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
- *
9
- * @see BETTER-AUTH-MIGRATION-SPEC.md
10
- */
11
-
12
- import 'server-only';
13
- import { betterAuth } from 'better-auth';
14
- import { nextCookies } from 'better-auth/next-js';
15
- import { toNextJsHandler } from 'better-auth/next-js';
16
- import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
17
- import type { IDPClientConfig } from '../lib/idp-client-config';
18
- import { getIDPClientConfig } from '../lib/idp-client-config';
19
- import { getAppSlug } from '../lib/app-slug';
20
- import { getRedis } from '../lib/redis';
21
-
22
- /**
23
- * Better Auth social provider config shape.
24
- */
25
- export interface BetterAuthSocialProvider {
26
- clientId: string;
27
- clientSecret: string;
28
- scope?: string[];
29
- prompt?: string;
30
- accessType?: 'offline' | 'online';
31
- hd?: string;
32
- }
33
-
34
- /**
35
- * Build Better Auth social providers from IDP config.
36
- */
37
- export function buildBetterAuthProviders(
38
- config: IDPClientConfig
39
- ): Record<string, BetterAuthSocialProvider> {
40
- const providers: Record<string, BetterAuthSocialProvider> = {};
41
-
42
- for (const oauth of config.oauthProviders || []) {
43
- if (!oauth.enabled) continue;
44
- const name = oauth.provider.toLowerCase();
45
- const additionalParams = oauth.additionalParams ?? {};
46
- const rawPrompt = additionalParams.prompt;
47
- const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
48
- const rawHostedDomain = additionalParams.hd;
49
-
50
- // Ensure profile scope is present for Google so avatar image is returned
51
- const scopes = oauth.scopes?.split(' ') || [];
52
- if (name === 'google' && !scopes.includes('profile')) {
53
- scopes.push('profile');
54
- }
55
-
56
- providers[name] = {
57
- clientId: oauth.clientId,
58
- clientSecret: oauth.clientSecret,
59
- scope: scopes.length > 0 ? scopes : undefined,
60
- // Google is overly eager to reuse the last account unless we
61
- // explicitly ask for account selection on each social login.
62
- prompt: typeof rawPrompt === 'string'
63
- ? rawPrompt
64
- : name === 'google'
65
- ? 'select_account'
66
- : undefined,
67
- accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
68
- hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
69
- };
70
- }
71
-
72
- return providers;
73
- }
74
-
75
- /**
76
- * Optional configuration for `createBetterAuthInstance`.
77
- *
78
- * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
79
- * The host app supplies its own `sendMagicLink` callback — typically a
80
- * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
81
- * Omit the `magicLink` key entirely to skip the plugin; the consuming
82
- * app will not have a magic-link flow.
83
- */
84
- export interface CreateBetterAuthInstanceOptions {
85
- magicLink?: MagicLinkOptions;
86
- }
87
-
88
- /**
89
- * Create Better Auth instance from IDP config.
90
- *
91
- * No database — runs in stateless mode with JWE cookie cache.
92
- * Call after getIDPClientConfig() resolves.
93
- */
94
- export function createBetterAuthInstance(
95
- idpConfig: IDPClientConfig,
96
- opts: CreateBetterAuthInstanceOptions = {}
97
- ) {
98
- const appSlug = idpConfig.clientSlug || getAppSlug();
99
-
100
- // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
101
- // Must include /api/auth since that's where the catch-all route is mounted
102
- const rawBaseURL = process.env.BETTER_AUTH_URL
103
- || idpConfig.baseClientUrl
104
- || `http://localhost:${process.env.PORT || '3000'}`;
105
- const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
106
-
107
- return betterAuth({
108
- baseURL,
109
- secret: idpConfig.authSecret as string,
110
-
111
- socialProviders: buildBetterAuthProviders(idpConfig),
112
-
113
- // Trust the app's own origin + any configured base URL
114
- trustedOrigins: [
115
- rawBaseURL,
116
- baseURL,
117
- ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
118
- 'http://localhost:3000',
119
- 'http://localhost:3400',
120
- 'http://localhost:3600',
121
- ],
122
-
123
- // Redis-backed session storage via secondaryStorage
124
- secondaryStorage: {
125
- get: async (key: string) => {
126
- try {
127
- return await getRedis().get(`ba:${appSlug}:${key}`);
128
- } catch { return null; }
129
- },
130
- set: async (key: string, value: string, ttl?: number) => {
131
- try {
132
- const redis = getRedis();
133
- if (ttl) {
134
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
135
- } else {
136
- await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
137
- }
138
- } catch { /* Redis unavailable — cookie cache still works */ }
139
- },
140
- delete: async (key: string) => {
141
- try {
142
- await getRedis().del(`ba:${appSlug}:${key}`);
143
- } catch { /* ignore */ }
144
- },
145
- },
146
-
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.
165
- cookieCache: {
166
- enabled: false,
167
- maxAge: 300,
168
- refreshCache: false,
169
- },
170
- },
171
-
172
- // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
173
- advanced: {
174
- cookiePrefix: appSlug,
175
- cookies: {
176
- session_token: {
177
- name: `${appSlug}.session-token`,
178
- },
179
- },
180
- },
181
-
182
- plugins: [
183
- nextCookies(),
184
- ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
185
- ],
186
- });
187
- }
188
-
189
- /**
190
- * Better Auth is always enabled (NextAuth removed in 4.0).
191
- */
192
- export function isBetterAuthEnabled(): boolean {
193
- return true;
194
- }
195
-
196
- /**
197
- * Get Better Auth Next.js route handlers (GET, POST).
198
- * Initializes Better Auth from IDP config on first call, caches the instance.
199
- */
200
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
201
- let cachedInstance: any = null;
202
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
- let initPromise: Promise<any> | null = null;
204
- let configuredOpts: CreateBetterAuthInstanceOptions = {};
205
-
206
- // Expose for server-side session access (decode-session.ts)
207
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
- export { cachedInstance as __betterAuthInstance };
209
-
210
- /**
211
- * Configure Better Auth instance options for this process.
212
- *
213
- * Must be called before the first auth request — before
214
- * `getBetterAuthInstance()` caches an instance. Typically called once at
215
- * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
216
- * server bootstrap hook.
217
- *
218
- * Throws if called after the instance has already been resolved: options
219
- * cannot be applied retroactively.
220
- */
221
- export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
222
- if (cachedInstance) {
223
- throw new Error(
224
- '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
225
- 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
226
- );
227
- }
228
- configuredOpts = opts;
229
- }
230
-
231
- export async function getBetterAuthInstance() {
232
- if (cachedInstance) return cachedInstance;
233
-
234
- if (!initPromise) {
235
- initPromise = getIDPClientConfig(true).then(config => {
236
- const instance = createBetterAuthInstance(config, configuredOpts);
237
- cachedInstance = instance;
238
- console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
239
- return instance;
240
- });
241
- }
242
-
243
- return initPromise;
244
- }
245
-
246
- /**
247
- * Get flag-gated auth handler for Next.js route.
248
- *
249
- * When USE_BETTER_AUTH=true, returns Better Auth handlers.
250
- * Otherwise returns null (auth disabled).
251
- *
252
- * Usage in host app route:
253
- * ```ts
254
- * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
255
- *
256
- * export async function GET(req: Request) {
257
- * const ba = await getBetterAuthHandler();
258
- * if (ba) return ba.GET(req);
259
- * }
260
- * ```
261
- */
262
- export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
263
- if (!isBetterAuthEnabled()) return null;
264
-
265
- const auth = await getBetterAuthInstance();
266
- return toNextJsHandler(auth);
267
- }
268
-
269
- /**
270
- * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
271
- *
272
- * Call this from the OAuth callback route AFTER better-auth has processed the
273
- * callback and created the session. Reads the session token from the Set-Cookie
274
- * header of the response to find the BA Redis key.
275
- *
276
- * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
277
- */
278
- export async function exchangeOAuthForIdpTokens(
279
- sessionToken: string,
280
- provider: string = 'google'
281
- ): Promise<boolean> {
282
- try {
283
- const config = await getIDPClientConfig();
284
- const appSlug = config.clientSlug || getAppSlug();
285
- const baKey = `ba:${appSlug}:${sessionToken}`;
286
-
287
- // Read the BA session from Redis
288
- const baRaw = await getRedis().get(baKey).catch(() => null);
289
- if (!baRaw) {
290
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
291
- return false;
292
- }
293
-
294
- const baData = JSON.parse(baRaw);
295
- const email = baData?.user?.email;
296
- const name = baData?.user?.name;
297
- const image = baData?.user?.image;
298
- const baUserId = baData?.session?.userId || baData?.user?.id;
299
-
300
- if (!email) {
301
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
302
- return false;
303
- }
304
-
305
- // Call IDP oauth-callback
306
- const idpUrl = process.env.IDP_URL || '';
307
- if (!idpUrl) {
308
- console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
309
- return false;
310
- }
311
-
312
- console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
313
-
314
- const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
315
- method: 'POST',
316
- headers: { 'Content-Type': 'application/json' },
317
- body: JSON.stringify({
318
- provider,
319
- provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
320
- email,
321
- name,
322
- image,
323
- client_id: config.clientSlug || String(config.clientId),
324
- }),
325
- });
326
-
327
- const oauthResText = await oauthRes.text();
328
- console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
329
-
330
- if (!oauthRes.ok) {
331
- console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
332
- return false;
333
- }
334
-
335
- let idpData: any;
336
- try { idpData = JSON.parse(oauthResText); } catch { return false; }
337
- const result = idpData?.data?.result || idpData?.data || idpData;
338
-
339
- if (!result?.access_token) {
340
- console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
341
- return false;
342
- }
343
-
344
- // Build IDP token data
345
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
346
- // !hasRefreshToken was a reliable "2FA required" signal.
347
- //
348
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
349
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
350
- // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
351
- // bypassing the 2FA gate entirely.
352
- //
353
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
354
- // The tenant's require_2fa setting is enforced by the viability endpoint
355
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
356
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
357
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
358
- // IDP correctly withholds refresh_token until post-2FA.
359
- const hasRefreshToken = !!result.refresh_token;
360
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
361
- const idpTokenData = {
362
- idpAccessToken: result.access_token,
363
- idpRefreshToken: result.refresh_token,
364
- idpAccessTokenExpires: result.expires_in
365
- ? Date.now() + result.expires_in * 1000
366
- : Date.now() + 15 * 60 * 1000,
367
- userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
368
- email: result.user?.email || result.email || email,
369
- name: result.user?.full_name || result.user?.name || result.name || name,
370
- image: image,
371
- roles: result.user?.roles || result.roles || [],
372
- mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
373
- idpClientId: result.client_id ? String(result.client_id) : undefined,
374
- merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
375
- };
376
-
377
- // Store in BA Redis session (for decodeSession)
378
- baData.idpTokens = idpTokenData;
379
- await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
380
-
381
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
382
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
383
- try {
384
- const { getSessionPrefix } = await import('../lib/app-slug');
385
- const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
386
- await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
387
- ...idpTokenData,
388
- oauthProvider: provider,
389
- }));
390
- } catch (canonicalErr) {
391
- console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
392
- }
393
-
394
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
395
- return true;
396
- } catch (err) {
397
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
398
- return false;
399
- }
400
- }
401
-
402
- /**
403
- * Create a production-ready GET handler for the auth catch-all route.
404
- *
405
- * Wraps better-auth's GET handler with:
406
- * - OAuth state error recovery (redirects to login instead of error page)
407
- * - IDP token exchange after successful OAuth callback
408
- *
409
- * Usage in host app:
410
- * ```ts
411
- * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
412
- * export const GET = createAuthGetHandler('/account-auth/login');
413
- * export async function POST(req: Request) {
414
- * const ba = await getBetterAuthHandler();
415
- * return ba!.POST(req);
416
- * }
417
- * ```
418
- */
419
- export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
420
- return async function GET(request: Request): Promise<Response> {
421
- const ba = await getBetterAuthHandler();
422
- if (!ba) {
423
- return new Response('Auth handler not configured', { status: 500 });
424
- }
425
-
426
- const response = await ba.GET(request);
427
-
428
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
429
- if (response.status === 302) {
430
- const location = response.headers.get('location') || '';
431
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
432
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
433
- return Response.redirect(new URL(loginPath, request.url), 302);
434
- }
435
- }
436
-
437
- // After successful OAuth callback: exchange Google identity for IDP tokens
438
- const url = new URL(request.url);
439
- if (url.pathname.includes('/callback/') && response.status === 302) {
440
- try {
441
- const auth = await getBetterAuthInstance();
442
- if (auth?.api?.getSession) {
443
- const setCookies = response.headers.getSetCookie?.() || [];
444
- const cookieHeader = setCookies
445
- .map((c: string) => c.split(';')[0])
446
- .join('; ');
447
-
448
- const headers = new Headers();
449
- headers.set('cookie', cookieHeader);
450
-
451
- const session = await auth.api.getSession({ headers });
452
- if (session?.session?.token) {
453
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
454
- await exchangeOAuthForIdpTokens(session.session.token);
455
- } else {
456
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
457
- }
458
- }
459
- } catch (err: any) {
460
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
461
- }
462
- }
463
-
464
- return response;
465
- };
466
- }
1
+ /**
2
+ * Better Auth Configuration
3
+ *
4
+ * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
+ *
6
+ * Architecture: No database adapter — Better Auth runs in stateless mode
7
+ * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
+ *
9
+ * @see BETTER-AUTH-MIGRATION-SPEC.md
10
+ */
11
+
12
+ import 'server-only';
13
+ import { betterAuth } from 'better-auth';
14
+ import { nextCookies } from 'better-auth/next-js';
15
+ import { toNextJsHandler } from 'better-auth/next-js';
16
+ import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
17
+ import type { IDPClientConfig } from '../lib/idp-client-config';
18
+ import { getIDPClientConfig } from '../lib/idp-client-config';
19
+ import { getAppSlug } from '../lib/app-slug';
20
+ import { getRedis } from '../lib/redis';
21
+ import { SESSION_TTL } from '../lib/session-store';
22
+
23
+ /**
24
+ * Better Auth social provider config shape.
25
+ */
26
+ export interface BetterAuthSocialProvider {
27
+ clientId: string;
28
+ clientSecret: string;
29
+ scope?: string[];
30
+ prompt?: string;
31
+ accessType?: 'offline' | 'online';
32
+ hd?: string;
33
+ }
34
+
35
+ /**
36
+ * Build Better Auth social providers from IDP config.
37
+ */
38
+ export function buildBetterAuthProviders(
39
+ config: IDPClientConfig
40
+ ): Record<string, BetterAuthSocialProvider> {
41
+ const providers: Record<string, BetterAuthSocialProvider> = {};
42
+
43
+ for (const oauth of config.oauthProviders || []) {
44
+ if (!oauth.enabled) continue;
45
+ const name = oauth.provider.toLowerCase();
46
+ const additionalParams = oauth.additionalParams ?? {};
47
+ const rawPrompt = additionalParams.prompt;
48
+ const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
49
+ const rawHostedDomain = additionalParams.hd;
50
+
51
+ // Ensure profile scope is present for Google so avatar image is returned
52
+ const scopes = oauth.scopes?.split(' ') || [];
53
+ if (name === 'google' && !scopes.includes('profile')) {
54
+ scopes.push('profile');
55
+ }
56
+
57
+ providers[name] = {
58
+ clientId: oauth.clientId,
59
+ clientSecret: oauth.clientSecret,
60
+ scope: scopes.length > 0 ? scopes : undefined,
61
+ // Google is overly eager to reuse the last account unless we
62
+ // explicitly ask for account selection on each social login.
63
+ prompt: typeof rawPrompt === 'string'
64
+ ? rawPrompt
65
+ : name === 'google'
66
+ ? 'select_account'
67
+ : undefined,
68
+ accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
69
+ hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
70
+ };
71
+ }
72
+
73
+ return providers;
74
+ }
75
+
76
+ /**
77
+ * Optional configuration for `createBetterAuthInstance`.
78
+ *
79
+ * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
80
+ * The host app supplies its own `sendMagicLink` callback — typically a
81
+ * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
82
+ * Omit the `magicLink` key entirely to skip the plugin; the consuming
83
+ * app will not have a magic-link flow.
84
+ */
85
+ export interface CreateBetterAuthInstanceOptions {
86
+ magicLink?: MagicLinkOptions;
87
+ }
88
+
89
+ /**
90
+ * Create Better Auth instance from IDP config.
91
+ *
92
+ * No database — runs in stateless mode with JWE cookie cache.
93
+ * Call after getIDPClientConfig() resolves.
94
+ */
95
+ export function createBetterAuthInstance(
96
+ idpConfig: IDPClientConfig,
97
+ opts: CreateBetterAuthInstanceOptions = {}
98
+ ) {
99
+ const appSlug = idpConfig.clientSlug || getAppSlug();
100
+
101
+ // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
102
+ // Must include /api/auth since that's where the catch-all route is mounted
103
+ const rawBaseURL = process.env.BETTER_AUTH_URL
104
+ || idpConfig.baseClientUrl
105
+ || `http://localhost:${process.env.PORT || '3000'}`;
106
+ const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
107
+
108
+ return betterAuth({
109
+ baseURL,
110
+ secret: idpConfig.authSecret as string,
111
+
112
+ socialProviders: buildBetterAuthProviders(idpConfig),
113
+
114
+ // Trust the app's own origin + any configured base URL
115
+ trustedOrigins: [
116
+ rawBaseURL,
117
+ baseURL,
118
+ ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
119
+ 'http://localhost:3000',
120
+ 'http://localhost:3400',
121
+ 'http://localhost:3600',
122
+ ],
123
+
124
+ // Redis-backed session storage via secondaryStorage
125
+ secondaryStorage: {
126
+ get: async (key: string) => {
127
+ try {
128
+ return await getRedis().get(`ba:${appSlug}:${key}`);
129
+ } catch { return null; }
130
+ },
131
+ set: async (key: string, value: string, ttl?: number) => {
132
+ try {
133
+ const redis = getRedis();
134
+ if (ttl) {
135
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
136
+ } else {
137
+ await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
138
+ }
139
+ } catch { /* Redis unavailable — cookie cache still works */ }
140
+ },
141
+ delete: async (key: string) => {
142
+ try {
143
+ await getRedis().del(`ba:${appSlug}:${key}`);
144
+ } catch { /* ignore */ }
145
+ },
146
+ },
147
+
148
+ session: {
149
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
150
+ //
151
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
152
+ // a decoded session in process memory for 5 minutes, bypassing Redis
153
+ // reads during the window. Consequence: if the canonical app session
154
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
155
+ // Better Auth's in-memory copy stayed alive — and callers got
156
+ // contradictory answers depending on whether they consulted Better
157
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
158
+ // visible in production traces: viability 200 + idp-token 200 +
159
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
160
+ //
161
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
162
+ // read on the secondary-storage path. Latency cost is acceptable
163
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
164
+ // workarounds in every consumer app — already shipped in idealvibe.online
165
+ // at b6a91f6.
166
+ cookieCache: {
167
+ enabled: false,
168
+ maxAge: 300,
169
+ refreshCache: false,
170
+ },
171
+ },
172
+
173
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
174
+ advanced: {
175
+ cookiePrefix: appSlug,
176
+ cookies: {
177
+ session_token: {
178
+ name: `${appSlug}.session-token`,
179
+ },
180
+ },
181
+ },
182
+
183
+ plugins: [
184
+ nextCookies(),
185
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
186
+ ],
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Better Auth is always enabled (NextAuth removed in 4.0).
192
+ */
193
+ export function isBetterAuthEnabled(): boolean {
194
+ return true;
195
+ }
196
+
197
+ /**
198
+ * Get Better Auth Next.js route handlers (GET, POST).
199
+ * Initializes Better Auth from IDP config on first call, caches the instance.
200
+ */
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ let cachedInstance: any = null;
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ let initPromise: Promise<any> | null = null;
205
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
206
+
207
+ // Expose for server-side session access (decode-session.ts)
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ export { cachedInstance as __betterAuthInstance };
210
+
211
+ /**
212
+ * Configure Better Auth instance options for this process.
213
+ *
214
+ * Must be called before the first auth request — before
215
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
216
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
217
+ * server bootstrap hook.
218
+ *
219
+ * Throws if called after the instance has already been resolved: options
220
+ * cannot be applied retroactively.
221
+ */
222
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
223
+ if (cachedInstance) {
224
+ throw new Error(
225
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
226
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
227
+ );
228
+ }
229
+ configuredOpts = opts;
230
+ }
231
+
232
+ export async function getBetterAuthInstance() {
233
+ if (cachedInstance) return cachedInstance;
234
+
235
+ if (!initPromise) {
236
+ initPromise = getIDPClientConfig(true).then(config => {
237
+ const instance = createBetterAuthInstance(config, configuredOpts);
238
+ cachedInstance = instance;
239
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
240
+ return instance;
241
+ });
242
+ }
243
+
244
+ return initPromise;
245
+ }
246
+
247
+ /**
248
+ * Get flag-gated auth handler for Next.js route.
249
+ *
250
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
251
+ * Otherwise returns null (auth disabled).
252
+ *
253
+ * Usage in host app route:
254
+ * ```ts
255
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
256
+ *
257
+ * export async function GET(req: Request) {
258
+ * const ba = await getBetterAuthHandler();
259
+ * if (ba) return ba.GET(req);
260
+ * }
261
+ * ```
262
+ */
263
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
264
+ if (!isBetterAuthEnabled()) return null;
265
+
266
+ const auth = await getBetterAuthInstance();
267
+ return toNextJsHandler(auth);
268
+ }
269
+
270
+ /**
271
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
272
+ *
273
+ * Call this from the OAuth callback route AFTER better-auth has processed the
274
+ * callback and created the session. Reads the session token from the Set-Cookie
275
+ * header of the response to find the BA Redis key.
276
+ *
277
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
278
+ */
279
+ export async function exchangeOAuthForIdpTokens(
280
+ sessionToken: string,
281
+ provider: string = 'google'
282
+ ): Promise<boolean> {
283
+ try {
284
+ const config = await getIDPClientConfig();
285
+ const appSlug = config.clientSlug || getAppSlug();
286
+ const baKey = `ba:${appSlug}:${sessionToken}`;
287
+
288
+ // Read the BA session from Redis
289
+ const baRaw = await getRedis().get(baKey).catch(() => null);
290
+ if (!baRaw) {
291
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
292
+ return false;
293
+ }
294
+
295
+ const baData = JSON.parse(baRaw);
296
+ const email = baData?.user?.email;
297
+ const name = baData?.user?.name;
298
+ const image = baData?.user?.image;
299
+ const baUserId = baData?.session?.userId || baData?.user?.id;
300
+
301
+ if (!email) {
302
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
303
+ return false;
304
+ }
305
+
306
+ // Call IDP oauth-callback
307
+ const idpUrl = process.env.IDP_URL || '';
308
+ if (!idpUrl) {
309
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
310
+ return false;
311
+ }
312
+
313
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
314
+
315
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({
319
+ provider,
320
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
321
+ email,
322
+ name,
323
+ image,
324
+ client_id: config.clientSlug || String(config.clientId),
325
+ }),
326
+ });
327
+
328
+ const oauthResText = await oauthRes.text();
329
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
330
+
331
+ if (!oauthRes.ok) {
332
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
333
+ return false;
334
+ }
335
+
336
+ let idpData: any;
337
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
338
+ const result = idpData?.data?.result || idpData?.data || idpData;
339
+
340
+ if (!result?.access_token) {
341
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
342
+ return false;
343
+ }
344
+
345
+ // Build IDP token data
346
+ // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
347
+ // !hasRefreshToken was a reliable "2FA required" signal.
348
+ //
349
+ // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
350
+ // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
351
+ // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
352
+ // bypassing the 2FA gate entirely.
353
+ //
354
+ // FIX: Always set mfaVerified=false on the initial OAuth exchange.
355
+ // The tenant's require_2fa setting is enforced by the viability endpoint
356
+ // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
357
+ // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
358
+ // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
359
+ // IDP correctly withholds refresh_token until post-2FA.
360
+ const hasRefreshToken = !!result.refresh_token;
361
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
362
+ const idpTokenData = {
363
+ idpAccessToken: result.access_token,
364
+ idpRefreshToken: result.refresh_token,
365
+ idpAccessTokenExpires: result.expires_in
366
+ ? Date.now() + result.expires_in * 1000
367
+ : Date.now() + 15 * 60 * 1000,
368
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
369
+ email: result.user?.email || result.email || email,
370
+ name: result.user?.full_name || result.user?.name || result.name || name,
371
+ image: image,
372
+ roles: result.user?.roles || result.roles || [],
373
+ mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
374
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
375
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
376
+ };
377
+
378
+ // Store in BA Redis session (for decodeSession)
379
+ baData.idpTokens = idpTokenData;
380
+ await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
381
+
382
+ // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
383
+ // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
384
+ try {
385
+ const { getSessionPrefix } = await import('../lib/app-slug');
386
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
387
+ await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
388
+ ...idpTokenData,
389
+ oauthProvider: provider,
390
+ }));
391
+ } catch (canonicalErr) {
392
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
393
+ }
394
+
395
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
396
+ return true;
397
+ } catch (err) {
398
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
399
+ return false;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Create a production-ready GET handler for the auth catch-all route.
405
+ *
406
+ * Wraps better-auth's GET handler with:
407
+ * - OAuth state error recovery (redirects to login instead of error page)
408
+ * - IDP token exchange after successful OAuth callback
409
+ *
410
+ * Usage in host app:
411
+ * ```ts
412
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
413
+ * export const GET = createAuthGetHandler('/account-auth/login');
414
+ * export async function POST(req: Request) {
415
+ * const ba = await getBetterAuthHandler();
416
+ * return ba!.POST(req);
417
+ * }
418
+ * ```
419
+ */
420
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
421
+ return async function GET(request: Request): Promise<Response> {
422
+ const ba = await getBetterAuthHandler();
423
+ if (!ba) {
424
+ return new Response('Auth handler not configured', { status: 500 });
425
+ }
426
+
427
+ const response = await ba.GET(request);
428
+
429
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
430
+ if (response.status === 302) {
431
+ const location = response.headers.get('location') || '';
432
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
433
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
434
+ return Response.redirect(new URL(loginPath, request.url), 302);
435
+ }
436
+ }
437
+
438
+ // After successful OAuth callback: exchange Google identity for IDP tokens
439
+ const url = new URL(request.url);
440
+ if (url.pathname.includes('/callback/') && response.status === 302) {
441
+ try {
442
+ const auth = await getBetterAuthInstance();
443
+ if (auth?.api?.getSession) {
444
+ const setCookies = response.headers.getSetCookie?.() || [];
445
+ const cookieHeader = setCookies
446
+ .map((c: string) => c.split(';')[0])
447
+ .join('; ');
448
+
449
+ const headers = new Headers();
450
+ headers.set('cookie', cookieHeader);
451
+
452
+ const session = await auth.api.getSession({ headers });
453
+ if (session?.session?.token) {
454
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
455
+ await exchangeOAuthForIdpTokens(session.session.token);
456
+ } else {
457
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
458
+ }
459
+ }
460
+ } catch (err: any) {
461
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
462
+ }
463
+ }
464
+
465
+ return response;
466
+ };
467
+ }