@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,467 +1,450 @@
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
- }
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
+ return await getRedis().get(`ba:${appSlug}:${key}`);
127
+ },
128
+ set: async (key: string, value: string, ttl?: number) => {
129
+ const redis = getRedis();
130
+ if (ttl) {
131
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
132
+ } else {
133
+ await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
134
+ }
135
+ },
136
+ delete: async (key: string) => {
137
+ try {
138
+ await getRedis().del(`ba:${appSlug}:${key}`);
139
+ } catch { /* ignore */ }
140
+ },
141
+ },
142
+
143
+ session: {
144
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
145
+ //
146
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
147
+ // a decoded session in process memory for 5 minutes, bypassing Redis
148
+ // reads during the window. Consequence: if the canonical app session
149
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
150
+ // Better Auth's in-memory copy stayed alive — and callers got
151
+ // contradictory answers depending on whether they consulted Better
152
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
153
+ // visible in production traces: viability 200 + idp-token 200 +
154
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
155
+ //
156
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
157
+ // read on the secondary-storage path. Latency cost is acceptable
158
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
159
+ // workarounds in every consumer app — already shipped in idealvibe.online
160
+ // at b6a91f6.
161
+ cookieCache: {
162
+ enabled: false,
163
+ maxAge: 300,
164
+ refreshCache: false,
165
+ },
166
+ },
167
+
168
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
169
+ advanced: {
170
+ cookiePrefix: appSlug,
171
+ cookies: {
172
+ session_token: {
173
+ name: `${appSlug}.session-token`,
174
+ },
175
+ },
176
+ },
177
+
178
+ plugins: [
179
+ nextCookies(),
180
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
181
+ ],
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Better Auth is always enabled (NextAuth removed in 4.0).
187
+ */
188
+ export function isBetterAuthEnabled(): boolean {
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Get Better Auth Next.js route handlers (GET, POST).
194
+ * Initializes Better Auth from IDP config on first call, caches the instance.
195
+ */
196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
197
+ let cachedInstance: any = null;
198
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
+ let initPromise: Promise<any> | null = null;
200
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
201
+
202
+ // Expose for server-side session access (decode-session.ts)
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ export { cachedInstance as __betterAuthInstance };
205
+
206
+ /**
207
+ * Configure Better Auth instance options for this process.
208
+ *
209
+ * Must be called before the first auth request — before
210
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
211
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
212
+ * server bootstrap hook.
213
+ *
214
+ * Throws if called after the instance has already been resolved: options
215
+ * cannot be applied retroactively.
216
+ */
217
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
218
+ if (cachedInstance) {
219
+ throw new Error(
220
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
221
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
222
+ );
223
+ }
224
+ configuredOpts = opts;
225
+ }
226
+
227
+ export async function getBetterAuthInstance() {
228
+ if (cachedInstance) return cachedInstance;
229
+
230
+ if (!initPromise) {
231
+ initPromise = getIDPClientConfig(true).then(config => {
232
+ const instance = createBetterAuthInstance(config, configuredOpts);
233
+ cachedInstance = instance;
234
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
235
+ return instance;
236
+ });
237
+ }
238
+
239
+ return initPromise;
240
+ }
241
+
242
+ /**
243
+ * Get flag-gated auth handler for Next.js route.
244
+ *
245
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
246
+ * Otherwise returns null (auth disabled).
247
+ *
248
+ * Usage in host app route:
249
+ * ```ts
250
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
251
+ *
252
+ * export async function GET(req: Request) {
253
+ * const ba = await getBetterAuthHandler();
254
+ * if (ba) return ba.GET(req);
255
+ * }
256
+ * ```
257
+ */
258
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
259
+ if (!isBetterAuthEnabled()) return null;
260
+
261
+ const auth = await getBetterAuthInstance();
262
+ return toNextJsHandler(auth);
263
+ }
264
+
265
+ /**
266
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
267
+ *
268
+ * Call this from the OAuth callback route AFTER better-auth has processed the
269
+ * callback and created the session. Reads the session token from the Set-Cookie
270
+ * header of the response to find the BA Redis key.
271
+ *
272
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
273
+ */
274
+ export async function exchangeOAuthForIdpTokens(
275
+ sessionToken: string,
276
+ provider: string = 'google'
277
+ ): Promise<boolean> {
278
+ try {
279
+ const config = await getIDPClientConfig();
280
+ const appSlug = config.clientSlug || getAppSlug();
281
+ const baKey = `ba:${appSlug}:${sessionToken}`;
282
+
283
+ // Read the BA session from Redis
284
+ const baRaw = await getRedis().get(baKey).catch(() => null);
285
+ if (!baRaw) {
286
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
287
+ return false;
288
+ }
289
+
290
+ const baData = JSON.parse(baRaw);
291
+ const email = baData?.user?.email;
292
+ const name = baData?.user?.name;
293
+ const image = baData?.user?.image;
294
+ const baUserId = baData?.session?.userId || baData?.user?.id;
295
+
296
+ if (!email) {
297
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
298
+ return false;
299
+ }
300
+
301
+ // Call IDP oauth-callback
302
+ const idpUrl = process.env.IDP_URL || '';
303
+ if (!idpUrl) {
304
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
305
+ return false;
306
+ }
307
+
308
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
309
+
310
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify({
314
+ provider,
315
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
316
+ email,
317
+ name,
318
+ image,
319
+ client_id: config.clientSlug || String(config.clientId),
320
+ }),
321
+ });
322
+
323
+ const oauthResText = await oauthRes.text();
324
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
325
+
326
+ if (!oauthRes.ok) {
327
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
328
+ return false;
329
+ }
330
+
331
+ let idpData: any;
332
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
333
+ const result = idpData?.data?.result || idpData?.data || idpData;
334
+
335
+ if (!result?.access_token) {
336
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
337
+ return false;
338
+ }
339
+
340
+ // Build IDP token data
341
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
342
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
343
+ const hasRefreshToken = !!result.refresh_token;
344
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
345
+ const idpTokenData = {
346
+ idpAccessToken: result.access_token,
347
+ idpRefreshToken: result.refresh_token,
348
+ idpAccessTokenExpires: result.expires_in
349
+ ? Date.now() + result.expires_in * 1000
350
+ : Date.now() + 15 * 60 * 1000,
351
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
352
+ email: result.user?.email || result.email || email,
353
+ name: result.user?.full_name || result.user?.name || result.name || name,
354
+ image: image,
355
+ roles: result.user?.roles || result.roles || [],
356
+ mfaVerified: !requiresTwoFactor,
357
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
358
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
359
+ };
360
+
361
+ // Store in BA Redis session (for decodeSession)
362
+ baData.idpTokens = idpTokenData;
363
+ await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
364
+
365
+ // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
366
+ // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
367
+ try {
368
+ const { getSessionPrefix } = await import('../lib/app-slug');
369
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
370
+ await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
371
+ ...idpTokenData,
372
+ oauthProvider: provider,
373
+ }));
374
+ } catch (canonicalErr) {
375
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
376
+ }
377
+
378
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
379
+ return true;
380
+ } catch (err) {
381
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
382
+ return false;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Create a production-ready GET handler for the auth catch-all route.
388
+ *
389
+ * Wraps better-auth's GET handler with:
390
+ * - OAuth state error recovery (redirects to login instead of error page)
391
+ * - IDP token exchange after successful OAuth callback
392
+ *
393
+ * Usage in host app:
394
+ * ```ts
395
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
396
+ * export const GET = createAuthGetHandler('/account-auth/login');
397
+ * export async function POST(req: Request) {
398
+ * const ba = await getBetterAuthHandler();
399
+ * return ba!.POST(req);
400
+ * }
401
+ * ```
402
+ */
403
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
404
+ return async function GET(request: Request): Promise<Response> {
405
+ const ba = await getBetterAuthHandler();
406
+ if (!ba) {
407
+ return new Response('Auth handler not configured', { status: 500 });
408
+ }
409
+
410
+ const response = await ba.GET(request);
411
+
412
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
413
+ if (response.status === 302) {
414
+ const location = response.headers.get('location') || '';
415
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
416
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
417
+ return Response.redirect(new URL(loginPath, request.url), 302);
418
+ }
419
+ }
420
+
421
+ // After successful OAuth callback: exchange Google identity for IDP tokens
422
+ const url = new URL(request.url);
423
+ if (url.pathname.includes('/callback/') && response.status === 302) {
424
+ try {
425
+ const auth = await getBetterAuthInstance();
426
+ if (auth?.api?.getSession) {
427
+ const setCookies = response.headers.getSetCookie?.() || [];
428
+ const cookieHeader = setCookies
429
+ .map((c: string) => c.split(';')[0])
430
+ .join('; ');
431
+
432
+ const headers = new Headers();
433
+ headers.set('cookie', cookieHeader);
434
+
435
+ const session = await auth.api.getSession({ headers });
436
+ if (session?.session?.token) {
437
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
438
+ await exchangeOAuthForIdpTokens(session.session.token);
439
+ } else {
440
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
441
+ }
442
+ }
443
+ } catch (err: any) {
444
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
445
+ }
446
+ }
447
+
448
+ return response;
449
+ };
450
+ }